diff --git a/.eslintignore b/.eslintignore index aa8b769dfede..3d966d096add 100644 --- a/.eslintignore +++ b/.eslintignore @@ -10,3 +10,4 @@ docs/vendor/** docs/assets/** web/gtm.js **/.expo/** +src/libs/SearchParser/searchParser.js diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index ffce73644263..0d5879217ea0 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -257,12 +257,12 @@ jobs: - name: Check if test failed, if so post the results and add the DeployBlocker label id: checkIfRegressionDetected run: | - if grep -q '🔴' ./output.md; then + if grep -q '🔴' "./Host_Machine_Files/\$WORKING_DIRECTORY/output.md"; then # Create an output to the GH action that the test failed: echo "performanceRegressionDetected=true" >> "$GITHUB_OUTPUT" gh pr edit ${{ inputs.PR_NUMBER }} --add-label DeployBlockerCash - gh pr comment ${{ inputs.PR_NUMBER }} -F ./output.md + gh pr comment ${{ inputs.PR_NUMBER }} -F "./Host_Machine_Files/\$WORKING_DIRECTORY/output.md" gh pr comment ${{ inputs.PR_NUMBER }} -b "@Expensify/mobile-deployers 📣 Please look into this performance regression as it's a deploy blocker." else echo "performanceRegressionDetected=false" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 476b01f87b07..3bfc0ed28d1a 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -34,7 +34,7 @@ jobs: # - git diff is used to see the files that were added on this branch # - gh pr view is used to list files touched by this PR. Git diff may give false positives if the branch isn't up-to-date with main # - wc counts the words in the result of the intersection - count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/*.js' '__mocks__/*.js' '.storybook/*.js' 'assets/*.js' 'config/*.js' 'desktop/*.js' 'jest/*.js' 'scripts/*.js' 'tests/*.js' 'workflow_tests/*.js' '.github/libs/*.js' '.github/scripts/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l) + count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/*.js' '__mocks__/*.js' '.storybook/*.js' 'assets/*.js' 'config/*.js' 'desktop/*.js' 'jest/*.js' 'scripts/*.js' 'tests/*.js' 'workflow_tests/*.js' '.github/libs/*.js' '.github/scripts/*.js' ':!src/libs/SearchParser/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l) if [ "$count_new_js" -gt "0" ]; then echo "ERROR: Found new JavaScript files in the project; use TypeScript instead." exit 1 diff --git a/.prettierignore b/.prettierignore index 09de20ba30b0..a9f7e1464529 100644 --- a/.prettierignore +++ b/.prettierignore @@ -19,3 +19,6 @@ package-lock.json src/libs/E2E/reactNativeLaunchingTest.ts # Temporary while we keep react-compiler in our repo lib/** + +# Automatically generated files +src/libs/SearchParser/searchParser.js diff --git a/android/app/build.gradle b/android/app/build.gradle index c53ad2cd3cc7..bf3aa74e537f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -107,8 +107,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009000512 - versionName "9.0.5-12" + versionCode 1009000601 + versionName "9.0.6-1" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/assets/images/simple-illustrations/simple-illustration__empty-state.svg b/assets/images/simple-illustrations/simple-illustration__empty-state.svg new file mode 100644 index 000000000000..154b2269c285 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__empty-state.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/simple-illustrations/simple-illustration__receipt-location-marker.svg b/assets/images/simple-illustrations/simple-illustration__receipt-location-marker.svg new file mode 100644 index 000000000000..01669d07c0f0 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__receipt-location-marker.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 2ccce98e6a21..680e0ed493a1 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.5 + 9.0.6 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.5.12 + 9.0.6.1 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 8248e7db0454..14d830fc2300 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.5 + 9.0.6 CFBundleSignature ???? CFBundleVersion - 9.0.5.12 + 9.0.6.1 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 87cdb420af38..33bae95b8ae4 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.5 + 9.0.6 CFBundleVersion - 9.0.5.12 + 9.0.6.1 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 29ab90c4b7db..50dfc65d07b2 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1871,7 +1871,7 @@ PODS: - RNGoogleSignin (10.0.1): - GoogleSignIn (~> 7.0) - React-Core - - RNLiveMarkdown (0.1.91): + - RNLiveMarkdown (0.1.103): - glog - hermes-engine - RCT-Folly (= 2022.05.16.00) @@ -1889,9 +1889,9 @@ PODS: - React-utils - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/common (= 0.1.91) + - RNLiveMarkdown/common (= 0.1.103) - Yoga - - RNLiveMarkdown/common (0.1.91): + - RNLiveMarkdown/common (0.1.103): - glog - hermes-engine - RCT-Folly (= 2022.05.16.00) @@ -1935,7 +1935,7 @@ PODS: - ReactCommon/turbomodule/core - Turf - Yoga - - RNPermissions (3.9.3): + - RNPermissions (3.10.1): - glog - hermes-engine - RCT-Folly (= 2022.05.16.00) @@ -2614,10 +2614,10 @@ SPEC CHECKSUMS: RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNGestureHandler: 74b7b3d06d667ba0bbf41da7718f2607ae0dfe8f RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0 - RNLiveMarkdown: 24fbb7370eefee2f325fb64cfe904b111ffcd81b + RNLiveMarkdown: f12157fc91b72e19705c9cc8c98034c4c1669d5a RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 rnmapbox-maps: df8fe93dbd251f25022f4023d31bc04160d4d65c - RNPermissions: 0b61d30d21acbeafe25baaa47d9bae40a0c65216 + RNPermissions: d2392b754e67bc14491f5b12588bef2864e783f3 RNReactNativeHapticFeedback: 616c35bdec7d20d4c524a7949ca9829c09e35f37 RNReanimated: 323436b1a5364dca3b5f8b1a13458455e0de9efe RNScreens: abd354e98519ed267600b7ee64fdcb8e060b1218 diff --git a/package-lock.json b/package-lock.json index 9a2cea929335..5f0fde749efa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "new.expensify", - "version": "9.0.5-12", + "version": "9.0.6-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.5-12", + "version": "9.0.6-1", "hasInstallScript": true, "license": "MIT", "dependencies": { "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.91", + "@expensify/react-native-live-markdown": "0.1.103", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-listformat": "^7.2.2", @@ -106,7 +106,7 @@ "react-native-pager-view": "6.2.3", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", - "react-native-permissions": "^3.9.3", + "react-native-permissions": "^3.10.0", "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf", "react-native-plaid-link-sdk": "11.5.0", "react-native-qrcode-svg": "^6.2.0", @@ -233,6 +233,7 @@ "memfs": "^4.6.0", "onchange": "^7.1.0", "patch-package": "^8.0.0", + "peggy": "^4.0.3", "portfinder": "^1.0.28", "prettier": "^2.8.8", "pusher-js-mock": "^0.3.3", @@ -3784,9 +3785,9 @@ } }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.91", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.91.tgz", - "integrity": "sha512-6uQTgwhpvLqQKdtNqSgh45sRuQRXzv/WwyhdvQNge6EYtulyGFqT82GIP+LIGW8Xnl73nzFZTuMKwWxFFR/Cow==", + "version": "0.1.103", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.103.tgz", + "integrity": "sha512-w9jQoxBE9LghfL8UdYbG+8A+CApmER/XMH8N7/bINn7w57+FnnBa5ckPWx6/UYX7OYsmYxSaHJLQkJEXYlDRZg==", "workspaces": [ "parser", "example", @@ -7879,6 +7880,33 @@ "react-native": ">=0.70.0 <1.0.x" } }, + "node_modules/@peggyjs/from-mem": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@peggyjs/from-mem/-/from-mem-1.3.0.tgz", + "integrity": "sha512-kzGoIRJjkg3KuGI4bopz9UvF3KguzfxalHRDEIdqEZUe45xezsQ6cx30e0RKuxPUexojQRBfu89Okn7f4/QXsw==", + "dev": true, + "dependencies": { + "semver": "7.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@peggyjs/from-mem/node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@perf-profiler/android": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/@perf-profiler/android/-/android-0.12.1.tgz", @@ -35858,6 +35886,32 @@ "through2": "^2.0.3" } }, + "node_modules/peggy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/peggy/-/peggy-4.0.3.tgz", + "integrity": "sha512-v7/Pt6kGYsfXsCrfb52q7/yg5jaAwiVaUMAPLPvy4DJJU6Wwr72t6nDIqIDkGfzd1B4zeVuTnQT0RGeOhe/uSA==", + "dev": true, + "dependencies": { + "@peggyjs/from-mem": "1.3.0", + "commander": "^12.1.0", + "source-map-generator": "0.8.0" + }, + "bin": { + "peggy": "bin/peggy.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/peggy/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/pend": { "version": "1.2.0", "dev": true, @@ -37435,8 +37489,9 @@ } }, "node_modules/react-native-permissions": { - "version": "3.9.3", - "license": "MIT", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-3.10.1.tgz", + "integrity": "sha512-Gc5BxxpjZn4QNUDiVeHOO0vXh3AH7ToolmwTJozqC6DsxV7NAf3ttap+8BSmzDR8WxuAM3Cror+YNiBhHJx7/w==", "peerDependencies": { "react": ">=16.13.1", "react-native": ">=0.63.3", @@ -40226,6 +40281,15 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-generator": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map-generator/-/source-map-generator-0.8.0.tgz", + "integrity": "sha512-psgxdGMwl5MZM9S3FWee4EgsEaIjahYV5AzGnwUvPhWeITz/j6rKpysQHlQ4USdxvINlb8lKfWGIXwfkrgtqkA==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, "node_modules/source-map-js": { "version": "1.0.2", "license": "BSD-3-Clause", diff --git a/package.json b/package.json index 0c7631f0c914..d14553213691 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.5-12", + "version": "9.0.6-1", "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.", @@ -61,13 +61,14 @@ "workflow-test:generate": "ts-node workflow_tests/utils/preGenerateTest.ts", "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", "e2e-test-runner-build": "ncc build tests/e2e/testRunner.ts -o tests/e2e/dist/", - "react-compiler-healthcheck": "react-compiler-healthcheck --verbose" + "react-compiler-healthcheck": "react-compiler-healthcheck --verbose", + "generate-search-parser": "peggy --format es -o src/libs/SearchParser/searchParser.js src/libs/SearchParser/searchParser.peggy " }, "dependencies": { "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.91", + "@expensify/react-native-live-markdown": "0.1.103", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-listformat": "^7.2.2", @@ -160,7 +161,7 @@ "react-native-pager-view": "6.2.3", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", - "react-native-permissions": "^3.9.3", + "react-native-permissions": "^3.10.0", "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf", "react-native-plaid-link-sdk": "11.5.0", "react-native-qrcode-svg": "^6.2.0", @@ -287,6 +288,7 @@ "memfs": "^4.6.0", "onchange": "^7.1.0", "patch-package": "^8.0.0", + "peggy": "^4.0.3", "portfinder": "^1.0.28", "prettier": "^2.8.8", "pusher-js-mock": "^0.3.3", diff --git a/patches/@expensify+react-native-live-markdown+0.1.91.patch b/patches/@expensify+react-native-live-markdown+0.1.91.patch deleted file mode 100644 index c77e46accae3..000000000000 --- a/patches/@expensify+react-native-live-markdown+0.1.91.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/node_modules/@expensify/react-native-live-markdown/src/web/cursorUtils.ts b/node_modules/@expensify/react-native-live-markdown/src/web/cursorUtils.ts -index 1cda659..ba5c3c3 100644 ---- a/node_modules/@expensify/react-native-live-markdown/src/web/cursorUtils.ts -+++ b/node_modules/@expensify/react-native-live-markdown/src/web/cursorUtils.ts -@@ -66,7 +66,7 @@ function setCursorPosition(target: HTMLElement, start: number, end: number | nul - // 3. Caret at the end of whole input, when pressing enter - // 4. All other placements - if (prevChar === '\n' && prevTextLength !== undefined && prevTextLength < textCharacters.length) { -- if (nextChar !== '\n') { -+ if (nextChar && nextChar !== '\n' && i !== n - 1) { - range.setStart(textNodes[i + 1] as Node, 0); - } else if (i !== textNodes.length - 1) { - range.setStart(textNodes[i] as Node, 1); diff --git a/scripts/applyPatches.sh b/scripts/applyPatches.sh index a4be88984561..9145629015ee 100755 --- a/scripts/applyPatches.sh +++ b/scripts/applyPatches.sh @@ -8,24 +8,17 @@ SCRIPTS_DIR=$(dirname "${BASH_SOURCE[0]}") source "$SCRIPTS_DIR/shellUtils.sh" # Wrapper to run patch-package. -# We use `script` to preserve colorization when the output of patch-package is piped to tee -# and we provide /dev/null to discard the output rather than sending it to a file -# `script` has different syntax on macOS vs linux, so that's why we need a wrapper function function patchPackage { OS="$(uname)" - if [[ "$OS" == "Darwin" ]]; then - # macOS - script -q /dev/null npx patch-package --error-on-fail - elif [[ "$OS" == "Linux" ]]; then - # Ubuntu/Linux - script -q -c "npx patch-package --error-on-fail" /dev/null + if [[ "$OS" == "Darwin" || "$OS" == "Linux" ]]; then + npx patch-package --error-on-fail else error "Unsupported OS: $OS" + exit 1 fi } # Run patch-package and capture its output and exit code, while still displaying the original output to the terminal -# (we use `script -q /dev/null` to preserve colorization in the output) TEMP_OUTPUT="$(mktemp)" patchPackage 2>&1 | tee "$TEMP_OUTPUT" EXIT_CODE=${PIPESTATUS[0]} @@ -36,7 +29,7 @@ rm -f "$TEMP_OUTPUT" echo "$OUTPUT" | grep -q "Warning:" WARNING_FOUND=$? -printf "\n"; +printf "\n" # Determine the final exit code if [ "$EXIT_CODE" -eq 0 ]; then diff --git a/src/CONST.ts b/src/CONST.ts index b809bdaacaf6..2f34b640c815 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -840,6 +840,8 @@ const CONST = { IOU: 'iou', TASK: 'task', INVOICE: 'invoice', + PAYCHECK: 'paycheck', + BILL: 'bill', }, CHAT_TYPE: chatTypes, WORKSPACE_CHAT_ROOMS: { @@ -1123,8 +1125,6 @@ const CONST = { // around each header. EMOJI_NUM_PER_ROW: 8, - EMOJI_FREQUENT_ROW_COUNT: 3, - EMOJI_DEFAULT_SKIN_TONE: -1, // Amount of emojis to render ahead at the end of the update cycle @@ -1245,7 +1245,7 @@ const CONST = { MAX_AMOUNT_OF_SUGGESTIONS: 20, MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER: 5, HERE_TEXT: '@here', - SUGGESTION_BOX_MAX_SAFE_DISTANCE: 38, + SUGGESTION_BOX_MAX_SAFE_DISTANCE: 10, BIG_SCREEN_SUGGESTION_WIDTH: 300, }, COMPOSER_MAX_HEIGHT: 125, @@ -5250,6 +5250,13 @@ const CONST = { }, EXCLUDE_FROM_LAST_VISITED_PATH: [SCREENS.NOT_FOUND, SCREENS.SAML_SIGN_IN, SCREENS.VALIDATE_LOGIN] as string[], + + EMPTY_STATE_MEDIA: { + ANIMATION: 'animation', + ILLUSTRATION: 'illustration', + VIDEO: 'video', + }, + UPGRADE_FEATURE_INTRO_MAPPING: [ { id: 'reportFields', @@ -5260,6 +5267,7 @@ const CONST = { icon: 'Pencil', }, ], + REPORT_FIELD_TYPES: { TEXT: 'text', DATE: 'date', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index bd4b294a6d68..8abb7738289c 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -445,6 +445,9 @@ const ONYXKEYS = { * So for example: card_12345_Expensify Card */ WORKSPACE_CARDS_LIST: 'card_', + + /** The bank account that Expensify Card payments will be reconciled against */ + SHARED_NVP_EXPENSIFY_CARD_CONTINUOUS_RECONCILIATION_CONNECTION: 'sharedNVP_expensifyCard_continuousReconciliationConnection_', }, /** List of Form ids */ @@ -535,8 +538,8 @@ const ONYXKEYS = { REPORT_VIRTUAL_CARD_FRAUD_DRAFT: 'reportVirtualCardFraudFormDraft', GET_PHYSICAL_CARD_FORM: 'getPhysicalCardForm', GET_PHYSICAL_CARD_FORM_DRAFT: 'getPhysicalCardFormDraft', - REPORT_FIELD_EDIT_FORM: 'reportFieldEditForm', - REPORT_FIELD_EDIT_FORM_DRAFT: 'reportFieldEditFormDraft', + REPORT_FIELDS_EDIT_FORM: 'reportFieldsEditForm', + REPORT_FIELDS_EDIT_FORM_DRAFT: 'reportFieldsEditFormDraft', REIMBURSEMENT_ACCOUNT_FORM: 'reimbursementAccount', REIMBURSEMENT_ACCOUNT_FORM_DRAFT: 'reimbursementAccountDraft', PERSONAL_BANK_ACCOUNT_FORM: 'personalBankAccount', @@ -622,7 +625,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD]: FormTypes.ReportVirtualCardFraudForm; [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: FormTypes.ReportPhysicalCardForm; [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM]: FormTypes.GetPhysicalCardForm; - [ONYXKEYS.FORMS.REPORT_FIELD_EDIT_FORM]: FormTypes.ReportFieldEditForm; + [ONYXKEYS.FORMS.REPORT_FIELDS_EDIT_FORM]: FormTypes.ReportFieldsEditForm; [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM]: FormTypes.ReimbursementAccountForm; [ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_FORM]: FormTypes.PersonalBankAccountForm; [ONYXKEYS.FORMS.WORKSPACE_DESCRIPTION_FORM]: FormTypes.WorkspaceDescriptionForm; @@ -692,6 +695,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END]: OnyxTypes.BillingGraceEndPeriod; [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_EXPENSIFY_CARD_SETTINGS]: OnyxTypes.ExpensifyCardSettings; [ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST]: OnyxTypes.WorkspaceCardsList; + [ONYXKEYS.COLLECTION.SHARED_NVP_EXPENSIFY_CARD_CONTINUOUS_RECONCILIATION_CONNECTION]: OnyxTypes.BankAccount; }; type OnyxValuesMapping = { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index a54bb4f5cca5..2189522e45ea 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -676,6 +676,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/accounting/quickbooks-online/invoice-account-selector', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/invoice-account-selector` as const, }, + WORKSPACE_ACCOUNTING_RECONCILIATION_ACCOUNT_SETTINGS: { + route: 'settings/workspaces/:policyID/accounting/:connection/card-reconciliation', + getRoute: (policyID: string, connection: ValueOf) => `settings/workspaces/${policyID}/accounting/${connection}/card-reconciliation` as const, + }, WORKSPACE_CATEGORIES: { route: 'settings/workspaces/:policyID/categories', getRoute: (policyID: string) => `settings/workspaces/${policyID}/categories` as const, @@ -797,30 +801,30 @@ const ROUTES = { route: 'settings/workspaces/:policyID/reportFields/new', getRoute: (policyID: string) => `settings/workspaces/${policyID}/reportFields/new` as const, }, - WORKSPACE_REPORT_FIELD_SETTINGS: { - route: 'settings/workspaces/:policyID/reportField/:reportFieldID/edit', - getRoute: (policyID: string, reportFieldID: string) => `settings/workspaces/${policyID}/reportField/${encodeURIComponent(reportFieldID)}/edit` as const, + WORKSPACE_REPORT_FIELDS_SETTINGS: { + route: 'settings/workspaces/:policyID/reportFields/:reportFieldID/edit', + getRoute: (policyID: string, reportFieldID: string) => `settings/workspaces/${policyID}/reportFields/${encodeURIComponent(reportFieldID)}/edit` as const, }, - WORKSPACE_REPORT_FIELD_LIST_VALUES: { - route: 'settings/workspaces/:policyID/reportField/listValues/:reportFieldID?', - getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportField/listValues/${encodeURIComponent(reportFieldID ?? '')}` as const, + WORKSPACE_REPORT_FIELDS_LIST_VALUES: { + route: 'settings/workspaces/:policyID/reportFields/listValues/:reportFieldID?', + getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportFields/listValues/${encodeURIComponent(reportFieldID ?? '')}` as const, }, - WORKSPACE_REPORT_FIELD_ADD_VALUE: { - route: 'settings/workspaces/:policyID/reportField/addValue/:reportFieldID?', - getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportField/addValue/${encodeURIComponent(reportFieldID ?? '')}` as const, + WORKSPACE_REPORT_FIELDS_ADD_VALUE: { + route: 'settings/workspaces/:policyID/reportFields/addValue/:reportFieldID?', + getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportFields/addValue/${encodeURIComponent(reportFieldID ?? '')}` as const, }, - WORKSPACE_REPORT_FIELD_VALUE_SETTINGS: { - route: 'settings/workspaces/:policyID/reportField/:valueIndex/:reportFieldID?', + WORKSPACE_REPORT_FIELDS_VALUE_SETTINGS: { + route: 'settings/workspaces/:policyID/reportFields/:valueIndex/:reportFieldID?', getRoute: (policyID: string, valueIndex: number, reportFieldID?: string) => - `settings/workspaces/${policyID}/reportField/${valueIndex}/${encodeURIComponent(reportFieldID ?? '')}` as const, + `settings/workspaces/${policyID}/reportFields/${valueIndex}/${encodeURIComponent(reportFieldID ?? '')}` as const, }, - WORKSPACE_REPORT_FIELD_EDIT_VALUE: { - route: 'settings/workspaces/:policyID/reportField/new/:valueIndex/edit', - getRoute: (policyID: string, valueIndex: number) => `settings/workspaces/${policyID}/reportField/new/${valueIndex}/edit` as const, + WORKSPACE_REPORT_FIELDS_EDIT_VALUE: { + route: 'settings/workspaces/:policyID/reportFields/new/:valueIndex/edit', + getRoute: (policyID: string, valueIndex: number) => `settings/workspaces/${policyID}/reportFields/new/${valueIndex}/edit` as const, }, - WORKSPACE_EDIT_REPORT_FIELD_INITIAL_VALUE: { - route: 'settings/workspaces/:policyID/reportField/:reportFieldID/edit/initialValue', - getRoute: (policyID: string, reportFieldID: string) => `settings/workspaces/${policyID}/reportField/${encodeURIComponent(reportFieldID)}/edit/initialValue` as const, + WORKSPACE_EDIT_REPORT_FIELDS_INITIAL_VALUE: { + route: 'settings/workspaces/:policyID/reportFields/:reportFieldID/edit/initialValue', + getRoute: (policyID: string, reportFieldID: string) => `settings/workspaces/${policyID}/reportFields/${encodeURIComponent(reportFieldID)}/edit/initialValue` as const, }, WORKSPACE_EXPENSIFY_CARD: { route: 'settings/workspaces/:policyID/expensify-card', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index d2a6b7c19ddd..0768ca8bb291 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -330,6 +330,7 @@ const SCREENS = { SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT: 'Policy_Accounting_Sage_Intacct_Non_Reimbursable_Credit_Card_Account', SAGE_INTACCT_ADVANCED: 'Policy_Accounting_Sage_Intacct_Advanced', SAGE_INTACCT_PAYMENT_ACCOUNT: 'Policy_Accounting_Sage_Intacct_Payment_Account', + RECONCILIATION_ACCOUNT_SETTINGS: 'Policy_Accounting_Reconciliation_Account_Settings', }, INITIAL: 'Workspace_Initial', PROFILE: 'Workspace_Profile', @@ -353,7 +354,7 @@ const SCREENS = { TAG_EDIT: 'Tag_Edit', TAXES: 'Workspace_Taxes', REPORT_FIELDS: 'Workspace_ReportFields', - REPORT_FIELD_SETTINGS: 'Workspace_ReportField_Settings', + REPORT_FIELDS_SETTINGS: 'Workspace_ReportFields_Settings', REPORT_FIELDS_CREATE: 'Workspace_ReportFields_Create', REPORT_FIELDS_LIST_VALUES: 'Workspace_ReportFields_ListValues', REPORT_FIELDS_ADD_VALUE: 'Workspace_ReportFields_AddValue', diff --git a/src/components/AccountingListSkeletonView.tsx b/src/components/AccountingListSkeletonView.tsx index b977903d3adc..dbe8ada6c4b7 100644 --- a/src/components/AccountingListSkeletonView.tsx +++ b/src/components/AccountingListSkeletonView.tsx @@ -4,12 +4,14 @@ import ItemListSkeletonView from './Skeletons/ItemListSkeletonView'; type AccountingListSkeletonViewProps = { shouldAnimate?: boolean; + gradientOpacityEnabled?: boolean; }; -function AccountingListSkeletonView({shouldAnimate = true}: AccountingListSkeletonViewProps) { +function AccountingListSkeletonView({shouldAnimate = true, gradientOpacityEnabled = false}: AccountingListSkeletonViewProps) { return ( ( <> & { + contentHeight: number; + topInset: number; +}; +function isEnoughSpaceToRenderMenuAboveCursor({y, cursorCoordinates, scrollValue, contentHeight, topInset}: IsEnoughSpaceToRenderMenuAboveCursor): boolean { + return y + (cursorCoordinates.y - scrollValue) > contentHeight + topInset + CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_BOX_MAX_SAFE_DISTANCE; } /** @@ -35,7 +43,7 @@ function isSuggestionRenderedAbove(isEnoughSpaceAboveForBig: boolean, isEnoughSp function AutoCompleteSuggestions({measureParentContainerAndReportCursor = () => {}, ...props}: AutoCompleteSuggestionsProps) { const containerRef = React.useRef(null); const isInitialRender = React.useRef(true); - const isSuggestionAboveRef = React.useRef(false); + const isSuggestionMenuAboveRef = React.useRef(false); const leftValue = React.useRef(0); const prevLeftValue = React.useRef(0); const {windowHeight, windowWidth, isSmallScreenWidth} = useWindowDimensions(); @@ -44,11 +52,12 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu width: 0, left: 0, bottom: 0, + cursorCoordinates: {x: 0, y: 0}, }); const StyleUtils = useStyleUtils(); const insets = useSafeAreaInsets(); const {keyboardHeight} = useKeyboardState(); - const {paddingBottom: bottomInset} = StyleUtils.getSafeAreaPadding(insets ?? undefined); + const {paddingBottom: bottomInset, paddingTop: topInset} = StyleUtils.getSafeAreaPadding(insets ?? undefined); useEffect(() => { const container = containerRef.current; @@ -73,51 +82,51 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu measureParentContainerAndReportCursor(({x, y, width, scrollValue, cursorCoordinates}: MeasureParentContainerAndCursor) => { const xCoordinatesOfCursor = x + cursorCoordinates.x; - const leftValueForBigScreen = + const bigScreenLeftOffset = xCoordinatesOfCursor + CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH > windowWidth ? windowWidth - CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH : xCoordinatesOfCursor; - - let bottomValue = windowHeight - y - cursorCoordinates.y + scrollValue - (keyboardHeight || bottomInset); - const widthValue = isSmallScreenWidth ? width : CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH; - const contentMaxHeight = measureHeightOfSuggestionRows(suggestionsLength, true); const contentMinHeight = measureHeightOfSuggestionRows(suggestionsLength, false); - const isEnoughSpaceAboveForBig = windowHeight - bottomValue - contentMaxHeight > CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_BOX_MAX_SAFE_DISTANCE; - const isEnoughSpaceAboveForSmall = windowHeight - bottomValue - contentMinHeight > CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_BOX_MAX_SAFE_DISTANCE; + let bottomValue = windowHeight - (cursorCoordinates.y - scrollValue + y) - keyboardHeight; + const widthValue = isSmallScreenWidth ? width : CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH; + + const isEnoughSpaceToRenderMenuAboveForBig = isEnoughSpaceToRenderMenuAboveCursor({y, cursorCoordinates, scrollValue, contentHeight: contentMaxHeight, topInset}); + const isEnoughSpaceToRenderMenuAboveForSmall = isEnoughSpaceToRenderMenuAboveCursor({y, cursorCoordinates, scrollValue, contentHeight: contentMinHeight, topInset}); - const newLeftValue = isSmallScreenWidth ? x : leftValueForBigScreen; + const newLeftOffset = isSmallScreenWidth ? x : bigScreenLeftOffset; // If the suggested word is longer than 150 (approximately half the width of the suggestion popup), then adjust a new position of popup - const isAdjustmentNeeded = Math.abs(prevLeftValue.current - leftValueForBigScreen) > 150; + const isAdjustmentNeeded = Math.abs(prevLeftValue.current - bigScreenLeftOffset) > 150; if (isInitialRender.current || isAdjustmentNeeded) { - isSuggestionAboveRef.current = isSuggestionRenderedAbove(isEnoughSpaceAboveForBig, isEnoughSpaceAboveForSmall); - leftValue.current = newLeftValue; + isSuggestionMenuAboveRef.current = isSuggestionMenuRenderedAbove(isEnoughSpaceToRenderMenuAboveForBig, isEnoughSpaceToRenderMenuAboveForSmall); + leftValue.current = newLeftOffset; isInitialRender.current = false; - prevLeftValue.current = newLeftValue; + prevLeftValue.current = newLeftOffset; } let measuredHeight = 0; - if (isSuggestionAboveRef.current && isEnoughSpaceAboveForBig) { + if (isSuggestionMenuAboveRef.current && isEnoughSpaceToRenderMenuAboveForBig) { // calculation for big suggestion box above the cursor measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, true); - } else if (isSuggestionAboveRef.current && isEnoughSpaceAboveForSmall) { + } else if (isSuggestionMenuAboveRef.current && isEnoughSpaceToRenderMenuAboveForSmall) { // calculation for small suggestion box above the cursor measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, false); } else { // calculation for big suggestion box below the cursor measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, true); - bottomValue = windowHeight - y - cursorCoordinates.y + scrollValue - measuredHeight - CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + bottomValue = windowHeight - y - cursorCoordinates.y + scrollValue - measuredHeight - CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT - keyboardHeight; } setSuggestionHeight(measuredHeight); setContainerState({ left: leftValue.current, bottom: bottomValue, width: widthValue, + cursorCoordinates, }); }); - }, [measureParentContainerAndReportCursor, windowHeight, windowWidth, keyboardHeight, isSmallScreenWidth, suggestionsLength, bottomInset]); + }, [measureParentContainerAndReportCursor, windowHeight, windowWidth, keyboardHeight, isSmallScreenWidth, suggestionsLength, bottomInset, topInset]); - if (containerState.width === 0 && containerState.left === 0 && containerState.bottom === 0) { + if ((containerState.width === 0 && containerState.left === 0 && containerState.bottom === 0) || (containerState.cursorCoordinates.x === 0 && containerState.cursorCoordinates.y === 0)) { return null; } return ( diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx index e641a0c2218a..4cbf85cb0014 100644 --- a/src/components/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs.tsx @@ -36,10 +36,11 @@ function Breadcrumbs({breadcrumbs, style}: BreadcrumbsProps) { const theme = useTheme(); const styles = useThemeStyles(); const [primaryBreadcrumb, secondaryBreadcrumb] = breadcrumbs; + const isRootBreadcrumb = primaryBreadcrumb.type === CONST.BREADCRUMB_TYPE.ROOT; const fontScale = PixelRatio.getFontScale() > CONST.LOGO_MAX_SCALE ? CONST.LOGO_MAX_SCALE : PixelRatio.getFontScale(); return ( - {primaryBreadcrumb.type === CONST.BREADCRUMB_TYPE.ROOT ? ( + {isRootBreadcrumb ? (
/ {secondaryBreadcrumb.text} diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 4b3f0f70db24..0fd3cc0728ca 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -118,6 +118,9 @@ type ButtonProps = Partial & { /** Whether the button should use split style or not */ isSplitButton?: boolean; + + /** Whether button's content should be centered */ + isContentCentered?: boolean; }; type KeyboardShortcutComponentProps = Pick; @@ -202,6 +205,7 @@ function Button( id = '', accessibilityLabel = '', isSplitButton = false, + isContentCentered = false, ...rest }: ButtonProps, ref: ForwardedRef, @@ -239,7 +243,7 @@ function Button( // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (icon || shouldShowRightIcon) { return ( - + {icon && ( diff --git a/src/components/ConfirmContent.tsx b/src/components/ConfirmContent.tsx index 26331f92401c..36f24c2a3477 100644 --- a/src/components/ConfirmContent.tsx +++ b/src/components/ConfirmContent.tsx @@ -14,8 +14,11 @@ import type IconAsset from '@src/types/utils/IconAsset'; import Button from './Button'; import Header from './Header'; import Icon from './Icon'; +import {Close} from './Icon/Expensicons'; import ImageSVG from './ImageSVG'; +import {PressableWithoutFeedback} from './Pressable'; import Text from './Text'; +import Tooltip from './Tooltip'; type ConfirmContentProps = { /** Title of the modal */ @@ -51,15 +54,36 @@ type ConfirmContentProps = { /** Icon to display above the title */ iconSource?: IconAsset; + /** Fill color for the Icon */ + iconFill?: string | false; + + /** Icon width */ + iconWidth?: number; + + /** Icon height */ + iconHeight?: number; + + /** Should the icon be centered? */ + shouldCenterIcon?: boolean; + /** Whether to center the icon / text content */ shouldCenterContent?: boolean; + /** Whether to show the dismiss icon */ + shouldShowDismissIcon?: boolean; + /** Whether to stack the buttons */ shouldStackButtons?: boolean; + /** Whether to reverse the order of the stacked buttons */ + shouldReverseStackedButtons?: boolean; + /** Styles for title */ titleStyles?: StyleProp; + /** Styles for title container */ + titleContainerStyles?: StyleProp; + /** Styles for prompt */ promptStyles?: StyleProp; @@ -85,13 +109,20 @@ function ConfirmContent({ shouldDisableConfirmButtonWhenOffline = false, shouldShowCancelButton = false, iconSource, + iconFill, shouldCenterContent = false, shouldStackButtons = true, titleStyles, promptStyles, contentStyles, iconAdditionalStyles, + iconWidth = variables.appModalAppIconSize, + iconHeight = variables.appModalAppIconSize, + shouldCenterIcon = false, + shouldShowDismissIcon = false, image, + titleContainerStyles, + shouldReverseStackedButtons = false, }: ConfirmContentProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -116,19 +147,35 @@ function ConfirmContent({ )} + {shouldShowDismissIcon && ( + + + + + + + + )} - {typeof iconSource === 'function' && ( - + {iconSource && ( + )} - +
+ {shouldShowCancelButton && shouldReverseStackedButtons && ( + + )} + + + + + ); +} + +EmptyStateComponent.displayName = 'EmptyStateComponent'; +export default EmptyStateComponent; diff --git a/src/components/EmptyStateComponent/types.ts b/src/components/EmptyStateComponent/types.ts new file mode 100644 index 000000000000..326b25542f42 --- /dev/null +++ b/src/components/EmptyStateComponent/types.ts @@ -0,0 +1,41 @@ +import type {ImageStyle} from 'expo-image'; +import type {StyleProp, ViewStyle} from 'react-native'; +import type {ValueOf} from 'type-fest'; +import type DotLottieAnimation from '@components/LottieAnimations/types'; +import type SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; +import type TableRowSkeleton from '@components/Skeletons/TableRowSkeleton'; +import type CONST from '@src/CONST'; +import type IconAsset from '@src/types/utils/IconAsset'; + +type ValidSkeletons = typeof SearchRowSkeleton | typeof TableRowSkeleton; +type MediaTypes = ValueOf; + +type SharedProps = { + SkeletonComponent: ValidSkeletons; + title: string; + subtitle: string; + buttonText?: string; + buttonAction?: () => void; + headerStyles?: StyleProp; + headerMediaType: T; + headerContentStyles?: StyleProp; +}; + +type MediaType = SharedProps & { + headerMedia: HeaderMedia; +}; + +type VideoProps = MediaType; +type IllustrationProps = MediaType; +type AnimationProps = MediaType; + +type EmptyStateComponentProps = VideoProps | IllustrationProps | AnimationProps; + +type VideoLoadedEventType = { + srcElement: { + videoWidth: number; + videoHeight: number; + }; +}; + +export type {EmptyStateComponentProps, VideoLoadedEventType}; diff --git a/src/components/Form/SafariFormWrapper.tsx b/src/components/Form/SafariFormWrapper.tsx new file mode 100644 index 000000000000..8ad411e547be --- /dev/null +++ b/src/components/Form/SafariFormWrapper.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import {isSafari} from '@libs/Browser'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; + +type SafariFormWrapperProps = ChildrenProps; + +/** + * If we used any without
wrapper, Safari 11+ would show the auto-fill suggestion popup. + */ +function SafariFormWrapper({children}: SafariFormWrapperProps) { + if (isSafari()) { + return {children}
; + } + + return children; +} + +export default SafariFormWrapper; diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 7a8186d2f38e..e44eda0c5128 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -53,6 +53,7 @@ import ConciergeNew from '@assets/images/simple-illustrations/simple-illustratio import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustration__credit-cards.svg'; import CreditCardEyes from '@assets/images/simple-illustrations/simple-illustration__creditcardeyes.svg'; import EmailAddress from '@assets/images/simple-illustrations/simple-illustration__email-address.svg'; +import EmptyState from '@assets/images/simple-illustrations/simple-illustration__empty-state.svg'; import FolderOpen from '@assets/images/simple-illustrations/simple-illustration__folder-open.svg'; import Gears from '@assets/images/simple-illustrations/simple-illustration__gears.svg'; import HandCard from '@assets/images/simple-illustrations/simple-illustration__handcard.svg'; @@ -78,6 +79,7 @@ import PiggyBank from '@assets/images/simple-illustrations/simple-illustration__ import Profile from '@assets/images/simple-illustrations/simple-illustration__profile.svg'; import QRCode from '@assets/images/simple-illustrations/simple-illustration__qr-code.svg'; import ReceiptEnvelope from '@assets/images/simple-illustrations/simple-illustration__receipt-envelope.svg'; +import ReceiptLocationMarker from '@assets/images/simple-illustrations/simple-illustration__receipt-location-marker.svg'; import ReceiptWrangler from '@assets/images/simple-illustrations/simple-illustration__receipt-wrangler.svg'; import ReceiptUpload from '@assets/images/simple-illustrations/simple-illustration__receiptupload.svg'; import SanFrancisco from '@assets/images/simple-illustrations/simple-illustration__sanfrancisco.svg'; @@ -188,6 +190,7 @@ export { Pencil, Tag, CarIce, + ReceiptLocationMarker, Lightbulb, EmptyStateTravel, SubscriptionAnnual, @@ -198,6 +201,7 @@ export { CheckmarkCircle, CreditCardEyes, LockClosedOrange, + EmptyState, FolderWithPapers, VirtualCard, }; diff --git a/src/components/LocationPermissionModal/index.android.tsx b/src/components/LocationPermissionModal/index.android.tsx new file mode 100644 index 000000000000..811537e00e67 --- /dev/null +++ b/src/components/LocationPermissionModal/index.android.tsx @@ -0,0 +1,90 @@ +import React, {useEffect, useState} from 'react'; +import {Linking} from 'react-native'; +import {RESULTS} from 'react-native-permissions'; +import ConfirmModal from '@components/ConfirmModal'; +import * as Illustrations from '@components/Icon/Illustrations'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getLocationPermission, requestLocationPermission} from '@pages/iou/request/step/IOURequestStepScan/LocationPermission'; +import type {LocationPermissionModalProps} from './types'; + +function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDeny, onGrant}: LocationPermissionModalProps) { + const [hasError, setHasError] = useState(false); + const [showModal, setShowModal] = useState(false); + + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + useEffect(() => { + if (!startPermissionFlow) { + return; + } + + getLocationPermission().then((status) => { + if (status === RESULTS.GRANTED || status === RESULTS.LIMITED) { + return onGrant(); + } + + setShowModal(true); + setHasError(status === RESULTS.BLOCKED); + }); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- We only want to run this effect when startPermissionFlow changes + }, [startPermissionFlow]); + + const handledBlockedPermission = (cb: () => void) => () => { + if (hasError && Linking.openSettings) { + Linking.openSettings(); + setShowModal(false); + setHasError(false); + resetPermissionFlow(); + return; + } + cb(); + }; + + const grantLocationPermission = handledBlockedPermission(() => { + requestLocationPermission().then((status) => { + if (status === RESULTS.GRANTED || status === RESULTS.LIMITED) { + onGrant(); + } else if (status === RESULTS.BLOCKED) { + setHasError(true); + return; + } else { + onDeny(status); + } + setShowModal(false); + setHasError(false); + }); + }); + + const skipLocationPermission = () => { + onDeny(RESULTS.DENIED); + setShowModal(false); + setHasError(false); + }; + + return ( + + ); +} + +LocationPermissionModal.displayName = 'LocationPermissionModal'; + +export default LocationPermissionModal; diff --git a/src/components/LocationPermissionModal/index.tsx b/src/components/LocationPermissionModal/index.tsx new file mode 100644 index 000000000000..2bc4a7393822 --- /dev/null +++ b/src/components/LocationPermissionModal/index.tsx @@ -0,0 +1,90 @@ +import React, {useEffect, useState} from 'react'; +import {Linking} from 'react-native'; +import {RESULTS} from 'react-native-permissions'; +import ConfirmModal from '@components/ConfirmModal'; +import * as Illustrations from '@components/Icon/Illustrations'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getLocationPermission, requestLocationPermission} from '@pages/iou/request/step/IOURequestStepScan/LocationPermission'; +import type {LocationPermissionModalProps} from './types'; + +function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDeny, onGrant}: LocationPermissionModalProps) { + const [hasError, setHasError] = useState(false); + const [showModal, setShowModal] = useState(false); + + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + useEffect(() => { + if (!startPermissionFlow) { + return; + } + + getLocationPermission().then((status) => { + if (status === RESULTS.GRANTED || status === RESULTS.LIMITED) { + return onGrant(); + } + + setShowModal(true); + setHasError(status === RESULTS.BLOCKED); + }); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- We only want to run this effect when startPermissionFlow changes + }, [startPermissionFlow]); + + const handledBlockedPermission = (cb: () => void) => () => { + if (hasError && Linking.openSettings) { + Linking.openSettings(); + setShowModal(false); + setHasError(false); + resetPermissionFlow(); + return; + } + cb(); + }; + + const grantLocationPermission = handledBlockedPermission(() => { + requestLocationPermission() + .then((status) => { + if (status === RESULTS.GRANTED || status === RESULTS.LIMITED) { + onGrant(); + } else { + onDeny(status); + } + }) + .finally(() => { + setShowModal(false); + setHasError(false); + }); + }); + + const skipLocationPermission = () => { + onDeny(RESULTS.DENIED); + setShowModal(false); + setHasError(false); + }; + + return ( + + ); +} + +LocationPermissionModal.displayName = 'LocationPermissionModal'; + +export default LocationPermissionModal; diff --git a/src/components/LocationPermissionModal/types.ts b/src/components/LocationPermissionModal/types.ts new file mode 100644 index 000000000000..ec603bfdb8c1 --- /dev/null +++ b/src/components/LocationPermissionModal/types.ts @@ -0,0 +1,19 @@ +import type {PermissionStatus} from 'react-native-permissions'; + +type LocationPermissionModalProps = { + /** A callback to call when the permission has been granted */ + onGrant: () => void; + + /** A callback to call when the permission has been denied */ + onDeny: (permission: PermissionStatus) => void; + + /** Should start the permission flow? */ + startPermissionFlow: boolean; + + /** Reset the permission flow */ + resetPermissionFlow: () => void; +}; + +export default {}; + +export type {LocationPermissionModalProps}; diff --git a/src/components/Lottie/index.tsx b/src/components/Lottie/index.tsx index 6395a715f339..a9b223a87a54 100644 --- a/src/components/Lottie/index.tsx +++ b/src/components/Lottie/index.tsx @@ -1,7 +1,7 @@ -import type {LottieViewProps} from 'lottie-react-native'; +import type {AnimationObject, LottieViewProps} from 'lottie-react-native'; import LottieView from 'lottie-react-native'; import type {ForwardedRef} from 'react'; -import React, {forwardRef} from 'react'; +import React, {forwardRef, useEffect, useState} from 'react'; import {View} from 'react-native'; import type DotLottieAnimation from '@components/LottieAnimations/types'; import useAppState from '@hooks/useAppState'; @@ -19,6 +19,12 @@ function Lottie({source, webStyle, ...props}: Props, ref: ForwardedRef setIsError(false)}); + const [animationFile, setAnimationFile] = useState(); + + useEffect(() => { + setAnimationFile(source.file); + }, [setAnimationFile, source.file]); + const aspectRatioStyle = styles.aspectRatioLottie(source); // If the image fails to load or app is in background state, we'll just render an empty view @@ -28,17 +34,17 @@ function Lottie({source, webStyle, ...props}: Props, ref: ForwardedRef; } - return ( + return animationFile ? ( setIsError(true)} /> - ); + ) : null; } Lottie.displayName = 'Lottie'; diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 1fbd6a6b2630..a7638b8cdb71 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -162,6 +162,9 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & /** The action to take */ action?: IOUAction; + + /** Should play sound on confirmation */ + shouldPlaySound?: boolean; }; type MoneyRequestConfirmationListItem = Participant | ReportUtils.OptionData; @@ -204,6 +207,7 @@ function MoneyRequestConfirmationList({ action = CONST.IOU.ACTION.CREATE, currencyList, shouldDisplayReceipt = false, + shouldPlaySound = true, }: MoneyRequestConfirmationListProps) { const policy = policyReal ?? policyDraft; const policyCategories = policyCategoriesReal ?? policyCategoriesDraft; @@ -689,7 +693,9 @@ function MoneyRequestConfirmationList({ return; } - playSound(SOUNDS.DONE); + if (shouldPlaySound) { + playSound(SOUNDS.DONE); + } setDidConfirm(true); onConfirm?.(selectedParticipants); } else { @@ -723,6 +729,7 @@ function MoneyRequestConfirmationList({ isDistanceRequestWithPendingRoute, iouAmount, onConfirm, + shouldPlaySound, ], ); diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 8dfff6466ab9..48190fb3c759 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -256,7 +256,8 @@ function MoneyRequestConfirmationListFooter({ // Do not hide fields in case of paying someone const shouldShowAllFields = !!isDistanceRequest || shouldExpandFields || !shouldShowSmartScanFields || isTypeSend || !!isEditingSplitBill; // Calculate the formatted tax amount based on the transaction's tax amount and the IOU currency code - const formattedTaxAmount = CurrencyUtils.convertToDisplayString(transaction?.taxAmount, iouCurrencyCode); + const taxAmount = TransactionUtils.getTaxAmount(transaction, false); + const formattedTaxAmount = CurrencyUtils.convertToDisplayString(taxAmount, iouCurrencyCode); // Get the tax rate title based on the policy and transaction const taxRateTitle = TransactionUtils.getTaxName(policy, transaction); // Determine if the merchant error should be displayed diff --git a/src/components/OptionsListSkeletonView.tsx b/src/components/OptionsListSkeletonView.tsx index a11077f95bb5..6dede512f405 100644 --- a/src/components/OptionsListSkeletonView.tsx +++ b/src/components/OptionsListSkeletonView.tsx @@ -18,16 +18,18 @@ function getLinedWidth(index: number): string { type OptionsListSkeletonViewProps = { shouldAnimate?: boolean; + gradientOpacityEnabled?: boolean; shouldStyleAsTable?: boolean; }; -function OptionsListSkeletonView({shouldAnimate = true, shouldStyleAsTable = false}: OptionsListSkeletonViewProps) { +function OptionsListSkeletonView({shouldAnimate = true, shouldStyleAsTable = false, gradientOpacityEnabled = false}: OptionsListSkeletonViewProps) { const styles = useThemeStyles(); return ( { const lineWidth = getLinedWidth(itemIndex); diff --git a/src/components/PopoverWithoutOverlay/index.tsx b/src/components/PopoverWithoutOverlay/index.tsx index bcead42a64f2..7d58ad6d22be 100644 --- a/src/components/PopoverWithoutOverlay/index.tsx +++ b/src/components/PopoverWithoutOverlay/index.tsx @@ -124,6 +124,7 @@ function PopoverWithoutOverlay( ref={viewRef(withoutOverlayRef)} // Prevent the parent element to capture a click. This is useful when the modal component is put inside a pressable. onClick={(e) => e.stopPropagation()} + dataSet={{dragArea: false}} > { + // If we're already navigating to these task editing pages, early return not to mark as completed, otherwise we would have not found page. + if (TaskUtils.isActiveTaskEditRoute(report.reportID)) { + return; + } if (isCompleted) { Task.reopenTask(report); } else { diff --git a/src/components/Search/SearchListWithHeader.tsx b/src/components/Search/SearchListWithHeader.tsx index 48d9a2b4ae3a..02da657609ba 100644 --- a/src/components/Search/SearchListWithHeader.tsx +++ b/src/components/Search/SearchListWithHeader.tsx @@ -1,7 +1,12 @@ import type {ForwardedRef} from 'react'; -import React, {forwardRef, useEffect, useMemo, useState} from 'react'; +import React, {forwardRef, useCallback, useEffect, useMemo, useState} from 'react'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import Modal from '@components/Modal'; import SelectionList from '@components/SelectionList'; import type {BaseSelectionListProps, ReportListItemType, SelectionListHandle, TransactionListItemType} from '@components/SelectionList/types'; +import useLocalize from '@hooks/useLocalize'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import * as SearchUtils from '@libs/SearchUtils'; import CONST from '@src/CONST'; import type {SearchDataTypes, SearchQuery} from '@src/types/onyx/SearchResults'; @@ -13,6 +18,8 @@ type SearchListWithHeaderProps = Omit void; }; function mapTransactionItemToSelectedEntry(item: TransactionListItemType): [string, SelectedTransactionInfo] { @@ -33,7 +40,14 @@ function mapToItemWithSelectionInfo(item: TransactionListItemType | ReportListIt }; } -function SearchListWithHeader({ListItem, onSelectRow, query, hash, data, searchType, ...props}: SearchListWithHeaderProps, ref: ForwardedRef) { +function SearchListWithHeader( + {ListItem, onSelectRow, query, hash, data, searchType, isMobileSelectionModeActive, setIsMobileSelectionModeActive, ...props}: SearchListWithHeaderProps, + ref: ForwardedRef, +) { + const {isSmallScreenWidth} = useWindowDimensions(); + const {translate} = useLocalize(); + const [isModalVisible, setIsModalVisible] = useState(false); + const [longPressedItem, setLongPressedItem] = useState(null); const [selectedItems, setSelectedItems] = useState({}); const clearSelectedItems = () => setSelectedItems({}); @@ -42,39 +56,72 @@ function SearchListWithHeader({ListItem, onSelectRow, query, hash, data, searchT clearSelectedItems(); }, [hash]); - const toggleTransaction = (item: TransactionListItemType | ReportListItemType) => { - if (SearchUtils.isTransactionListItemType(item)) { - if (!item.keyForList) { + const toggleTransaction = useCallback( + (item: TransactionListItemType | ReportListItemType) => { + if (SearchUtils.isTransactionListItemType(item)) { + if (!item.keyForList) { + return; + } + + setSelectedItems((prev) => { + if (prev[item.keyForList]?.isSelected) { + const {[item.keyForList]: omittedTransaction, ...transactions} = prev; + return transactions; + } + return {...prev, [item.keyForList]: {isSelected: true, canDelete: item.canDelete, action: item.action}}; + }); + return; } - setSelectedItems((prev) => { - if (prev[item.keyForList]?.isSelected) { - const {[item.keyForList]: omittedTransaction, ...transactions} = prev; - return transactions; - } - return {...prev, [item.keyForList]: {isSelected: true, canDelete: item.canDelete, action: item.action}}; + if (item.transactions.every((transaction) => selectedItems[transaction.keyForList]?.isSelected)) { + const reducedSelectedItems: SelectedTransactions = {...selectedItems}; + + item.transactions.forEach((transaction) => { + delete reducedSelectedItems[transaction.keyForList]; + }); + + setSelectedItems(reducedSelectedItems); + return; + } + + setSelectedItems({ + ...selectedItems, + ...Object.fromEntries(item.transactions.map(mapTransactionItemToSelectedEntry)), }); + }, + [selectedItems], + ); + const openBottomModal = (item: TransactionListItemType | ReportListItemType | null) => { + if (!isSmallScreenWidth) { return; } - if (item.transactions.every((transaction) => selectedItems[transaction.keyForList]?.isSelected)) { - const reducedSelectedItems: SelectedTransactions = {...selectedItems}; + setLongPressedItem(item); + setIsModalVisible(true); + }; - item.transactions.forEach((transaction) => { - delete reducedSelectedItems[transaction.keyForList]; - }); + const turnOnSelectionMode = useCallback(() => { + setIsMobileSelectionModeActive?.(true); + setIsModalVisible(false); + + if (longPressedItem) { + toggleTransaction(longPressedItem); + } + }, [longPressedItem, setIsMobileSelectionModeActive, toggleTransaction]); - setSelectedItems(reducedSelectedItems); + const closeBottomModal = useCallback(() => { + setIsModalVisible(false); + }, []); + + useEffect(() => { + if (isMobileSelectionModeActive) { return; } - setSelectedItems({ - ...selectedItems, - ...Object.fromEntries(item.transactions.map(mapTransactionItemToSelectedEntry)), - }); - }; + setSelectedItems({}); + }, [setSelectedItems, isMobileSelectionModeActive]); const toggleAllTransactions = () => { const areItemsOfReportType = searchType === CONST.SEARCH.DATA_TYPES.REPORT; @@ -104,6 +151,8 @@ function SearchListWithHeader({ListItem, onSelectRow, query, hash, data, searchT clearSelectedItems={clearSelectedItems} query={query} hash={hash} + isMobileSelectionModeActive={isMobileSelectionModeActive} + setIsMobileSelectionModeActive={setIsMobileSelectionModeActive} /> // eslint-disable-next-line react/jsx-props-no-spreading @@ -111,10 +160,24 @@ function SearchListWithHeader({ListItem, onSelectRow, query, hash, data, searchT sections={[{data: sortedSelectedData, isDisabled: false}]} ListItem={ListItem} onSelectRow={onSelectRow} + onLongPressRow={openBottomModal} ref={ref} onCheckboxPress={toggleTransaction} onSelectAll={toggleAllTransactions} + isMobileSelectionModeActive={isMobileSelectionModeActive} /> + + + + ); } diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index 8d42f9e6da36..b0f2acfb57d1 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -1,4 +1,4 @@ -import React, {useCallback} from 'react'; +import React, {useMemo} from 'react'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -10,6 +10,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as SearchActions from '@libs/actions/Search'; +import SearchSelectedNarrow from '@pages/Search/SearchSelectedNarrow'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {SearchQuery} from '@src/types/onyx/SearchResults'; @@ -22,11 +23,13 @@ type SearchHeaderProps = { selectedItems?: SelectedTransactions; clearSelectedItems?: () => void; hash: number; + isMobileSelectionModeActive?: boolean; + setIsMobileSelectionModeActive?: (isMobileSelectionModeActive: boolean) => void; }; type SearchHeaderOptionValue = DeepValueOf | undefined; -function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}: SearchHeaderProps) { +function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems, isMobileSelectionModeActive, setIsMobileSelectionModeActive}: SearchHeaderProps) { const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); @@ -39,12 +42,13 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}: finished: {icon: Illustrations.CheckmarkCircle, title: translate('common.finished')}, }; - const getHeaderButtons = useCallback(() => { + const selectedItemsKeys = Object.keys(selectedItems ?? []); + + const headerButtonsOptions = useMemo(() => { const options: Array> = []; - const selectedItemsKeys = Object.keys(selectedItems ?? []); if (selectedItemsKeys.length === 0) { - return null; + return options; } const itemsToDelete = selectedItemsKeys.filter((id) => selectedItems[id].canDelete); @@ -56,6 +60,9 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}: value: CONST.SEARCH.BULK_ACTION_TYPES.DELETE, onSelected: () => { clearSelectedItems?.(); + if (isMobileSelectionModeActive) { + setIsMobileSelectionModeActive?.(false); + } SearchActions.deleteMoneyRequestOnSearch(hash, itemsToDelete); }, }); @@ -70,6 +77,9 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}: value: CONST.SEARCH.BULK_ACTION_TYPES.HOLD, onSelected: () => { clearSelectedItems?.(); + if (isMobileSelectionModeActive) { + setIsMobileSelectionModeActive?.(false); + } SearchActions.holdMoneyRequestOnSearch(hash, itemsToHold, ''); }, }); @@ -84,6 +94,9 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}: value: CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD, onSelected: () => { clearSelectedItems?.(); + if (isMobileSelectionModeActive) { + setIsMobileSelectionModeActive?.(false); + } SearchActions.unholdMoneyRequestOnSearch(hash, itemsToUnhold); }, }); @@ -107,21 +120,18 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}: }); } - return ( - null} - shouldAlwaysShowDropdownMenu - pressOnEnter - buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} - customText={translate('workspace.common.selected', {selectedNumber: selectedItemsKeys.length})} - options={options} - isSplitButton={false} - isDisabled={isOffline} - /> - ); - }, [clearSelectedItems, hash, isOffline, selectedItems, styles.colorMuted, styles.fontWeightNormal, theme.icon, translate]); + return options; + }, [clearSelectedItems, hash, selectedItems, selectedItemsKeys, styles, theme, translate, isMobileSelectionModeActive, setIsMobileSelectionModeActive]); if (isSmallScreenWidth) { + if (isMobileSelectionModeActive) { + return ( + + ); + } return null; } @@ -131,11 +141,23 @@ function SearchPageHeader({query, selectedItems = {}, hash, clearSelectedItems}: icon={headerContent[query]?.icon} shouldShowBackButton={false} > - {getHeaderButtons()} + {headerButtonsOptions.length > 0 && ( + null} + shouldAlwaysShowDropdownMenu + pressOnEnter + buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} + customText={translate('workspace.common.selected', {selectedNumber: selectedItemsKeys.length})} + options={headerButtonsOptions} + isSplitButton={false} + isDisabled={isOffline} + /> + )} ); } SearchPageHeader.displayName = 'SearchPageHeader'; +export type {SearchHeaderOptionValue}; export default SearchPageHeader; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index fc5c23d5c9ec..d65d9826c281 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,11 +1,12 @@ import {useNavigation} from '@react-navigation/native'; import type {StackNavigationProp} from '@react-navigation/stack'; +import lodashMemoize from 'lodash/memoize'; import React, {useCallback, useEffect, useRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import SearchTableHeader from '@components/SelectionList/SearchTableHeader'; import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; -import TableListItemSkeleton from '@components/Skeletons/TableListItemSkeleton'; +import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -34,6 +35,8 @@ type SearchProps = { policyIDs?: string; sortBy?: SearchColumnType; sortOrder?: SortOrder; + isMobileSelectionModeActive?: boolean; + setIsMobileSelectionModeActive?: (isMobileSelectionModeActive: boolean) => void; }; const sortableSearchTabs: SearchQuery[] = [CONST.SEARCH.TAB.ALL]; @@ -41,15 +44,17 @@ const transactionItemMobileHeight = 100; const reportItemTransactionHeight = 52; const listItemPadding = 12; // this is equivalent to 'mb3' on every transaction/report list item const searchHeaderHeight = 54; - -function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { +function Search({query, policyIDs, sortBy, sortOrder, isMobileSelectionModeActive, setIsMobileSelectionModeActive}: SearchProps) { const {isOffline} = useNetwork(); const styles = useThemeStyles(); - const {isLargeScreenWidth} = useWindowDimensions(); + const {isLargeScreenWidth, isSmallScreenWidth} = useWindowDimensions(); const navigation = useNavigation>(); const lastSearchResultsRef = useRef>(); const {setCurrentSearchHash} = useSearchContext(); + const hash = SearchUtils.getQueryHash(query, policyIDs, sortBy, sortOrder); + const [currentSearchResults, searchResultsMeta] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`); + const getItemHeight = useCallback( (item: TransactionListItemType | ReportListItemType) => { if (SearchUtils.isTransactionListItemType(item)) { @@ -70,8 +75,15 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { [isLargeScreenWidth], ); - const hash = SearchUtils.getQueryHash(query, policyIDs, sortBy, sortOrder); - const [currentSearchResults, searchResultsMeta] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`); + const getItemHeightMemoized = lodashMemoize( + (item: TransactionListItemType | ReportListItemType) => getItemHeight(item), + (item) => { + // List items are displayed differently on "L"arge and "N"arrow screens so the height will differ + // in addition the same items might be displayed as part of different Search screens ("Expenses", "All", "Finished") + const screenSizeHash = isLargeScreenWidth ? 'L' : 'N'; + return `${hash}-${item.keyForList}-${screenSizeHash}`; + }, + ); // save last non-empty search results to avoid ugly flash of loading screen when hash changes and onyx returns empty data if (currentSearchResults?.data && currentSearchResults !== lastSearchResultsRef.current) { @@ -101,7 +113,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { query={query} hash={hash} /> - + ); } @@ -165,6 +177,8 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { const shouldShowYear = SearchUtils.shouldShowYear(searchResults?.data); + const canSelectMultiple = isSmallScreenWidth ? isMobileSelectionModeActive : true; + return ( + !isLargeScreenWidth ? null : ( + + ) } - canSelectMultiple={isLargeScreenWidth} + canSelectMultiple={canSelectMultiple} customListHeaderHeight={searchHeaderHeight} // To enhance the smoothness of scrolling and minimize the risk of encountering blank spaces during scrolling, // we have configured a larger windowSize and a longer delay between batch renders. @@ -197,7 +213,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { updateCellsBatchingPeriod={200} ListItem={ListItem} onSelectRow={openReport} - getItemHeight={getItemHeight} + getItemHeight={getItemHeightMemoized} shouldDebounceRowSelect shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} listHeaderWrapperStyle={[styles.ph8, styles.pv3, styles.pb5]} @@ -205,9 +221,11 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { showScrollIndicator={false} onEndReachedThreshold={0.75} onEndReached={fetchMoreResults} + setIsMobileSelectionModeActive={setIsMobileSelectionModeActive} + isMobileSelectionModeActive={isMobileSelectionModeActive} listFooterContent={ isLoadingMoreItems ? ( - diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx index 99330478c75f..5be228f0156e 100644 --- a/src/components/SelectionList/BaseListItem.tsx +++ b/src/components/SelectionList/BaseListItem.tsx @@ -32,6 +32,7 @@ function BaseListItem({ shouldSyncFocus = true, onFocus = () => {}, hoverStyle, + onLongPressRow, }: BaseListItemProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -42,7 +43,7 @@ function BaseListItem({ // Sync focus on an item useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus); - const handleMouseUp = (e: React.MouseEvent) => { + const handleMouseLeave = (e: React.MouseEvent) => { e.stopPropagation(); setMouseUp(); }; @@ -71,6 +72,9 @@ function BaseListItem({ // eslint-disable-next-line react/jsx-props-no-spreading {...bind} ref={pressableRef} + onLongPress={() => { + onLongPressRow?.(item); + }} onPress={(e) => { if (isMouseDownOnInput) { e?.stopPropagation(); // Preventing the click action @@ -92,8 +96,7 @@ function BaseListItem({ id={keyForList ?? ''} style={pressableStyle} onFocus={onFocus} - onMouseUp={handleMouseUp} - onMouseLeave={handleMouseUp} + onMouseLeave={handleMouseLeave} tabIndex={item.tabIndex} > diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 8b6ba790e6b0..eb2e66ad9a78 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -93,6 +93,8 @@ function BaseSelectionList( updateCellsBatchingPeriod = 50, removeClippedSubviews = true, shouldDelayFocus = true, + onLongPressRow, + isMobileSelectionModeActive, }: BaseSelectionListProps, ref: ForwardedRef, ) { @@ -447,6 +449,8 @@ function BaseSelectionList( isDisabled={isDisabled} showTooltip={showTooltip} canSelectMultiple={canSelectMultiple} + onLongPressRow={onLongPressRow} + isMobileSelectionModeActive={isMobileSelectionModeActive} onSelectRow={() => selectRow(item)} onCheckboxPress={handleOnCheckboxPress()} onDismissError={() => onDismissError?.(item)} diff --git a/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx b/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx index f634f84509b1..13e181264b25 100644 --- a/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx +++ b/src/components/SelectionList/Search/ExpenseItemHeaderNarrow.tsx @@ -2,15 +2,18 @@ import React, {memo} from 'react'; import {View} from 'react-native'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; +import {PressableWithFeedback} from '@components/Pressable'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import type {SearchAccountDetails, SearchTransactionAction} from '@src/types/onyx/SearchResults'; import ActionCell from './ActionCell'; import UserInfoCell from './UserInfoCell'; type ExpenseItemHeaderNarrowProps = { + text?: string; participantFrom: SearchAccountDetails; participantTo: SearchAccountDetails; participantFromDisplayName: string; @@ -18,9 +21,28 @@ type ExpenseItemHeaderNarrowProps = { action?: SearchTransactionAction; transactionID?: string; onButtonPress: () => void; + canSelectMultiple?: boolean; + isSelected?: boolean; + isDisabled?: boolean | null; + isDisabledCheckbox?: boolean; + handleCheckboxPress?: () => void; }; -function ExpenseItemHeaderNarrow({participantFrom, participantFromDisplayName, participantTo, participantToDisplayName, action, transactionID, onButtonPress}: ExpenseItemHeaderNarrowProps) { +function ExpenseItemHeaderNarrow({ + participantFrom, + participantFromDisplayName, + participantTo, + participantToDisplayName, + onButtonPress, + action, + canSelectMultiple, + isDisabledCheckbox, + isSelected, + isDisabled, + handleCheckboxPress, + text, + transactionID, +}: ExpenseItemHeaderNarrowProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const theme = useTheme(); @@ -28,6 +50,26 @@ function ExpenseItemHeaderNarrow({participantFrom, participantFromDisplayName, p return ( + {canSelectMultiple && ( + handleCheckboxPress?.()} + style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), isDisabledCheckbox && styles.cursorDisabled, styles.mr1]} + > + + {isSelected && ( + + )} + + + )} ({ onSelectRow, onDismissError, onFocus, + onLongPressRow, shouldSyncFocus, }: ReportListItemProps) { const reportItem = item as unknown as ReportListItemType; @@ -110,6 +111,7 @@ function ReportListItem({ onSelectRow={() => openReportInRHP(transactionItem)} onDismissError={onDismissError} onFocus={onFocus} + onLongPressRow={onLongPressRow} shouldSyncFocus={shouldSyncFocus} /> ); @@ -126,6 +128,7 @@ function ReportListItem({ showTooltip={showTooltip} canSelectMultiple={canSelectMultiple} onSelectRow={onSelectRow} + onLongPressRow={onLongPressRow} onDismissError={onDismissError} errors={item.errors} pendingAction={item.pendingAction} @@ -155,7 +158,7 @@ function ReportListItem({ containerStyle={[StyleUtils.getCheckboxContainerStyle(20), StyleUtils.getMultiselectListStyles(!!item.isSelected, !!item.isDisabled)]} disabled={!!isDisabled || item.isDisabledCheckbox} accessibilityLabel={item.text ?? ''} - style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled]} + style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled, !isLargeScreenWidth && styles.mr3]} /> )} @@ -200,6 +203,7 @@ function ReportListItem({ isDisabled={!!isDisabled} canSelectMultiple={!!canSelectMultiple} isButtonSelected={item.isSelected} + shouldShowTransactionCheckbox /> ))} diff --git a/src/components/SelectionList/Search/TransactionListItem.tsx b/src/components/SelectionList/Search/TransactionListItem.tsx index 6db308831baa..a10552ca9ad8 100644 --- a/src/components/SelectionList/Search/TransactionListItem.tsx +++ b/src/components/SelectionList/Search/TransactionListItem.tsx @@ -15,6 +15,7 @@ function TransactionListItem({ onCheckboxPress, onDismissError, onFocus, + onLongPressRow, shouldSyncFocus, }: TransactionListItemProps) { const transactionItem = item as unknown as TransactionListItemType; @@ -46,6 +47,7 @@ function TransactionListItem({ pendingAction={item.pendingAction} keyForList={item.keyForList} onFocus={onFocus} + onLongPressRow={onLongPressRow} shouldSyncFocus={shouldSyncFocus} hoverStyle={item.isSelected && styles.activeComponentBG} > @@ -59,6 +61,7 @@ function TransactionListItem({ isDisabled={!!isDisabled} canSelectMultiple={!!canSelectMultiple} isButtonSelected={item.isSelected} + shouldShowTransactionCheckbox={false} /> ); diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx index f9ca70536e4b..6a8e4dc52bb7 100644 --- a/src/components/SelectionList/Search/TransactionListItemRow.tsx +++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx @@ -4,6 +4,7 @@ import {View} from 'react-native'; import Checkbox from '@components/Checkbox'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; +import {PressableWithFeedback} from '@components/Pressable'; import ReceiptImage from '@components/ReceiptImage'; import type {TransactionListItemType} from '@components/SelectionList/types'; import TextWithTooltip from '@components/TextWithTooltip'; @@ -52,6 +53,7 @@ type TransactionListItemRowProps = { isDisabled: boolean; canSelectMultiple: boolean; isButtonSelected?: boolean; + shouldShowTransactionCheckbox?: boolean; }; const getTypeIcon = (type?: SearchTransactionType) => { @@ -240,27 +242,55 @@ function TransactionListItemRow({ containerStyle, isChildListItem = false, isButtonSelected = false, + shouldShowTransactionCheckbox, }: TransactionListItemRowProps) { const styles = useThemeStyles(); const {isLargeScreenWidth} = useWindowDimensions(); const StyleUtils = useStyleUtils(); + const theme = useTheme(); if (!isLargeScreenWidth) { return ( {showItemHeaderOnNarrowLayout && ( )} - + + {canSelectMultiple && shouldShowTransactionCheckbox && ( + + + {item.isSelected && ( + + )} + + + )} = { /** Handles what to do when the item is focused */ onFocus?: () => void; + + /** Callback to fire when the item is long pressed */ + onLongPressRow?: (item: TItem) => void; + + /** Whether Selection Mode is active - used only on small screens */ + isMobileSelectionModeActive?: boolean; } & TRightHandSideComponent; type ListItem = { @@ -465,6 +471,12 @@ type BaseSelectionListProps = Partial & { * https://reactnative.dev/docs/optimizing-flatlist-configuration#windowsize */ windowSize?: number; + + /** Callback to fire when the item is long pressed */ + onLongPressRow?: (item: TItem) => void; + + /** Whether Selection Mode is active - used only on small screens */ + isMobileSelectionModeActive?: boolean; } & TRightHandSideComponent; type SelectionListHandle = { diff --git a/src/components/Skeletons/ItemListSkeletonView.tsx b/src/components/Skeletons/ItemListSkeletonView.tsx index 1ee2da8a8019..046cdfffbee5 100644 --- a/src/components/Skeletons/ItemListSkeletonView.tsx +++ b/src/components/Skeletons/ItemListSkeletonView.tsx @@ -1,6 +1,6 @@ -import React, {useMemo, useState} from 'react'; -import {View} from 'react-native'; -import type {StyleProp, ViewStyle} from 'react-native'; +import React, {useCallback, useMemo, useState} from 'react'; +import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; +import {StyleSheet, View} from 'react-native'; import SkeletonViewContentLoader from '@components/SkeletonViewContentLoader'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -10,22 +10,62 @@ type ListItemSkeletonProps = { shouldAnimate?: boolean; renderSkeletonItem: (args: {itemIndex: number}) => React.ReactNode; fixedNumItems?: number; + gradientOpacityEnabled?: boolean; itemViewStyle?: StyleProp; itemViewHeight?: number; }; -function ItemListSkeletonView({shouldAnimate = true, renderSkeletonItem, fixedNumItems, itemViewStyle = {}, itemViewHeight = CONST.LHN_SKELETON_VIEW_ITEM_HEIGHT}: ListItemSkeletonProps) { +const getVerticalMargin = (style: StyleProp): number => { + if (!style) { + return 0; + } + + const flattenStyle = StyleSheet.flatten(style); + const marginVertical = Number(flattenStyle?.marginVertical ?? 0); + const marginTop = Number(flattenStyle?.marginTop ?? 0); + const marginBottom = Number(flattenStyle?.marginBottom ?? 0); + + return marginVertical + marginTop + marginBottom; +}; + +function ItemListSkeletonView({ + shouldAnimate = true, + renderSkeletonItem, + fixedNumItems, + gradientOpacityEnabled = false, + itemViewStyle = {}, + itemViewHeight = CONST.LHN_SKELETON_VIEW_ITEM_HEIGHT, +}: ListItemSkeletonProps) { const theme = useTheme(); const themeStyles = useThemeStyles(); const [numItems, setNumItems] = useState(fixedNumItems ?? 0); + + const totalItemHeight = itemViewHeight + getVerticalMargin(itemViewStyle); + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + if (fixedNumItems) { + return; + } + + const totalHeight = event.nativeEvent.layout.height; + const newNumItems = Math.ceil(totalHeight / totalItemHeight); + if (newNumItems !== numItems) { + setNumItems(newNumItems); + } + }, + [fixedNumItems, numItems, totalItemHeight], + ); + const skeletonViewItems = useMemo(() => { const items = []; for (let i = 0; i < numItems; i++) { + const opacity = gradientOpacityEnabled ? 1 - i / (numItems - 1) : 1; items.push( { - if (fixedNumItems) { - return; - } - - const newNumItems = Math.ceil(event.nativeEvent.layout.height / itemViewHeight); - if (newNumItems === numItems) { - return; - } - setNumItems(newNumItems); - }} + onLayout={handleLayout} > - {skeletonViewItems} + {skeletonViewItems} ); } diff --git a/src/components/Skeletons/TableListItemSkeleton.tsx b/src/components/Skeletons/SearchRowSkeleton.tsx similarity index 54% rename from src/components/Skeletons/TableListItemSkeleton.tsx rename to src/components/Skeletons/SearchRowSkeleton.tsx index 6ff3a3aedbb9..2359e47b7520 100644 --- a/src/components/Skeletons/TableListItemSkeleton.tsx +++ b/src/components/Skeletons/SearchRowSkeleton.tsx @@ -2,26 +2,41 @@ import React from 'react'; import {Circle, Rect} from 'react-native-svg'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import ItemListSkeletonView from './ItemListSkeletonView'; -type TableListItemSkeletonProps = { +type SearchRowSkeletonProps = { shouldAnimate?: boolean; fixedNumItems?: number; + gradientOpacityEnabled?: boolean; }; -const barHeight = '10'; -const shortBarWidth = '40'; -const longBarWidth = '120'; +const barHeight = 8; +const longBarWidth = 120; +const leftPaneWidth = variables.sideBarWidth; -function TableListItemSkeleton({shouldAnimate = true, fixedNumItems}: TableListItemSkeletonProps) { +// 12 is the gap between the element and the right button +const gapWidth = 12; + +// 80 is the width of the element itself +const rightSideElementWidth = 80; + +// 24 is the padding of the central pane summing two sides +const centralPanePadding = 40; + +// 80 is the width of the button on the right side +const rightButtonWidth = 80; + +function SearchRowSkeleton({shouldAnimate = true, fixedNumItems, gradientOpacityEnabled = false}: SearchRowSkeletonProps) { const styles = useThemeStyles(); - const {windowWidth, isSmallScreenWidth} = useWindowDimensions(); + const {windowWidth, isSmallScreenWidth, isLargeScreenWidth} = useWindowDimensions(); if (isSmallScreenWidth) { return ( ( @@ -51,7 +66,7 @@ function TableListItemSkeleton({shouldAnimate = true, fixedNumItems}: TableListI height={4} /> ); } + return ( ( <> - + {isLargeScreenWidth && ( + <> + + + + + )} + + )} @@ -146,6 +181,6 @@ function TableListItemSkeleton({shouldAnimate = true, fixedNumItems}: TableListI ); } -TableListItemSkeleton.displayName = 'TableListItemSkeleton'; +SearchRowSkeleton.displayName = 'SearchRowSkeleton'; -export default TableListItemSkeleton; +export default SearchRowSkeleton; diff --git a/src/components/Skeletons/TableRowSkeleton.tsx b/src/components/Skeletons/TableRowSkeleton.tsx new file mode 100644 index 000000000000..865bffc5842f --- /dev/null +++ b/src/components/Skeletons/TableRowSkeleton.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import {Circle, Rect} from 'react-native-svg'; +import useThemeStyles from '@hooks/useThemeStyles'; +import ItemListSkeletonView from './ItemListSkeletonView'; + +type TableListItemSkeletonProps = { + shouldAnimate?: boolean; + fixedNumItems?: number; + gradientOpacityEnabled?: boolean; +}; + +const barHeight = '8'; +const shortBarWidth = '60'; +const longBarWidth = '124'; + +function TableListItemSkeleton({shouldAnimate = true, fixedNumItems, gradientOpacityEnabled = false}: TableListItemSkeletonProps) { + const styles = useThemeStyles(); + + return ( + ( + <> + + + + + )} + /> + ); +} + +TableListItemSkeleton.displayName = 'TableListItemSkeleton'; + +export default TableListItemSkeleton; diff --git a/src/components/TaskHeaderActionButton.tsx b/src/components/TaskHeaderActionButton.tsx index 0c7e603a4aa2..5e563ea99763 100644 --- a/src/components/TaskHeaderActionButton.tsx +++ b/src/components/TaskHeaderActionButton.tsx @@ -5,6 +5,7 @@ import {withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ReportUtils from '@libs/ReportUtils'; +import * as TaskUtils from '@libs/TaskUtils'; import * as Session from '@userActions/Session'; import * as Task from '@userActions/Task'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -36,7 +37,17 @@ function TaskHeaderActionButton({report, session}: TaskHeaderActionButtonProps) isDisabled={!Task.canModifyTask(report, session?.accountID ?? -1)} medium text={translate(ReportUtils.isCompletedTaskReport(report) ? 'task.markAsIncomplete' : 'task.markAsComplete')} - onPress={Session.checkIfActionIsAllowed(() => (ReportUtils.isCompletedTaskReport(report) ? Task.reopenTask(report) : Task.completeTask(report)))} + onPress={Session.checkIfActionIsAllowed(() => { + // If we're already navigating to these task editing pages, early return not to mark as completed, otherwise we would have not found page. + if (TaskUtils.isActiveTaskEditRoute(report.reportID)) { + return; + } + if (ReportUtils.isCompletedTaskReport(report)) { + Task.reopenTask(report); + } else { + Task.completeTask(report); + } + })} style={styles.flex1} /> diff --git a/src/hooks/useHtmlPaste/index.ts b/src/hooks/useHtmlPaste/index.ts index 5888f96d1c15..022d6178877d 100644 --- a/src/hooks/useHtmlPaste/index.ts +++ b/src/hooks/useHtmlPaste/index.ts @@ -1,5 +1,6 @@ import {useNavigation} from '@react-navigation/native'; import {useCallback, useEffect} from 'react'; +import type {ClipboardEvent as PasteEvent} from 'react'; import Parser from '@libs/Parser'; import type UseHtmlPaste from './types'; @@ -20,8 +21,10 @@ const insertAtCaret = (target: HTMLElement, text: string) => { range.setEnd(node, node.length); selection.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset); - // Dispatch paste event to simulate real browser behavior - target.dispatchEvent(new Event('paste', {bubbles: true})); + // Dispatch paste event to make Markdown Input properly set cursor position + const pasteEvent = new ClipboardEvent('paste', {bubbles: true, cancelable: true}); + (pasteEvent as unknown as PasteEvent).isDefaultPrevented = () => false; + target.dispatchEvent(pasteEvent); // Dispatch input event to trigger Markdown Input to parse the new text target.dispatchEvent(new Event('input', {bubbles: true})); } else { @@ -142,18 +145,18 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, removeLi let unsubscribeFocus: () => void; let unsubscribeBlur: () => void; if (removeListenerOnScreenBlur) { - unsubscribeFocus = navigation.addListener('focus', () => document.addEventListener('paste', handlePaste)); - unsubscribeBlur = navigation.addListener('blur', () => document.removeEventListener('paste', handlePaste)); + unsubscribeFocus = navigation.addListener('focus', () => document.addEventListener('paste', handlePaste, true)); + unsubscribeBlur = navigation.addListener('blur', () => document.removeEventListener('paste', handlePaste, true)); } - document.addEventListener('paste', handlePaste); + document.addEventListener('paste', handlePaste, true); return () => { if (removeListenerOnScreenBlur) { unsubscribeFocus(); unsubscribeBlur(); } - document.removeEventListener('paste', handlePaste); + document.removeEventListener('paste', handlePaste, true); }; // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts new file mode 100644 index 000000000000..b806c0dea95a --- /dev/null +++ b/src/hooks/usePaginatedReportActions.ts @@ -0,0 +1,39 @@ +import {useMemo} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import PaginationUtils from '@libs/PaginationUtils'; +import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; + +/** + * Get the longest continuous chunk of reportActions including the linked reportAction. If not linking to a specific action, returns the continuous chunk of newest reportActions. + */ +function usePaginatedReportActions(reportID?: string, reportActionID?: string) { + // Use `||` instead of `??` to handle empty string. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const reportIDWithDefault = reportID || '-1'; + + const [sortedAllReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportIDWithDefault}`, { + canEvict: false, + selector: (allReportActions) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, true), + }); + const [reportActionPages] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${reportIDWithDefault}`); + + const reportActions = useMemo(() => { + if (!sortedAllReportActions?.length) { + return []; + } + return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages ?? [], (reportAction) => reportAction.reportActionID, reportActionID); + }, [reportActionID, reportActionPages, sortedAllReportActions]); + + const linkedAction = useMemo( + () => sortedAllReportActions?.find((reportAction) => String(reportAction.reportActionID) === String(reportActionID)), + [reportActionID, sortedAllReportActions], + ); + + return { + reportActions, + linkedAction, + }; +} + +export default usePaginatedReportActions; diff --git a/src/hooks/useTackInputFocus/index.ts b/src/hooks/useTackInputFocus/index.ts index 124f8460127c..e6caa15f9dde 100644 --- a/src/hooks/useTackInputFocus/index.ts +++ b/src/hooks/useTackInputFocus/index.ts @@ -1,5 +1,6 @@ import {useCallback, useEffect} from 'react'; import useDebouncedState from '@hooks/useDebouncedState'; +import * as Browser from '@libs/Browser'; /** * Detects input or text area focus on browsers, to avoid scrolling on virtual viewports @@ -28,7 +29,13 @@ export default function useTackInputFocus(enable = false): boolean { ); const resetScrollPositionOnVisualViewport = useCallback(() => { - window.scrollTo({top: 0}); + if (Browser.isChromeIOS() && window.visualViewport?.offsetTop) { + // On Chrome iOS, the visual viewport triggers a scroll event when the keyboard is opened, but some time the scroll position is not correct. + // So this change is specific to Chrome iOS, helping to reset the viewport position correctly. + window.scrollTo({top: -window.visualViewport.offsetTop}); + } else { + window.scrollTo({top: 0}); + } }, []); useEffect(() => { diff --git a/src/hooks/useWindowDimensions/index.ts b/src/hooks/useWindowDimensions/index.ts index 25757fda17e5..b391e45a61aa 100644 --- a/src/hooks/useWindowDimensions/index.ts +++ b/src/hooks/useWindowDimensions/index.ts @@ -23,7 +23,7 @@ export default function (useCachedViewportHeight = false): WindowDimensions { unlockWindowDimensions: () => {}, }; - const isCachedViewportHeight = useCachedViewportHeight && Browser.isMobileSafari(); + const isCachedViewportHeight = useCachedViewportHeight && Browser.isMobileWebKit(); const cachedViewportHeightWithKeyboardRef = useRef(initalViewportHeight); const {width: windowWidth, height: windowHeight} = useWindowDimensions(); diff --git a/src/languages/en.ts b/src/languages/en.ts index 1ac9684ac22e..27940a53487e 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -135,6 +135,7 @@ export default { yes: 'Yes', no: 'No', ok: 'OK', + notNow: 'Not now', learnMore: 'Learn more', buttonConfirm: 'Got it', name: 'Name', @@ -521,6 +522,7 @@ export default { replyInThread: 'Reply in thread', joinThread: 'Join thread', leaveThread: 'Leave thread', + copyOnyxData: 'Copy Onyx data', flagAsOffensive: 'Flag as offensive', menu: 'Menu', }, @@ -625,6 +627,10 @@ export default { cameraAccess: 'Camera access is required to take pictures of receipts.', cameraErrorTitle: 'Camera error', cameraErrorMessage: 'An error occurred while taking a photo. Please try again.', + locationAccessTitle: 'Allow location access', + locationAccessMessage: 'We’ll use your location to accurately determine your default currency and timezone. You can edit access in your device’s settings anytime.', + locationErrorTitle: 'Enable location in settings', + locationErrorMessage: 'Allowing location access is required to help accurately determine your default currency and timezone. Tap Settings to update permissions.', dropTitle: 'Let it go', dropMessage: 'Drop your file here', flash: 'flash', @@ -1153,9 +1159,9 @@ export default { deleteAccount: 'Delete account', deleteConfirmation: 'Are you sure you want to delete this account?', error: { - notOwnerOfBankAccount: 'There was an error setting this bank account as your default payment method.', + notOwnerOfBankAccount: 'An error occurred while setting this bank account as your default payment method.', invalidBankAccount: 'This bank account is temporarily suspended.', - notOwnerOfFund: 'There was an error setting this card as your default payment method.', + notOwnerOfFund: 'An error occurred while setting this card as your default payment method.', setDefaultFailure: 'Something went wrong. Please chat with Concierge for further assistance.', }, addBankAccountFailure: 'An unexpected error occurred while trying to add your bank account. Please try again.', @@ -1619,7 +1625,7 @@ export default { phrase4: 'verify your account here', }, hasPhoneLoginError: 'To add a verified bank account please ensure your primary login is a valid email and try again. You can add your phone number as a secondary login.', - hasBeenThrottledError: 'There was an error adding your bank account. Please wait a few minutes and try again.', + hasBeenThrottledError: 'An error occurred while adding your bank account. Please wait a few minutes and try again.', hasCurrencyError: 'Oops! It appears that your workspace currency is set to a different currency than USD. To proceed, please set it to USD and try again.', error: { youNeedToSelectAnOption: 'You need to select an option to proceed.', @@ -1685,7 +1691,7 @@ export default { verifyIdentity: 'Verify identity', letsVerifyIdentity: "Let's verify your identity.", butFirst: `But first, the boring stuff. Read up on the legalese in the next step and click "Accept" when you're ready.`, - genericError: 'There was an error while processing this step. Please try again.', + genericError: 'An error occurred while processing this step. Please try again.', cameraPermissionsNotGranted: 'Enable camera access', cameraRequestMessage: 'We need access to your camera to complete bank account verification. Please enable via Settings > New Expensify.', microphonePermissionsNotGranted: 'Enable microphone access', @@ -3125,6 +3131,13 @@ export default { defaultVendor: 'Default vendor', autoSync: 'Auto-sync', reimbursedReports: 'Sync reimbursed reports', + reconciliationAccount: 'Reconciliation account', + chooseReconciliationAccount: { + chooseBankAccount: 'Choose the bank account that your Expensify Card payments will be reconciled against.', + accountMatches: 'Make sure this account matches your ', + settlementAccount: 'Expensify Card settlement account ', + reconciliationWorks: (lastFourPAN: string) => `(ending in ${lastFourPAN}) so Continuous Reconciliation works properly.`, + }, }, bills: { manageYourBills: 'Manage your bills', @@ -3167,7 +3180,7 @@ export default { member: 'Invite member', members: 'Invite members', invitePeople: 'Invite new members', - genericFailureMessage: 'An error occurred inviting the member to the workspace. Please try again.', + genericFailureMessage: 'An error occurred while inviting the member to the workspace. Please try again.', pleaseEnterValidLogin: `Please ensure the email or phone number is valid (e.g. ${CONST.EXAMPLE_PHONE_NUMBER}).`, user: 'user', users: 'users', @@ -3181,7 +3194,7 @@ export default { inviteMessageTitle: 'Add message', inviteMessagePrompt: 'Make your invitation extra special by adding a message below', personalMessagePrompt: 'Message', - genericFailureMessage: 'An error occurred inviting the member to the workspace. Please try again.', + genericFailureMessage: 'An error occurred while inviting the member to the workspace. Please try again.', inviteNoMembersError: 'Please select at least one member to invite.', }, distanceRates: { @@ -3215,7 +3228,7 @@ export default { currencyInputHelpText: 'All expenses on this workspace will be converted to this currency.', currencyInputDisabledText: "The default currency can't be changed because this workspace is linked to a USD bank account.", save: 'Save', - genericFailureMessage: 'An error occurred updating the workspace. Please try again.', + genericFailureMessage: 'An error occurred while updating the workspace. Please try again.', avatarUploadFailureMessage: 'An error occurred uploading the avatar. Please try again.', addressContext: 'A Workspace Address is required to enable Expensify Travel. Please enter an address associated with your business.', }, @@ -3410,7 +3423,7 @@ export default { }, markAsComplete: 'Mark as complete', markAsIncomplete: 'Mark as incomplete', - assigneeError: 'There was an error assigning this task. Please try another assignee.', + assigneeError: 'An error occurred while assigning this task. Please try another assignee.', genericCreateTaskFailureMessage: 'There was an error creating this task. Please try again later.', deleteTask: 'Delete task', deleteConfirmation: 'Are you sure you want to delete this task?', @@ -3436,6 +3449,7 @@ export default { screenShareRequest: 'Expensify is inviting you to a screen share', }, search: { + selectMultiple: 'Select multiple', resultsAreLimited: 'Search results are limited.', searchResults: { emptyResults: { @@ -3901,7 +3915,7 @@ export default { "You appear to be offline. Unfortunately, Expensify Classic doesn't work offline, but New Expensify does. If you prefer to use Expensify Classic, try again when you have an internet connection.", }, listBoundary: { - errorMessage: 'There was an error loading more messages.', + errorMessage: 'An error occurred while loading more messages.', tryAgain: 'Try again', }, systemMessage: { @@ -4050,7 +4064,7 @@ export default { }, paymentCard: { addPaymentCard: 'Add payment card', - enterPaymentCardDetails: 'Enter your payment card details.', + enterPaymentCardDetails: 'Enter your payment card details', security: 'Expensify is PCI-DSS compliant, uses bank-level encryption, and utilizes redundant infrastructure to protect your data.', learnMoreAboutSecurity: 'Learn more about our security.', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index ea9186daee78..ab5ebc719a27 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -125,6 +125,7 @@ export default { yes: 'Sí', no: 'No', ok: 'OK', + notNow: 'Ahora no', learnMore: 'Más información', buttonConfirm: 'Ok, entendido', name: 'Nombre', @@ -372,8 +373,8 @@ export default { cameraPermissionRequired: 'Permiso para acceder a la cámara', expensifyDoesntHaveAccessToCamera: 'Expensify no puede tomar fotos sin acceso a la cámara. Haz click en configuración para actualizar los permisos.', attachmentError: 'Error al adjuntar archivo', - errorWhileSelectingAttachment: 'Ha ocurrido un error al seleccionar un archivo adjunto. Por favor, inténtalo de nuevo.', - errorWhileSelectingCorruptedAttachment: 'Ha ocurrido un error al seleccionar un archivo adjunto corrupto. Por favor, inténtalo con otro archivo.', + errorWhileSelectingAttachment: 'Se ha producido un error al seleccionar un archivo adjunto. Por favor, inténtalo de nuevo.', + errorWhileSelectingCorruptedAttachment: 'Se ha producido un error al seleccionar un archivo adjunto corrupto. Por favor, inténtalo con otro archivo.', takePhoto: 'Hacer una foto', chooseFromGallery: 'Elegir de la galería', chooseDocument: 'Elegir un archivo', @@ -513,6 +514,7 @@ export default { replyInThread: 'Responder en el hilo', joinThread: 'Unirse al hilo', leaveThread: 'Dejar hilo', + copyOnyxData: 'Copiar datos de Onyx', flagAsOffensive: 'Marcar como ofensivo', menu: 'Menú', }, @@ -617,7 +619,13 @@ export default { takePhoto: 'Haz una foto', cameraAccess: 'Se requiere acceso a la cámara para hacer fotos de los recibos.', cameraErrorTitle: 'Error en la cámara', - cameraErrorMessage: 'Se produjo un error al hacer una foto. Por favor, inténtalo de nuevo.', + locationAccessTitle: 'Permitir acceso a la ubicación', + locationAccessMessage: + 'Usaremos tu ubicación para determinar con precisión la moneda y zona horaria predeterminadas. Puedes editar el acceso en la configuración de tu dispositivo en cualquier momento.', + locationErrorTitle: 'Habilitar ubicación en la configuración', + locationErrorMessage: + 'Es necesario permitir el acceso a la ubicación para ayudar a determinar con precisión su moneda y zona horaria predeterminadas. Haz click en Configuración para actualizar los permisos.', + cameraErrorMessage: 'Se ha producido un error al hacer una foto. Por favor, inténtalo de nuevo.', dropTitle: 'Suéltalo', dropMessage: 'Suelta tu archivo aquí', flash: 'flash', @@ -899,13 +907,13 @@ export default { 'Este es tu método de contacto predeterminado. Antes de poder eliminarlo, tendrás que elegir otro método de contacto y haz clic en "Establecer como predeterminado".', removeContactMethod: 'Eliminar método de contacto', removeAreYouSure: '¿Estás seguro de que quieres eliminar este método de contacto? Esta acción no se puede deshacer.', - failedNewContact: 'Hubo un error al añadir este método de contacto.', + failedNewContact: 'Se ha producido un error al añadir este método de contacto.', genericFailureMessages: { requestContactMethodValidateCode: 'No se ha podido enviar un nuevo código mágico. Espera un rato y vuelve a intentarlo.', validateSecondaryLogin: 'Código mágico incorrecto o no válido. Inténtalo de nuevo o solicita otro código.', deleteContactMethod: 'No se ha podido eliminar este método de contacto. Por favor, contacta con Concierge para obtener ayuda.', setDefaultContactMethod: 'No se pudo establecer un nuevo método de contacto predeterminado. Por favor contacta con Concierge para obtener ayuda.', - addContactMethod: 'Hubo un error al añadir este método de contacto. Por favor, contacta con Concierge para obtener ayuda.', + addContactMethod: 'Se ha producido un error al añadir este método de contacto. Por favor, contacta con Concierge para obtener ayuda.', enteredMethodIsAlreadySubmited: 'El método de contacto ingresado ya existe.', passwordRequired: 'Se requiere contraseña', contactMethodRequired: 'Se requiere método de contacto.', @@ -1124,7 +1132,7 @@ export default { addressStreet: 'Por favor, introduce una dirección de facturación válida que no sea un apartado postal.', addressState: 'Por favor, selecciona un estado.', addressCity: 'Por favor, introduce una ciudad.', - genericFailureMessage: 'Se produjo un error al añadir tu tarjeta. Por favor, vuelva a intentarlo.', + genericFailureMessage: 'Se ha producido un error al añadir tu tarjeta. Por favor, vuelva a intentarlo.', password: 'Por favor, introduce tu contraseña de Expensify.', }, }, @@ -1147,7 +1155,7 @@ export default { addressStreet: 'Por favor, introduce una dirección de facturación válida que no sea un apartado postal.', addressState: 'Por favor, selecciona un estado.', addressCity: 'Por favor, introduce una ciudad.', - genericFailureMessage: 'Se produjo un error al añadir tu tarjeta. Por favor, vuelva a intentarlo.', + genericFailureMessage: 'Se ha producido un error al añadir tu tarjeta. Por favor, vuelva a intentarlo.', password: 'Por favor, introduce tu contraseña de Expensify.', }, }, @@ -1158,9 +1166,9 @@ export default { deleteAccount: 'Eliminar cuenta', deleteConfirmation: '¿Estás seguro de que quieres eliminar esta cuenta?', error: { - notOwnerOfBankAccount: 'Ha ocurrido un error al establecer esta cuenta bancaria como método de pago predeterminado.', + notOwnerOfBankAccount: 'Se ha producido un error al establecer esta cuenta bancaria como método de pago predeterminado.', invalidBankAccount: 'Esta cuenta bancaria está temporalmente suspendida.', - notOwnerOfFund: 'Ha ocurrido un error al establecer esta tarjeta de crédito como método de pago predeterminado.', + notOwnerOfFund: 'Se ha producido un error al establecer esta tarjeta de crédito como método de pago predeterminado.', setDefaultFailure: 'No se ha podido configurar el método de pago.', }, addBankAccountFailure: 'Ocurrió un error inesperado al intentar añadir la cuenta bancaria. Inténtalo de nuevo.', @@ -1645,7 +1653,7 @@ export default { }, hasPhoneLoginError: 'Para añadir una cuenta bancaria verificada, asegúrate de que tu nombre de usuario principal sea un correo electrónico válido y vuelve a intentarlo. Puedes añadir tu número de teléfono como nombre de usuario secundario.', - hasBeenThrottledError: 'Se produjo un error al intentar añadir tu cuenta bancaria. Por favor, espera unos minutos e inténtalo de nuevo.', + hasBeenThrottledError: 'Se ha producido un error al intentar añadir tu cuenta bancaria. Por favor, espera unos minutos e inténtalo de nuevo.', hasCurrencyError: '¡Ups! Parece que la moneda de tu espacio de trabajo está configurada en una moneda diferente a USD. Para continuar, por favor configúrala en USD e inténtalo nuevamente.', error: { @@ -1691,7 +1699,7 @@ export default { unknownFilename: 'Archivo desconocido', passwordRequired: 'Por favor, introduce tu contraseña', passwordIncorrect: 'Contraseña incorrecta. Por favor, inténtalo de nuevo.', - failedToLoadPDF: 'Hubo un error al intentar cargar el PDF.', + failedToLoadPDF: 'Se ha producido un error al intentar cargar el PDF.', pdfPasswordForm: { title: 'PDF protegido con contraseña', infoText: 'Este PDF esta protegido con contraseña.', @@ -1713,7 +1721,7 @@ export default { verifyIdentity: 'Verificar identidad', letsVerifyIdentity: '¡Vamos a verificar tu identidad!', butFirst: 'Pero primero, lo aburrido. Lee la jerga legal en el siguiente paso y haz clic en "Aceptar" cuando estés listo.', - genericError: 'Hubo un error al procesar este paso. Inténtalo de nuevo.', + genericError: 'Se ha producido un error al procesar este paso. Inténtalo de nuevo.', cameraPermissionsNotGranted: 'Permiso para acceder a la cámara', cameraRequestMessage: 'Necesitamos acceso a tu cámara para completar la verificación de tu cuenta de banco. Por favor habilita los permisos en Configuración > Nuevo Expensify.', microphonePermissionsNotGranted: 'Permiso para acceder al micrófono', @@ -2846,7 +2854,7 @@ export default { deleteFailureMessage: 'Se ha producido un error al intentar eliminar la etiqueta. Por favor, inténtalo más tarde.', tagRequiredError: 'Lo nombre de la etiqueta es obligatorio.', existingTagError: 'Ya existe una etiqueta con este nombre.', - genericFailureMessage: 'Se produjo un error al actualizar la etiqueta. Por favor, inténtelo nuevamente.', + genericFailureMessage: 'Se ha producido un error al actualizar la etiqueta. Por favor, inténtelo nuevamente.', importedFromAccountingSoftware: 'Etiquetas importadas desde', }, taxes: { @@ -3111,6 +3119,13 @@ export default { defaultVendor: 'Proveedor predeterminado', autoSync: 'Autosincronización', reimbursedReports: 'Sincronizar informes reembolsados', + reconciliationAccount: 'Cuenta de conciliación', + chooseReconciliationAccount: { + chooseBankAccount: 'Elige la cuenta bancaria con la que se conciliarán los pagos de tu Tarjeta Expensify.', + accountMatches: 'Asegúrate de que esta cuenta coincide con ', + settlementAccount: 'la cuenta de liquidación de tu Tarjeta Expensify ', + reconciliationWorks: (lastFourPAN: string) => `(que termina en ${lastFourPAN}) para que la conciliación continua funcione correctamente.`, + }, }, card: { header: 'Desbloquea Tarjetas Expensify gratis', @@ -3214,7 +3229,7 @@ export default { member: 'Invitar miembros', members: 'Invitar miembros', invitePeople: 'Invitar nuevos miembros', - genericFailureMessage: 'Se produjo un error al invitar al miembro al espacio de trabajo. Vuelva a intentarlo..', + genericFailureMessage: 'Se ha producido un error al invitar al miembro al espacio de trabajo. Vuelva a intentarlo..', pleaseEnterValidLogin: `Asegúrese de que el correo electrónico o el número de teléfono sean válidos (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER}).`, user: 'miembro', users: 'miembros', @@ -3229,7 +3244,7 @@ export default { inviteMessagePrompt: 'Añadir un mensaje para hacer tu invitación destacar', personalMessagePrompt: 'Mensaje', inviteNoMembersError: 'Por favor, selecciona al menos un miembro a invitar.', - genericFailureMessage: 'Se produjo un error al invitar al miembro al espacio de trabajo. Por favor, vuelva a intentarlo..', + genericFailureMessage: 'Se ha producido un error al invitar al miembro al espacio de trabajo. Por favor, vuelva a intentarlo..', }, distanceRates: { oopsNotSoFast: 'Ups! No tan rápido...', @@ -3262,7 +3277,7 @@ export default { currencyInputHelpText: 'Todas los gastos en este espacio de trabajo serán convertidos a esta moneda.', currencyInputDisabledText: 'La moneda predeterminada no se puede cambiar porque este espacio de trabajo está vinculado a una cuenta bancaria en USD.', save: 'Guardar', - genericFailureMessage: 'Se produjo un error al guardar el espacio de trabajo. Por favor, inténtalo de nuevo.', + genericFailureMessage: 'Se ha producido un error al guardar el espacio de trabajo. Por favor, inténtalo de nuevo.', avatarUploadFailureMessage: 'No se pudo subir el avatar. Por favor, inténtalo de nuevo.', addressContext: 'Se requiere una dirección para habilitar Expensify Travel. Por favor, introduce una dirección asociada con tu negocio.', }, @@ -3459,7 +3474,7 @@ export default { }, markAsComplete: 'Marcar como completada', markAsIncomplete: 'Marcar como incompleta', - assigneeError: 'Hubo un error al asignar esta tarea. Por favor, inténtalo con otro miembro.', + assigneeError: 'Se ha producido un error al asignar esta tarea. Por favor, inténtalo con otro miembro.', genericCreateTaskFailureMessage: 'Error inesperado al crear el tarea. Por favor, inténtalo más tarde.', deleteTask: 'Eliminar tarea', deleteConfirmation: '¿Estás seguro de que quieres eliminar esta tarea?', @@ -3485,6 +3500,7 @@ export default { screenShareRequest: 'Expensify te está invitando a compartir la pantalla', }, search: { + selectMultiple: 'Seleccionar múltiples', resultsAreLimited: 'Los resultados de búsqueda están limitados.', searchResults: { emptyResults: { @@ -4414,7 +4430,7 @@ export default { 'Parece que estás desconectado. Desafortunadamente, Expensify Classic no funciona sin conexión, pero New Expensify sí. Si prefieres utilizar Expensify Classic, inténtalo de nuevo cuando tengas conexión a internet.', }, listBoundary: { - errorMessage: 'Se produjo un error al cargar más mensajes.', + errorMessage: 'Se ha producido un error al cargar más mensajes.', tryAgain: 'Inténtalo de nuevo', }, systemMessage: { @@ -4565,7 +4581,7 @@ export default { }, paymentCard: { addPaymentCard: 'Añade tarjeta de pago', - enterPaymentCardDetails: 'Introduce los datos de tu tarjeta de pago.', + enterPaymentCardDetails: 'Introduce los datos de tu tarjeta de pago', security: 'Expensify es PCI-DSS obediente, utiliza cifrado a nivel bancario, y emplea infraestructura redundante para proteger tus datos.', learnMoreAboutSecurity: 'Conozca más sobre nuestra seguridad.', }, diff --git a/src/libs/API/parameters/PolicyReportFieldsReplace.ts b/src/libs/API/parameters/DeletePolicyReportField.ts similarity index 66% rename from src/libs/API/parameters/PolicyReportFieldsReplace.ts rename to src/libs/API/parameters/DeletePolicyReportField.ts index c6d1834f0789..d79e9b07249e 100644 --- a/src/libs/API/parameters/PolicyReportFieldsReplace.ts +++ b/src/libs/API/parameters/DeletePolicyReportField.ts @@ -1,4 +1,4 @@ -type PolicyReportFieldsReplace = { +type DeletePolicyReportField = { policyID: string; /** * Stringified JSON object with type of following structure: @@ -7,4 +7,4 @@ type PolicyReportFieldsReplace = { reportFields: string; }; -export default PolicyReportFieldsReplace; +export default DeletePolicyReportField; diff --git a/src/libs/API/parameters/UpdateFrequentlyUsedEmojisParams.ts b/src/libs/API/parameters/UpdateFrequentlyUsedEmojisParams.ts deleted file mode 100644 index f790ada3aad9..000000000000 --- a/src/libs/API/parameters/UpdateFrequentlyUsedEmojisParams.ts +++ /dev/null @@ -1,3 +0,0 @@ -type UpdateFrequentlyUsedEmojisParams = {value: string}; - -export default UpdateFrequentlyUsedEmojisParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index ff8465cfeec7..ff62d9b69ea6 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -64,7 +64,6 @@ export type {default as UpdateAutomaticTimezoneParams} from './UpdateAutomaticTi export type {default as UpdateChatPriorityModeParams} from './UpdateChatPriorityModeParams'; export type {default as UpdateDateOfBirthParams} from './UpdateDateOfBirthParams'; export type {default as UpdateDisplayNameParams} from './UpdateDisplayNameParams'; -export type {default as UpdateFrequentlyUsedEmojisParams} from './UpdateFrequentlyUsedEmojisParams'; export type {default as UpdateGroupChatNameParams} from './UpdateGroupChatNameParams'; export type {default as UpdateGroupChatMemberRolesParams} from './UpdateGroupChatMemberRolesParams'; export type {default as UpdateHomeAddressParams} from './UpdateHomeAddressParams'; @@ -242,7 +241,7 @@ export type {default as DeleteMoneyRequestOnSearchParams} from './DeleteMoneyReq export type {default as HoldMoneyRequestOnSearchParams} from './HoldMoneyRequestOnSearchParams'; export type {default as UnholdMoneyRequestOnSearchParams} from './UnholdMoneyRequestOnSearchParams'; export type {default as UpdateNetSuiteSubsidiaryParams} from './UpdateNetSuiteSubsidiaryParams'; -export type {default as PolicyReportFieldsReplace} from './PolicyReportFieldsReplace'; +export type {default as DeletePolicyReportField} from './DeletePolicyReportField'; export type {default as ConnectPolicyToNetSuiteParams} from './ConnectPolicyToNetSuiteParams'; export type {default as CreateWorkspaceReportFieldParams} from './CreateWorkspaceReportFieldParams'; export type {default as UpdateWorkspaceReportFieldInitialValueParams} from './UpdateWorkspaceReportFieldInitialValueParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index ca284321e3bb..948ed7f76373 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -57,7 +57,6 @@ const WRITE_COMMANDS = { VALIDATE_LOGIN: 'ValidateLogin', VALIDATE_SECONDARY_LOGIN: 'ValidateSecondaryLogin', UPDATE_PREFERRED_EMOJI_SKIN_TONE: 'UpdatePreferredEmojiSkinTone', - UPDATE_FREQUENTLY_USED_EMOJIS: 'UpdateFrequentlyUsedEmojis', UPDATE_CHAT_PRIORITY_MODE: 'UpdateChatPriorityMode', SET_CONTACT_METHOD_AS_DEFAULT: 'SetContactMethodAsDefault', UPDATE_THEME: 'UpdateTheme', @@ -351,7 +350,6 @@ type WriteCommandParameters = { [WRITE_COMMANDS.VALIDATE_LOGIN]: Parameters.ValidateLoginParams; [WRITE_COMMANDS.VALIDATE_SECONDARY_LOGIN]: Parameters.ValidateSecondaryLoginParams; [WRITE_COMMANDS.UPDATE_PREFERRED_EMOJI_SKIN_TONE]: Parameters.UpdatePreferredEmojiSkinToneParams; - [WRITE_COMMANDS.UPDATE_FREQUENTLY_USED_EMOJIS]: Parameters.UpdateFrequentlyUsedEmojisParams; [WRITE_COMMANDS.UPDATE_CHAT_PRIORITY_MODE]: Parameters.UpdateChatPriorityModeParams; [WRITE_COMMANDS.SET_CONTACT_METHOD_AS_DEFAULT]: Parameters.SetContactMethodAsDefaultParams; [WRITE_COMMANDS.UPDATE_THEME]: Parameters.UpdateThemeParams; @@ -426,7 +424,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.RENAME_WORKSPACE_CATEGORY]: Parameters.RenameWorkspaceCategoriesParams; [WRITE_COMMANDS.SET_WORKSPACE_REQUIRES_CATEGORY]: Parameters.SetWorkspaceRequiresCategoryParams; [WRITE_COMMANDS.DELETE_WORKSPACE_CATEGORIES]: Parameters.DeleteWorkspaceCategoriesParams; - [WRITE_COMMANDS.DELETE_POLICY_REPORT_FIELD]: Parameters.PolicyReportFieldsReplace; + [WRITE_COMMANDS.DELETE_POLICY_REPORT_FIELD]: Parameters.DeletePolicyReportField; [WRITE_COMMANDS.SET_POLICY_REQUIRES_TAG]: Parameters.SetPolicyRequiresTag; [WRITE_COMMANDS.SET_POLICY_TAGS_REQUIRED]: Parameters.SetPolicyTagsRequired; [WRITE_COMMANDS.RENAME_POLICY_TAG_LIST]: Parameters.RenamePolicyTaglistParams; diff --git a/src/libs/Browser/index.ts b/src/libs/Browser/index.ts index 98ad449c3dd0..aeec4f4def4a 100644 --- a/src/libs/Browser/index.ts +++ b/src/libs/Browser/index.ts @@ -1,4 +1,4 @@ -import type {GetBrowser, IsMobile, IsMobileChrome, IsMobileSafari, IsMobileWebKit, IsSafari, OpenRouteInDesktopApp} from './types'; +import type {GetBrowser, IsChromeIOS, IsMobile, IsMobileChrome, IsMobileSafari, IsMobileWebKit, IsSafari, OpenRouteInDesktopApp} from './types'; const getBrowser: GetBrowser = () => ''; @@ -10,8 +10,10 @@ const isMobileChrome: IsMobileChrome = () => false; const isMobileWebKit: IsMobileWebKit = () => false; +const isChromeIOS: IsChromeIOS = () => false; + const isSafari: IsSafari = () => false; const openRouteInDesktopApp: OpenRouteInDesktopApp = () => {}; -export {getBrowser, isMobile, isMobileSafari, isMobileWebKit, isSafari, isMobileChrome, openRouteInDesktopApp}; +export {getBrowser, isMobile, isMobileSafari, isMobileWebKit, isSafari, isMobileChrome, isChromeIOS, openRouteInDesktopApp}; diff --git a/src/libs/Browser/index.website.ts b/src/libs/Browser/index.website.ts index a83fa1cac70e..b89190dc7f78 100644 --- a/src/libs/Browser/index.website.ts +++ b/src/libs/Browser/index.website.ts @@ -1,7 +1,7 @@ import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import type {GetBrowser, IsMobile, IsMobileChrome, IsMobileSafari, IsMobileWebKit, IsSafari, OpenRouteInDesktopApp} from './types'; +import type {GetBrowser, IsChromeIOS, IsMobile, IsMobileChrome, IsMobileSafari, IsMobileWebKit, IsSafari, OpenRouteInDesktopApp} from './types'; /** * Fetch browser name from UA string @@ -66,6 +66,14 @@ const isMobileWebKit: IsMobileWebKit = () => { return /iP(ad|od|hone)/i.test(userAgent) && /WebKit/i.test(userAgent); }; +/** + * Checks if the requesting user agent is a Chrome browser on an iOS mobile device. + */ +const isChromeIOS: IsChromeIOS = () => { + const userAgent = navigator.userAgent; + return /iP(ad|od|hone)/i.test(userAgent) && /CriOS/i.test(userAgent); +}; + const isSafari: IsSafari = () => getBrowser() === 'safari' || isMobileSafari(); /** @@ -109,4 +117,4 @@ const openRouteInDesktopApp: OpenRouteInDesktopApp = (shortLivedAuthToken = '', } }; -export {getBrowser, isMobile, isMobileSafari, isMobileWebKit, isSafari, isMobileChrome, openRouteInDesktopApp}; +export {getBrowser, isMobile, isMobileSafari, isMobileWebKit, isSafari, isMobileChrome, isChromeIOS, openRouteInDesktopApp}; diff --git a/src/libs/Browser/types.ts b/src/libs/Browser/types.ts index 25f305953c87..cb242d3729aa 100644 --- a/src/libs/Browser/types.ts +++ b/src/libs/Browser/types.ts @@ -8,8 +8,10 @@ type IsMobileChrome = () => boolean; type IsMobileWebKit = () => boolean; +type IsChromeIOS = () => boolean; + type IsSafari = () => boolean; type OpenRouteInDesktopApp = (shortLivedAuthToken?: string, email?: string) => void; -export type {GetBrowser, IsMobile, IsMobileSafari, IsMobileChrome, IsMobileWebKit, IsSafari, OpenRouteInDesktopApp}; +export type {GetBrowser, IsMobile, IsMobileSafari, IsMobileChrome, IsMobileWebKit, IsSafari, IsChromeIOS, OpenRouteInDesktopApp}; diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index 862b0ae5e928..c3b80797d750 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -125,9 +125,11 @@ function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURR style: 'currency', currency: currencyWithFallback, - // We are forcing the number of decimals because we override the default number of decimals in the backend for RSD + // We are forcing the number of decimals because we override the default number of decimals in the backend for some currencies // See: https://github.com/Expensify/PHP-Libs/pull/834 - minimumFractionDigits: currency === 'RSD' ? getCurrencyDecimals(currency) : undefined, + minimumFractionDigits: getCurrencyDecimals(currency), + // For currencies that have decimal places > 2, floor to 2 instead as we don't support more than 2 decimal places. + maximumFractionDigits: 2, }); } @@ -175,9 +177,11 @@ function convertToDisplayStringWithoutCurrency(amountInCents: number, currency: style: 'currency', currency, - // We are forcing the number of decimals because we override the default number of decimals in the backend for RSD + // We are forcing the number of decimals because we override the default number of decimals in the backend for some currencies // See: https://github.com/Expensify/PHP-Libs/pull/834 - minimumFractionDigits: currency === 'RSD' ? getCurrencyDecimals(currency) : undefined, + minimumFractionDigits: getCurrencyDecimals(currency), + // For currencies that have decimal places > 2, floor to 2 instead as we don't support more than 2 decimal places. + maximumFractionDigits: 2, }) .filter((x) => x.type !== 'currency') .filter((x) => x.type !== 'literal' || x.value.trim().length !== 0) diff --git a/src/libs/E2E/reactNativeLaunchingTest.ts b/src/libs/E2E/reactNativeLaunchingTest.ts index f952998f0aad..f61bee8dae6a 100644 --- a/src/libs/E2E/reactNativeLaunchingTest.ts +++ b/src/libs/E2E/reactNativeLaunchingTest.ts @@ -36,6 +36,7 @@ const tests: Tests = { [E2EConfig.TEST_NAMES.ChatOpening]: require('./tests/chatOpeningTest.e2e').default, [E2EConfig.TEST_NAMES.ReportTyping]: require('./tests/reportTypingTest.e2e').default, [E2EConfig.TEST_NAMES.Linking]: require('./tests/linkingTest.e2e').default, + [E2EConfig.TEST_NAMES.PreloadedLinking]: require('./tests/preloadedLinkingTest.e2e').default, }; // Once we receive the TII measurement we know that the app is initialized and ready to be used: diff --git a/src/libs/E2E/tests/preloadedLinkingTest.e2e.ts b/src/libs/E2E/tests/preloadedLinkingTest.e2e.ts new file mode 100644 index 000000000000..a36200b1a702 --- /dev/null +++ b/src/libs/E2E/tests/preloadedLinkingTest.e2e.ts @@ -0,0 +1,82 @@ +import {DeviceEventEmitter} from 'react-native'; +import type {NativeConfig} from 'react-native-config'; +import Config from 'react-native-config'; +import Timing from '@libs/actions/Timing'; +import E2ELogin from '@libs/E2E/actions/e2eLogin'; +import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded'; +import E2EClient from '@libs/E2E/client'; +import getConfigValueOrThrow from '@libs/E2E/utils/getConfigValueOrThrow'; +import getPromiseWithResolve from '@libs/E2E/utils/getPromiseWithResolve'; +import Navigation from '@libs/Navigation/Navigation'; +import Performance from '@libs/Performance'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +type ViewableItem = { + reportActionID?: string; +}; +type ViewableItemResponse = Array<{item?: ViewableItem}>; + +const test = (config: NativeConfig) => { + console.debug('[E2E] Logging in for comment linking'); + + const reportID = getConfigValueOrThrow('reportID', config); + const linkedReportActionID = getConfigValueOrThrow('linkedReportActionID', config); + + E2ELogin().then((neededLogin) => { + if (neededLogin) { + return waitForAppLoaded().then(() => E2EClient.submitTestDone()); + } + + const [appearMessagePromise, appearMessageResolve] = getPromiseWithResolve(); + const [switchReportPromise, switchReportResolve] = getPromiseWithResolve(); + + Promise.all([appearMessagePromise, switchReportPromise]) + .then(() => { + console.debug('[E2E] Test completed successfully, exiting…'); + E2EClient.submitTestDone(); + }) + .catch((err) => { + console.debug('[E2E] Error while submitting test results:', err); + }); + + const subscription = DeviceEventEmitter.addListener('onViewableItemsChanged', (res: ViewableItemResponse) => { + console.debug('[E2E] Viewable items retrieved, verifying correct message…', res); + if (!!res && res?.[0]?.item?.reportActionID === linkedReportActionID) { + appearMessageResolve(); + subscription.remove(); + } else { + console.debug(`[E2E] Provided message id '${res?.[0]?.item?.reportActionID}' doesn't match to an expected '${linkedReportActionID}'. Waiting for a next one…`); + } + }); + + Performance.subscribeToMeasurements((entry) => { + if (entry.name === CONST.TIMING.SIDEBAR_LOADED) { + console.debug('[E2E] Sidebar loaded, navigating to a report…'); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); + return; + } + + if (entry.name === CONST.TIMING.REPORT_INITIAL_RENDER) { + console.debug('[E2E] Navigating to linked report action…'); + Timing.start(CONST.TIMING.SWITCH_REPORT); + Performance.markStart(CONST.TIMING.SWITCH_REPORT); + + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID, linkedReportActionID)); + return; + } + + if (entry.name === CONST.TIMING.CHAT_RENDER) { + E2EClient.submitTestResults({ + branch: Config.E2E_BRANCH, + name: 'Comment linking', + metric: entry.duration, + }); + + switchReportResolve(); + } + }); + }); +}; + +export default test; diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts index 6fb5725addfc..007a892c048e 100644 --- a/src/libs/EmojiUtils.ts +++ b/src/libs/EmojiUtils.ts @@ -1,4 +1,3 @@ -import {getUnixTime} from 'date-fns'; import {Str} from 'expensify-common'; import memoize from 'lodash/memoize'; import Onyx from 'react-native-onyx'; @@ -235,37 +234,6 @@ function mergeEmojisWithFrequentlyUsedEmojis(emojis: PickerEmojis): EmojiPickerL return addSpacesToEmojiCategories(mergedEmojis); } -/** - * Get the updated frequently used emojis list by usage - */ -function getFrequentlyUsedEmojis(newEmoji: Emoji | Emoji[]): FrequentlyUsedEmoji[] { - let frequentEmojiList = [...frequentlyUsedEmojis]; - - const maxFrequentEmojiCount = CONST.EMOJI_FREQUENT_ROW_COUNT * CONST.EMOJI_NUM_PER_ROW - 1; - - const currentTimestamp = getUnixTime(new Date()); - (Array.isArray(newEmoji) ? [...newEmoji] : [newEmoji]).forEach((emoji) => { - let currentEmojiCount = 1; - const emojiIndex = frequentEmojiList.findIndex((e) => e.code === emoji.code); - if (emojiIndex >= 0) { - currentEmojiCount = frequentEmojiList[emojiIndex].count + 1; - frequentEmojiList.splice(emojiIndex, 1); - } - - const updatedEmoji = {...Emojis.emojiCodeTableWithSkinTones[emoji.code], count: currentEmojiCount, lastUpdatedAt: currentTimestamp}; - - // We want to make sure the current emoji is added to the list - // Hence, we take one less than the current frequent used emojis - frequentEmojiList = frequentEmojiList.slice(0, maxFrequentEmojiCount); - frequentEmojiList.push(updatedEmoji); - - // Sort the list by count and lastUpdatedAt in descending order - frequentEmojiList.sort((a, b) => b.count - a.count || b.lastUpdatedAt - a.lastUpdatedAt); - }); - - return frequentEmojiList; -} - /** * Given an emoji item object, return an emoji code based on its type. */ @@ -601,7 +569,6 @@ export { getLocalizedEmojiName, getHeaderEmojis, mergeEmojisWithFrequentlyUsedEmojis, - getFrequentlyUsedEmojis, containsOnlyEmojis, replaceEmojis, suggestEmojis, diff --git a/src/libs/Environment/Environment.ts b/src/libs/Environment/Environment.ts index 1f5a391d3b13..c343788bed05 100644 --- a/src/libs/Environment/Environment.ts +++ b/src/libs/Environment/Environment.ts @@ -38,6 +38,13 @@ function isDevelopment(): boolean { return (Config?.ENVIRONMENT ?? CONST.ENVIRONMENT.DEV) === CONST.ENVIRONMENT.DEV; } +/** + * Are we running the app in staging? + */ +function isStaging(): boolean { + return (Config?.ENVIRONMENT ?? CONST.ENVIRONMENT.DEV) === CONST.ENVIRONMENT.STAGING; +} + /** * Are we running the app in production? */ @@ -76,4 +83,4 @@ function getSpotnanaEnvironmentTMCID(): Promise { return getEnvironment().then((environment) => SPOTNANA_ENVIRONMENT_TMC_ID[environment]); } -export {getEnvironment, isInternalTestBuild, isDevelopment, isProduction, getEnvironmentURL, getOldDotEnvironmentURL, getTravelDotEnvironmentURL, getSpotnanaEnvironmentTMCID}; +export {getEnvironment, isInternalTestBuild, isDevelopment, isStaging, isProduction, getEnvironmentURL, getOldDotEnvironmentURL, getTravelDotEnvironmentURL, getSpotnanaEnvironmentTMCID}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 0b94972b2aa9..ce43c78a6fee 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -396,6 +396,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage').default, [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PAYMENT_ACCOUNT]: () => require('../../../../pages/workspace/accounting/intacct/advanced/SageIntacctPaymentAccountPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.RECONCILIATION_ACCOUNT_SETTINGS]: () => require('../../../../pages/workspace/accounting/ReconciliationAccountSettingsPage').default, [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY]: () => require('../../../../pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage').default, [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET]: () => require('../../../../pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage').default, [SCREENS.WORKSPACE.TAX_EDIT]: () => require('../../../../pages/workspace/taxes/WorkspaceEditTaxPage').default, @@ -408,13 +409,13 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Subscription/PaymentCard/ChangeBillingCurrency').default, [SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD]: () => require('../../../../pages/settings/Subscription/PaymentCard').default, [SCREENS.SETTINGS.ADD_PAYMENT_CARD_CHANGE_CURRENCY]: () => require('../../../../pages/settings/PaymentCard/ChangeCurrency').default, - [SCREENS.WORKSPACE.REPORT_FIELDS_CREATE]: () => require('../../../../pages/workspace/reportFields/CreateReportFieldPage').default, - [SCREENS.WORKSPACE.REPORT_FIELD_SETTINGS]: () => require('../../../../pages/workspace/reportFields/ReportFieldSettingsPage').default, - [SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: () => require('../../../../pages/workspace/reportFields/ReportFieldListValuesPage').default, - [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldAddListValuePage').default, - [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: () => require('../../../../pages/workspace/reportFields/ReportFieldValueSettingsPage').default, - [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_INITIAL_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldInitialValuePage').default, - [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldEditValuePage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_CREATE]: () => require('../../../../pages/workspace/reportFields/CreateReportFieldsPage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_SETTINGS]: () => require('../../../../pages/workspace/reportFields/ReportFieldsSettingsPage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: () => require('../../../../pages/workspace/reportFields/ReportFieldsListValuesPage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldsAddListValuePage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: () => require('../../../../pages/workspace/reportFields/ReportFieldsValueSettingsPage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_INITIAL_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldsInitialValuePage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: () => require('../../../../pages/workspace/reportFields/ReportFieldsEditValuePage').default, [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_IMPORT]: () => require('../../../../pages/workspace/accounting/intacct/import/SageIntacctImportPage').default, [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_TOGGLE_MAPPING]: () => require('../../../../pages/workspace/accounting/intacct/import/SageIntacctToggleMappingsPage').default, diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts index 54804a495754..31b44a2681fd 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -112,6 +112,7 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT, SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_ADVANCED, SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PAYMENT_ACCOUNT, + SCREENS.WORKSPACE.ACCOUNTING.RECONCILIATION_ACCOUNT_SETTINGS, ], [SCREENS.WORKSPACE.TAXES]: [ SCREENS.WORKSPACE.TAXES_SETTINGS, @@ -143,7 +144,7 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { ], [SCREENS.WORKSPACE.REPORT_FIELDS]: [ SCREENS.WORKSPACE.REPORT_FIELDS_CREATE, - SCREENS.WORKSPACE.REPORT_FIELD_SETTINGS, + SCREENS.WORKSPACE.REPORT_FIELDS_SETTINGS, SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES, SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE, SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index c0d1a79f635f..5262850d4e81 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -447,6 +447,7 @@ const config: LinkingOptions['config'] = { }, [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_ADVANCED]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ADVANCED.route}, [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PAYMENT_ACCOUNT]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_PAYMENT_ACCOUNT.route}, + [SCREENS.WORKSPACE.ACCOUNTING.RECONCILIATION_ACCOUNT_SETTINGS]: {path: ROUTES.WORKSPACE_ACCOUNTING_RECONCILIATION_ACCOUNT_SETTINGS.route}, [SCREENS.WORKSPACE.DESCRIPTION]: { path: ROUTES.WORKSPACE_PROFILE_DESCRIPTION.route, }, @@ -585,34 +586,34 @@ const config: LinkingOptions['config'] = { path: ROUTES.WORKSPACE_CREATE_REPORT_FIELD.route, }, [SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: { - path: ROUTES.WORKSPACE_REPORT_FIELD_LIST_VALUES.route, + path: ROUTES.WORKSPACE_REPORT_FIELDS_LIST_VALUES.route, parse: { reportFieldID: (reportFieldID: string) => decodeURIComponent(reportFieldID), }, }, [SCREENS.WORKSPACE.REPORT_FIELDS_ADD_VALUE]: { - path: ROUTES.WORKSPACE_REPORT_FIELD_ADD_VALUE.route, + path: ROUTES.WORKSPACE_REPORT_FIELDS_ADD_VALUE.route, parse: { reportFieldID: (reportFieldID: string) => decodeURIComponent(reportFieldID), }, }, [SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS]: { - path: ROUTES.WORKSPACE_REPORT_FIELD_VALUE_SETTINGS.route, + path: ROUTES.WORKSPACE_REPORT_FIELDS_VALUE_SETTINGS.route, parse: { reportFieldID: (reportFieldID: string) => decodeURIComponent(reportFieldID), }, }, [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE]: { - path: ROUTES.WORKSPACE_REPORT_FIELD_EDIT_VALUE.route, + path: ROUTES.WORKSPACE_REPORT_FIELDS_EDIT_VALUE.route, }, - [SCREENS.WORKSPACE.REPORT_FIELD_SETTINGS]: { - path: ROUTES.WORKSPACE_REPORT_FIELD_SETTINGS.route, + [SCREENS.WORKSPACE.REPORT_FIELDS_SETTINGS]: { + path: ROUTES.WORKSPACE_REPORT_FIELDS_SETTINGS.route, parse: { reportFieldID: (reportFieldID: string) => decodeURIComponent(reportFieldID), }, }, [SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_INITIAL_VALUE]: { - path: ROUTES.WORKSPACE_EDIT_REPORT_FIELD_INITIAL_VALUE.route, + path: ROUTES.WORKSPACE_EDIT_REPORT_FIELDS_INITIAL_VALUE.route, parse: { reportFieldID: (reportFieldID: string) => decodeURIComponent(reportFieldID), }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index fc67fe6b8cc0..00fd98dc51aa 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -294,7 +294,7 @@ type SettingsNavigatorParamList = { policyID: string; valueIndex: number; }; - [SCREENS.WORKSPACE.REPORT_FIELD_SETTINGS]: { + [SCREENS.WORKSPACE.REPORT_FIELDS_SETTINGS]: { policyID: string; reportFieldID: string; }; @@ -588,6 +588,10 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PAYMENT_ACCOUNT]: { policyID: string; }; + [SCREENS.WORKSPACE.ACCOUNTING.RECONCILIATION_ACCOUNT_SETTINGS]: { + policyID: string; + connection: ValueOf; + }; [SCREENS.GET_ASSISTANCE]: { backTo: Routes; }; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 330d9d6ef61d..73b04742878a 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -657,10 +657,11 @@ function isSearchStringMatchUserDetails(personalDetail: PersonalDetails, searchV * Get the last message text from the report directly or from other sources for special cases. */ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails: Partial | null, policy?: OnyxEntry): string { - const lastReportAction = visibleReportActionItems[report?.reportID ?? '-1'] ?? null; + const reportID = report?.reportID ?? '-1'; + const lastReportAction = visibleReportActionItems[reportID] ?? null; // some types of actions are filtered out for lastReportAction, in some cases we need to check the actual last action - const lastOriginalReportAction = lastReportActions[report?.reportID ?? '-1'] ?? null; + const lastOriginalReportAction = lastReportActions[reportID] ?? null; let lastMessageTextFromReport = ''; if (ReportUtils.isArchivedRoom(report)) { @@ -720,8 +721,10 @@ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(TaskUtils.getTaskReportActionMessage(lastReportAction).text); } else if (ReportActionUtils.isCreatedTaskReportAction(lastReportAction)) { lastMessageTextFromReport = TaskUtils.getTaskCreatedMessage(lastReportAction); - } else if (ReportActionUtils.isApprovedOrSubmittedReportAction(lastReportAction)) { - lastMessageTextFromReport = ReportActionUtils.getReportActionMessageText(lastReportAction); + } else if (lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.SUBMITTED) { + lastMessageTextFromReport = ReportUtils.getIOUSubmittedMessage(reportID); + } else if (lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.APPROVED) { + lastMessageTextFromReport = ReportUtils.getIOUApprovedMessage(reportID); } return lastMessageTextFromReport || (report?.lastMessageText ?? ''); diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 3f8acd0e06fe..85850c15e534 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -260,6 +260,18 @@ function isRoomChangeLogAction(reportAction: OnyxEntry): reportAct return isActionOfType(reportAction, ...Object.values(CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG)); } +function isInviteOrRemovedAction( + reportAction: OnyxInputOrEntry, +): reportAction is ReportAction> { + return isActionOfType( + reportAction, + CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM, + CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.REMOVE_FROM_ROOM, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.INVITE_TO_ROOM, + CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_FROM_ROOM, + ); +} + /** * Returns whether the comment is a thread parent message/the first message in a thread */ @@ -1474,6 +1486,7 @@ export { isClosedAction, isRenamedAction, isRoomChangeLogAction, + isInviteOrRemovedAction, isChronosOOOListAction, isAddCommentAction, isPolicyChangeLogAction, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index edfb048f9da7..a452b0520dad 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -5,6 +5,7 @@ import lodashEscape from 'lodash/escape'; import lodashFindLastIndex from 'lodash/findLastIndex'; import lodashIntersection from 'lodash/intersection'; import lodashIsEqual from 'lodash/isEqual'; +import lodashMaxBy from 'lodash/maxBy'; import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {OriginalMessageModifiedExpense} from 'src/types/onyx/OriginalMessage'; @@ -51,6 +52,7 @@ import AccountUtils from './AccountUtils'; import * as IOU from './actions/IOU'; import * as PolicyActions from './actions/Policy/Policy'; import * as store from './actions/ReimbursementAccount/store'; +import * as SessionUtils from './actions/Session'; import * as CurrencyUtils from './CurrencyUtils'; import DateUtils from './DateUtils'; import {hasValidDraftComment} from './DraftCommentUtils'; @@ -1064,7 +1066,7 @@ function isSystemChat(report: OnyxEntry): boolean { * Only returns true if this is our main 1:1 DM report with Concierge. */ function isConciergeChatReport(report: OnyxInputOrEntry): boolean { - const participantAccountIDs = Object.keys(report?.participants ?? {}); + const participantAccountIDs = Object.keys(report?.participants ?? {}).filter((accountID) => Number(accountID) !== currentUserAccountID); return participantAccountIDs.length === 1 && Number(participantAccountIDs[0]) === CONST.ACCOUNT_ID.CONCIERGE && !isChatThread(report); } @@ -1096,20 +1098,6 @@ function filterReportsByPolicyIDAndMemberAccountIDs(reports: Array !!report && doesReportBelongToWorkspace(report, policyMemberAccountIDs, policyID)); } -/** - * Given an array of reports, return them sorted by the last read timestamp. - */ -function sortReportsByLastRead(reports: Array>, reportMetadata: OnyxCollection): Array> { - return reports - .filter((report) => !!report?.reportID && !!(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]?.lastVisitTime ?? report?.lastReadTime)) - .sort((a, b) => { - const aTime = new Date(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${a?.reportID}`]?.lastVisitTime ?? a?.lastReadTime ?? ''); - const bTime = new Date(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${b?.reportID}`]?.lastVisitTime ?? b?.lastReadTime ?? ''); - - return aTime.valueOf() - bTime.valueOf(); - }); -} - /** * Returns true if report is still being processed */ @@ -1170,6 +1158,13 @@ function hasExpensifyGuidesEmails(accountIDs: number[]): boolean { return accountIDs.some((accountID) => Str.extractEmailDomain(allPersonalDetails?.[accountID]?.login ?? '') === CONST.EMAIL.GUIDES_DOMAIN); } +function getMostRecentlyVisitedReport(reports: Array>, reportMetadata: OnyxCollection): OnyxEntry { + const filteredReports = reports.filter( + (report) => !!report?.reportID && !!(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]?.lastVisitTime ?? report?.lastReadTime), + ); + return lodashMaxBy(filteredReports, (a) => new Date(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${a?.reportID}`]?.lastVisitTime ?? a?.lastReadTime ?? '').valueOf()); +} + function findLastAccessedReport(ignoreDomainRooms: boolean, openOnAdminRoom = false, policyID?: string, excludeReportID?: string): OnyxEntry { // If it's the user's first time using New Expensify, then they could either have: // - just a Concierge report, if so we'll return that @@ -1186,11 +1181,9 @@ function findLastAccessedReport(ignoreDomainRooms: boolean, openOnAdminRoom = fa reportsValues = filterReportsByPolicyIDAndMemberAccountIDs(reportsValues, policyMemberAccountIDs, policyID); } - let sortedReports = sortReportsByLastRead(reportsValues, allReportMetadata); - let adminReport: OnyxEntry; if (openOnAdminRoom) { - adminReport = sortedReports.find((report) => { + adminReport = reportsValues.find((report) => { const chatType = getChatType(report); return chatType === CONST.REPORT.CHAT_TYPE.POLICY_ADMINS; }); @@ -1199,7 +1192,7 @@ function findLastAccessedReport(ignoreDomainRooms: boolean, openOnAdminRoom = fa // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const shouldFilter = excludeReportID || ignoreDomainRooms; if (shouldFilter) { - sortedReports = sortedReports.filter((report) => { + reportsValues = reportsValues.filter((report) => { if (excludeReportID && report?.reportID === excludeReportID) { return false; } @@ -1222,22 +1215,25 @@ function findLastAccessedReport(ignoreDomainRooms: boolean, openOnAdminRoom = fa if (isFirstTimeNewExpensifyUser) { // Filter out the systemChat report from the reports list, as we don't want to drop the user into that report over Concierge when they first log in - sortedReports = sortedReports.filter((report) => !isSystemChat(report)) ?? []; - if (sortedReports.length === 1) { - return sortedReports[0]; + reportsValues = reportsValues.filter((report) => !isSystemChat(report)) ?? []; + if (reportsValues.length === 1) { + return reportsValues[0]; } - return adminReport ?? sortedReports.find((report) => !isConciergeChatReport(report)); + return adminReport ?? reportsValues.find((report) => !isConciergeChatReport(report)); } // If we only have two reports and one of them is the system chat, filter it out so we don't // overwrite showing the concierge chat - const hasSystemChat = sortedReports.find((report) => isSystemChat(report)) ?? false; - if (sortedReports.length === 2 && hasSystemChat) { - sortedReports = sortedReports.filter((report) => !isSystemChat(report)) ?? []; + const hasSystemChat = reportsValues.find((report) => isSystemChat(report)) ?? false; + if (reportsValues.length === 2 && hasSystemChat) { + reportsValues = reportsValues.filter((report) => !isSystemChat(report)) ?? []; } - return adminReport ?? sortedReports.at(-1); + // We are getting the last read report from the metadata of the report. + const lastRead = getMostRecentlyVisitedReport(reportsValues, allReportMetadata); + + return adminReport ?? lastRead; } /** @@ -1476,10 +1472,10 @@ function isOneTransactionReport(reportID: string): boolean { /** * Checks if a report is a transaction thread associated with a report that has only one transaction */ -function isOneTransactionThread(reportID: string, parentReportID: string): boolean { +function isOneTransactionThread(reportID: string, parentReportID: string, threadParentReportAction: OnyxEntry): boolean { const parentReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`] ?? ([] as ReportAction[]); const transactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(parentReportID, parentReportActions); - return reportID === transactionThreadReportID; + return reportID === transactionThreadReportID && !ReportActionsUtils.isSentMoneyReportAction(threadParentReportAction); } /** @@ -4007,11 +4003,19 @@ function buildOptimisticExpenseReport(chatReportID: string, policyID: string, pa return expenseReport; } -function getIOUSubmittedMessage(reportID: string) { +function getFormattedAmount(reportID: string) { const report = getReportOrDraftReport(reportID); const linkedReport = isChatThread(report) ? getParentReport(report) : report; const formattedAmount = CurrencyUtils.convertToDisplayString(Math.abs(linkedReport?.total ?? 0), linkedReport?.currency); - return Localize.translateLocal('iou.submittedAmount', {formattedAmount}); + return formattedAmount; +} + +function getIOUSubmittedMessage(reportID: string) { + return Localize.translateLocal('iou.submittedAmount', {formattedAmount: getFormattedAmount(reportID)}); +} + +function getIOUApprovedMessage(reportID: string) { + return Localize.translateLocal('iou.approvedAmount', {amount: getFormattedAmount(reportID)}); } /** @@ -5422,6 +5426,8 @@ function shouldReportBeInOptionList({ // This can also happen for anyone accessing a public room or archived room for which they don't have access to the underlying policy. // Optionally exclude reports that do not belong to currently active workspace + const parentReportAction = ReportActionsUtils.getParentReportAction(report); + if ( !report?.reportID || !report?.type || @@ -5452,7 +5458,11 @@ function shouldReportBeInOptionList({ } // If this is a transaction thread associated with a report that only has one transaction, omit it - if (isOneTransactionThread(report.reportID, report.parentReportID ?? '-1')) { + if (isOneTransactionThread(report.reportID, report.parentReportID ?? '-1', parentReportAction)) { + return false; + } + + if (report?.type === CONST.REPORT.TYPE.PAYCHECK || report?.type === CONST.REPORT.TYPE.BILL) { return false; } @@ -5521,8 +5531,6 @@ function shouldReportBeInOptionList({ return false; } - const parentReportAction = ReportActionsUtils.getParentReportAction(report); - // Hide chat threads where the parent message is pending removal if ( !isEmptyObject(parentReportAction) && @@ -6927,6 +6935,10 @@ function canJoinChat(report: OnyxInputOrEntry, parentReportAction: OnyxI * Whether the user can leave a report */ function canLeaveChat(report: OnyxEntry, policy: OnyxEntry): boolean { + if (isPublicRoom(report) && SessionUtils.isAnonymousUser()) { + return false; + } + if (report?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { return false; } @@ -7172,6 +7184,7 @@ export { getGroupChatName, getIOUReportActionDisplayMessage, getIOUReportActionMessage, + getIOUApprovedMessage, getIOUSubmittedMessage, getIcons, getIconsForParticipants, @@ -7344,7 +7357,6 @@ export { shouldShowFlagComment, shouldShowRBRForMissingSmartscanFields, shouldUseFullTitleToDisplay, - sortReportsByLastRead, updateOptimisticParentReportAction, updateReportPreview, temporary_getMoneyRequestOptions, @@ -7362,6 +7374,7 @@ export { getChatUsedForOnboarding, findPolicyExpenseChatByPolicyID, hasOnlyNonReimbursableTransactions, + getMostRecentlyVisitedReport, }; export type { diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js new file mode 100644 index 000000000000..28143edb40f7 --- /dev/null +++ b/src/libs/SearchParser/searchParser.js @@ -0,0 +1,1161 @@ +// @generated by Peggy 4.0.3. +// +// https://peggyjs.org/ + + +function peg$subclass(child, parent) { + function C() { this.constructor = child; } + C.prototype = parent.prototype; + child.prototype = new C(); +} + +function peg$SyntaxError(message, expected, found, location) { + var self = Error.call(this, message); + // istanbul ignore next Check is a necessary evil to support older environments + if (Object.setPrototypeOf) { + Object.setPrototypeOf(self, peg$SyntaxError.prototype); + } + self.expected = expected; + self.found = found; + self.location = location; + self.name = "SyntaxError"; + return self; +} + +peg$subclass(peg$SyntaxError, Error); + +function peg$padEnd(str, targetLength, padString) { + padString = padString || " "; + if (str.length > targetLength) { return str; } + targetLength -= str.length; + padString += padString.repeat(targetLength); + return str + padString.slice(0, targetLength); +} + +peg$SyntaxError.prototype.format = function(sources) { + var str = "Error: " + this.message; + if (this.location) { + var src = null; + var k; + for (k = 0; k < sources.length; k++) { + if (sources[k].source === this.location.source) { + src = sources[k].text.split(/\r\n|\n|\r/g); + break; + } + } + var s = this.location.start; + var offset_s = (this.location.source && (typeof this.location.source.offset === "function")) + ? this.location.source.offset(s) + : s; + var loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; + if (src) { + var e = this.location.end; + var filler = peg$padEnd("", offset_s.line.toString().length, ' '); + var line = src[s.line - 1]; + var last = s.line === e.line ? e.column : line.length + 1; + var hatLen = (last - s.column) || 1; + str += "\n --> " + loc + "\n" + + filler + " |\n" + + offset_s.line + " | " + line + "\n" + + filler + " | " + peg$padEnd("", s.column - 1, ' ') + + peg$padEnd("", hatLen, "^"); + } else { + str += "\n at " + loc; + } + } + return str; +}; + +peg$SyntaxError.buildMessage = function(expected, found) { + var DESCRIBE_EXPECTATION_FNS = { + literal: function(expectation) { + return "\"" + literalEscape(expectation.text) + "\""; + }, + + class: function(expectation) { + var escapedParts = expectation.parts.map(function(part) { + return Array.isArray(part) + ? classEscape(part[0]) + "-" + classEscape(part[1]) + : classEscape(part); + }); + + return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]"; + }, + + any: function() { + return "any character"; + }, + + end: function() { + return "end of input"; + }, + + other: function(expectation) { + return expectation.description; + } + }; + + function hex(ch) { + return ch.charCodeAt(0).toString(16).toUpperCase(); + } + + function literalEscape(s) { + return s + .replace(/\\/g, "\\\\") + .replace(/"/g, "\\\"") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) + .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); + } + + function classEscape(s) { + return s + .replace(/\\/g, "\\\\") + .replace(/\]/g, "\\]") + .replace(/\^/g, "\\^") + .replace(/-/g, "\\-") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) + .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); + } + + function describeExpectation(expectation) { + return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); + } + + function describeExpected(expected) { + var descriptions = expected.map(describeExpectation); + var i, j; + + descriptions.sort(); + + if (descriptions.length > 0) { + for (i = 1, j = 1; i < descriptions.length; i++) { + if (descriptions[i - 1] !== descriptions[i]) { + descriptions[j] = descriptions[i]; + j++; + } + } + descriptions.length = j; + } + + switch (descriptions.length) { + case 1: + return descriptions[0]; + + case 2: + return descriptions[0] + " or " + descriptions[1]; + + default: + return descriptions.slice(0, -1).join(", ") + + ", or " + + descriptions[descriptions.length - 1]; + } + } + + function describeFound(found) { + return found ? "\"" + literalEscape(found) + "\"" : "end of input"; + } + + return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; +}; + +function peg$parse(input, options) { + options = options !== undefined ? options : {}; + + var peg$FAILED = {}; + var peg$source = options.grammarSource; + + var peg$startRuleFunctions = { query: peg$parsequery }; + var peg$startRuleFunction = peg$parsequery; + + var peg$c0 = "!="; + var peg$c1 = ">"; + var peg$c2 = ">="; + var peg$c3 = "<"; + var peg$c4 = "<="; + var peg$c5 = "type"; + var peg$c6 = "status"; + var peg$c7 = "date"; + var peg$c8 = "amount"; + var peg$c9 = "expenseType"; + var peg$c10 = "in"; + var peg$c11 = "currency"; + var peg$c12 = "merchant"; + var peg$c13 = "description"; + var peg$c14 = "from"; + var peg$c15 = "to"; + var peg$c16 = "category"; + var peg$c17 = "tag"; + var peg$c18 = "taxRate"; + var peg$c19 = "card"; + var peg$c20 = "reportID"; + var peg$c21 = "keyword"; + var peg$c22 = "sortBy"; + var peg$c23 = "sortOrder"; + var peg$c24 = "offset"; + var peg$c25 = "\""; + + var peg$r0 = /^[:=]/; + var peg$r1 = /^[^"\r\n]/; + var peg$r2 = /^[A-Za-z0-9_@.\/#&+\-\\',]/; + var peg$r3 = /^[ \t\r\n]/; + + var peg$e0 = peg$classExpectation([":", "="], false, false); + var peg$e1 = peg$literalExpectation("!=", false); + var peg$e2 = peg$literalExpectation(">", false); + var peg$e3 = peg$literalExpectation(">=", false); + var peg$e4 = peg$literalExpectation("<", false); + var peg$e5 = peg$literalExpectation("<=", false); + var peg$e6 = peg$literalExpectation("type", false); + var peg$e7 = peg$literalExpectation("status", false); + var peg$e8 = peg$literalExpectation("date", false); + var peg$e9 = peg$literalExpectation("amount", false); + var peg$e10 = peg$literalExpectation("expenseType", false); + var peg$e11 = peg$literalExpectation("in", false); + var peg$e12 = peg$literalExpectation("currency", false); + var peg$e13 = peg$literalExpectation("merchant", false); + var peg$e14 = peg$literalExpectation("description", false); + var peg$e15 = peg$literalExpectation("from", false); + var peg$e16 = peg$literalExpectation("to", false); + var peg$e17 = peg$literalExpectation("category", false); + var peg$e18 = peg$literalExpectation("tag", false); + var peg$e19 = peg$literalExpectation("taxRate", false); + var peg$e20 = peg$literalExpectation("card", false); + var peg$e21 = peg$literalExpectation("reportID", false); + var peg$e22 = peg$literalExpectation("keyword", false); + var peg$e23 = peg$literalExpectation("sortBy", false); + var peg$e24 = peg$literalExpectation("sortOrder", false); + var peg$e25 = peg$literalExpectation("offset", false); + var peg$e26 = peg$literalExpectation("\"", false); + var peg$e27 = peg$classExpectation(["\"", "\r", "\n"], true, false); + var peg$e28 = peg$classExpectation([["A", "Z"], ["a", "z"], ["0", "9"], "_", "@", ".", "/", "#", "&", "+", "-", "\\", "'", ","], false, false); + var peg$e29 = peg$otherExpectation("whitespace"); + var peg$e30 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false); + + var peg$f0 = function(filters) { return applyDefaults(filters); }; + var peg$f1 = function(head, tail) { + const allFilters = [head, ...tail.map(([_, filter]) => filter)].filter(filter => filter !== null); + if (!allFilters.length) { + return null; + } + return allFilters.reduce((result, filter) => buildFilter("and", result, filter)); + }; + var peg$f2 = function(field, op, value) { + if (isDefaultField(field)) { + updateDefaultValues(field, value.trim()); + return null; + } + + if (!field && !op) { + return buildFilter('eq', 'keyword', value.trim()); + } + + const values = value.split(','); + const operatorValue = op ?? 'eq'; + + return values.slice(1).reduce((acc, val) => buildFilter('or', acc, buildFilter(operatorValue, field, val.trim())), buildFilter(operatorValue, field, values[0])); + }; + var peg$f3 = function() { return "eq"; }; + var peg$f4 = function() { return "neq"; }; + var peg$f5 = function() { return "gt"; }; + var peg$f6 = function() { return "gte"; }; + var peg$f7 = function() { return "lt"; }; + var peg$f8 = function() { return "lte"; }; + var peg$f9 = function() { return "type"; }; + var peg$f10 = function() { return "status"; }; + var peg$f11 = function() { return "date"; }; + var peg$f12 = function() { return "amount"; }; + var peg$f13 = function() { return "expenseType"; }; + var peg$f14 = function() { return "in"; }; + var peg$f15 = function() { return "currency"; }; + var peg$f16 = function() { return "merchant"; }; + var peg$f17 = function() { return "description"; }; + var peg$f18 = function() { return "from"; }; + var peg$f19 = function() { return "to"; }; + var peg$f20 = function() { return "category"; }; + var peg$f21 = function() { return "tag"; }; + var peg$f22 = function() { return "taxRate"; }; + var peg$f23 = function() { return "card"; }; + var peg$f24 = function() { return "reportID"; }; + var peg$f25 = function() { return "keyword"; }; + var peg$f26 = function() { return "sortBy"; }; + var peg$f27 = function() { return "sortOrder"; }; + var peg$f28 = function() { return "offset"; }; + var peg$f29 = function(parts) { return parts.join(''); }; + var peg$f30 = function(chars) { return chars.join(''); }; + var peg$f31 = function(chars) { return chars.join(''); }; + var peg$f32 = function() { return "and"; }; + var peg$currPos = options.peg$currPos | 0; + var peg$savedPos = peg$currPos; + var peg$posDetailsCache = [{ line: 1, column: 1 }]; + var peg$maxFailPos = peg$currPos; + var peg$maxFailExpected = options.peg$maxFailExpected || []; + var peg$silentFails = options.peg$silentFails | 0; + + var peg$result; + + if (options.startRule) { + if (!(options.startRule in peg$startRuleFunctions)) { + throw new Error("Can't start parsing from rule \"" + options.startRule + "\"."); + } + + peg$startRuleFunction = peg$startRuleFunctions[options.startRule]; + } + + function text() { + return input.substring(peg$savedPos, peg$currPos); + } + + function offset() { + return peg$savedPos; + } + + function range() { + return { + source: peg$source, + start: peg$savedPos, + end: peg$currPos + }; + } + + function location() { + return peg$computeLocation(peg$savedPos, peg$currPos); + } + + function expected(description, location) { + location = location !== undefined + ? location + : peg$computeLocation(peg$savedPos, peg$currPos); + + throw peg$buildStructuredError( + [peg$otherExpectation(description)], + input.substring(peg$savedPos, peg$currPos), + location + ); + } + + function error(message, location) { + location = location !== undefined + ? location + : peg$computeLocation(peg$savedPos, peg$currPos); + + throw peg$buildSimpleError(message, location); + } + + function peg$literalExpectation(text, ignoreCase) { + return { type: "literal", text: text, ignoreCase: ignoreCase }; + } + + function peg$classExpectation(parts, inverted, ignoreCase) { + return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; + } + + function peg$anyExpectation() { + return { type: "any" }; + } + + function peg$endExpectation() { + return { type: "end" }; + } + + function peg$otherExpectation(description) { + return { type: "other", description: description }; + } + + function peg$computePosDetails(pos) { + var details = peg$posDetailsCache[pos]; + var p; + + if (details) { + return details; + } else { + if (pos >= peg$posDetailsCache.length) { + p = peg$posDetailsCache.length - 1; + } else { + p = pos; + while (!peg$posDetailsCache[--p]) {} + } + + details = peg$posDetailsCache[p]; + details = { + line: details.line, + column: details.column + }; + + while (p < pos) { + if (input.charCodeAt(p) === 10) { + details.line++; + details.column = 1; + } else { + details.column++; + } + + p++; + } + + peg$posDetailsCache[pos] = details; + + return details; + } + } + + function peg$computeLocation(startPos, endPos, offset) { + var startPosDetails = peg$computePosDetails(startPos); + var endPosDetails = peg$computePosDetails(endPos); + + var res = { + source: peg$source, + start: { + offset: startPos, + line: startPosDetails.line, + column: startPosDetails.column + }, + end: { + offset: endPos, + line: endPosDetails.line, + column: endPosDetails.column + } + }; + if (offset && peg$source && (typeof peg$source.offset === "function")) { + res.start = peg$source.offset(res.start); + res.end = peg$source.offset(res.end); + } + return res; + } + + function peg$fail(expected) { + if (peg$currPos < peg$maxFailPos) { return; } + + if (peg$currPos > peg$maxFailPos) { + peg$maxFailPos = peg$currPos; + peg$maxFailExpected = []; + } + + peg$maxFailExpected.push(expected); + } + + function peg$buildSimpleError(message, location) { + return new peg$SyntaxError(message, null, null, location); + } + + function peg$buildStructuredError(expected, found, location) { + return new peg$SyntaxError( + peg$SyntaxError.buildMessage(expected, found), + expected, + found, + location + ); + } + + function peg$parsequery() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = peg$parse_(); + s2 = peg$parsefilterList(); + if (s2 === peg$FAILED) { + s2 = null; + } + s3 = peg$parse_(); + peg$savedPos = s0; + s0 = peg$f0(s2); + + return s0; + } + + function peg$parsefilterList() { + var s0, s1, s2, s3, s4, s5; + + s0 = peg$currPos; + s1 = peg$parsefilter(); + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$currPos; + s4 = peg$parselogicalAnd(); + s5 = peg$parsefilter(); + if (s5 !== peg$FAILED) { + s4 = [s4, s5]; + s3 = s4; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$currPos; + s4 = peg$parselogicalAnd(); + s5 = peg$parsefilter(); + if (s5 !== peg$FAILED) { + s4 = [s4, s5]; + s3 = s4; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } + peg$savedPos = s0; + s0 = peg$f1(s1, s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsefilter() { + var s0, s1, s2, s3, s4, s5, s6; + + s0 = peg$currPos; + s1 = peg$parse_(); + s2 = peg$parsekey(); + if (s2 === peg$FAILED) { + s2 = null; + } + s3 = peg$parse_(); + s4 = peg$parseoperator(); + if (s4 === peg$FAILED) { + s4 = null; + } + s5 = peg$parse_(); + s6 = peg$parseidentifier(); + if (s6 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f2(s2, s4, s6); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseoperator() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.charAt(peg$currPos); + if (peg$r0.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f3(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c0) { + s1 = peg$c0; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f4(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 62) { + s1 = peg$c1; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e2); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f5(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c2) { + s1 = peg$c2; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e3); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f6(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 60) { + s1 = peg$c3; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e4); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f7(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c4) { + s1 = peg$c4; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e5); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f8(); + } + s0 = s1; + } + } + } + } + } + + return s0; + } + + function peg$parsekey() { + var s0, s1; + + s0 = peg$currPos; + if (input.substr(peg$currPos, 4) === peg$c5) { + s1 = peg$c5; + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e6); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f9(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 6) === peg$c6) { + s1 = peg$c6; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e7); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f10(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 4) === peg$c7) { + s1 = peg$c7; + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e8); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f11(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 6) === peg$c8) { + s1 = peg$c8; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e9); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f12(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 11) === peg$c9) { + s1 = peg$c9; + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e10); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f13(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c10) { + s1 = peg$c10; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e11); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f14(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 8) === peg$c11) { + s1 = peg$c11; + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e12); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f15(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 8) === peg$c12) { + s1 = peg$c12; + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e13); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f16(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 11) === peg$c13) { + s1 = peg$c13; + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e14); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f17(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 4) === peg$c14) { + s1 = peg$c14; + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e15); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f18(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c15) { + s1 = peg$c15; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e16); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f19(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 8) === peg$c16) { + s1 = peg$c16; + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e17); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f20(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 3) === peg$c17) { + s1 = peg$c17; + peg$currPos += 3; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e18); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f21(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 7) === peg$c18) { + s1 = peg$c18; + peg$currPos += 7; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e19); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f22(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 4) === peg$c19) { + s1 = peg$c19; + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e20); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f23(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 8) === peg$c20) { + s1 = peg$c20; + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e21); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f24(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 7) === peg$c21) { + s1 = peg$c21; + peg$currPos += 7; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e22); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f25(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 6) === peg$c22) { + s1 = peg$c22; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e23); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f26(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 9) === peg$c23) { + s1 = peg$c23; + peg$currPos += 9; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e24); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f27(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 6) === peg$c24) { + s1 = peg$c24; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e25); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f28(); + } + s0 = s1; + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + + return s0; + } + + function peg$parseidentifier() { + var s0, s1, s2; + + s0 = peg$currPos; + s1 = []; + s2 = peg$parsequotedString(); + if (s2 === peg$FAILED) { + s2 = peg$parsealphanumeric(); + } + if (s2 !== peg$FAILED) { + while (s2 !== peg$FAILED) { + s1.push(s2); + s2 = peg$parsequotedString(); + if (s2 === peg$FAILED) { + s2 = peg$parsealphanumeric(); + } + } + } else { + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f29(s1); + } + s0 = s1; + + return s0; + } + + function peg$parsequotedString() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 34) { + s1 = peg$c25; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e26); } + } + if (s1 !== peg$FAILED) { + s2 = []; + s3 = input.charAt(peg$currPos); + if (peg$r1.test(s3)) { + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e27); } + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = input.charAt(peg$currPos); + if (peg$r1.test(s3)) { + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e27); } + } + } + if (input.charCodeAt(peg$currPos) === 34) { + s3 = peg$c25; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e26); } + } + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f30(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsealphanumeric() { + var s0, s1, s2; + + s0 = peg$currPos; + s1 = []; + s2 = input.charAt(peg$currPos); + if (peg$r2.test(s2)) { + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e28); } + } + if (s2 !== peg$FAILED) { + while (s2 !== peg$FAILED) { + s1.push(s2); + s2 = input.charAt(peg$currPos); + if (peg$r2.test(s2)) { + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e28); } + } + } + } else { + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f31(s1); + } + s0 = s1; + + return s0; + } + + function peg$parselogicalAnd() { + var s0, s1; + + s0 = peg$currPos; + s1 = peg$parse_(); + peg$savedPos = s0; + s1 = peg$f32(); + s0 = s1; + + return s0; + } + + function peg$parse_() { + var s0, s1; + + peg$silentFails++; + s0 = []; + s1 = input.charAt(peg$currPos); + if (peg$r3.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e30); } + } + while (s1 !== peg$FAILED) { + s0.push(s1); + s1 = input.charAt(peg$currPos); + if (peg$r3.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e30); } + } + } + peg$silentFails--; + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e29); } + + return s0; + } + + + const defaultValues = { + "type": "expense", + "status": "all", + "sortBy": "date", + "sortOrder": "desc", + "offset": 0 + }; + + function buildFilter(operator, left, right) { + return { operator, left, right }; + } + + function applyDefaults(filters) { + return { + ...defaultValues, + filters + }; + } + + function updateDefaultValues(field, value) { + defaultValues[field] = value; + } + + function isDefaultField(field) { + return defaultValues.hasOwnProperty(field); + } + + peg$result = peg$startRuleFunction(); + + if (options.peg$library) { + return /** @type {any} */ ({ + peg$result, + peg$currPos, + peg$FAILED, + peg$maxFailExpected, + peg$maxFailPos + }); + } + if (peg$result !== peg$FAILED && peg$currPos === input.length) { + return peg$result; + } else { + if (peg$result !== peg$FAILED && peg$currPos < input.length) { + peg$fail(peg$endExpectation()); + } + + throw peg$buildStructuredError( + peg$maxFailExpected, + peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, + peg$maxFailPos < input.length + ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) + : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) + ); + } +} + +const peg$allowedStartRules = [ + "query" +]; + +export { + peg$allowedStartRules as StartRules, + peg$SyntaxError as SyntaxError, + peg$parse as parse +}; diff --git a/src/libs/SearchParser/searchParser.peggy b/src/libs/SearchParser/searchParser.peggy new file mode 100644 index 000000000000..0957239b6acb --- /dev/null +++ b/src/libs/SearchParser/searchParser.peggy @@ -0,0 +1,122 @@ +// This files defines the grammar that's used by [Peggy](https://peggyjs.org/) to generate the searchParser.js file. +// The searchParser is setup to parse our custom search syntax and output an AST with the filters. +// +// Here's a general grammar structure: +// +// start: entry point for the parser. It calls the query rule and return its value. +// query: rule to process the values returned by the filterList rule. Takes filters as an argument and returns the final AST output. +// filterList: rule to process the array of filters returned by the filter rule. It takes head and tail as arguments, filters it for null values and builds the AST. +// filter: rule to build the filter object. It takes field, operator and value as input and returns {operator, left: field, right: value} or null if the left value is a defaultValues +// operator: rule to match pre-defined search syntax operators, e.g. !=, >, etc +// key: rule to match pre-defined search syntax fields, e.g. amount, merchant, etc +// identifier: composite rule to match patterns defined by the quotedString and alphanumeric rules +// quotedString: rule to match a quoted string pattern, e.g. "this is a quoted string" +// alphanumeric: rule to match unquoted alphanumeric characters, e.g. a-z, 0-9, _, @, etc +// logicalAnd: rule to match whitespace and return it as a logical 'and' operator +// whitespace: rule to match whitespaces + +{ + const defaultValues = { + "type": "expense", + "status": "all", + "sortBy": "date", + "sortOrder": "desc", + "offset": 0 + }; + + function buildFilter(operator, left, right) { + return { operator, left, right }; + } + + function applyDefaults(filters) { + return { + ...defaultValues, + filters + }; + } + + function updateDefaultValues(field, value) { + defaultValues[field] = value; + } + + function isDefaultField(field) { + return defaultValues.hasOwnProperty(field); + } +} + +query + = _ filters:filterList? _ { return applyDefaults(filters); } + +filterList + = head:filter tail:(logicalAnd filter)* { + const allFilters = [head, ...tail.map(([_, filter]) => filter)].filter(filter => filter !== null); + if (!allFilters.length) { + return null; + } + return allFilters.reduce((result, filter) => buildFilter("and", result, filter)); + } + +filter + = _ field:key? _ op:operator? _ value:identifier { + if (isDefaultField(field)) { + updateDefaultValues(field, value.trim()); + return null; + } + + if (!field && !op) { + return buildFilter('eq', 'keyword', value.trim()); + } + + const values = value.split(','); + const operatorValue = op ?? 'eq'; + + return values.slice(1).reduce((acc, val) => buildFilter('or', acc, buildFilter(operatorValue, field, val.trim())), buildFilter(operatorValue, field, values[0])); + } + +operator + = (":" / "=") { return "eq"; } + / "!=" { return "neq"; } + / ">" { return "gt"; } + / ">=" { return "gte"; } + / "<" { return "lt"; } + / "<=" { return "lte"; } + +key + = "type" { return "type"; } + / "status" { return "status"; } + / "date" { return "date"; } + / "amount" { return "amount"; } + / "expenseType" { return "expenseType"; } + / "in" { return "in"; } + / "currency" { return "currency"; } + / "merchant" { return "merchant"; } + / "description" { return "description"; } + / "from" { return "from"; } + / "to" { return "to"; } + / "category" { return "category"; } + / "tag" { return "tag"; } + / "taxRate" { return "taxRate"; } + / "card" { return "card"; } + / "reportID" { return "reportID"; } + / "keyword" { return "keyword"; } + / "sortBy" { return "sortBy"; } + / "sortOrder" { return "sortOrder"; } + / "offset" { return "offset"; } + +identifier + = parts:(quotedString / alphanumeric)+ { return parts.join(''); } + +quotedString + = '"' chars:[^"\r\n]* '"' { return chars.join(''); } + +alphanumeric + = chars:[A-Za-z0-9_@./#&+\-\\',]+ { return chars.join(''); } + +logicalAnd + = _ { return "and"; } + +_ "whitespace" + = [ \t\r\n]* + +start + = query diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 91d742f44e62..3225a21c2661 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -11,6 +11,7 @@ import DateUtils from './DateUtils'; import getTopmostCentralPaneRoute from './Navigation/getTopmostCentralPaneRoute'; import navigationRef from './Navigation/navigationRef'; import type {AuthScreensParamList, RootStackParamList, State} from './Navigation/types'; +import * as searchParser from './SearchParser/searchParser'; import * as TransactionUtils from './TransactionUtils'; import * as UserUtils from './UserUtils'; @@ -301,7 +302,29 @@ function isSearchResultsEmpty(searchResults: SearchResults) { return !Object.keys(searchResults?.data).some((key) => key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)); } +function getQueryHashFromString(query: string): number { + return UserUtils.hashText(query, 2 ** 32); +} + +type JSONQuery = { + input: string; + hash: number; +}; + +function buildJSONQuery(query: string) { + try { + // Add the full input and hash to the results + const result = searchParser.parse(query) as JSONQuery; + result.input = query; + result.hash = getQueryHashFromString(query); + return result; + } catch (e) { + console.error(e); + } +} + export { + buildJSONQuery, getListItem, getQueryHash, getSections, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 80081c8f89c7..f2942a4c5876 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -6,6 +6,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, PersonalDetailsList, ReportActions, TransactionViolation} from '@src/types/onyx'; import type Beta from '@src/types/onyx/Beta'; +import type {OriginalMessageChangeLog} from '@src/types/onyx/OriginalMessage'; import type Policy from '@src/types/onyx/Policy'; import type PriorityMode from '@src/types/onyx/PriorityMode'; import type Report from '@src/types/onyx/Report'; @@ -88,13 +89,14 @@ function getOrderedReportIDs( return; } const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? {}; + const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '-1', report?.parentReportActionID ?? '-1'); const doesReportHaveViolations = OptionsListUtils.shouldShowViolations(report, betas ?? [], transactionViolations); const isHidden = report.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; const isFocused = report.reportID === currentReportId; const allReportErrors = OptionsListUtils.getAllReportErrors(report, reportActions) ?? {}; const hasErrorsOtherThanFailedReceipt = doesReportHaveViolations || Object.values(allReportErrors).some((error) => error?.[0] !== Localize.translateLocal('iou.error.genericSmartscanFailureMessage')); - if (ReportUtils.isOneTransactionThread(report.reportID, report.parentReportID ?? '0')) { + if (ReportUtils.isOneTransactionThread(report.reportID, report.parentReportID ?? '0', parentReportAction)) { return; } if (hasErrorsOtherThanFailedReceipt) { @@ -353,7 +355,7 @@ function getOptionData({ result.alternateText = Localize.translate(preferredLocale, 'newRoomPage.roomRenamedTo', {newName}); } else if (ReportActionsUtils.isTaskAction(lastAction)) { result.alternateText = ReportUtils.formatReportLastMessageText(TaskUtils.getTaskReportActionMessage(lastAction).text); - } else if (ReportActionsUtils.isRoomChangeLogAction(lastAction)) { + } else if (ReportActionsUtils.isInviteOrRemovedAction(lastAction)) { const lastActionOriginalMessage = lastAction?.actionName ? ReportActionsUtils.getOriginalMessage(lastAction) : null; const targetAccountIDs = lastActionOriginalMessage?.targetAccountIDs ?? []; const targetAccountIDsLength = targetAccountIDs.length !== 0 ? targetAccountIDs.length : report.lastMessageHtml?.match(/]*><\/mention-user>/g)?.length ?? 0; @@ -372,11 +374,9 @@ function getOptionData({ : ` ${Localize.translate(preferredLocale, 'workspace.invite.from')}`; result.alternateText += `${preposition} ${roomName}`; } - if (lastActionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_DESCRIPTION) { - result.alternateText = `${lastActorDisplayName} ${Localize.translate(preferredLocale, 'roomChangeLog.updateRoomDescription')} ${ - lastActionOriginalMessage?.description - }`.trim(); - } + } else if (lastActionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_DESCRIPTION) { + const lastActionOriginalMessage = lastAction?.actionName ? (ReportActionsUtils.getOriginalMessage(lastAction) as OriginalMessageChangeLog | undefined) : null; + result.alternateText = `${lastActorDisplayName} ${Localize.translate(preferredLocale, 'roomChangeLog.updateRoomDescription')} ${lastActionOriginalMessage?.description}`.trim(); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.LEAVE_POLICY) { result.alternateText = Localize.translateLocal('workspace.invite.leftWorkspace'); } else if (lastAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && lastActorDisplayName && lastMessageTextFromReport) { diff --git a/src/libs/TaskUtils.ts b/src/libs/TaskUtils.ts index bd0bd10cd83e..06745a49217b 100644 --- a/src/libs/TaskUtils.ts +++ b/src/libs/TaskUtils.ts @@ -1,12 +1,21 @@ import type {OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type {Message} from '@src/types/onyx/ReportAction'; import type ReportAction from '@src/types/onyx/ReportAction'; import * as Localize from './Localize'; +import Navigation from './Navigation/Navigation'; import {getReportActionHtml, getReportActionText} from './ReportActionsUtils'; import * as ReportConnection from './ReportConnection'; +/** + * Check if the active route belongs to task edit flow. + */ +function isActiveTaskEditRoute(reportID: string): boolean { + return [ROUTES.TASK_TITLE, ROUTES.TASK_ASSIGNEE, ROUTES.REPORT_DESCRIPTION].map((route) => route.getRoute(reportID)).some(Navigation.isActiveRoute); +} + /** * Given the Task reportAction name, return the appropriate message to be displayed and copied to clipboard. */ @@ -42,4 +51,4 @@ function getTaskCreatedMessage(reportAction: OnyxEntry) { return taskTitle ? Localize.translateLocal('task.messages.created', {title: taskTitle}) : ''; } -export {getTaskReportActionMessage, getTaskTitle, getTaskCreatedMessage}; +export {isActiveTaskEditRoute, getTaskReportActionMessage, getTaskTitle, getTaskCreatedMessage}; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index b9a8a05ba046..d72e7d5c83ba 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -322,14 +322,31 @@ function getReportPreviewAction(chatReportID: string, iouReportID: string): Onyx * @param policy * @param isFromGlobalCreate * @param iouRequestType one of manual/scan/distance - * @param skipConfirmation if true, skip confirmation step */ function initMoneyRequest(reportID: string, policy: OnyxEntry, isFromGlobalCreate: boolean, iouRequestType: IOURequestType = CONST.IOU.REQUEST_TYPE.MANUAL) { // Generate a brand new transactionID const newTransactionID = CONST.IOU.OPTIMISTIC_TRANSACTION_ID; + const currency = policy?.outputCurrency ?? currentUserPersonalDetails?.localCurrencyCode ?? CONST.CURRENCY.USD; // Disabling this line since currentDate can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const created = currentDate || format(new Date(), 'yyyy-MM-dd'); + + const currentTransaction = allTransactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${newTransactionID}`]; + + // in case we have to re-init money request, but the IOU request type is the same with the old draft transaction, + // we should keep most of the existing data by using the ONYX MERGE operation + if (currentTransaction?.iouRequestType === iouRequestType) { + // so, we just need to update the reportID, isFromGlobalCreate, created, currency + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${newTransactionID}`, { + reportID, + isFromGlobalCreate, + created, + currency, + transactionID: newTransactionID, + }); + return; + } + const comment: Comment = {}; // Add initial empty waypoints when starting a distance expense @@ -350,7 +367,7 @@ function initMoneyRequest(reportID: string, policy: OnyxEntry, amount: 0, comment, created, - currency: policy?.outputCurrency ?? currentUserPersonalDetails?.localCurrencyCode ?? CONST.CURRENCY.USD, + currency, iouRequestType, reportID, transactionID: newTransactionID, diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index 5870d642d8cd..8e2fff3868ae 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -25,7 +25,6 @@ import ROUTES from '@src/ROUTES'; import type {DateOfBirthForm} from '@src/types/form'; import type {PersonalDetails, PersonalDetailsList} from '@src/types/onyx'; import type {SelectedTimezone, Timezone} from '@src/types/onyx/PersonalDetails'; -import * as Session from './Session'; let currentUserEmail = ''; let currentUserAccountID = -1; @@ -191,10 +190,6 @@ function updateAddress(street: string, street2: string, city: string, state: str * selected timezone if set to automatically update. */ function updateAutomaticTimezone(timezone: Timezone) { - if (Session.isAnonymousUser()) { - return; - } - if (!currentUserAccountID) { return; } diff --git a/src/libs/actions/Policy/ReportField.ts b/src/libs/actions/Policy/ReportField.ts index bccb08c47c18..27b67c9fe686 100644 --- a/src/libs/actions/Policy/ReportField.ts +++ b/src/libs/actions/Policy/ReportField.ts @@ -5,8 +5,8 @@ import * as API from '@libs/API'; import type { CreateWorkspaceReportFieldListValueParams, CreateWorkspaceReportFieldParams, + DeletePolicyReportField, EnableWorkspaceReportFieldListValueParams, - PolicyReportFieldsReplace, RemoveWorkspaceReportFieldListValueParams, UpdateWorkspaceReportFieldInitialValueParams, } from '@libs/API/parameters'; @@ -260,7 +260,7 @@ function deleteReportFields(policyID: string, reportFieldsToUpdate: string[]) { ], }; - const parameters: PolicyReportFieldsReplace = { + const parameters: DeletePolicyReportField = { policyID, reportFields: JSON.stringify(Object.values(updatedReportFields)), }; diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index 2558969be2f3..9dc0f96c5886 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -1,4 +1,4 @@ -import type {NullishDeep, OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; import type {EnablePolicyTagsParams, OpenPolicyTagsPageParams, RenamePolicyTaglistParams, RenamePolicyTagsParams, SetPolicyTagsEnabled, SetPolicyTagsRequired} from '@libs/API/parameters'; @@ -624,6 +624,9 @@ function renamePolicyTaglist(policyID: string, policyTagListName: {oldName: stri } function setPolicyRequiresTag(policyID: string, requiresTag: boolean) { + const policyTags = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {}; + const isMultiLevelTags = PolicyUtils.isMultiLevelTags(policyTags); + const onyxData: OnyxData = { optimisticData: [ { @@ -667,6 +670,26 @@ function setPolicyRequiresTag(policyID: string, requiresTag: boolean) { ], }; + if (isMultiLevelTags) { + const getUpdatedTagsData = (required: boolean): OnyxUpdate => ({ + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + onyxMethod: Onyx.METHOD.MERGE, + value: { + ...Object.keys(policyTags).reduce((acc, key) => { + acc[key] = { + ...acc[key], + required, + }; + return acc; + }, {}), + }, + }); + + onyxData.optimisticData?.push(getUpdatedTagsData(requiresTag)); + onyxData.failureData?.push(getUpdatedTagsData(!requiresTag)); + onyxData.successData?.push(getUpdatedTagsData(requiresTag)); + } + const parameters = { policyID, requiresTag, diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 7acc79485f0c..7b3b1abd04ef 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -13,7 +13,6 @@ import type { SetContactMethodAsDefaultParams, SetNameValuePairParams, UpdateChatPriorityModeParams, - UpdateFrequentlyUsedEmojisParams, UpdateNewsletterSubscriptionParams, UpdatePreferredEmojiSkinToneParams, UpdateStatusParams, @@ -37,7 +36,7 @@ import Visibility from '@libs/Visibility'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {BlockedFromConcierge, CustomStatusDraft, FrequentlyUsedEmoji, Policy} from '@src/types/onyx'; +import type {BlockedFromConcierge, CustomStatusDraft, Policy} from '@src/types/onyx'; import type Login from '@src/types/onyx/Login'; import type {OnyxServerUpdate} from '@src/types/onyx/OnyxUpdatesFromServer'; import type OnyxPersonalDetails from '@src/types/onyx/PersonalDetails'; @@ -655,23 +654,6 @@ function updatePreferredSkinTone(skinTone: number) { API.write(WRITE_COMMANDS.UPDATE_PREFERRED_EMOJI_SKIN_TONE, parameters, {optimisticData}); } -/** - * Sync frequentlyUsedEmojis with Onyx and Server - */ -function updateFrequentlyUsedEmojis(frequentlyUsedEmojis: FrequentlyUsedEmoji[]) { - const optimisticData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.FREQUENTLY_USED_EMOJIS, - value: frequentlyUsedEmojis, - }, - ]; - - const parameters: UpdateFrequentlyUsedEmojisParams = {value: JSON.stringify(frequentlyUsedEmojis)}; - - API.write(WRITE_COMMANDS.UPDATE_FREQUENTLY_USED_EMOJIS, parameters, {optimisticData}); -} - /** * Sync user chat priority mode with Onyx and Server * @param mode @@ -1045,7 +1027,6 @@ export { setShouldUseStagingServer, setMuteAllSounds, clearUserErrorMessage, - updateFrequentlyUsedEmojis, joinScreenShare, clearScreenShareRequest, generateStatementPDF, diff --git a/src/libs/focusComposerWithDelay/types.ts b/src/libs/focusComposerWithDelay/types.ts index 4cd2f785f2bc..97a1298e8c7a 100644 --- a/src/libs/focusComposerWithDelay/types.ts +++ b/src/libs/focusComposerWithDelay/types.ts @@ -3,6 +3,8 @@ import type {TextInput} from 'react-native'; type Selection = { start: number; end: number; + positionX?: number; + positionY?: number; }; type FocusComposerWithDelay = (shouldDelay?: boolean, forcedSelectionRange?: Selection) => void; diff --git a/src/pages/EditReportFieldDate.tsx b/src/pages/EditReportFieldDate.tsx index 38209ba1083b..06ba24f780ec 100644 --- a/src/pages/EditReportFieldDate.tsx +++ b/src/pages/EditReportFieldDate.tsx @@ -24,7 +24,7 @@ type EditReportFieldDatePageProps = { isRequired: boolean; /** Callback to fire when the Save button is pressed */ - onSubmit: (form: FormOnyxValues) => void; + onSubmit: (form: FormOnyxValues) => void; }; function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, fieldKey}: EditReportFieldDatePageProps) { @@ -33,8 +33,8 @@ function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, f const inputRef = useRef(null); const validate = useCallback( - (value: FormOnyxValues) => { - const errors: FormInputErrors = {}; + (value: FormOnyxValues) => { + const errors: FormInputErrors = {}; if (isRequired && value[fieldKey].trim() === '') { errors[fieldKey] = translate('common.error.fieldRequired'); } @@ -46,7 +46,7 @@ function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, f return ( ) => { + const handleReportFieldChange = (form: FormOnyxValues) => { const value = form[fieldKey]; if (isReportFieldTitle) { ReportActions.updateReportName(report.reportID, value, report.reportName ?? ''); diff --git a/src/pages/EditReportFieldText.tsx b/src/pages/EditReportFieldText.tsx index d619eb52b695..b855acf3e1c0 100644 --- a/src/pages/EditReportFieldText.tsx +++ b/src/pages/EditReportFieldText.tsx @@ -24,7 +24,7 @@ type EditReportFieldTextPageProps = { isRequired: boolean; /** Callback to fire when the Save button is pressed */ - onSubmit: (form: FormOnyxValues) => void; + onSubmit: (form: FormOnyxValues) => void; }; function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, fieldKey}: EditReportFieldTextPageProps) { @@ -33,8 +33,8 @@ function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, f const {inputCallbackRef} = useAutoFocusInput(); const validate = useCallback( - (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; + (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; if (isRequired && values[fieldKey].trim() === '') { errors[fieldKey] = translate('common.error.fieldRequired'); } @@ -46,7 +46,7 @@ function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, f return ( ) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, true), - }); - const [reportActionPages = []] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${report.reportID || '-1'}`); - - const reportActions = useMemo(() => { - if (!sortedAllReportActions.length) { - return []; - } - return PaginationUtils.getContinuousChain(sortedAllReportActions, reportActionPages, (reportAction) => reportAction.reportActionID); - }, [sortedAllReportActions, reportActionPages]); + const {reportActions} = usePaginatedReportActions(report.reportID || '-1'); const transactionThreadReportID = useMemo( () => ReportActionsUtils.getOneTransactionThreadReportID(report.reportID, reportActions ?? [], isOffline), diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx index bfeb46f06298..474cbfc0aff8 100644 --- a/src/pages/Search/EmptySearchView.tsx +++ b/src/pages/Search/EmptySearchView.tsx @@ -1,14 +1,22 @@ import React from 'react'; +import EmptyStateComponent from '@components/EmptyStateComponent'; import * as Illustrations from '@components/Icon/Illustrations'; -import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection'; +import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; function EmptySearchView() { const {translate} = useLocalize(); + const styles = useThemeStyles(); return ( - diff --git a/src/pages/Search/SearchPageBottomTab.tsx b/src/pages/Search/SearchPageBottomTab.tsx index eb662fd49046..be38185926d7 100644 --- a/src/pages/Search/SearchPageBottomTab.tsx +++ b/src/pages/Search/SearchPageBottomTab.tsx @@ -1,6 +1,7 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import React, {useMemo} from 'react'; +import React, {useMemo, useState} from 'react'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import Search from '@components/Search'; import useActiveRoute from '@hooks/useActiveRoute'; @@ -29,6 +30,7 @@ function SearchPageBottomTab() { const {isSmallScreenWidth} = useWindowDimensions(); const activeRoute = useActiveRoute(); const styles = useThemeStyles(); + const [isMobileSelectionModeActive, setIsMobileSelectionModeActive] = useState(false); const { query: rawQuery, @@ -59,18 +61,29 @@ function SearchPageBottomTab() { onBackButtonPress={handleOnBackButtonPress} shouldShowLink={false} > - - + {!isMobileSelectionModeActive ? ( + <> + + + + ) : ( + setIsMobileSelectionModeActive(false)} + /> + )} {isSmallScreenWidth && ( )} diff --git a/src/pages/Search/SearchSelectedNarrow.tsx b/src/pages/Search/SearchSelectedNarrow.tsx new file mode 100644 index 000000000000..b90142ff5873 --- /dev/null +++ b/src/pages/Search/SearchSelectedNarrow.tsx @@ -0,0 +1,57 @@ +import React, {useRef, useState} from 'react'; +import {View} from 'react-native'; +import Button from '@components/Button'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import MenuItem from '@components/MenuItem'; +import Modal from '@components/Modal'; +import type {SearchHeaderOptionValue} from '@components/Search/SearchPageHeader'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as Expensicons from '@src/components/Icon/Expensicons'; +import CONST from '@src/CONST'; + +type SearchSelectedNarrowProps = {options: Array>; itemsLength: number}; + +function SearchSelectedNarrow({options, itemsLength}: SearchSelectedNarrowProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const [isModalVisible, setIsModalVisible] = useState(false); + const buttonRef = useRef(null); + + const openMenu = () => setIsModalVisible(true); + const closeMenu = () => setIsModalVisible(false); + + return ( + +