diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8f12229707ff..21a8fa73289a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -183,6 +183,11 @@ jobs: id: setup-node uses: ./.github/actions/composite/setupNode + - name: Run grunt build + run: | + cd Mobile-Expensify + npm run grunt:build:shared + - name: Setup Java uses: actions/setup-java@v4 with: @@ -508,12 +513,12 @@ jobs: uses: actions/cache@v4 id: pods-cache with: - path: Mobile-Expensify/ios/Pods - key: ${{ runner.os }}-pods-cache-${{ hashFiles('Mobile-Expensify/ios/Podfile.lock', 'firebase.json') }} + path: Mobile-Expensify/iOS/Pods + key: ${{ runner.os }}-pods-cache-${{ hashFiles('Mobile-Expensify/iOS/Podfile.lock', 'firebase.json') }} - name: Compare Podfile.lock and Manifest.lock id: compare-podfile-and-manifest - run: echo "IS_PODFILE_SAME_AS_MANIFEST=${{ hashFiles('Mobile-Expensify/ios/Podfile.lock') == hashFiles('Mobile-Expensify/ios/Pods/Manifest.lock') }}" >> "$GITHUB_OUTPUT" + run: echo "IS_PODFILE_SAME_AS_MANIFEST=${{ hashFiles('Mobile-Expensify/iOS/Podfile.lock') == hashFiles('Mobile-Expensify/iOS/Pods/Manifest.lock') }}" >> "$GITHUB_OUTPUT" - name: Install cocoapods uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847 diff --git a/.github/workflows/testBuildHybrid.yml b/.github/workflows/testBuildHybrid.yml index b670e9b9cdb3..42a5f15f8910 100644 --- a/.github/workflows/testBuildHybrid.yml +++ b/.github/workflows/testBuildHybrid.yml @@ -9,7 +9,6 @@ on: OLD_DOT_COMMIT: description: The branch, tag or SHA to checkout on Old Dot side required: false - default: 'main' pull_request_target: types: [opened, synchronize, labeled] branches: ['*ci-test/**'] @@ -88,37 +87,39 @@ jobs: needs: [validateActor, getBranchRef] if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} runs-on: ubuntu-latest-xl - defaults: - run: - working-directory: Mobile-Expensify/react-native outputs: S3_APK_PATH: ${{ steps.exportAndroidS3Path.outputs.S3_APK_PATH }} steps: - name: Checkout uses: actions/checkout@v4 with: - repository: 'Expensify/Mobile-Expensify' submodules: true - path: 'Mobile-Expensify' - ref: ${{ env.OLD_DOT_COMMIT }} + ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} token: ${{ secrets.OS_BOTIFY_TOKEN }} # fetch-depth: 0 is required in order to fetch the correct submodule branch fetch-depth: 0 - - name: Update submodule + - name: Update submodule to match main + env: + OLD_DOT_COMMIT: ${{ env.OLD_DOT_COMMIT }} run: | - git submodule update --init - git fetch - git checkout ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} + git submodule update --init --remote + if [[ -z "$OLD_DOT_COMMIT" ]]; then + git fetch + git checkout ${{ env.OLD_DOT_COMMIT }} + fi - name: Configure MapBox SDK run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - - uses: actions/setup-node@v4 - with: - node-version-file: 'Mobile-Expensify/react-native/.nvmrc' - cache: npm - cache-dependency-path: 'Mobile-Expensify/react-native' + - name: Setup Node + id: setup-node + uses: ./.github/actions/composite/setupNode + + - name: Run grunt build + run: | + cd Mobile-Expensify + npm run grunt:build:shared - name: Setup dotenv run: | @@ -126,14 +127,6 @@ jobs: sed -i 's/ENVIRONMENT=staging/ENVIRONMENT=adhoc/' .env.adhoc echo "PULL_REQUEST_NUMBER=${{ inputs.pull_request_number }}" >> .env.adhoc - - name: Install node modules - run: | - npm install - cd .. && npm install - - # Fixes https://github.com/Expensify/App/issues/51682 - npm run grunt:build:shared - - name: Setup Java uses: actions/setup-java@v4 with: @@ -144,7 +137,6 @@ jobs: uses: ruby/setup-ruby@v1.190.0 with: bundler-cache: true - working-directory: 'Mobile-Expensify/react-native' - name: Install 1Password CLI uses: 1password/install-cli-action@v1 @@ -156,7 +148,7 @@ jobs: op document get --output ./upload-key.keystore upload-key.keystore op document get --output ./android-fastlane-json-key.json android-fastlane-json-key.json # Copy the keystore to the Android directory for Fullstory - cp ./upload-key.keystore ../Android + cp ./upload-key.keystore Mobile-Expensify/Android - name: Load Android upload keystore credentials from 1Password id: load-credentials @@ -205,53 +197,43 @@ jobs: env: DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer runs-on: macos-13-xlarge - defaults: - run: - working-directory: Mobile-Expensify/react-native steps: - name: Checkout uses: actions/checkout@v4 with: - repository: 'Expensify/Mobile-Expensify' submodules: true - path: 'Mobile-Expensify' - ref: ${{ env.OLD_DOT_COMMIT }} + ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} token: ${{ secrets.OS_BOTIFY_TOKEN }} # fetch-depth: 0 is required in order to fetch the correct submodule branch fetch-depth: 0 - - name: Update submodule + - name: Update submodule to match main + env: + OLD_DOT_COMMIT: ${{ env.OLD_DOT_COMMIT }} run: | - git submodule update --init - git fetch - git checkout ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} + git submodule update --init --remote + if [[ -z "$OLD_DOT_COMMIT" ]]; then + git fetch + git checkout ${{ env.OLD_DOT_COMMIT }} + fi - name: Configure MapBox SDK run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - - uses: actions/setup-node@v4 + - name: Setup Node id: setup-node - with: - node-version-file: 'Mobile-Expensify/react-native/.nvmrc' - cache: npm - cache-dependency-path: 'Mobile-Expensify/react-native' - + uses: ./.github/actions/composite/setupNode + - name: Create .env.adhoc file based on staging and add PULL_REQUEST_NUMBER env to it run: | cp .env.staging .env.adhoc sed -i '' 's/ENVIRONMENT=staging/ENVIRONMENT=adhoc/' .env.adhoc echo "PULL_REQUEST_NUMBER=$PULL_REQUEST_NUMBER" >> .env.adhoc - - name: Install node modules - run: | - npm install - cd .. && npm install - - name: Setup Ruby uses: ruby/setup-ruby@v1.190.0 with: bundler-cache: true - working-directory: 'Mobile-Expensify/react-native' - name: Install New Expensify Gems run: bundle install @@ -260,12 +242,12 @@ jobs: uses: actions/cache@v4 id: pods-cache with: - path: ios/Pods - key: ${{ runner.os }}-pods-cache-${{ hashFiles('ios/Podfile.lock', 'firebase.json') }} + path: Mobile-Expensify/iOS/Pods + key: ${{ runner.os }}-pods-cache-${{ hashFiles('Mobile-Expensify/iOS/Podfile.lock', 'firebase.json') }} - name: Compare Podfile.lock and Manifest.lock id: compare-podfile-and-manifest - run: echo "IS_PODFILE_SAME_AS_MANIFEST=${{ hashFiles('ios/Podfile.lock') == hashFiles('ios/Pods/Manifest.lock') }}" >> "$GITHUB_OUTPUT" + run: echo "IS_PODFILE_SAME_AS_MANIFEST=${{ hashFiles('Mobile-Expensify/iOS/Podfile.lock') == hashFiles('Mobile-Expensify/iOS/Manifest.lock') }}" >> "$GITHUB_OUTPUT" - name: Install cocoapods uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847 @@ -273,7 +255,7 @@ jobs: with: timeout_minutes: 10 max_attempts: 5 - command: cd Mobile-Expensify/iOS && bundle exec pod install + command: npm run pod-install - name: Install 1Password CLI uses: 1password/install-cli-action@v1 diff --git a/Mobile-Expensify b/Mobile-Expensify index c0cf9cfe6fd7..af549932c171 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit c0cf9cfe6fd7c760a2f8621cf0473f0694bec10f +Subproject commit af549932c17151a57655466e4912e038b21f501a diff --git a/README.md b/README.md index 77b9d509a74d..8f7161b7fe96 100644 --- a/README.md +++ b/README.md @@ -456,7 +456,7 @@ You can only build HybridApp if you have been granted access to [`Mobile-Expensi ## Getting started with HybridApp 1. If you haven't, please follow [these instructions](https://github.com/Expensify/App?tab=readme-ov-file#getting-started) to setup the NewDot local environment. -2. Run `git submodule update --init` to download the `Mobile-Expensify` sourcecode. +2. Run `git submodule update --init --progress` to download the `Mobile-Expensify` sourcecode. - If you have access to `Mobile-Expensify` and the command fails with a https-related error add this to your `~/.gitconfig` file: ``` diff --git a/android/app/build.gradle b/android/app/build.gradle index 23f777896f61..1391594d72a8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009007500 - versionName "9.0.75-0" + versionCode 1009007506 + versionName "9.0.75-6" // 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/docs/articles/expensify-classic/connections/accelo/Accelo-Troubleshooting.md b/docs/articles/expensify-classic/connections/accelo/Accelo-Troubleshooting.md index fd0a6ca59069..f49ac1ead30e 100644 --- a/docs/articles/expensify-classic/connections/accelo/Accelo-Troubleshooting.md +++ b/docs/articles/expensify-classic/connections/accelo/Accelo-Troubleshooting.md @@ -1,7 +1,42 @@ --- title: Accelo Troubleshooting -description: Accelo Troubleshooting -order: 3 +description: Resources to help you solve issues with your Accelo integration. --- -# Coming soon +# Overview +Most of the Accelo integration with Expensify is managed on the Accelo side. You will find their [help site](https://help.accelo.com/guides/integrations-guide/expensify/) helpful, especially the [FAQs](https://help.accelo.com/guides/integrations-guide/expensify/#faq). + +## Information sync between Expensify and Accelo +The Accelo integration does a one-way sync, bringing expenses from Expensify into Accelo. When this happens, it transfers specific information from Expensify expenses to Accelo: + +| Expensify | Accelo | +|---------------------|-----------------------| +| Description | Title | +| Date | Date Incurred | +| Category | Type | +| Tags | Against (relevant Project, Ticket or Retainer) | +| Distance (mileage) | Quantity | +| Hours (time expenses) | Quantity | +| Amount | Purchase Price and Sale Price | +| Reimbursable? | Reimbursable? | +| Billable? | Billable? | +| Receipt | Attachment | +| Tax Rate | Tax Code | +| Attendees | Submitted By | + +## Expense Status +The status of your expense report in Expensify is also synced in Accelo. + +| Expensify Report Status | Accelo Expense Status | +|-------------------------|-----------------------| +| Open | Submitted | +| Submitted | Submitted | +| Approved | Approved | +| Reimbursed | Approved | +| Rejected | Declined | +| Archived | Approved | +| Closed | Approved | + + +## Can I use an Accelo and an accounting integration in Expensify at the same time? +Yes, you can use Accelo and an accounting system simultaneously. In order to update your Expensify tags with your Accelo Projects, Tickets, or Retainers, you will need to have a special switch enabled that allows you to have non-accounting tags alongside your accounting connection. Please contact Concierge to request that our support team enable the “Indirect Tag Uploads” switch for you. diff --git a/docs/assets/images/ExpensifyHelp-CreateExpense-1.png b/docs/assets/images/ExpensifyHelp-CreateExpense-1.png new file mode 100644 index 000000000000..7b6459440d5e Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-CreateExpense-1.png differ diff --git a/docs/assets/images/ExpensifyHelp-CreateExpense-2.png b/docs/assets/images/ExpensifyHelp-CreateExpense-2.png new file mode 100644 index 000000000000..65aaf8017a32 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-CreateExpense-2.png differ diff --git a/docs/assets/images/ExpensifyHelp-CreateExpense-3.png b/docs/assets/images/ExpensifyHelp-CreateExpense-3.png new file mode 100644 index 000000000000..0173de29d68d Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-CreateExpense-3.png differ diff --git a/docs/assets/images/ExpensifyHelp-CreateExpense-4.png b/docs/assets/images/ExpensifyHelp-CreateExpense-4.png new file mode 100644 index 000000000000..901d08f1771d Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-CreateExpense-4.png differ diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 880a12023056..798e328f73fa 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -88,9 +88,9 @@ platform :android do desc "Generate AdHoc HybridApp apk" lane :build_adhoc_hybrid do - ENV["ENVFILE"]="../.env.adhoc.hybridapp" + ENV["ENVFILE"]="Mobile-Expensify/.env.adhoc.hybridapp" gradle( - project_dir: '../Android', + project_dir: 'Mobile-Expensify/Android', task: 'assembleAdhoc', properties: { "android.injected.signing.store.file" => './upload-key.keystore', @@ -408,7 +408,7 @@ platform :ios do desc "Build an iOS HybridApp Adhoc build" lane :build_adhoc_hybrid do - ENV["ENVFILE"]="../.env.adhoc.hybridapp" + ENV["ENVFILE"]="Mobile-Expensify/.env.adhoc.hybridapp" setupIOSSigningCertificate() @@ -425,7 +425,7 @@ platform :ios do ) build_app( - workspace: "../iOS/Expensify.xcworkspace", + workspace: "Mobile-Expensify/iOS/Expensify.xcworkspace", scheme: "Expensify", output_name: "Expensify.ipa", export_method: "app-store", diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 2c05d4b504bc..74d34f52214b 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.75.0 + 9.0.75.6 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index bdba334ae098..c594f105f833 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 9.0.75.0 + 9.0.75.6 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 70d8151a90cc..2b8181d88d5b 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.0.75 CFBundleVersion - 9.0.75.0 + 9.0.75.6 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 86f1d38ac1e5..9c0683631269 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2034,8 +2034,27 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-view-shot (3.8.0): + - react-native-view-shot (4.0.0): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - react-native-webview (13.8.6): - DoubleConversion - glog @@ -3296,7 +3315,7 @@ SPEC CHECKSUMS: react-native-quick-sqlite: 7c793c9f5834e756b336257a8d8b8239b7ceb451 react-native-release-profiler: 131ec5e4145d900b2be2a8d6641e2ce0dd784259 react-native-safe-area-context: 458f6b948437afcb59198016b26bbd02ff9c3b47 - react-native-view-shot: 6b7ed61d77d88580fed10954d45fad0eb2d47688 + react-native-view-shot: 6bafd491eb295b5834e05c469a37ecbd796d5b22 react-native-webview: ad29375839c9aa0409ce8e8693291b42bdc067a4 React-nativeconfig: aeed6e2a8ac02b2df54476afcc7c663416c12bf7 React-NativeModulesApple: c5b7813da94136f50ef084fa1ac077332dcfc658 diff --git a/package-lock.json b/package-lock.json index b70f9fc1d07b..5703d2fca1ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.75-0", + "version": "9.0.75-6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.75-0", + "version": "9.0.75-6", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -97,7 +97,7 @@ "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.82", + "react-native-onyx": "2.0.86", "react-native-pager-view": "6.5.1", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -116,7 +116,7 @@ "react-native-svg": "15.9.0", "react-native-tab-view": "^3.5.2", "react-native-url-polyfill": "^2.0.0", - "react-native-view-shot": "3.8.0", + "react-native-view-shot": "4.0.0", "react-native-vision-camera": "^4.6.1", "react-native-web": "0.19.13", "react-native-webview": "13.8.6", @@ -32358,9 +32358,9 @@ } }, "node_modules/react-native-onyx": { - "version": "2.0.82", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.82.tgz", - "integrity": "sha512-12+NgkC4fOeGu2J6s985NKUuLHP4aijBhpE6Us5IfVL+9dwxr/KqUVgV00OzXtYAABcWcpMC5PrvESqe8T5Iyw==", + "version": "2.0.86", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.86.tgz", + "integrity": "sha512-3pjyzlo8We4tSx/xf+4IRnBMcm5rk0E+aHBUSUxJ5jaFermx0SXZJlnvE5Emkw+iu0bXKkwea6zt2LhxD1JSsg==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", @@ -32666,7 +32666,9 @@ } }, "node_modules/react-native-view-shot": { - "version": "3.8.0", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-4.0.0.tgz", + "integrity": "sha512-e7wtfdm981DQVqkW+YE9mkemYarI0VZQ7PzRcHzQOmXlVrGKvNVD2MzRXOg+gK8msQIQ95QxATJKzG/QkQ9QHQ==", "license": "MIT", "dependencies": { "html2canvas": "^1.4.1" diff --git a/package.json b/package.json index d4b1757782ea..f297e01263a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.75-0", + "version": "9.0.75-6", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -160,7 +160,7 @@ "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.82", + "react-native-onyx": "2.0.86", "react-native-pager-view": "6.5.1", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -179,7 +179,7 @@ "react-native-svg": "15.9.0", "react-native-tab-view": "^3.5.2", "react-native-url-polyfill": "^2.0.0", - "react-native-view-shot": "3.8.0", + "react-native-view-shot": "4.0.0", "react-native-vision-camera": "^4.6.1", "react-native-web": "0.19.13", "react-native-webview": "13.8.6", diff --git a/src/App.tsx b/src/App.tsx index 52904e0a06c4..cc824b78fa4c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import KeyboardProvider from './components/KeyboardProvider'; import {LocaleContextProvider} from './components/LocaleContextProvider'; import OnyxProvider from './components/OnyxProvider'; import PopoverContextProvider from './components/PopoverProvider'; +import {ProductTrainingContextProvider} from './components/ProductTrainingContext'; import SafeArea from './components/SafeArea'; import ScrollOffsetContextProvider from './components/ScrollOffsetContextProvider'; import {SearchRouterContextProvider} from './components/Search/SearchRouter/SearchRouterContext'; @@ -95,6 +96,7 @@ function App({url}: AppProps) { VideoPopoverMenuContextProvider, KeyboardProvider, SearchRouterContextProvider, + ProductTrainingContextProvider, ]} > diff --git a/src/CONST.ts b/src/CONST.ts index 15554719ca9d..4fcc1cada6ff 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -344,6 +344,8 @@ const CONST = { ANIMATION_GYROSCOPE_VALUE: 0.4, ANIMATION_PAID_DURATION: 200, ANIMATION_PAID_CHECKMARK_DELAY: 300, + ANIMATION_THUMBSUP_DURATION: 250, + ANIMATION_THUMBSUP_DELAY: 200, ANIMATION_PAID_BUTTON_HIDE_DELAY: 1000, BACKGROUND_IMAGE_TRANSITION_DURATION: 1000, SCREEN_TRANSITION_END_TIMEOUT: 1000, @@ -6405,6 +6407,13 @@ const CONST = { }, MIGRATED_USER_WELCOME_MODAL: 'migratedUserWelcomeModal', + + PRODUCT_TRAINING_TOOLTIP_NAMES: { + CONCEIRGE_LHN_GBR: 'conciergeLHNGBR', + RENAME_SAVED_SEARCH: 'renameSavedSearch', + QUICK_ACTION_BUTTON: 'quickActionButton', + WORKSAPCE_CHAT_CREATE: 'workspaceChatCreate', + }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 45d636c0b1df..e19aa71cce52 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -111,9 +111,6 @@ const ONYXKEYS = { /** NVP keys */ - /** Boolean flag only true when first set */ - NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER: 'nvp_isFirstTimeNewExpensifyUser', - /** This NVP contains list of at most 5 recent attendees */ NVP_RECENT_ATTENDEES: 'nvp_expensify_recentAttendees', @@ -216,18 +213,9 @@ const ONYXKEYS = { /** The end date (epoch timestamp) of the workspace owner’s grace period after the free trial ends. */ NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END: 'nvp_private_billingGracePeriodEnd', - /** The NVP containing all information related to educational tooltip in workspace chat */ - NVP_WORKSPACE_TOOLTIP: 'workspaceTooltip', - /** The NVP containing the target url to navigate to when deleting a transaction */ NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL: 'nvp_deleteTransactionNavigateBackURL', - /** Whether to show save search rename tooltip */ - SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP: 'shouldShowSavedSearchRenameTooltip', - - /** Whether to hide gbr tooltip */ - NVP_SHOULD_HIDE_GBR_TOOLTIP: 'nvp_should_hide_gbr_tooltip', - /** Does this user have push notifications enabled for this device? */ PUSH_NOTIFICATIONS_ENABLED: 'pushNotificationsEnabled', @@ -464,6 +452,9 @@ const ONYXKEYS = { /** The user's Concierge reportID */ CONCIERGE_REPORT_ID: 'conciergeReportID', + /** The user's session that will be preserved when using imported state */ + PRESERVED_USER_SESSION: 'preservedUserSession', + /** Collection Keys */ COLLECTION: { DOWNLOAD: 'download_', @@ -876,7 +867,6 @@ type OnyxCollectionValuesMapping = { type OnyxValuesMapping = { [ONYXKEYS.ACCOUNT]: OnyxTypes.Account; [ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID]: string; - [ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER]: boolean; // NVP_ONBOARDING is an array for old users. [ONYXKEYS.NVP_ONBOARDING]: Onboarding | []; @@ -1017,9 +1007,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_BILLING_FUND_ID]: number; [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: number; [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: number; - [ONYXKEYS.NVP_WORKSPACE_TOOLTIP]: OnyxTypes.WorkspaceTooltip; [ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL]: string | undefined; - [ONYXKEYS.NVP_SHOULD_HIDE_GBR_TOOLTIP]: boolean; [ONYXKEYS.NVP_PRIVATE_CANCELLATION_DETAILS]: OnyxTypes.CancellationDetails[]; [ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE]: string; [ONYXKEYS.APPROVAL_WORKFLOW]: OnyxTypes.ApprovalWorkflowOnyx; @@ -1027,9 +1015,9 @@ type OnyxValuesMapping = { [ONYXKEYS.LAST_ROUTE]: string; [ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY]: boolean | undefined; [ONYXKEYS.IS_USING_IMPORTED_STATE]: boolean; - [ONYXKEYS.SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP]: boolean; [ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES]: Record; [ONYXKEYS.CONCIERGE_REPORT_ID]: string; + [ONYXKEYS.PRESERVED_USER_SESSION]: OnyxTypes.Session; [ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING]: OnyxTypes.DismissedProductTraining; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping; diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx index 12189d22dba0..6be2b43c09d7 100644 --- a/src/components/AmountTextInput.tsx +++ b/src/components/AmountTextInput.tsx @@ -39,7 +39,7 @@ type AmountTextInputProps = { /** Hide the focus styles on TextInput */ hideFocusedState?: boolean; -} & Pick; +} & Pick; function AmountTextInput( { diff --git a/src/components/ArchivedReportFooter.tsx b/src/components/ArchivedReportFooter.tsx index af77a20b4caa..fc5c77958635 100644 --- a/src/components/ArchivedReportFooter.tsx +++ b/src/components/ArchivedReportFooter.tsx @@ -1,7 +1,6 @@ import lodashEscape from 'lodash/escape'; import React from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import {getCurrentUserAccountID} from '@libs/actions/Report'; @@ -10,26 +9,20 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetailsList, Report, ReportAction} from '@src/types/onyx'; +import type {Report} from '@src/types/onyx'; import Banner from './Banner'; -type ArchivedReportFooterOnyxProps = { - /** The reason this report was archived */ - reportClosedAction: OnyxEntry; - - /** Personal details of all users */ - personalDetails: OnyxEntry; -}; - -type ArchivedReportFooterProps = ArchivedReportFooterOnyxProps & { +type ArchivedReportFooterProps = { /** The archived report */ report: Report; }; -function ArchivedReportFooter({report, reportClosedAction, personalDetails = {}}: ArchivedReportFooterProps) { +function ArchivedReportFooter({report}: ArchivedReportFooterProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {initialValue: {}}); + const [reportClosedAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, {canEvict: false, selector: ReportActionsUtils.getLastClosedReportAction}); const originalMessage = ReportActionsUtils.isClosedAction(reportClosedAction) ? ReportActionsUtils.getOriginalMessage(reportClosedAction) : null; const archiveReason = originalMessage?.reason ?? CONST.REPORT.ARCHIVE_REASON.DEFAULT; const actorPersonalDetails = personalDetails?.[reportClosedAction?.actorAccountID ?? -1]; @@ -78,13 +71,4 @@ function ArchivedReportFooter({report, reportClosedAction, personalDetails = {}} ArchivedReportFooter.displayName = 'ArchivedReportFooter'; -export default withOnyx({ - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - reportClosedAction: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, - canEvict: false, - selector: ReportActionsUtils.getLastClosedReportAction, - }, -})(ArchivedReportFooter); +export default ArchivedReportFooter; diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 07edd148778d..84767c6347e7 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -122,6 +122,9 @@ type ButtonProps = Partial & { /** Id to use for this button */ id?: string; + /** Used to locate this button in ui tests */ + testID?: string; + /** Accessibility label for the component */ accessibilityLabel?: string; @@ -237,6 +240,7 @@ function Button( shouldShowRightIcon = false, id = '', + testID = undefined, accessibilityLabel = '', isSplitButton = false, link = false, @@ -405,6 +409,7 @@ function Button( ]} disabledStyle={disabledStyle} id={id} + testID={testID} accessibilityLabel={accessibilityLabel} role={CONST.ROLE.BUTTON} hoverDimmingValue={1} diff --git a/src/components/ConfirmationPage.tsx b/src/components/ConfirmationPage.tsx index a95cf9bf87d2..7b55f2317d46 100644 --- a/src/components/ConfirmationPage.tsx +++ b/src/components/ConfirmationPage.tsx @@ -82,6 +82,7 @@ function ConfirmationPage({ success large text={buttonText} + testID="confirmation-button" style={styles.mt6} pressOnEnter onPress={onButtonPress} diff --git a/src/components/ImportOnyxState/index.native.tsx b/src/components/ImportOnyxState/index.native.tsx index 2258da4c8f6c..bdd805241c55 100644 --- a/src/components/ImportOnyxState/index.native.tsx +++ b/src/components/ImportOnyxState/index.native.tsx @@ -1,11 +1,12 @@ import React, {useState} from 'react'; import ReactNativeBlobUtil from 'react-native-blob-util'; -import Onyx from 'react-native-onyx'; +import Onyx, {useOnyx} from 'react-native-onyx'; import type {FileObject} from '@components/AttachmentModal'; -import {KEYS_TO_PRESERVE, setIsUsingImportedState} from '@libs/actions/App'; +import {KEYS_TO_PRESERVE, setIsUsingImportedState, setPreservedUserSession} from '@libs/actions/App'; import {setShouldForceOffline} from '@libs/actions/Network'; import Navigation from '@libs/Navigation/Navigation'; import type {OnyxValues} from '@src/ONYXKEYS'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import BaseImportOnyxState from './BaseImportOnyxState'; import type ImportOnyxStateProps from './types'; @@ -45,8 +46,9 @@ function applyStateInChunks(state: OnyxValues) { return promise; } -export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxStateProps) { +export default function ImportOnyxState({setIsLoading}: ImportOnyxStateProps) { const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + const [session] = useOnyx(ONYXKEYS.SESSION); const handleFileRead = (file: FileObject) => { if (!file.uri) { @@ -57,6 +59,8 @@ export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxSta readOnyxFile(file.uri) .then((fileContent: string) => { const transformedState = cleanAndTransformState(fileContent); + const currentUserSessionCopy = {...session}; + setPreservedUserSession(currentUserSessionCopy); setShouldForceOffline(true); Onyx.clear(KEYS_TO_PRESERVE).then(() => { applyStateInChunks(transformedState).then(() => { @@ -67,14 +71,7 @@ export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxSta }) .catch(() => { setIsErrorModalVisible(true); - }) - .finally(() => { - setIsLoading(false); }); - - if (isLoading) { - setIsLoading(false); - } }; return ( diff --git a/src/components/ImportOnyxState/index.tsx b/src/components/ImportOnyxState/index.tsx index 8add2d9172fd..2f9a2b70b65b 100644 --- a/src/components/ImportOnyxState/index.tsx +++ b/src/components/ImportOnyxState/index.tsx @@ -1,17 +1,19 @@ import React, {useState} from 'react'; -import Onyx from 'react-native-onyx'; +import Onyx, {useOnyx} from 'react-native-onyx'; import type {FileObject} from '@components/AttachmentModal'; -import {KEYS_TO_PRESERVE, setIsUsingImportedState} from '@libs/actions/App'; +import {KEYS_TO_PRESERVE, setIsUsingImportedState, setPreservedUserSession} from '@libs/actions/App'; import {setShouldForceOffline} from '@libs/actions/Network'; import Navigation from '@libs/Navigation/Navigation'; import type {OnyxValues} from '@src/ONYXKEYS'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import BaseImportOnyxState from './BaseImportOnyxState'; import type ImportOnyxStateProps from './types'; import {cleanAndTransformState} from './utils'; -export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxStateProps) { +export default function ImportOnyxState({setIsLoading}: ImportOnyxStateProps) { const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + const [session] = useOnyx(ONYXKEYS.SESSION); const handleFileRead = (file: FileObject) => { if (!file.uri) { @@ -27,26 +29,20 @@ export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxSta .then((text) => { const fileContent = text; const transformedState = cleanAndTransformState(fileContent); + const currentUserSessionCopy = {...session}; + setPreservedUserSession(currentUserSessionCopy); setShouldForceOffline(true); Onyx.clear(KEYS_TO_PRESERVE).then(() => { - Onyx.multiSet(transformedState) - .then(() => { - setIsUsingImportedState(true); - Navigation.navigate(ROUTES.HOME); - }) - .finally(() => { - setIsLoading(false); - }); + Onyx.multiSet(transformedState).then(() => { + setIsUsingImportedState(true); + Navigation.navigate(ROUTES.HOME); + }); }); }) .catch(() => { setIsErrorModalVisible(true); setIsLoading(false); }); - - if (isLoading) { - setIsLoading(false); - } }; return ( diff --git a/src/components/ImportOnyxState/types.ts b/src/components/ImportOnyxState/types.ts index 8e504c493529..2b4b56a3b20c 100644 --- a/src/components/ImportOnyxState/types.ts +++ b/src/components/ImportOnyxState/types.ts @@ -1,5 +1,4 @@ type ImportOnyxStateProps = { - isLoading: boolean; setIsLoading: (isLoading: boolean) => void; }; diff --git a/src/components/ImportOnyxState/utils.ts b/src/components/ImportOnyxState/utils.ts index a5f24fa80714..94779868384d 100644 --- a/src/components/ImportOnyxState/utils.ts +++ b/src/components/ImportOnyxState/utils.ts @@ -3,7 +3,7 @@ import type {UnknownRecord} from 'type-fest'; import ONYXKEYS from '@src/ONYXKEYS'; // List of Onyx keys from the .txt file we want to keep for the local override -const keysToOmit = [ONYXKEYS.ACTIVE_CLIENTS, ONYXKEYS.FREQUENTLY_USED_EMOJIS, ONYXKEYS.NETWORK, ONYXKEYS.CREDENTIALS, ONYXKEYS.SESSION, ONYXKEYS.PREFERRED_THEME]; +const keysToOmit = [ONYXKEYS.ACTIVE_CLIENTS, ONYXKEYS.FREQUENTLY_USED_EMOJIS, ONYXKEYS.NETWORK, ONYXKEYS.CREDENTIALS, ONYXKEYS.PREFERRED_THEME]; function isRecord(value: unknown): value is Record { return typeof value === 'object' && !Array.isArray(value) && value !== null; diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 25d790e3d391..8340e7d6a976 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -11,6 +11,7 @@ import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {useSession} from '@components/OnyxProvider'; import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; +import {useProductTrainingContext} from '@components/ProductTrainingContext'; import SubscriptAvatar from '@components/SubscriptAvatar'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; @@ -22,7 +23,6 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; import DomUtils from '@libs/DomUtils'; -import {hasCompletedGuidedSetupFlowSelector} from '@libs/onboardingSelectors'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; import Performance from '@libs/Performance'; @@ -32,7 +32,6 @@ import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportA import FreeTrial from '@pages/settings/Subscription/FreeTrial'; import variables from '@styles/variables'; import Timing from '@userActions/Timing'; -import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -48,18 +47,19 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${optionItem?.reportID || -1}`); - const [isFirstTimeNewExpensifyUser] = useOnyx(ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER); - const [isOnboardingCompleted = true] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { - selector: hasCompletedGuidedSetupFlowSelector, - }); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const session = useSession(); // Guides are assigned for the MANAGE_TEAM onboarding action, except for emails that have a '+'. const isOnboardingGuideAssigned = introSelected?.choice === CONST.ONBOARDING_CHOICES.MANAGE_TEAM && !session?.email?.includes('+'); const shouldShowToooltipOnThisReport = isOnboardingGuideAssigned ? ReportUtils.isAdminRoom(report) : ReportUtils.isConciergeChatReport(report); - const [shouldHideGBRTooltip] = useOnyx(ONYXKEYS.NVP_SHOULD_HIDE_GBR_TOOLTIP, {initialValue: true}); + const shouldShowGetStartedTooltip = shouldShowToooltipOnThisReport && isScreenFocused; + const {shouldShowProductTrainingTooltip, renderProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext( + CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.CONCEIRGE_LHN_GBR, + shouldShowGetStartedTooltip, + ); const {translate} = useLocalize(); const [isContextMenuActive, setIsContextMenuActive] = useState(false); @@ -72,30 +72,6 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti }, []), ); - const renderGBRTooltip = useCallback( - () => ( - - - {translate('sidebarScreen.tooltip')} - - ), - [ - styles.alignItemsCenter, - styles.flexRow, - styles.justifyContentCenter, - styles.flexWrap, - styles.textAlignCenter, - styles.gap1, - styles.quickActionTooltipSubtitle, - theme.tooltipHighlightText, - translate, - ], - ); - const isInFocusMode = viewMode === CONST.OPTION_MODE.COMPACT; const sidebarInnerRowStyle = StyleSheet.flatten( isInFocusMode @@ -180,17 +156,17 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti needsOffscreenAlphaCompositing > diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx index ccf12aa4ce24..7c992dbeae24 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx @@ -40,8 +40,6 @@ function OptionRowLHNData({ const shouldDisplayViolations = ReportUtils.shouldDisplayViolationsRBRInLHN(fullReport, transactionViolations); const isSettled = ReportUtils.isSettled(fullReport); const shouldDisplayReportViolations = !isSettled && ReportUtils.isReportOwner(fullReport) && ReportUtils.hasReportViolations(reportID); - // We only want to show RBR for expense reports with transaction violations not for transaction threads reports. - const doesExpenseReportHasViolations = ReportUtils.isExpenseReport(fullReport) && !isSettled && ReportUtils.hasViolations(reportID, transactionViolations, true); const optionItem = useMemo(() => { // Note: ideally we'd have this as a dependent selector in onyx! @@ -52,7 +50,7 @@ function OptionRowLHNData({ preferredLocale: preferredLocale ?? CONST.LOCALES.DEFAULT, policy, parentReportAction, - hasViolations: !!shouldDisplayViolations || shouldDisplayReportViolations || doesExpenseReportHasViolations, + hasViolations: !!shouldDisplayViolations || shouldDisplayReportViolations, lastMessageTextFromReport, transactionViolations, invoiceReceiverPolicy, diff --git a/src/components/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx index 717659c16fd3..9ef33900bb00 100644 --- a/src/components/MoneyRequestAmountInput.tsx +++ b/src/components/MoneyRequestAmountInput.tsx @@ -12,7 +12,6 @@ import CONST from '@src/CONST'; import isTextInputFocused from './TextInput/BaseTextInput/isTextInputFocused'; import type {BaseTextInputRef} from './TextInput/BaseTextInput/types'; import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol'; -import type {TextInputWithCurrencySymbolProps} from './TextInputWithCurrencySymbol/types'; type CurrentMoney = {amount: string; currency: string}; @@ -92,7 +91,7 @@ type MoneyRequestAmountInputProps = { /** The width of inner content */ contentWidth?: number; -} & Pick; +}; type Selection = { start: number; @@ -127,7 +126,6 @@ function MoneyRequestAmountInput( hideFocusedState = true, shouldKeepUserInput = false, autoGrow = true, - autoGrowExtraSpace, contentWidth, ...props }: MoneyRequestAmountInputProps, @@ -291,7 +289,6 @@ function MoneyRequestAmountInput( return ( { - if (!errors.includes(formError)) { - return; - } - - setFormError(''); - }, - [formError, setFormError], - ); - const shouldDisplayFieldError: boolean = useMemo(() => { if (!isEditingSplitBill) { return false; @@ -314,34 +303,6 @@ function MoneyRequestConfirmationList({ return false; }; - const routeError = Object.values(transaction?.errorFields?.route ?? {}).at(0); - - useEffect(() => { - // We want this effect to run only when the transaction is moving from Self DM to a workspace chat - if (!isDistanceRequest || !isMovingTransactionFromTrackExpense || !isPolicyExpenseChat) { - return; - } - - const errorKey = 'iou.error.invalidRate'; - const policyRates = DistanceRequestUtils.getMileageRates(policy); - - // If the selected rate belongs to the policy, clear the error - if (Object.keys(policyRates).includes(customUnitRateID)) { - clearFormErrors([errorKey]); - return; - } - - // If there is a distance rate in the policy that matches the rate and unit of the currently selected mileage rate, select it automatically - const matchingRate = Object.values(policyRates).find((policyRate) => policyRate.rate === mileageRate.rate && policyRate.unit === mileageRate.unit); - if (matchingRate?.customUnitRateID) { - IOU.setCustomUnitRateID(transactionID, matchingRate.customUnitRateID); - return; - } - - // If none of the above conditions are met, display the rate error - setFormError(errorKey); - }, [isDistanceRequest, isPolicyExpenseChat, transactionID, mileageRate, customUnitRateID, policy, isMovingTransactionFromTrackExpense, setFormError, clearFormErrors]); - useEffect(() => { if (shouldDisplayFieldError && didConfirmSplit) { setFormError('iou.error.genericSmartscanFailureMessage'); @@ -352,11 +313,12 @@ function MoneyRequestConfirmationList({ return; } // reset the form error whenever the screen gains or loses focus - clearFormErrors(['iou.error.genericSmartscanFailureMessage', 'iou.receiptScanningFailed']); + setFormError(''); // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want this effect to run if it's just setFormError that changes }, [isFocused, transaction, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit]); + const routeError = Object.values(transaction?.errorFields?.route ?? {}).at(0); const isFirstUpdatedDistanceAmount = useRef(false); useEffect(() => { @@ -507,8 +469,8 @@ function MoneyRequestConfirmationList({ return; } - clearFormErrors(['iou.error.invalidSplit', 'iou.error.invalidSplitParticipants', 'iou.error.invalidSplitYourself']); - }, [isFocused, transaction, isTypeSplit, transaction?.splitShares, currentUserPersonalDetails.accountID, iouAmount, iouCurrencyCode, setFormError, translate, clearFormErrors]); + setFormError(''); + }, [isFocused, transaction, isTypeSplit, transaction?.splitShares, currentUserPersonalDetails.accountID, iouAmount, iouCurrencyCode, setFormError, translate]); useEffect(() => { if (!isTypeSplit || !transaction?.splitShares) { @@ -675,9 +637,7 @@ function MoneyRequestConfirmationList({ }, [isTypeSplit, translate, payeePersonalDetails, getSplitSectionHeader, splitParticipants, selectedParticipants]); useEffect(() => { - if (!isDistanceRequest || (isMovingTransactionFromTrackExpense && !isPolicyExpenseChat)) { - // We don't want to recalculate the distance merchant when moving a transaction from Track Expense to a 1:1 chat, because the distance rate will be the same default P2P rate. - // When moving to a policy chat (e.g. sharing with an accountant), we should recalculate the distance merchant with the policy's rate. + if (!isDistanceRequest || isMovingTransactionFromTrackExpense) { return; } @@ -700,7 +660,6 @@ function MoneyRequestConfirmationList({ translate, toLocaleDigit, isDistanceRequest, - isPolicyExpenseChat, transaction, transactionID, action, diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index c391564a6d3d..e32c4eae410f 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -8,7 +8,6 @@ import {useOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; @@ -216,7 +215,6 @@ function MoneyRequestConfirmationListFooter({ unit, }: MoneyRequestConfirmationListFooterProps) { const styles = useThemeStyles(); - const theme = useTheme(); const {translate, toLocaleDigit} = useLocalize(); const {isOffline} = useNetwork(); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); @@ -259,7 +257,6 @@ function MoneyRequestConfirmationListFooter({ const taxRateTitle = TransactionUtils.getTaxName(policy, transaction); // Determine if the merchant error should be displayed const shouldDisplayMerchantError = isMerchantRequired && (shouldDisplayFieldError || formError === 'iou.error.invalidMerchant') && isMerchantEmpty; - const shouldDisplayDistanceRateError = formError === 'iou.error.invalidRate'; // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate") const shouldShowReceiptEmptyState = iouType === CONST.IOU.TYPE.SUBMIT && PolicyUtils.isPaidGroupPolicy(policy); const { @@ -370,7 +367,6 @@ function MoneyRequestConfirmationListFooter({ style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} - brickRoadIndicator={shouldDisplayDistanceRateError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} disabled={didConfirm} interactive={!!rate && !isReadOnly && isPolicyExpenseChat} /> @@ -536,7 +532,6 @@ function MoneyRequestConfirmationListFooter({ onToggle={(isOn) => onToggleBillable?.(isOn)} isActive={iouIsBillable} disabled={isReadOnly} - titleStyle={!iouIsBillable && {color: theme.textSupporting}} wrapperStyle={styles.flex1} /> diff --git a/src/components/ProcessMoneyReportHoldMenu.tsx b/src/components/ProcessMoneyReportHoldMenu.tsx index 3d6ad9006dc5..ba320a594135 100644 --- a/src/components/ProcessMoneyReportHoldMenu.tsx +++ b/src/components/ProcessMoneyReportHoldMenu.tsx @@ -66,6 +66,9 @@ function ProcessMoneyReportHoldMenu({ const onSubmit = (full: boolean) => { if (isApprove) { + if (startAnimation) { + startAnimation(); + } IOU.approveMoneyRequest(moneyRequestReport, full); if (!full && isLinkedTransactionHeld(Navigation.getTopmostReportActionId() ?? '-1', moneyRequestReport?.reportID ?? '')) { Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(moneyRequestReport?.reportID ?? '')); diff --git a/src/components/ProductTrainingContext/PRODUCT_TRAINING_TOOLTIP_DATA.ts b/src/components/ProductTrainingContext/PRODUCT_TRAINING_TOOLTIP_DATA.ts new file mode 100644 index 000000000000..d7f2a27d94d2 --- /dev/null +++ b/src/components/ProductTrainingContext/PRODUCT_TRAINING_TOOLTIP_DATA.ts @@ -0,0 +1,67 @@ +import type {ValueOf} from 'type-fest'; +import {dismissProductTraining} from '@libs/actions/Welcome'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; + +const {CONCEIRGE_LHN_GBR, RENAME_SAVED_SEARCH, WORKSAPCE_CHAT_CREATE, QUICK_ACTION_BUTTON} = CONST.PRODUCT_TRAINING_TOOLTIP_NAMES; + +type ProductTrainingTooltipName = ValueOf; + +type ShouldShowConditionProps = { + shouldUseNarrowLayout?: boolean; +}; + +type TooltipData = { + content: Array<{text: TranslationPaths; isBold: boolean}>; + onHideTooltip: () => void; + name: ProductTrainingTooltipName; + priority: number; + shouldShow: (props: ShouldShowConditionProps) => boolean; +}; + +const PRODUCT_TRAINING_TOOLTIP_DATA: Record = { + [CONCEIRGE_LHN_GBR]: { + content: [ + {text: 'productTrainingTooltip.conciergeLHNGBR.part1', isBold: false}, + {text: 'productTrainingTooltip.conciergeLHNGBR.part2', isBold: true}, + ], + onHideTooltip: () => dismissProductTraining(CONCEIRGE_LHN_GBR), + name: CONCEIRGE_LHN_GBR, + priority: 1300, + shouldShow: ({shouldUseNarrowLayout}) => !!shouldUseNarrowLayout, + }, + [RENAME_SAVED_SEARCH]: { + content: [ + {text: 'productTrainingTooltip.saveSearchTooltip.part1', isBold: true}, + {text: 'productTrainingTooltip.saveSearchTooltip.part2', isBold: false}, + ], + onHideTooltip: () => dismissProductTraining(RENAME_SAVED_SEARCH), + name: RENAME_SAVED_SEARCH, + priority: 1250, + shouldShow: ({shouldUseNarrowLayout}) => !shouldUseNarrowLayout, + }, + [QUICK_ACTION_BUTTON]: { + content: [ + {text: 'productTrainingTooltip.quickActionButton.part1', isBold: true}, + {text: 'productTrainingTooltip.quickActionButton.part2', isBold: false}, + ], + onHideTooltip: () => dismissProductTraining(QUICK_ACTION_BUTTON), + name: QUICK_ACTION_BUTTON, + priority: 1200, + shouldShow: () => true, + }, + [WORKSAPCE_CHAT_CREATE]: { + content: [ + {text: 'productTrainingTooltip.workspaceChatCreate.part1', isBold: false}, + {text: 'productTrainingTooltip.workspaceChatCreate.part2', isBold: true}, + {text: 'productTrainingTooltip.workspaceChatCreate.part3', isBold: false}, + ], + onHideTooltip: () => dismissProductTraining(WORKSAPCE_CHAT_CREATE), + name: WORKSAPCE_CHAT_CREATE, + priority: 1100, + shouldShow: () => true, + }, +}; + +export default PRODUCT_TRAINING_TOOLTIP_DATA; +export type {ProductTrainingTooltipName}; diff --git a/src/components/ProductTrainingContext/index.tsx b/src/components/ProductTrainingContext/index.tsx new file mode 100644 index 000000000000..92997fe70af3 --- /dev/null +++ b/src/components/ProductTrainingContext/index.tsx @@ -0,0 +1,214 @@ +import React, {createContext, useCallback, useContext, useEffect, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {hasCompletedGuidedSetupFlowSelector} from '@libs/onboardingSelectors'; +import Permissions from '@libs/Permissions'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import type {ProductTrainingTooltipName} from './PRODUCT_TRAINING_TOOLTIP_DATA'; +import PRODUCT_TRAINING_TOOLTIP_DATA from './PRODUCT_TRAINING_TOOLTIP_DATA'; + +type ProductTrainingContextType = { + shouldRenderTooltip: (tooltipName: ProductTrainingTooltipName) => boolean; + registerTooltip: (tooltipName: ProductTrainingTooltipName) => void; + unregisterTooltip: (tooltipName: ProductTrainingTooltipName) => void; +}; + +const ProductTrainingContext = createContext({ + shouldRenderTooltip: () => false, + registerTooltip: () => {}, + unregisterTooltip: () => {}, +}); + +function ProductTrainingContextProvider({children}: ChildrenProps) { + const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRYNEWDOT); + const hasBeenAddedToNudgeMigration = !!tryNewDot?.nudgeMigration?.timestamp; + const [isOnboardingCompleted = true] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { + selector: hasCompletedGuidedSetupFlowSelector, + }); + const [dismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); + const [allBetas] = useOnyx(ONYXKEYS.BETAS); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + const [activeTooltips, setActiveTooltips] = useState>(new Set()); + + const unregisterTooltip = useCallback( + (tooltipName: ProductTrainingTooltipName) => { + setActiveTooltips((prev) => { + const next = new Set(prev); + next.delete(tooltipName); + return next; + }); + }, + [setActiveTooltips], + ); + + const determineVisibleTooltip = useCallback(() => { + if (activeTooltips.size === 0) { + return null; + } + + const sortedTooltips = Array.from(activeTooltips) + .map((name) => ({ + name, + priority: PRODUCT_TRAINING_TOOLTIP_DATA[name]?.priority ?? 0, + })) + .sort((a, b) => b.priority - a.priority); + + const highestPriorityTooltip = sortedTooltips.at(0); + + if (!highestPriorityTooltip) { + return null; + } + + return highestPriorityTooltip.name; + }, [activeTooltips]); + + const shouldTooltipBeVisible = useCallback( + (tooltipName: ProductTrainingTooltipName) => { + const isDismissed = !!dismissedProductTraining?.[tooltipName]; + + if (isDismissed || !Permissions.shouldShowProductTrainingElements(allBetas)) { + return false; + } + const tooltipConfig = PRODUCT_TRAINING_TOOLTIP_DATA[tooltipName]; + + if (!isOnboardingCompleted && !hasBeenAddedToNudgeMigration) { + return false; + } + + return tooltipConfig.shouldShow({ + shouldUseNarrowLayout, + }); + }, + [allBetas, dismissedProductTraining, hasBeenAddedToNudgeMigration, isOnboardingCompleted, shouldUseNarrowLayout], + ); + + const registerTooltip = useCallback( + (tooltipName: ProductTrainingTooltipName) => { + const shouldRegister = shouldTooltipBeVisible(tooltipName); + if (!shouldRegister) { + return; + } + setActiveTooltips((prev) => new Set([...prev, tooltipName])); + }, + [shouldTooltipBeVisible], + ); + + const shouldRenderTooltip = useCallback( + (tooltipName: ProductTrainingTooltipName) => { + // First check base conditions + const shouldShow = shouldTooltipBeVisible(tooltipName); + if (!shouldShow) { + return false; + } + const visibleTooltip = determineVisibleTooltip(); + + // If this is the highest priority visible tooltip, show it + if (tooltipName === visibleTooltip) { + return true; + } + + return false; + }, + [shouldTooltipBeVisible, determineVisibleTooltip], + ); + + const contextValue = useMemo( + () => ({ + shouldRenderTooltip, + registerTooltip, + unregisterTooltip, + }), + [shouldRenderTooltip, registerTooltip, unregisterTooltip], + ); + + return {children}; +} + +const useProductTrainingContext = (tooltipName: ProductTrainingTooltipName, shouldShow = true) => { + const context = useContext(ProductTrainingContext); + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + + if (!context) { + throw new Error('useProductTourContext must be used within a ProductTourProvider'); + } + + const {shouldRenderTooltip, registerTooltip, unregisterTooltip} = context; + + useEffect(() => { + if (shouldShow) { + registerTooltip(tooltipName); + return () => { + unregisterTooltip(tooltipName); + }; + } + return () => {}; + }, [tooltipName, registerTooltip, unregisterTooltip, shouldShow]); + + const renderProductTrainingTooltip = useCallback(() => { + const tooltip = PRODUCT_TRAINING_TOOLTIP_DATA[tooltipName]; + return ( + + + + {tooltip.content.map(({text, isBold}) => { + const translatedText = translate(text); + return ( + + {translatedText} + + ); + })} + + + ); + }, [ + styles.alignItemsCenter, + styles.flexRow, + styles.flexWrap, + styles.gap1, + styles.justifyContentCenter, + styles.p2, + styles.quickActionTooltipSubtitle, + styles.textAlignCenter, + styles.textBold, + theme.tooltipHighlightText, + tooltipName, + translate, + ]); + + const shouldShowProductTrainingTooltip = useMemo(() => { + return shouldRenderTooltip(tooltipName); + }, [shouldRenderTooltip, tooltipName]); + + const hideProductTrainingTooltip = useCallback(() => { + const tooltip = PRODUCT_TRAINING_TOOLTIP_DATA[tooltipName]; + tooltip.onHideTooltip(); + unregisterTooltip(tooltipName); + }, [tooltipName, unregisterTooltip]); + + return { + renderProductTrainingTooltip, + hideProductTrainingTooltip, + shouldShowProductTrainingTooltip: shouldShow && shouldShowProductTrainingTooltip, + }; +}; + +export {ProductTrainingContextProvider, useProductTrainingContext}; diff --git a/src/components/RNMarkdownTextInput.tsx b/src/components/RNMarkdownTextInput.tsx index d36af6e13826..11f8a852dbcf 100644 --- a/src/components/RNMarkdownTextInput.tsx +++ b/src/components/RNMarkdownTextInput.tsx @@ -5,13 +5,14 @@ import React from 'react'; import type {TextInput} from 'react-native'; import Animated from 'react-native-reanimated'; import useTheme from '@hooks/useTheme'; +import CONST from '@src/CONST'; // Convert the underlying TextInput into an Animated component so that we can take an animated ref and pass it to a worklet const AnimatedMarkdownTextInput = Animated.createAnimatedComponent(MarkdownTextInput); type AnimatedMarkdownTextInputRef = typeof AnimatedMarkdownTextInput & TextInput & HTMLInputElement; -function RNMarkdownTextInputWithRef(props: MarkdownTextInputProps, ref: ForwardedRef) { +function RNMarkdownTextInputWithRef({maxLength, ...props}: MarkdownTextInputProps, ref: ForwardedRef) { const theme = useTheme(); return ( @@ -27,6 +28,10 @@ function RNMarkdownTextInputWithRef(props: MarkdownTextInputProps, ref: Forwarde }} // eslint-disable-next-line {...props} + /** + * If maxLength is not set, we should set the it to CONST.MAX_COMMENT_LENGTH + 1, to avoid parsing markdown for large text + */ + maxLength={maxLength ?? CONST.MAX_COMMENT_LENGTH + 1} /> ); } diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 1c2a552db476..e3d4a8d31cf6 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -15,7 +15,6 @@ import ViolationMessages from '@components/ViolationMessages'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useViolations from '@hooks/useViolations'; import type {ViolationField} from '@hooks/useViolations'; @@ -79,7 +78,6 @@ const getTransactionID = (report: OnyxEntry, parentReportActio }; function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = false, updatedTransaction, isFromReviewDuplicates = false}: MoneyRequestViewProps) { - const theme = useTheme(); const styles = useThemeStyles(); const session = useSession(); const {isOffline} = useNetwork(); @@ -390,7 +388,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals const shouldShowReceiptAudit = isReceiptAllowed && (shouldShowReceiptEmptyState || hasReceipt); const errors = { - ...(transaction?.errorFields?.route ?? transaction?.errors), + ...(transaction?.errorFields?.route ?? transaction?.errorFields?.waypoints ?? transaction?.errors), ...parentReportAction?.errors, }; @@ -729,7 +727,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals {shouldShowBillable && ( - {translate('common.billable')} + {translate('common.billable')} {!!getErrorForField('billable') && ( (); const [paymentType, setPaymentType] = useState(); const getCanIOUBePaid = useCallback( - (onlyShowPayElsewhere = false) => IOU.canIOUBePaid(iouReport, chatReport, policy, allTransactions, onlyShowPayElsewhere), + (onlyShowPayElsewhere = false, shouldCheckApprovedState = true) => + IOU.canIOUBePaid(iouReport, chatReport, policy, allTransactions, onlyShowPayElsewhere, undefined, undefined, shouldCheckApprovedState), [iouReport, chatReport, policy, allTransactions], ); const canIOUBePaid = useMemo(() => getCanIOUBePaid(), [getCanIOUBePaid]); + const canIOUBePaidAndApproved = useMemo(() => getCanIOUBePaid(false, false), [getCanIOUBePaid]); const onlyShowPayElsewhere = useMemo(() => !canIOUBePaid && getCanIOUBePaid(true), [canIOUBePaid, getCanIOUBePaid]); const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere; + const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(iouReport, policy), [iouReport, policy]) || isApprovedAnimationRunning; + + const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(iouReport); const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = ReportUtils.getNonHeldAndFullAmount(iouReport, shouldShowPayButton); const hasOnlyHeldExpenses = ReportUtils.hasOnlyHeldExpenses(iouReport?.reportID ?? ''); @@ -151,12 +157,18 @@ function ReportPreview({ })); const checkMarkScale = useSharedValue(iouSettled ? 1 : 0); + const isApproved = ReportUtils.isReportApproved(iouReport, action); + const thumbsUpScale = useSharedValue(isApproved ? 1 : 0); + const thumbsUpStyle = useAnimatedStyle(() => ({ + ...styles.defaultCheckmarkWrapper, + transform: [{scale: thumbsUpScale.get()}], + })); + const moneyRequestComment = action?.childLastMoneyRequestComment ?? ''; const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport); const isInvoiceRoom = ReportUtils.isInvoiceRoom(chatReport); const isOpenExpenseReport = isPolicyExpenseChat && ReportUtils.isOpenExpenseReport(iouReport); - const isApproved = ReportUtils.isReportApproved(iouReport, action); const canAllowSettlement = ReportUtils.hasUpdatedTotal(iouReport, policy); const numberOfRequests = allTransactions.length; const transactionsWithReceipts = ReportUtils.getTransactionsWithReceipts(iouReportID); @@ -207,11 +219,19 @@ function ReportPreview({ const {isDelegateAccessRestricted} = useDelegateUserDetails(); const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); - const stopAnimation = useCallback(() => setIsPaidAnimationRunning(false), []); + const stopAnimation = useCallback(() => { + setIsPaidAnimationRunning(false); + setIsApprovedAnimationRunning(false); + }, []); const startAnimation = useCallback(() => { setIsPaidAnimationRunning(true); HapticFeedback.longPress(); }, []); + const startApprovedAnimation = useCallback(() => { + setIsApprovedAnimationRunning(true); + HapticFeedback.longPress(); + }, []); + const confirmPayment = useCallback( (type: PaymentMethodType | undefined, payAsBusiness?: boolean) => { if (!type) { @@ -243,6 +263,8 @@ function ReportPreview({ } else if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { setIsHoldMenuVisible(true); } else { + setIsApprovedAnimationRunning(true); + HapticFeedback.longPress(); IOU.approveMoneyRequest(iouReport, true); } }; @@ -340,9 +362,6 @@ function ReportPreview({ ]); const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport); - const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(iouReport, policy), [iouReport, policy]); - - const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(iouReport); const shouldShowSettlementButton = (shouldShowPayButton || shouldShowApproveButton) && !showRTERViolationMessage && !shouldShowBrokenConnectionViolation; @@ -427,7 +446,7 @@ function ReportPreview({ const shouldShowExportIntegrationButton = !shouldShowPayButton && !shouldShowSubmitButton && connectedIntegration && isAdmin && ReportUtils.canBeExported(iouReport); useEffect(() => { - if (!isPaidAnimationRunning) { + if (!isPaidAnimationRunning || isApprovedAnimationRunning) { return; } @@ -448,6 +467,14 @@ function ReportPreview({ checkMarkScale.set(isPaidAnimationRunning ? withDelay(CONST.ANIMATION_PAID_CHECKMARK_DELAY, withSpring(1, {duration: CONST.ANIMATION_PAID_DURATION})) : 1); }, [isPaidAnimationRunning, iouSettled, checkMarkScale]); + useEffect(() => { + if (!isApproved) { + return; + } + + thumbsUpScale.set(isApprovedAnimationRunning ? withDelay(CONST.ANIMATION_THUMBSUP_DELAY, withSpring(1, {duration: CONST.ANIMATION_THUMBSUP_DURATION})) : 1); + }, [isApproved, isApprovedAnimationRunning, thumbsUpScale]); + const openReportFromPreview = useCallback(() => { Performance.markStart(CONST.TIMING.OPEN_REPORT_FROM_PREVIEW); Timing.start(CONST.TIMING.OPEN_REPORT_FROM_PREVIEW); @@ -483,7 +510,7 @@ function ReportPreview({ - {previewMessage} + {previewMessage} {shouldShowRBR && ( )} + {isApproved && ( + + + + )} {shouldShowSubtitle && !!supportText && ( @@ -537,6 +572,8 @@ function ReportPreview({ shouldUseSuccessStyle={!hasHeldExpenses} onlyShowPayElsewhere={onlyShowPayElsewhere} isPaidAnimationRunning={isPaidAnimationRunning} + isApprovedAnimationRunning={isApprovedAnimationRunning} + canIOUBePaid={canIOUBePaidAndApproved || isPaidAnimationRunning} onAnimationFinish={stopAnimation} formattedAmount={getSettlementAmount() ?? ''} currency={iouReport?.currency} @@ -604,7 +641,13 @@ function ReportPreview({ chatReport={chatReport} moneyRequestReport={iouReport} transactionCount={numberOfRequests} - startAnimation={startAnimation} + startAnimation={() => { + if (requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE) { + startApprovedAnimation(); + } else { + startAnimation(); + } + }} /> )} diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 07f23a96424d..4c08c477f29d 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -78,7 +78,10 @@ function mapToItemWithSelectionInfo( shouldAnimateInHighlight: boolean, ) { if (SearchUIUtils.isReportActionListItemType(item)) { - return item; + return { + ...item, + shouldAnimateInHighlight, + }; } return SearchUIUtils.isTransactionListItemType(item) @@ -134,6 +137,8 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo const [currentSearchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`); const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const previousTransactions = usePrevious(transactions); + const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + const previousReportActions = usePrevious(reportActions); useEffect(() => { if (!currentSearchResults?.search?.type) { @@ -211,6 +216,8 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo previousTransactions, queryJSON, offset, + reportActions, + previousReportActions, }); // There's a race condition in Onyx which makes it return data from the previous Search, so in addition to checking that the data is loaded @@ -323,15 +330,20 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo const ListItem = SearchUIUtils.getListItem(type, status); const sortedData = SearchUIUtils.getSortedSections(type, status, data, sortBy, sortOrder); + const isChat = type === CONST.SEARCH.DATA_TYPES.CHAT; const sortedSelectedData = sortedData.map((item) => { - const baseKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${(item as TransactionListItemType).transactionID}`; + const baseKey = isChat + ? `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${(item as ReportActionListItemType).reportActionID}` + : `${ONYXKEYS.COLLECTION.TRANSACTION}${(item as TransactionListItemType).transactionID}`; // Check if the base key matches the newSearchResultKey (TransactionListItemType) const isBaseKeyMatch = baseKey === newSearchResultKey; // Check if any transaction within the transactions array (ReportListItemType) matches the newSearchResultKey - const isAnyTransactionMatch = (item as ReportListItemType)?.transactions?.some((transaction) => { - const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`; - return transactionKey === newSearchResultKey; - }); + const isAnyTransactionMatch = + !isChat && + (item as ReportListItemType)?.transactions?.some((transaction) => { + const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`; + return transactionKey === newSearchResultKey; + }); // Determine if either the base key or any transaction key matches const shouldAnimateInHighlight = isBaseKeyMatch || isAnyTransactionMatch; diff --git a/src/components/SelectionList/ChatListItem.tsx b/src/components/SelectionList/ChatListItem.tsx index a3e04c9088f1..4807aa7760c8 100644 --- a/src/components/SelectionList/ChatListItem.tsx +++ b/src/components/SelectionList/ChatListItem.tsx @@ -5,11 +5,13 @@ import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/M import MultipleAvatars from '@components/MultipleAvatars'; import {ShowContextMenuContext} from '@components/ShowContextMenuContext'; import TextWithTooltip from '@components/TextWithTooltip'; +import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import ReportActionItemDate from '@pages/home/report/ReportActionItemDate'; import ReportActionItemFragment from '@pages/home/report/ReportActionItemFragment'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import BaseListItem from './BaseListItem'; import type {ChatListItemProps, ListItem, ReportActionListItemType} from './types'; @@ -56,11 +58,24 @@ function ChatListItem({ const hoveredBackgroundColor = styles.sidebarLinkHover?.backgroundColor ? styles.sidebarLinkHover.backgroundColor : theme.sidebar; const mentionReportContextValue = useMemo(() => ({currentReportID: item?.reportID ?? '-1'}), [item.reportID]); - + const animatedHighlightStyle = useAnimatedHighlightStyle({ + borderRadius: variables.componentBorderRadius, + shouldHighlight: item?.shouldAnimateInHighlight ?? false, + highlightColor: theme.messageHighlightBG, + backgroundColor: theme.highlightBG, + }); + const pressableStyle = [ + styles.selectionListPressableItemWrapper, + styles.textAlignLeft, + // Removing background style because they are added to the parent OpacityView via animatedHighlightStyle + styles.bgTransparent, + item.isSelected && styles.activeComponentBG, + item.cursorStyle, + ]; return ( ({ keyForList={item.keyForList} onFocus={onFocus} shouldSyncFocus={shouldSyncFocus} + pressableWrapperStyle={[styles.mh5, animatedHighlightStyle]} hoverStyle={item.isSelected && styles.activeComponentBG} > {(hovered) => ( diff --git a/src/components/SelectionList/Search/ActionCell.tsx b/src/components/SelectionList/Search/ActionCell.tsx index c7e7f587769c..2608e4e2de8c 100644 --- a/src/components/SelectionList/Search/ActionCell.tsx +++ b/src/components/SelectionList/Search/ActionCell.tsx @@ -87,16 +87,19 @@ function ActionCell({ ); } - if (action === CONST.SEARCH.ACTION_TYPES.VIEW || shouldUseViewAction) { + if (action === CONST.SEARCH.ACTION_TYPES.VIEW || action === CONST.SEARCH.ACTION_TYPES.REVIEW || shouldUseViewAction) { return isLargeScreenWidth ? (