diff --git a/.github/workflows/cherryPick.yml b/.github/workflows/cherryPick.yml index 098a2696e5af..12e5c1d4e898 100644 --- a/.github/workflows/cherryPick.yml +++ b/.github/workflows/cherryPick.yml @@ -5,6 +5,7 @@ on: inputs: PULL_REQUEST_URL: description: The full URL of the E/App or E/Mobile-Expensify pull request to cherry-pick + type: string required: true TARGET: description: CP to staging or production? diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 9a822b6158a4..7767fed3b0a9 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -32,5 +32,6 @@ jobs: if: | always() && (github.event_name == 'issue_comment' || needs.validate.outputs.IS_AUTHORIZED == 'true') + cache-mode: read uses: Expensify/GitHub-Actions/.github/workflows/cla.yml@main secrets: inherit diff --git a/.github/workflows/deployBlocker.yml b/.github/workflows/deployBlocker.yml index 3de8b9c5f68a..2b7fd8f0df08 100644 --- a/.github/workflows/deployBlocker.yml +++ b/.github/workflows/deployBlocker.yml @@ -8,6 +8,7 @@ on: jobs: updateChecklist: if: github.event.label.name == 'DeployBlockerCash' + cache-mode: read uses: ./.github/workflows/createDeployChecklist.yml secrets: inherit diff --git a/.github/workflows/finishReleaseCycle.yml b/.github/workflows/finishReleaseCycle.yml index b7880d77c08b..9028e56f5946 100644 --- a/.github/workflows/finishReleaseCycle.yml +++ b/.github/workflows/finishReleaseCycle.yml @@ -93,6 +93,7 @@ jobs: updateProduction: needs: validate if: ${{ fromJSON(needs.validate.outputs.isValid) }} + cache-mode: read uses: ./.github/workflows/updateProtectedBranch.yml secrets: inherit with: @@ -118,6 +119,7 @@ jobs: createNewPatchVersion: needs: validate if: ${{ fromJSON(needs.validate.outputs.isValid) }} + cache-mode: read uses: ./.github/workflows/createNewVersion.yml secrets: inherit with: @@ -126,6 +128,7 @@ jobs: # Update the staging branch to trigger a staging deploy updateStaging: needs: [cleanupDeployBlockers, createNewPatchVersion] + cache-mode: read uses: ./.github/workflows/updateProtectedBranch.yml secrets: inherit with: diff --git a/.github/workflows/publishReactNativeArtifacts.yml b/.github/workflows/publishReactNativeArtifacts.yml index 50edbcdb48d7..a385e36e4a37 100644 --- a/.github/workflows/publishReactNativeArtifacts.yml +++ b/.github/workflows/publishReactNativeArtifacts.yml @@ -17,10 +17,12 @@ on: type: boolean app_pull_request_url: description: 'The Expensify/App pull request URL.' + type: string required: false default: '' mobile_expensify_pull_request_url: description: 'The Expensify/Mobile-Expensify pull request URL to check out the submodule.' + type: string required: false default: '' reviewed_code: diff --git a/.github/workflows/remote-build-android.yml b/.github/workflows/remote-build-android.yml index 6bf7b31b06fa..7462d1498d15 100644 --- a/.github/workflows/remote-build-android.yml +++ b/.github/workflows/remote-build-android.yml @@ -6,10 +6,12 @@ on: inputs: app_pull_request_url: description: 'The Expensify/App pull request URL. Its description is scanned for a MOBILE-EXPENSIFY entry.' + type: string required: false default: '' mobile_expensify_pull_request_url: description: 'The Expensify/Mobile-Expensify pull request URL to check out the submodule. Overrides MOBILE-EXPENSIFY set in the App PR description.' + type: string required: false default: '' reviewed_code: diff --git a/.github/workflows/remote-build-ios.yml b/.github/workflows/remote-build-ios.yml index 242dcfa21271..482f8a1d32f6 100644 --- a/.github/workflows/remote-build-ios.yml +++ b/.github/workflows/remote-build-ios.yml @@ -6,10 +6,12 @@ on: inputs: app_pull_request_url: description: 'The Expensify/App pull request URL. Its description is scanned for a MOBILE-EXPENSIFY entry.' + type: string required: false default: '' mobile_expensify_pull_request_url: description: 'The Expensify/Mobile-Expensify pull request URL to check out the submodule. Overrides MOBILE-EXPENSIFY set in the App PR description.' + type: string required: false default: '' reviewed_code: diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index ea7c66dfd9ea..c58bffcfb075 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -6,11 +6,13 @@ on: # If not specified, only build iOS and Android apps from the main branch of Expensify/App APP_PULL_REQUEST_URL: description: The Expensify/App pull request URL (e.g., https://github.com/Expensify/App/pull/12345). Defaults to main. + type: string required: false default: '' # Pull Request URL from Mobile-Expensify repo for correct placement of OD app. It will take precedence over MOBILE-EXPENSIFY from App's PR description if both are specified. If nothing is specified, the commit pinned by the App submodule is used. MOBILE_EXPENSIFY_PULL_REQUEST_URL: description: The Expensify/Mobile-Expensify pull request URL. Defaults to the pinned submodule commit. Overrides MOBILE-EXPENSIFY set in App's PR description. + type: string required: false default: '' REVIEWED_CODE: diff --git a/.github/workflows/verifyHybridApp.yml b/.github/workflows/verifyHybridApp.yml index 7b94b91393c9..f16b9f77ac5d 100644 --- a/.github/workflows/verifyHybridApp.yml +++ b/.github/workflows/verifyHybridApp.yml @@ -76,6 +76,7 @@ jobs: verify_android: name: Verify Android HybridApp builds on main if: ${{ !github.event.pull_request.head.repo.fork && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }} + cache-mode: read uses: ./.github/workflows/buildAndroid.yml with: ref: ${{ github.event.pull_request.head.sha }} @@ -85,6 +86,7 @@ jobs: verify_ios: name: Verify iOS HybridApp builds on main if: ${{ !github.event.pull_request.head.repo.fork && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }} + cache-mode: read uses: ./.github/workflows/buildIOS.yml with: ref: ${{ github.event.pull_request.head.sha }} diff --git a/Mobile-Expensify b/Mobile-Expensify index 8bbd442659f2..5efcdc3bd0b0 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 8bbd442659f200113163ea2994ba4ed3b1c4b859 +Subproject commit 5efcdc3bd0b00c180ae144ba099829d4920064ea diff --git a/__mocks__/@expensify/react-native-live-markdown.ts b/__mocks__/@expensify/react-native-live-markdown.ts index efbab047876e..b83d9a6181da 100644 --- a/__mocks__/@expensify/react-native-live-markdown.ts +++ b/__mocks__/@expensify/react-native-live-markdown.ts @@ -1,11 +1,11 @@ -import type {Ref} from 'react'; +import type {ComponentRef, Ref} from 'react'; import type {TextInputProps} from 'react-native'; import React from 'react'; import {TextInput} from 'react-native'; type MarkdownTextInputProps = TextInputProps & { - ref?: Ref; + ref?: Ref>; parser?: (value: string) => unknown[]; markdownStyle?: unknown; formatSelection?: unknown; diff --git a/__mocks__/expo-video.tsx b/__mocks__/expo-video.tsx index bc2327c01f9c..beb161827aab 100644 --- a/__mocks__/expo-video.tsx +++ b/__mocks__/expo-video.tsx @@ -1,4 +1,4 @@ -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; import type {ViewProps} from 'react-native'; /* eslint-disable no-underscore-dangle */ @@ -98,7 +98,7 @@ function useVideoPlayer(..._args: unknown[]): VideoPlayer { */ type VideoViewProps = ViewProps & {player?: VideoPlayer}; -const VideoView = forwardRef((props: VideoViewProps, ref: ForwardedRef) => ( +const VideoView = forwardRef((props: VideoViewProps, ref: ForwardedRef>) => ( \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__ice-cream-number-2.svg b/assets/images/simple-illustrations/simple-illustration__ice-cream-number-2.svg new file mode 100644 index 000000000000..0b4adf5f241c --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__ice-cream-number-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__magnifyingglass-chart.svg b/assets/images/simple-illustrations/simple-illustration__magnifyingglass-chart.svg new file mode 100644 index 000000000000..db42c0e68b31 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__magnifyingglass-chart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/themeDependent/ice-cream_background-fade-dark.svg b/assets/images/themeDependent/ice-cream_background-fade-dark.svg new file mode 100644 index 000000000000..a76b2e20606e --- /dev/null +++ b/assets/images/themeDependent/ice-cream_background-fade-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/themeDependent/ice-cream_background-fade-light.svg b/assets/images/themeDependent/ice-cream_background-fade-light.svg new file mode 100644 index 000000000000..a5fe068d9265 --- /dev/null +++ b/assets/images/themeDependent/ice-cream_background-fade-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 425c06457fb0..cdb13e88f0fa 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -48,7 +48,6 @@ "../../scripts/utils/TSCompilerUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../server/stubs/react-native-reanimated.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../server/victory-chart-renderer/src/initSkiaForCli.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/AccountingConnectionConfirmationModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/components/ActionSheetAwareScrollView/useActionSheetAwareScrollViewRef.ts" "react-hooks/immutability" 1 "../../src/components/ActionSheetAwareScrollView/useActionSheetKeyboardSpacing.ts" "@typescript-eslint/no-deprecated/useScrollViewOffset" 1 "../../src/components/AddPlaidBankAccount.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -88,7 +87,6 @@ "../../src/components/BookTravelButton.tsx" "no-restricted-imports" 1 "../../src/components/ButtonWithDropdownMenu/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/CategoryPicker/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/Charts/BarChart/BarChartContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Charts/hooks/useChartLabelFormats.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Charts/hooks/useChartLabelMeasurements.ts" "rulesdir/no-raw-typography" 1 "../../src/components/Charts/utils/chartFontAssets.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 13 @@ -223,7 +221,6 @@ "../../src/components/MoneyRequestHeader.tsx" "rulesdir/no-direct-personal-details-list" 1 "../../src/components/MoneyRequestHeaderPrimaryAction.tsx" "rulesdir/no-direct-personal-details-list" 1 "../../src/components/MoneyRequestHeaderSecondaryActions.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx" "rulesdir/no-direct-personal-details-list" 1 "../../src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/MoneyRequestReportView/MoneyRequestViewReportFields.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -288,7 +285,6 @@ "../../src/components/Rule/RuleBooleanBase.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Rule/TextBase.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/SAMLLoadingIndicator.tsx" "@typescript-eslint/no-deprecated/fontSize" 1 -"../../src/components/ScreenShareRequestModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/components/ScreenWrapper/ScreenWrapperContainer.tsx" "react-hooks/refs" 2 "../../src/components/Search/chartGroupByConfig.ts" "@typescript-eslint/no-unsafe-type-assertion" 20 "../../src/components/Search/FilterComponents/DateFilterBase.tsx" "react-hooks/set-state-in-effect" 1 @@ -401,7 +397,6 @@ "../../src/components/TransactionItemRow/DataCells/ChatBubbleCell.tsx" "rulesdir/no-raw-typography" 2 "../../src/components/TransactionItemRow/DataCells/MerchantCell.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/TransactionItemRow/EditableCell/usePopoverEditState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/UpdateAppModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/components/UploadFile.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/UserInitialsAvatar.tsx" "@typescript-eslint/no-deprecated/fontSize" 1 "../../src/components/ValidateCodeInput.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -459,7 +454,7 @@ "../../src/hooks/useExpensifyCardRulesList.ts" "rulesdir/no-direct-personal-details-list" 1 "../../src/hooks/useExportedToFilterOptions.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useFilteredOptions.ts" "rulesdir/no-direct-personal-details-list" 1 -"../../src/hooks/useFilterFormValues.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/hooks/useFilterFormValues.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/hooks/useGeographicalStateAndCountryFromRoute.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/hooks/useHoldMenuSubmit.ts" "rulesdir/no-direct-personal-details-list" 1 "../../src/hooks/useHtmlPaste/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -484,7 +479,6 @@ "../../src/hooks/useOptimisticNextStep.ts" "rulesdir/no-direct-personal-details-list" 1 "../../src/hooks/useOptimisticPersonalDetails.ts" "rulesdir/no-direct-personal-details-list" 1 "../../src/hooks/useOtherFeedsForFeedSelector.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/hooks/useOutstandingBalanceGuard.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/hooks/usePageRefresh/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useParticipantSubmission.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/usePaymentOptions.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 @@ -789,8 +783,6 @@ "../../src/libs/Navigation/AppNavigator/KeyboardShortcutsHandler/ShortcutsOverviewHandler.tsx" "no-restricted-syntax" 1 "../../src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx" "no-restricted-syntax" 2 -"../../src/libs/Navigation/AppNavigator/Navigators/ReportsSplitNavigator.tsx" "no-restricted-imports" 1 -"../../src/libs/Navigation/AppNavigator/Navigators/ReportsSplitNavigator.tsx" "no-restricted-syntax" 1 "../../src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx" "no-restricted-syntax" 1 "../../src/libs/Navigation/AppNavigator/Navigators/SearchFullscreenNavigator.tsx" "no-restricted-syntax" 1 @@ -822,7 +814,6 @@ "../../src/libs/Navigation/helpers/isRHPOnSearchMoneyRequestReportPage.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/helpers/isTabRouteAtRoot.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/helpers/lastVisitedTabPathUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/Navigation/helpers/linkTo/getMinimalAction.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/helpers/linkTo/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 15 "../../src/libs/Navigation/helpers/navigateToWorkspacesPage.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Navigation/helpers/popReportsSplitNavigatorToReport.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -843,7 +834,7 @@ "../../src/libs/Notification/LocalNotification/BrowserNotifications.ts" "@typescript-eslint/no-deprecated/translateLocal" 1 "../../src/libs/Notification/LocalNotification/BrowserNotifications.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Notification/LocalNotification/BrowserNotifications.ts" "no-restricted-imports" 1 -"../../src/libs/Notification/LocalNotification/BrowserNotifications.ts" "no-restricted-syntax" 2 +"../../src/libs/Notification/LocalNotification/BrowserNotifications.ts" "no-restricted-syntax" 1 "../../src/libs/Notification/PushNotification/parsePushNotificationPayload.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../src/libs/Notification/PushNotification/shouldShowPushNotification.ts" "no-restricted-syntax" 2 "../../src/libs/Notification/PushNotification/subscribeToPushNotifications.ts" "no-restricted-syntax" 1 @@ -857,9 +848,7 @@ "../../src/libs/Parser.ts" "rulesdir/no-onyx-connect" 2 "../../src/libs/ParsingUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/PaymentUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/libs/PersonalDetailsUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 2 "../../src/libs/PersonalDetailsUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/PersonalDetailsUtils.ts" "rulesdir/no-onyx-connect" 1 "../../src/libs/PhoneNumber.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/PolicyUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 9 "../../src/libs/prepareRequestPayload/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 @@ -944,7 +933,6 @@ "../../src/pages/DynamicReportChangeApproverPage.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/DynamicReportChangeWorkspacePage.tsx" "rulesdir/no-direct-personal-details-list" 3 "../../src/pages/DynamicReportDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/pages/DynamicReportDetailsPage.tsx" "react-hooks/preserve-manual-memoization" 4 "../../src/pages/DynamicReportDetailsPage.tsx" "rulesdir/no-direct-personal-details-list" 1 "../../src/pages/DynamicReportParticipantDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/DynamicReportParticipantsInvitePage.tsx" "rulesdir/no-direct-personal-details-list" 2 @@ -1119,7 +1107,6 @@ "../../src/pages/ReportDescriptionPage.tsx" "no-restricted-imports" 1 "../../src/pages/ReportDescriptionPage.tsx" "no-restricted-syntax" 1 "../../src/pages/ReportSubmitToContent.tsx" "rulesdir/no-direct-personal-details-list" 2 -"../../src/pages/ScheduleCall/ScheduleCallPage.tsx" "react-hooks/preserve-manual-memoization" 1 "../../src/pages/Search/SavedSearchRenamePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/Search/SearchAddApproverPage.tsx" "rulesdir/no-direct-personal-details-list" 1 "../../src/pages/Search/SearchChangeApproverPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1294,7 +1281,6 @@ "../../src/pages/workspace/accounting/xero/XeroTaxesConfigurationPage.tsx" "no-restricted-syntax" 4 "../../src/pages/workspace/accounting/xero/XeroTaxesConfigurationPage.tsx" "rulesdir/no-default-id-values" 1 "../../src/pages/workspace/accounting/xero/XeroTrackingCategoryConfigurationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/workspace/categories/CategorySettingsPage.tsx" "react-hooks/preserve-manual-memoization" 1 "../../src/pages/workspace/categories/WorkspaceCategoriesPage.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/companyCards/BankConnection/index.tsx" "react-hooks/set-state-in-effect" 1 @@ -1308,7 +1294,6 @@ "../../src/pages/workspace/companyCards/WorkspaceVerifyWorkAccountPage.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectFeaturesPage.tsx" "rulesdir/no-direct-personal-details-list" 1 "../../src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx" "rulesdir/no-direct-personal-details-list" 1 -"../../src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx" "react-hooks/preserve-manual-memoization" 2 "../../src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx" "rulesdir/no-direct-personal-details-list" 1 "../../src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 diff --git a/config/repack/rspack.config.mjs b/config/repack/rspack.config.mjs index deff02d265cf..20a98978f4fd 100644 --- a/config/repack/rspack.config.mjs +++ b/config/repack/rspack.config.mjs @@ -51,11 +51,15 @@ const BABEL_PACKAGES = [ // codegenNativeComponent in shipped JS: '@expensify/react-native-live-markdown', '@fullstory/react-native', + '@rnmapbox/maps', '@sentry/react-native', '@shopify/react-native-skia', + '@ua/react-native-airship', 'lottie-react-native', 'react-native-advanced-input-mask', + 'react-native-gesture-handler', 'react-native-keyboard-controller', + 'react-native-pager-view', 'react-native-plaid-link-sdk', 'react-native-reanimated', 'react-native-safe-area-context', diff --git a/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md b/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md index f2d65c7aab14..c942f0d06df3 100644 --- a/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md +++ b/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md @@ -1,7 +1,7 @@ --- title: Upcoming Features – Integrations & Accounting (New Expensify) -description: Internal AgentZero reference for planned and in-development accounting and integration features in New Expensify, including Puzzle.io, Microsoft Dynamics 365 Business Central, DualEntry, NetSuite REST, Greenhouse, Ashby, Lever, Campfire, Workable, and HR imports. Not for public search indexing. -keywords: [upcoming, coming soon, planned, roadmap, Campfire, Puzzle.io, DualEntry, Greenhouse, Ashby, Lever, Workable, Microsoft Dynamics 365, Microsoft Dynamics 365 Business Central, Dynamics, D365, NetSuite REST API, SuiteTalk, merge.dev, HR import, HRIS, OAuth, Integration Server, accounting integration] +description: Internal AgentZero reference for planned and in-development accounting and integration features in New Expensify, including Puzzle.io, Microsoft Dynamics 365 Business Central, NetSuite REST, Greenhouse, Ashby, Lever, Campfire, Workable, and HR imports. Not for public search indexing. +keywords: [upcoming, coming soon, planned, roadmap, Campfire, Puzzle.io, Greenhouse, Ashby, Lever, Workable, Microsoft Dynamics 365, Microsoft Dynamics 365 Business Central, Dynamics, D365, NetSuite REST API, SuiteTalk, merge.dev, HR import, HRIS, OAuth, Integration Server, accounting integration] noindex: true sitemap: false internalScope: For AgentZero and internal tooling only. Use this page to give accurate, forward-looking answers about accounting and integration features that are planned but not yet generally available. Do not expose this URL in user-facing help. diff --git a/docs/articles/new-expensify/chat/Chat-Features-and-Moderation-Tools.md b/docs/articles/new-expensify/chat/Chat-Features-and-Moderation-Tools.md index 6aeedde43e59..07f939b99c3f 100644 --- a/docs/articles/new-expensify/chat/Chat-Features-and-Moderation-Tools.md +++ b/docs/articles/new-expensify/chat/Chat-Features-and-Moderation-Tools.md @@ -2,7 +2,7 @@ title: Chat Features and Moderation Tools description: Learn how to send, format, edit, delete, report, and manage messages and chat membership in New Expensify. -keywords: [New Expensify, chat, edit messages, report chat, add members, leave room, thread] +keywords: [New Expensify, chat, edit messages, report chat, add members, leave room, thread, add attachment, take photo] --- @@ -18,15 +18,32 @@ You can send messages, attach files, and add emojis from the message bar at the **On Web:** 1. Open any chat from your inbox. -2. Click the field labeled **Write something**, then type your message. +2. Click the field labeled **Write something...**, then type your message. 3. To add an attachment, click the **plus icon** and select **Add attachment**. 4. To add an emoji, click the **emoji icon**. 5. Press **Enter** or click the **Send** icon. -**On Mobile:** +**On the Expensify mobile app (iOS and Android):** 1. Tap any chat from your inbox. -2. Use the message bar to type a message, add an attachment, or insert an emoji. -3. Tap the **Send** icon. +2. Tap the field labeled **Write something...**, then type your message. +3. To add an attachment, tap the **plus icon**, select **Add attachment**, then choose **Take photo**, **Choose from gallery**, or **Choose file**. +4. To add an emoji, tap the **emoji icon**. +5. Tap the **Send** icon. + +✏️ **Note:** The **Take photo**, **Choose from gallery**, and **Choose file** options are available on the iOS and Android apps only. On mobile web, **Add attachment** opens your device’s file picker instead. + +## How to take a photo to attach to a chat message (iOS and Android only) + +1. Open any chat from your inbox. +2. Tap the **plus icon**, then select **Add attachment**. +3. Tap **Take photo**. +4. The first time you do this, your device asks for camera access — allow it. If you declined earlier, Expensify shows a **Take photo** screen with a **Continue** button instead; tap **Continue** to ask for access again. +5. Tap the **flash** icon on the left to turn the flash on or off, or the **flip camera** icon on the right to switch between the front and back cameras. +6. Tap the **shutter** button in the center to capture the photo. The photo is sent to the chat immediately — there is no preview screen and no separate send step. If you already typed a message, it is sent along with the photo. + +To leave the camera without capturing a photo, tap the **X** in the top-right corner. + +The **flash** and **flip camera** icons are hidden when your device has no flash or only one camera. ## Formatting Messages diff --git a/docs/articles/new-expensify/connections/Connect-BambooHR-to-Expensify.md b/docs/articles/new-expensify/connections/Connect-BambooHR-to-Expensify.md index 3e90a460bf8a..17c1a9c818f6 100644 --- a/docs/articles/new-expensify/connections/Connect-BambooHR-to-Expensify.md +++ b/docs/articles/new-expensify/connections/Connect-BambooHR-to-Expensify.md @@ -103,7 +103,7 @@ To refresh the sync manually: 3. In the left menu, click **HR**. 4. Click **BambooHR**. 5. Select the three dots **(⋮)**, then click **Sync now**. -6. Wait for the sync to complete. The sync results will display a summary of any changes. +6. Stay on the page and wait for the sync to complete. The **BambooHR sync complete** screen then opens with the **Added**, **Removed**, and **Skipped** employee counts. Manual syncs are limited to two per day. If you've reached the limit, you'll see **You've reached your sync limit for the day**. @@ -130,7 +130,7 @@ No. BambooHR syncs all employees to a single Expensify workspace. You must choos ## Why were some employees skipped during sync? -Employees may be skipped if they do not have a work email address in BambooHR, or if they are not active employees. The sync results panel will provide specific reasons for each skipped employee. +Employees may be skipped if they do not have a work email address in BambooHR, or if they are not active employees. On the **BambooHR sync complete** screen, click **Skipped** to see each skipped employee and the reason they were skipped. ## Will my employees receive a notification when synced? diff --git a/docs/articles/new-expensify/connections/Connect-Gusto-to-Expensify.md b/docs/articles/new-expensify/connections/Connect-Gusto-to-Expensify.md index 3335b66c5144..43902496f119 100644 --- a/docs/articles/new-expensify/connections/Connect-Gusto-to-Expensify.md +++ b/docs/articles/new-expensify/connections/Connect-Gusto-to-Expensify.md @@ -45,8 +45,8 @@ Gusto syncs all employees to **one** Expensify workspace. If your company uses m After the initial sync completes: -- A sync results panel displays employees added, removed, and skipped. -- If you leave the **HR** or **Members** tab before the sync finishes, the sync results are emailed to the workspace's technical contact instead of displaying in the app. +- The **Gusto sync complete** screen opens with the **Added**, **Removed**, and **Skipped** employee counts. Click **Skipped** to see each skipped employee and the reason they were skipped, then click **Got it** to close the screen. +- If you leave the **HR** or **Members** tab before the sync finishes, the **Gusto sync complete** screen doesn't open, and the sync results are emailed to the workspace's technical contact instead. - The **Members** tab displays employees synced from Gusto. - Full-time active employees in Gusto are added to the Expensify workspace automatically. - Employees receive a welcome email with instructions to finish setting up their Expensify account. @@ -91,9 +91,14 @@ To refresh the sync manually: 1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces**. 2. Click the name of the workspace connected to Gusto. 3. In the left menu, click **HR**. -4. In the left menu, click **Members**. -5. Click the **Re-sync** button in the Gusto section. -6. Wait for the sync to complete. The sync results will display a summary of any changes. +4. Next to **Gusto**, select the three dots **(⋮)**, then click **Sync now**. +5. Stay on the page and wait for the sync to complete. The **Gusto sync complete** screen then opens with the **Added**, **Removed**, and **Skipped** employee counts. + +You can also start a manual sync from the **Members** tab: + +1. In the left menu, click **Members**. +2. Click **More**, then click **Sync with Gusto**. +3. Stay on the page and wait for the sync to complete. The **Gusto sync complete** screen then opens with the **Added**, **Removed**, and **Skipped** employee counts. --- @@ -118,7 +123,7 @@ No. Gusto syncs all employees to a single Expensify workspace. You must choose o ## Why were some employees skipped during sync? -Employees may be skipped if they do not have a work email address in Gusto, or if they are not classified as full-time active employees. The sync results panel will provide specific reasons for each skipped employee. +Employees may be skipped if they do not have a work email address in Gusto, or if they are not classified as full-time active employees. On the **Gusto sync complete** screen, click **Skipped** to see each skipped employee and the reason they were skipped. ## Will my employees receive a notification when synced? diff --git a/docs/articles/new-expensify/connections/Connect-HiBob-to-Expensify.md b/docs/articles/new-expensify/connections/Connect-HiBob-to-Expensify.md index dbe1a0e293e2..ff4789b92a53 100644 --- a/docs/articles/new-expensify/connections/Connect-HiBob-to-Expensify.md +++ b/docs/articles/new-expensify/connections/Connect-HiBob-to-Expensify.md @@ -103,7 +103,7 @@ To refresh the sync manually: 3. In the left menu, click **HR**. 4. Click **HiBob**. 5. Select the three dots **(⋮)**, then click **Sync now**. -6. Wait for the sync to complete. The sync results will display a summary of any changes. +6. Stay on the page and wait for the sync to complete. The **HiBob sync complete** screen then opens with the **Added**, **Removed**, and **Skipped** employee counts. Manual syncs are limited to two per day. If you've reached the limit, you'll see **You've reached your sync limit for the day**. @@ -130,7 +130,7 @@ No. HiBob syncs all employees to a single Expensify workspace. You must choose o ## Why were some employees skipped during sync? -Employees may be skipped if they do not have a work email address in HiBob, or if they are not active employees. The sync results panel will provide specific reasons for each skipped employee. +Employees may be skipped if they do not have a work email address in HiBob, or if they are not active employees. On the **HiBob sync complete** screen, click **Skipped** to see each skipped employee and the reason they were skipped. ## Will my employees receive a notification when synced? diff --git a/docs/articles/new-expensify/connections/Connect-Workday-to-Expensify.md b/docs/articles/new-expensify/connections/Connect-Workday-to-Expensify.md index 509019ec3adb..cb34475ca725 100644 --- a/docs/articles/new-expensify/connections/Connect-Workday-to-Expensify.md +++ b/docs/articles/new-expensify/connections/Connect-Workday-to-Expensify.md @@ -103,7 +103,7 @@ To refresh the sync manually: 3. In the left menu, click **HR**. 4. Click **Workday**. 5. Select the three dots **(⋮)**, then click **Sync now**. -6. Wait for the sync to complete. The sync results will display a summary of any changes. +6. Stay on the page and wait for the sync to complete. The **Workday sync complete** screen then opens with the **Added**, **Removed**, and **Skipped** employee counts. Manual syncs are limited to two per day. If you've reached the limit, you'll see **You've reached your sync limit for the day**. @@ -130,7 +130,7 @@ No. Workday syncs all employees to a single Expensify workspace. You must choose ## Why were some employees skipped during sync? -Employees may be skipped if they do not have a work email address in Workday, or if they are not active employees. The sync results panel will provide specific reasons for each skipped employee. +Employees may be skipped if they do not have a work email address in Workday, or if they are not active employees. On the **Workday sync complete** screen, click **Skipped** to see each skipped employee and the reason they were skipped. ## Will my employees receive a notification when synced? diff --git a/docs/articles/new-expensify/connections/Connect-an-HR-integration-to-Expensify.md b/docs/articles/new-expensify/connections/Connect-an-HR-integration-to-Expensify.md index e93c0d22ae43..ce782a690ce3 100644 --- a/docs/articles/new-expensify/connections/Connect-an-HR-integration-to-Expensify.md +++ b/docs/articles/new-expensify/connections/Connect-an-HR-integration-to-Expensify.md @@ -89,6 +89,7 @@ For provider-specific setup details, see the dedicated articles for [BambooHR](/ After the initial sync finishes: +- A sync complete screen opens — named for your provider, such as **Workday sync complete** — with the **Added**, **Removed**, and **Skipped** employee counts. Click **Skipped** to see each skipped employee and the reason they were skipped, then click **Got it** to close the screen. The screen only opens if you stay on the **HR** or **Members** tab while the sync runs. - The connection displays the **Last synced** timestamp. - Active employees in your HR system are added to the workspace. - Employees who don't already have an Expensify account receive an email invitation to finish setting up their account. diff --git a/docs/articles/new-expensify/connections/TriNet.md b/docs/articles/new-expensify/connections/TriNet.md index eed89ab94c4e..35dc466fe1b5 100644 --- a/docs/articles/new-expensify/connections/TriNet.md +++ b/docs/articles/new-expensify/connections/TriNet.md @@ -45,8 +45,8 @@ TriNet syncs all employees to **one** Expensify workspace. If your company uses After the initial sync completes: -- A sync results panel displays employees added, removed, and skipped. -- If you leave the **HR** or **Members** tab before the sync finishes, the sync results are emailed to the workspace's technical contact instead of displaying in the app. +- The **TriNet sync complete** screen opens with the **Added**, **Removed**, and **Skipped** employee counts. Click **Skipped** to see each skipped employee and the reason they were skipped, then click **Got it** to close the screen. +- If you leave the **HR** or **Members** tab before the sync finishes, the **TriNet sync complete** screen doesn't open, and the sync results are emailed to the workspace's technical contact instead. - The **Members** tab displays employees synced from TriNet. - Full-time active employees in TriNet are added to the Expensify workspace automatically. - Employees receive a welcome email with instructions to finish setting up their Expensify account. @@ -93,9 +93,14 @@ To refresh the sync manually: 1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces**. 2. Click the name of the workspace connected to TriNet. 3. In the left menu, click **HR**. -4. Next to **TriNet**, click the more options icon (three dots). -5. Click **Sync now**. -6. Wait for the sync to complete. The sync results will display a summary of any changes. +4. Next to **TriNet**, select the three dots **(⋮)**, then click **Sync now**. +5. Stay on the page and wait for the sync to complete. The **TriNet sync complete** screen then opens with the **Added**, **Removed**, and **Skipped** employee counts. + +You can also start a manual sync from the **Members** tab: + +1. In the left menu, click **Members**. +2. Click **More**, then click **Sync with TriNet**. +3. Stay on the page and wait for the sync to complete. The **TriNet sync complete** screen then opens with the **Added**, **Removed**, and **Skipped** employee counts. --- @@ -120,7 +125,7 @@ No. TriNet syncs all employees to a single Expensify workspace. You must choose ## Why were some employees skipped during sync? -Employees may be skipped if they do not have a work email address in TriNet, or if they are not classified as full-time active employees. The sync results panel will provide specific reasons for each skipped employee. +Employees may be skipped if they do not have a work email address in TriNet, or if they are not classified as full-time active employees. On the **TriNet sync complete** screen, click **Skipped** to see each skipped employee and the reason they were skipped. ## Will my employees receive a notification when synced? diff --git a/docs/articles/new-expensify/connections/rillet/Connect-to-Rillet.md b/docs/articles/new-expensify/connections/rillet/Connect-to-Rillet.md index 48f322243896..61eae1d34973 100644 --- a/docs/articles/new-expensify/connections/rillet/Connect-to-Rillet.md +++ b/docs/articles/new-expensify/connections/rillet/Connect-to-Rillet.md @@ -16,8 +16,6 @@ Once connected, the integration imports: - Departments, Projects, and Classes as tags. - Tax rates (when available). -The Rillet integration is currently in beta and is available to select customers. - --- ## Who can connect to Rillet diff --git a/docs/articles/new-expensify/domains/Claim-and-Verify-a-Domain.md b/docs/articles/new-expensify/domains/Claim-and-Verify-a-Domain.md index 518b6c76489d..95d50b08fef4 100644 --- a/docs/articles/new-expensify/domains/Claim-and-Verify-a-Domain.md +++ b/docs/articles/new-expensify/domains/Claim-and-Verify-a-Domain.md @@ -1,8 +1,8 @@ --- title: Claim and Verify a Domain description: Learn how to claim and verify a private domain in New Expensify to enable SAML login and unlock enhanced security features. -internalScope: Audience is Domain Admins and IT admins. Covers claiming a domain, requesting admin access to a domain someone else already set up, and verifying domain ownership via DNS. Does not cover SAML configuration details, Domain Members management, Domain Groups, or login troubleshooting. -keywords: [New Expensify, claim domain, verify domain, private domain, domain settings, enhanced security, SAML, domain admin, domain verification, DNS TXT record, verify domain for SAML, how to verify domain, SAML setup, domain already set up, domain exists, ask for admin access, request admin access, domain already exists in your account] +internalScope: Audience is Domain Admins and IT admins. Covers claiming a domain, requesting admin access to a domain someone else already set up (from both the Domain exists page and the Access restricted screen), and verifying domain ownership via DNS. Does not cover SAML configuration details, Domain Members management, Domain Groups, or login troubleshooting. +keywords: [New Expensify, claim domain, verify domain, private domain, domain settings, enhanced security, SAML, domain admin, domain verification, DNS TXT record, verify domain for SAML, how to verify domain, SAML setup, domain already set up, domain exists, ask for admin access, request admin access, access restricted, verification required, verify yourself, request sent, domain already exists in your account] --- If you have a private domain (e.g., yourcompany.com), you can claim and verify it in Expensify to manage employee permissions and enable additional security features. @@ -47,7 +47,7 @@ Depending on your email setup: After the domain is successfully claimed, the domain will appear on the **Domains** tab in the **Not verified** state. -## How to request admin access to a domain someone else set up +## How to request admin access when you add a domain someone else set up A private domain can only be set up once. If another Expensify account already set up your domain, you can ask that domain’s admins to make you a Domain Admin. @@ -62,15 +62,33 @@ To leave without sending a request, click **Never mind** instead. Both buttons r +## How to request admin access from the Access restricted screen + +If a domain is already on your **Domains** tab but you are not a Domain Admin for it, opening that domain takes you to the **Access restricted** screen instead of the domain’s settings. You can ask for admin access from there. + +1. From the left-hand menu, select **Workspaces**, then select the **Domains** tab. +2. Select the domain you are not an admin of. +3. On the **Access restricted** screen, under the **Verification required** heading, click **Request admin access**. + +The **Request admin access** button shows a spinner while the request sends, then changes to a disabled **Request sent** button. If the request fails, an error message appears above **Request admin access** and **Verify yourself**, and you can click **Request admin access** again to retry. **Request admin access** is disabled while you are offline. + +To prove you are an authorized company administrator yourself instead of waiting on an existing admin, click **Verify yourself** on the same screen to start DNS verification. + + + ## What happens after you ask for admin access - Your request goes to the existing Domain Admins for that domain. An admin has to add you before you can manage it. -- The domain does **not** appear on your **Domains** tab while the request is pending, because you don’t have access to it yet. -- If you start the flow again for the same domain before an admin responds, the **Domain exists** page shows a disabled **Request sent** button so you don’t send a duplicate request. +- A domain you asked about from the **Domain exists** page does **not** appear on your **Domains** tab while the request is pending, because you don’t have access to it yet. +- If you ask again for the same domain before an admin responds, the button is disabled and reads **Request sent**, so you don’t send a duplicate request. This applies on both the **Domain exists** page and the **Access restricted** screen. [Learn how to add and manage Domain Admins](/articles/new-expensify/domains/Domain-Admins) to see what you'll be able to do once an admin adds you. @@ -104,10 +122,18 @@ Each private domain can only be set up once, and someone at your company got the Requesting access doesn't give you access. The domain only appears on your **Domains** tab after an existing Domain Admin adds you as a Domain Admin. +## Why do I see Access restricted when I open a domain? + +The domain is on your **Domains** tab, but you are not a Domain Admin for it, so you can't open its settings. Click **Request admin access** to ask an existing Domain Admin to add you, or click **Verify yourself** to verify that you're an authorized company administrator. + ## Why does the button say Request sent? You already asked for admin access to that domain and no admin has responded yet. The button is disabled so you don't send a duplicate request. +## Why can't I click Request admin access? + +The button is disabled while you're offline, and it's also disabled after your request goes through, when it reads **Request sent**. Reconnect and reopen the **Access restricted** screen if you're offline. + ## Can I verify multiple domains? Yes, you can claim and verify multiple domains if your organization operates under more than one. Each must be verified through its own DNS record. diff --git a/docs/articles/new-expensify/domains/Create-and-Manage-Domain-Groups.md b/docs/articles/new-expensify/domains/Create-and-Manage-Domain-Groups.md index 09a99e91464e..7c4f2a8f9974 100644 --- a/docs/articles/new-expensify/domains/Create-and-Manage-Domain-Groups.md +++ b/docs/articles/new-expensify/domains/Create-and-Manage-Domain-Groups.md @@ -1,8 +1,8 @@ --- title: Create and Manage Domain Groups description: Learn how to create and manage domain groups (domain security groups) to apply different permission rules to different sets of domain members. -internalScope: Audience is Domain Admins. Covers creating and managing Domain Groups (Domain Security Groups) and what each group permission controls. Does not cover workspace-level rules configuration or troubleshooting user access issues. -keywords: [New Expensify, domain groups, domain security groups, how to create domain group, restrict workspace creation, enforce workspace rules, require company email, preferred workspace, Expensify Card preferred workspace, domain permissions, Domain Admin] +internalScope: Audience is Domain Admins. Covers creating and managing Domain Groups (Domain Security Groups), setting a Preferred Workspace for a group, and what each group permission controls. Does not cover workspace-level rules configuration or troubleshooting user access issues. +keywords: [New Expensify, domain groups, domain security groups, how to create domain group, restrict workspace creation, enforce workspace rules, restrict default login selection, preferred workspace, search preferred workspace, find a workspace in domain group, Expensify Card preferred workspace, domain permissions, Domain Admin] ---
@@ -27,7 +27,7 @@ Only **Domain Admins** can create and manage Domain Groups. 1. Click the navigation tabs (on the left on web, on the bottom on mobile). 2. Click **Workspaces**. -3. Scroll below your workspaces list to find **Domains**. +3. Click **Domains**. 4. Click your domain. 5. Click **Groups**. @@ -37,37 +37,60 @@ Only **Domain Admins** can create and manage Domain Groups. Follow the steps in **Where to find Domain Groups** above, then: -1. Click **Create group**. -2. Configure the group settings and permissions (see **What Domain Group permission settings control** below). -3. Click **Save**. +1. Click **New group**. +2. Enter a name for the group. +3. Configure the group settings and permissions (see **What Domain Group permission settings control** below). +4. Click **Create group**. + +--- + +## How to set a Preferred Workspace for a Domain Group + +Follow the steps in **Where to find Domain Groups** above, then: + +1. Click the group you want to update. +2. Turn on **Preferred Workspace**. +3. Click the **Preferred Workspace** row that appears below the toggle. +4. If you administer 12 or more workspaces, a **Search** field is shown above the list. Type part of a workspace name to filter the list. +5. Click the workspace you want to use. + +The list shows only the workspaces you administer. If a search returns nothing, **No results found** is shown, so clear the search and try a different name. + +You can set the **Preferred Workspace** the same way while creating a group, before you click **Create group**. + + --- ## What Domain Group permission settings control -## What the Default group setting does +## What the Default group for new members setting does -Enable this if you want all new domain members to be automatically added to this group. Setting a Default Group ensures new employees receive the correct permissions immediately. +Enable this if you want all new domain members to be automatically added to this group. Setting a default group ensures new employees receive the correct permissions immediately. -## What Strictly enforce expense workspace rules does +## What Strictly enforce workspace rules does -Use this to ensure workspace-level rules are followed before a report is submitted. Enabling Strictly enforce expense workspace rules ensures workspace rule compliance and prevents incomplete submissions. +Use this to ensure workspace-level rules are followed before a report is submitted. Enabling **Strictly enforce workspace rules** ensures workspace rule compliance and prevents incomplete submissions. -## What Restrict primary contact method selection does +## What Restrict default login selection does -Enable this to require members to use their company email address to access Expensify. Restricting primary contact method selection prevents members from using a personal email to access their Expensify account. +Enable this to require members to use their company email address to access Expensify. **Restrict default login selection** prevents members from changing their login email away from your company domain. ## What Restrict expense workspace creation/removal does -Enable this to prevent members from creating or removing workspaces. Restrict expense workspace creation/removal ensures centralized workspace management and prevents employees from creating additional workspaces outside the company’s approved setup. +Enable this to prevent members from creating or removing workspaces. **Restrict expense workspace creation/removal** ensures centralized workspace management and prevents employees from creating additional workspaces outside the company’s approved setup. -## What Preferred workspace does +## What Preferred Workspace does -Set a preferred workspace to automatically route a group’s expenses and reports to a specific workspace. This is helpful if different members use different workspaces and you want to reduce manual workspace selection. If you have multiple workspaces, use this to route a group’s expenses to the right workspace by default. +Set a **Preferred Workspace** to automatically route a group’s expenses and reports to a specific workspace. This is helpful if different members use different workspaces and you want to reduce manual workspace selection. If you have multiple workspaces, use this to route a group’s expenses to the right workspace by default. ## What Expensify Card preferred workspace does -If a preferred workspace is set, enable this option to automatically post **Expensify Card** transactions to that workspace. This ensures transactions are routed correctly and reconciliation is simplified. +If a **Preferred Workspace** is set, enable this option to automatically post **Expensify Card** transactions to that workspace instead. This ensures transactions are routed correctly and reconciliation is simplified. Your domain must have Expensify Cards set up before you can enable it. --- @@ -81,12 +104,20 @@ A Domain Group controls permissions and access settings at the domain level. A W No, each member can only belong to one Domain Group. -## What happens if I set a Default group? +## What happens if I set a default group? New domain members are automatically assigned to that group, ensuring they receive the correct permissions immediately. -## Does Preferred workspace move existing expenses? +## Does Preferred Workspace move existing expenses? + +No. **Preferred Workspace** applies to new expenses and reports going forward. + +## Why don’t I see a Search field on the Preferred Workspace page? + +The **Search** field is shown only when you administer 12 or more workspaces. With fewer than 12, the full list is short enough to scroll, so no search field is shown. + +## Which workspaces appear on the Preferred Workspace page? -No. Preferred workspace applies to new expenses and reports going forward. +Only the workspaces you administer. If a workspace is missing, ask a Workspace Admin of that workspace to add you as an admin.
diff --git a/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md b/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md index 15a85d1a7e4f..b618c589a432 100644 --- a/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md +++ b/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md @@ -102,6 +102,20 @@ Your workspace also has built-in default protections that automatically block ce ![Click Settings to adjust the settlement account or frequency]({{site.url}}/assets/images/ExpensifyHelp-ExpensifyCard_08.png){:width="100%"} +## How to change your Expensify Card settlement frequency + +Settlement frequency controls how often Expensify pulls from your settlement account to pay your Expensify Card balance. + +1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Workspaces > [Workspace Name] > Expensify Card**. +2. Click **Settings**. +3. Click **Settlement frequency**. +4. Select **Daily** or **Monthly**. +5. Click **Save**. + +Selecting an option does not change the setting on its own — the new frequency is saved only when you click **Save**. **Save** stays disabled until the option you select is different from the one currently saved, so leaving the page without clicking **Save** keeps your existing frequency. After you click **Save**, you return to the Expensify Card **Settings** page and the **Settlement frequency** row shows the new value. + +To switch to **Monthly**, you'll need to connect your bank account via Plaid and have a positive 90-day balance history. + ## When your Expensify Cards settle each month When your settlement frequency is set to **Monthly**, the **Settings** page shows the recurring day of the month your cards settle. On the Expensify Card **Settings** page, the **Settlement frequency** row displays supporting text below it, such as *Expensify cards will settle on the 27th of each month.* diff --git a/docs/articles/new-expensify/getting-started/Expensify-Home-Overview.md b/docs/articles/new-expensify/getting-started/Expensify-Home-Overview.md index 74c5a65093c1..433a0af914d9 100644 --- a/docs/articles/new-expensify/getting-started/Expensify-Home-Overview.md +++ b/docs/articles/new-expensify/getting-started/Expensify-Home-Overview.md @@ -1,7 +1,7 @@ --- title: Expensify Home Overview description: Learn what you’ll see on Home and how it helps you understand what needs attention, get oriented, and stay informed when you sign in. -keywords: [New Expensify, Expensify Home, Home, Home tab, For you, Recently added, recently added expenses, view all expenses, Discover, Announcements, time-sensitive, urgent alerts, Expensify Card fraud, broken bank connection, Your spend, awaiting approval, repaid, recent transactions, Expensify Card, Spend over time, spending chart, insights, insight switcher, Top spenders, Top categories, Top merchants, Chart unavailable, Getting started, onboarding checklist, track and budget my expenses, organize my personal spending, personal spending, customize spend categories, link personal card, Upcoming travel, upcoming trips on Home, flight to, hotel in, car rental in, train to, traveler] +keywords: [New Expensify, Expensify Home, Home, Home tab, For you, Recently added, recently added expenses, view all expenses, Discover, Announcements, time-sensitive, urgent alerts, Expensify Card fraud, broken bank connection, Your spend, awaiting approval, repaid, recent transactions, Expensify Card, Spend over time, spending chart, insights, insight switcher, Top spenders, Top categories, Top merchants, Chart unavailable, Getting started, onboarding checklist, track and budget my expenses, organize my personal spending, personal spending, customize spend categories, link personal card, Upcoming travel, upcoming trips on Home, flight to, hotel in, car rental in, train to, traveler, canceled reservation, canceled booking, canceled trip missing from Home] internalScope: Audience is all Expensify members. Covers what Home is and what appears on it, including the For you, Recently added, Upcoming travel, Spend over time and other insights, Discover, Announcements, Time-sensitive, Getting started, and Your spend sections. Does not cover how to complete actions like submitting, approving, paying, exporting, or fixing expenses, and does not cover booking or managing travel. --- @@ -97,7 +97,7 @@ Each row shows the reservation and when it starts, for example: - **Car rental in Lisbon** — Today - **Train to Porto** — In 1 day -Canceled reservations stay in the list and are labeled **Canceled**. +Canceled reservations are left out of this section, so it only lists travel you’re still expected to take. A reservation that is canceled disappears from **Upcoming travel** but stays in the trip itself, where it’s labeled **Canceled**. Select any row to open the full details for that reservation. @@ -206,7 +206,11 @@ The **Time-sensitive** section only appears when there is an urgent issue or lim ## Why don’t I see the Upcoming travel section? -The **Upcoming travel** section only appears when you have at least one reservation starting in the next 7 days on a trip where you are listed as a traveler. If you have no reservation in that window, or the only upcoming trips are ones you booked for someone else, the section won’t be visible. +The **Upcoming travel** section only appears when you have at least one reservation starting in the next 7 days on a trip where you are listed as a traveler. Canceled reservations don’t count toward this. If you have no reservation in that window, the only reservations in that window are canceled, or the only upcoming trips are ones you booked for someone else, the section won’t be visible. + +## Why did my canceled booking disappear from the Upcoming travel section? + +Canceling a booking removes it from **Upcoming travel** on Home, because that section is meant to show only the travel you’re still expected to take. The canceled booking isn’t deleted—open the trip to see it listed with the **Canceled** label. To cancel a booking, learn how to [cancel travel arrangements](/articles/travel/managing-trips/Canceling-Travel-Arrangements). ## Why don’t I see the Spend over time section? diff --git a/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md index 2c5df39332c8..e2618428e85c 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md @@ -1,7 +1,7 @@ --- title: Distance Expenses description: Learn how to create a Distance expense using GPS tracking, map-based routes, manual entry, or odometer readings, and how the reimbursement rate is determined in New Expensify. -keywords: [New Expensify, distance expense, mileage, mileage reimbursement, create expense, distance rate, workspace rate, map route, reimbursement rate, manual mileage, manual distance, global create, track distance, GPS, GPS tracking, start GPS, track route, track mileage, mileage tracking, calculate mileage reimbursement, mileage rate, odometer, odometer reading, odometer image, odometer mileage, odometer distance, start reading, end reading, save for later, in-progress odometer, incomplete odometer, edit distance, edit waypoints, manual edit distance, edit stop, trim trip, edit GPS trip, shorten GPS route, GPS slider, total distance] +keywords: [New Expensify, distance expense, mileage, mileage reimbursement, create expense, distance rate, workspace rate, map route, reimbursement rate, manual mileage, manual distance, global create, track distance, GPS, GPS tracking, start GPS, track route, track mileage, mileage tracking, calculate mileage reimbursement, mileage rate, odometer, odometer reading, odometer image, odometer mileage, odometer distance, start reading, end reading, save for later, in-progress odometer, incomplete odometer, edit distance, edit waypoints, manual edit distance, edit stop, trim trip, edit GPS trip, shorten GPS route, GPS slider, total distance, stop GPS trip, stop without moving, resume GPS trip, discard GPS trip, zero distance trip, same start and stop location, no edit icon, can't create expense] internalScope: Audience is all members. Covers creating Distance expenses using GPS tracking, map-based routes, manual entry, and odometer readings, plus how reimbursement rates are applied. Does not cover configuring Workspace distance rates in detail or broader report submission workflows. --- @@ -181,7 +181,17 @@ The GPS receipt shows a map of your actual route driven, along with the total di ## Can I edit a GPS trip after I stop tracking? -Yes. After you tap **Stop**, tap the pencil **Edit** icon next to the distance counter to open the **Edit stop** screen. Drag the slider to move the end point back to an earlier stop along your recorded route — the map route and the **Total distance** value update live as you drag — then tap **Save**. The GPS screen then shows the trimmed distance and updated end address. To restore the full trip, open **Edit stop** again and drag the slider all the way to the right. Tapping **Resume** and **Stop** also resets any trim and shows the full trip again. +Yes, as long as the trip is long enough to trim. After you tap **Stop**, tap the pencil **Edit** icon next to the distance counter to open the **Edit stop** screen. Drag the slider to move the end point back to an earlier stop along your recorded route — the map route and the **Total distance** value update live as you drag — then tap **Save**. The GPS screen then shows the trimmed distance and updated end location. To restore the full trip, open **Edit stop** again and drag the slider all the way to the right. Tapping **Resume** and **Stop** also resets any trim and shows the full trip again. + +The pencil **Edit** icon does not appear on a very short trip, such as one you stop without driving anywhere, because there is nothing to trim. + +## What happens if I start a GPS trip and tap **Stop** without moving? + +The trip stays on screen with a distance of 0.00, your current location shown under both **Start** and **Stop**, and the **Resume** and **Save** buttons available. Tap **Resume** to keep recording from where you are, or tap the trash icon next to the distance counter and confirm to discard the trip. + +A trip with a distance of 0.00 can't be turned into an expense. Tapping **Save** shows a **Can't create expense** message. Tap **Got it** to go back to the trip, then resume or discard it. + +If you tap **Stop** right after **Start**, before Expensify finds your location, no trip is kept and the **Start** button shows again. ## Can I use GPS tracking on web or desktop? diff --git a/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md b/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md index 1e1b8d00e6c6..093ac47a30d9 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md +++ b/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md @@ -1,7 +1,7 @@ --- title: Getting Started with the Spend Page description: Learn how to use the Spend page in New Expensify to view, filter, and manage your expense data using report previews, tables, filters, and smart suggestions. -keywords: [New Expensify, Spend page, report preview, report table, filters, advanced filters, smart suggestions, expense management, add expenses, bulk actions, date range, search, expenses missing, Spend navigation, inline editing, edit expense inline, filter popover, apply filters, reset filters, total spend, total spend currency, change currency, currency converter, multi-currency total] +keywords: [New Expensify, Spend page, report preview, report table, filters, advanced filters, smart suggestions, expense management, add expenses, bulk actions, date range, search, expenses missing, Spend navigation, inline editing, edit expense inline, filter popover, apply filters, reset filters, total spend, total spend currency, change currency, currency converter, multi-currency total, sort by amount, Amount column, sort amount column, negative amount, credit, refund, negative expense sorting, selected total, total spend negative] --- The Spend page in New Expensify gives you a full list of your reports and related expenses. From the Spend page you can explore, filter, and export your expense data. @@ -97,6 +97,8 @@ The **Spend** page footer shows the **Total spend** for what you're viewing. Whe - **Total spend:** the combined amount, shown with a currency code. With nothing selected, this covers the whole search result; when you select rows, it covers just your selection. - **Expenses:** the number of expenses currently selected (shown when you've made a selection). +Credits and refunds are negative amounts, so they reduce the **Total spend** instead of adding to it. If you select a $100 expense and a -$100 credit, the **Total spend** reads $0.00. + The total is shown in your default currency until you choose a different one. ### How to change the total spend currency @@ -124,6 +126,34 @@ The currency picker can't be opened in these cases: --- +## How to sort the Amount column on the Spend page + +On a wide screen: + +1. On the **Spend** page, select **Expenses**. +2. Click the **Amount** column header to sort from largest to smallest. +3. Click the **Amount** column header again to reverse the order. + +On mobile, or in a narrow browser window, the column headers aren't shown, so sort from the **Display** menu instead: + +1. On the **Spend** page, select **Expenses**. +2. Tap the **Display** icon. +3. Tap **Sort by**, select **Amount**, then tap **Apply**. +4. To change the direction, tap the **Display** icon again, tap **Sort by**, tap **Sort order**, select **Descending** (largest to smallest) or **Ascending** (smallest to largest), then tap **Apply**. + +The **Amount** column is only sortable when your results are shown as a table. When you group your results, this column reads **Total** instead of **Amount**. + +## How negative amounts sort in the Amount column on the Spend page + +The **Amount** column sorts by the signed value each row displays, not by its size. Because credits and refunds are negative amounts: + +- Sorting from largest to smallest puts every negative amount below every positive amount, ordered -$1, -$2, -$3 going down. +- Sorting from smallest to largest puts every negative amount above every positive amount. + +A -$4,000 credit therefore sorts below a $10 expense rather than next to a $4,000 expense. + +--- + # Filters ## How to apply filters in New Expensify diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md index ffdb7b5a68bc..a98b889237a0 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md @@ -7,17 +7,12 @@ internalScope: Audience is all members. Covers how to duplicate a single expense # How to duplicate an expense -You can create a copy of one or more expenses using **Duplicate expense**. This generates new expenses on your primary workspace with the same details as the originals, including the merchant, amount, category, tags, and tax. The date is set to today and receipt images are not copied. +You can create a copy of one or more expenses using **Duplicate expense**. This generates new expenses with the same details as the originals, including the merchant, amount, category, tags, and tax. The date is set to today and receipt images are not copied. An expense that is on a report is copied onto your primary workspace, while an Unreported expense is copied onto your personal space. This is useful when you need to create similar expenses quickly without re-entering all the details manually. You can duplicate a single expense or select multiple expenses and duplicate them all at once. **Note:** Card expenses cannot be duplicated. Only cash, distance, and per diem expenses are eligible to be duplicated. - - --- ## Who can duplicate an expense @@ -68,7 +63,9 @@ Purpose: Helps the user identify the bulk duplicate option in the dropdown. ## What happens after you duplicate an expense -The duplicate expense is created on your primary workspace with the original expense's merchant, amount, category, tags, tax, and billable or reimbursable status. The date is set to today and receipt images are not included. +The duplicate expense keeps the original expense's merchant, amount, category, tags, tax, and billable or reimbursable status. The date is set to today and receipt images are not included. + +The duplicate matches the reported state of the original. If the original expense is on a report, the copy is created on your primary workspace. If the original is an Unreported expense, the copy is created on your personal space and is not added to a report. If the original expense's coding (such as categories or tags) is not available on your primary workspace, the duplicate expense will still be created. You will be prompted to correct the coding on the expense. diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md index b9a2b488d90f..13a10917bd71 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md @@ -65,7 +65,7 @@ When you export, the file either downloads immediately or is prepared in the bac - **Immediate download** – **Basic export** and **Export current view**, when you export selected reports or use **Select all on this page**. - **Prepared in the background** – When you use **Select all** to export all matching reports, or select **All Data - expense level**, **All Data - report level**, or a **Custom template**. -While an export is being prepared, either wait for it to download automatically or select **Send me the file when it's ready** to close the export window and receive the file later. When it's ready, Expensify delivers it through Concierge and by email. If the export can't be generated, an error appears in the export window, or is delivered through Concierge if **Send me the file when it's ready** was selected. +While an export is being prepared, either wait for it to download automatically or select **Send me the file when it's ready**. Selecting it lets you close the export window and receive the file later: Expensify delivers it through Concierge and by email once it's ready. If you keep the export window open until the export finishes, the window switches to **Your file is ready!** so you can select **Download file** right away. If the export can't be generated, an error appears in the export window, or is delivered through Concierge if **Send me the file when it's ready** was selected. ## How to download a single report as a PDF @@ -120,6 +120,10 @@ You can download the receipts on several reports at once in a single ZIP file. **Basic export** and **Export current view** download directly to your device. Other templates — and any export started with **Select all** — are prepared in the background; the file then downloads automatically, or is delivered through Concierge and by email if you selected **Send me the file when it's ready**. +## What happens if I keep the export window open after selecting Send me the file when it's ready? + +The window stays open and switches to **Your file is ready!** when the export finishes, so you can select **Download file** to save the file right away. Concierge also sends you the file in a chat message. To close the window instead of waiting, select **Dismiss**. + ## What happens if some reports fail to download as PDFs? When you download multiple reports as PDFs, a message tells you how many of the selected reports were exported. If any reports could not be generated, open your Concierge chat in the **Inbox** to see which reports failed. diff --git a/docs/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses.md index 23b5bb5425ce..28791d84fd32 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses.md @@ -32,7 +32,7 @@ Expensify offers flexible options to search and export expenses and reports in v - **Current view** – A CSV that matches the columns currently shown on the page. Set the columns using **Display** > **Edit columns**. When a **Group by** filter is applied, the CSV keeps each group as its own section and uses those same columns. - **Custom Templates** – Any template created by you or your Workspace Admin (if available). -**Basic Export** and **Current view** download immediately when exporting individual selected reports or when using **Select all on this page**. If you choose one of the **All Data** exports, a custom export template, or use **Select all** to match every report, then the export is prepared in the background. You can wait for the file to download automatically, or select **Send me the file when it's ready** to have Concierge deliver it instead. Selecting this option allows the export window to be closed while the file is generated. Once the export is ready, Expensify sends it through Concierge and by email. If the export cannot be generated, Expensify displays an error in the export window or sends the error through Concierge if **Send me the file when it’s ready** was selected. +**Basic Export** and **Current view** download immediately when exporting individual selected reports or when using **Select all on this page**. If you choose one of the **All Data** exports, a custom export template, or use **Select all** to match every report, then the export is prepared in the background. You can wait for the file to download automatically, or select **Send me the file when it's ready** to have Concierge deliver it instead. Selecting this option allows the export window to be closed while the file is generated. Once the export is ready, Expensify sends it through Concierge and by email. If you keep the export window open until the export finishes, the window switches to **Your file is ready!** so you can select **Download file** right away. If the export cannot be generated, Expensify displays an error in the export window or sends the error through Concierge if **Send me the file when it’s ready** was selected. **Note:** Report-level templates only appear if you select full reports. @@ -66,7 +66,8 @@ When you group expenses on the **Spend** page, you can export the grouped result 3. Click **Apply**. 4. Check the box next to the groups you want to export, or use the top checkbox to select all. 5. Click the selection button at the top (for example, **1 selected**). -6. Select **Current view**. +6. Select **Export**. If **Export** is the only action available, the export options are already listed under an **Export** heading—skip to the next step. +7. Select **Current view**. In the exported file: - Each group appears as its own section with a header row. @@ -127,7 +128,7 @@ Yes, use the **All Data – Report Level Export** template. All other templates ## Can I keep my groups when I export? -Yes. Group your expenses using **Display** > **Group by**, select the groups, then choose **Current view**. The exported file keeps each group as its own section with the expenses listed below their group header, using the columns currently shown on the page. +Yes. Group your expenses using **Display** > **Group by**, select the groups, then choose **Export** > **Current view**. The exported file keeps each group as its own section with the expenses listed below their group header, using the columns currently shown on the page. ## Can I export in PDF or XLS format? @@ -140,7 +141,7 @@ No, PDF export is only available for full reports. ## Can I customize the columns in the CSV export? -Yes. Select **Current view** to download a CSV that matches the columns currently shown on the page. Adjust which columns appear by selecting **Display** > **Edit columns**. The other export templates use a fixed set of columns. +Yes. Select **Export** > **Current view** to download a CSV that matches the columns currently shown on the page. Adjust which columns appear by selecting **Display** > **Edit columns**. The other export templates use a fixed set of columns. ## How do I export to an accounting integration? @@ -148,7 +149,7 @@ Ensure your workspace is connected to a supported accounting platform. [Click he ## How do I receive my export? -**Basic Export** and **Current view** download directly to the device when exporting selected reports or using **Select all on this page**. Choosing **All Data**, a custom export template, or using **Select all** to match every report instead prepares the export in the background. Once ready, the file downloads automatically, or, if you selected **Send me the file when it's ready**, it's delivered through Concierge and by email instead. +**Basic Export** and **Current view** download directly to the device when exporting selected reports or using **Select all on this page**. Choosing **All Data**, a custom export template, or using **Select all** to match every report instead prepares the export in the background. Once ready, the file downloads automatically, or, if you selected **Send me the file when it's ready**, it's delivered through Concierge and by email instead. If you keep the export window open until the export finishes, the window switches to **Your file is ready!** so you can select **Download file** without waiting for the chat message. ## What happens if I refresh the page while my file is preparing? diff --git a/docs/articles/new-expensify/reports-and-expenses/Split-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Split-Expenses.md index 72fd93e6728a..56262b50a4d0 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Split-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Split-Expenses.md @@ -12,7 +12,7 @@ Expensify makes it easy to split expenses by dividing a single transaction acros ## Who can split expenses -- **Submitters** can split any editable expense they've created, excluding unreported expenses that haven't been added to a report yet (but this functionality will be available soon) +- **Submitters** can split any editable expense they've created, including unreported expenses in their self-DM that haven't been added to a report yet - **Approvers** can split expenses assigned to them for approval - **Admins** can split any expense that hasn't been fully approved diff --git a/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md b/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md index 2e21a035ddfb..a9d1c6c97491 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md +++ b/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md @@ -1,7 +1,7 @@ --- title: Use Search Operators to Filter and Analyze description: Learn how to use search operators, filters, and grouping to find, organize, and analyze expenses, chats, reports, and tasks in Expensify. -keywords: [New Expensify, search operators, advanced filters, search rules, expense search, report search, chat filters, advanced search, group-by, view, chart, search syntax, bank account filter, custom field, international reimbursement IDs] +keywords: [New Expensify, search operators, advanced filters, search rules, expense search, report search, chat filters, advanced search, group-by, view, chart, search syntax, bank account filter, custom field, international reimbursement IDs, transaction status filter, pending card expenses, posted card expenses] internalScope: Audience is all Expensify members. Covers search operator syntax for filtering, grouping, and chart views. Does not cover saved search management or Search page UI navigation. --- @@ -80,6 +80,7 @@ You can use the following operators to filter expenses: | `billable:` | Filter by whether an expense is billable (`yes` or `no`) | `billable:no` | | `attendee:` | Filter by expense attendee using an email, phone number, display name, or `me` | `attendee:"Jason Mills"` | | `posted:` | Filter by the date a credit card expense was posted | `posted:last-statement` | +| `transaction-status:` | Filter by whether a card expense is still `pending` or has `posted`. Accepts one value at a time, and only card expenses are returned. Available only when you have at least one card. | `transaction-status:pending` | | `bank-account:` | Filter by the settlement bank account an expense was reimbursed from. Start typing after the colon to choose an account, shown by bank name and last four digits. Available only when you have at least one bank account. | `bank-account:"Chase xx1234"` | **Example queries:** diff --git a/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md b/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md index c7a6b7eb28eb..ad4d43f4f30d 100644 --- a/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md +++ b/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md @@ -1,52 +1,66 @@ --- title: Delegate When Out of Office -description: Assign a vacation delegate to approve reports on your behalf while you're away. +description: Assign a Vacation delegate to approve reports on your behalf while you're out of office. keywords: [New Expensify, delegate approvals, out-of-office approver, vacation, out of office, approval delegation, temporary delegate, vacation delegate, copilot vs delegate, copilot, shared access, proxy, behalf of] +internalScope: Audience is New Expensify members. Covers assigning and removing your own Vacation delegate from your profile status. Does not cover Copilot access or Expensify Classic delegates. --- -If you're out of the office or on vacation, you can assign a **Vacation Delegate** to handle your approvals while you're away. +# Delegate When Out of Office -A **Vacation Delegate** can approve reports on your behalf. Once assigned, any reports sent to you for approval will be automatically redirected to your delegate. When you're back, just remove the delegate to resume handling your own reports. +If you're out of the office or on vacation, you can assign a **Vacation delegate** to handle your approvals while you're away. + +A **Vacation delegate** can approve reports on your behalf. Once assigned, any reports sent to you for approval will be automatically redirected to your delegate. When you're back, just remove the delegate to resume handling your own reports. All delegate actions are tracked in the report history for full visibility. --- -# Assign a Vacation Delegate +## How to assign a Vacation delegate 1. Go to **Account > Profile > Status**. -2. Scroll to the **Vacation Delegate** section. -3. Enter the email address or phone number of your delegate. -4. Click **Save**. +2. Scroll to the **Vacation delegate** section. +3. Select **Vacation delegate**. +4. Search by name, email, or phone number. +5. Select the person you want as your delegate. + +Your delegate is saved as soon as you select them, so there is no separate save step. + +If the person you select isn't a member of all your workspaces, a **Heads up!** message appears explaining that an email will be sent to your Workspace Admins to add them. Select **Confirm** to continue, or **Cancel** to choose someone else. Once set, any reports or chats that would normally come to you will be redirected to your delegate. You can view every action they take in the report's history and comments. -## Remove Your Delegate +## How to remove your Vacation delegate When you're ready to take back control of your reports: 1. Go to **Account > Profile > Status**. -2. Scroll to the **Vacation Delegate** section. -3. Clear the delegate's email or phone number. -4. Click **Save**. +2. Scroll to the **Vacation delegate** section. +3. Select your current delegate. +4. Select the same person again to clear them. + +The **Vacation delegate** row goes back to being empty once the delegate is removed. --- # FAQ -## Can my admin assign a Vacation Delegate for me? +## Can my admin assign a Vacation delegate for me? + +No. In New Expensify, only you can assign your own Vacation delegate. + +## Why does my Vacation delegate show a phone number instead of a name? -No. In New Expensify, only you can assign your own Vacation Delegate. +If you added your delegate by phone number and they haven't set a display name, Expensify shows their phone number formatted for your country. This is expected, and the delegate still receives your approvals as normal. -## Why can't my Vacation Delegate reimburse reports? +## Why can't my Vacation delegate reimburse reports? Your delegate can only reimburse reports if they also have access to the reimbursement account in your workspace. Without access, they won't see the option to reimburse—even if they're your delegate. ## What if my delegate is also on vacation? -Your delegate can assign their own Vacation Delegate too. This ensures approvals keep moving, even when multiple members are away. +Your delegate can assign their own Vacation delegate too. This ensures approvals keep moving, even when multiple members are away. -## If I clear my status, does that remove my Vacation Delegate? +## If I clear my status, does that remove my Vacation delegate? No. Clearing your status won't affect your delegate. You'll need to manually remove them when you're back. @@ -56,6 +70,6 @@ If your delegate isn’t part of your workspace, Workspace Admins will be notifi ## What should I use if someone needs to manage my expenses permanently? -A Vacation Delegate is intended for temporary approval coverage while you're away. If someone needs ongoing access to manage expenses on your behalf—such as an executive assistant, accountant, or bookkeeper, [add them as a Copilot](/articles/new-expensify/settings/Manage-Copilot-Access) instead. +A Vacation delegate is intended for temporary approval coverage while you're away. If someone needs ongoing access to manage expenses on your behalf—such as an executive assistant, accountant, or bookkeeper, [add them as a Copilot](/articles/new-expensify/settings/Manage-Copilot-Access) instead. -Copilots provide long-term account access for expense management, while Vacation Delegates are designed for temporary approval coverage. +Copilots provide long-term account access for expense management, while Vacation delegates are designed for temporary approval coverage. diff --git a/docs/articles/new-expensify/wallet-and-payments/Global-Reimbursement-United-States.md b/docs/articles/new-expensify/wallet-and-payments/Global-Reimbursement-United-States.md index f2d98153f53a..44d3cddc51ec 100644 --- a/docs/articles/new-expensify/wallet-and-payments/Global-Reimbursement-United-States.md +++ b/docs/articles/new-expensify/wallet-and-payments/Global-Reimbursement-United-States.md @@ -89,6 +89,20 @@ To begin reimbursing: --- +## How to enable Global Reimbursements when paying a report + +When you select **Pay** on an approved report, Expensify may prompt you to enable Global Reimbursements if the submitter has a non-USD bank account. + +1. Open the approved report and select **Pay**. +2. In the **Pay report** prompt, select **Enable Global Reimbursements**. +3. Complete the Global Reimbursements setup. + +After Global Reimbursements are enabled, you can reimburse the submitter to their non-USD bank account. + +If you select **Cancel**, the report remains Approved and is not reimbursed. + +--- + # FAQ ## How do employees add their deposit-only accounts? @@ -96,5 +110,3 @@ To begin reimbursing: An employee can add their deposit accounts by doing the following: 1. Navigate to **Account > Wallet**. 2. Click **Add deposit-only bank account** and input their bank account details. - - diff --git a/docs/articles/new-expensify/wallet-and-payments/Pay-Expenses.md b/docs/articles/new-expensify/wallet-and-payments/Pay-Expenses.md index 83ab0c1f3d9a..d221878838a8 100644 --- a/docs/articles/new-expensify/wallet-and-payments/Pay-Expenses.md +++ b/docs/articles/new-expensify/wallet-and-payments/Pay-Expenses.md @@ -102,6 +102,16 @@ Only bank accounts that match the report's currency appear as payment options. I --- +## Why do I see a Pay report prompt asking me to enable global reimbursements? + +The **Pay report** prompt appears when your workspace's USD business bank account is not enabled for global reimbursements and the submitter's deposit account is in a currency other than USD. Expensify cannot send the payment until one of those changes. + +Select **Enable Global Reimbursements** to start setting up global reimbursements on the USD business bank account, or select **Cancel** to close the prompt and ask the submitter to add a USD deposit account instead. Selecting **Cancel** leaves the report in the **Approved** state. + +[Learn how to enable global reimbursements on a USD business bank account](/articles/new-expensify/wallet-and-payments/Global-Reimbursement-United-States). + +--- + ## How do I pay a report with only non-reimbursable expenses? Reports containing only non-reimbursable expenses (e.g., company card charges) cannot be paid via ACH through Expensify. Instead, select **Mark as paid** from the **Pay** button dropdown to close the report. diff --git a/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md b/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md index ff9da16ebd28..44034aa810ac 100644 --- a/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md +++ b/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md @@ -24,11 +24,12 @@ A **Workspace member** is anyone added to a workspace with one of the following To invite someone to your workspace: 1. Go to **Workspaces > [Workspace Name] > Members**. -2. Click **Invite Member**. -3. Search for the user by name, email, or phone number. -4. (Optional) Click **Role** to change the default role (Member). -5. (Optional) Add a message to the invitation. -6. Click **Invite**. +2. Click **Invite member**. +3. Search for the user by name, email, or phone number, then select them from the list. +4. Click **Next** to open **Confirm details**. +5. (Optional) Click **Role** to change the default role (Member). +6. (Optional) Add a **Message** to the invitation. +7. Click **Invite**. **Tip:** You can also invite members under **Settings > Profile** by clicking **Share** to send the workspace’s URL or QR code. diff --git a/docs/articles/new-expensify/workspaces/Set-distance-rates.md b/docs/articles/new-expensify/workspaces/Set-distance-rates.md index 6e7662f8d1f6..c6453273c657 100644 --- a/docs/articles/new-expensify/workspaces/Set-distance-rates.md +++ b/docs/articles/new-expensify/workspaces/Set-distance-rates.md @@ -15,7 +15,7 @@ Distance rates determine how much members are reimbursed for distance-based trav Workspace admins can configure distance rates for a workspace. -Automatic updates are available for Control workspaces with a default currency of USD, CAD, GBP, AUD, or NZD. +Automatic updates are available for Control workspaces with a default currency of USD, CAD, GBP, or AUD. --- @@ -94,7 +94,7 @@ Expensify can automatically update distance rates in your workspace when the sta When **Auto-update government rate** is enabled, Expensify adds a new effective-dated distance rate when a supported government publishes a new rate. This means admins don't need to manually update the standard rate each time it changes. -Automatic updates are available for workspaces with a default currency of USD, CAD, GBP, AUD, or NZD. +Automatic updates are available for Control workspaces with a default currency of USD, CAD, GBP, or AUD. To enable automatic updates: @@ -165,4 +165,4 @@ Yes. When **Distance rates** is enabled, the workspace must always have at least ## Why isn't Auto-update government rate turned on for my workspace? -**Auto-update government rate** is turned on by default only for new workspaces. Existing workspaces are opted out by default, so you'll need to turn it on manually. It's also only available for workspaces with a default currency of USD, CAD, GBP, AUD, or NZD. +**Auto-update government rate** is turned on by default only for new Control workspaces. Existing workspaces are opted out by default, so you'll need to turn it on manually. It's also only available for Control workspaces with a default currency of USD, CAD, GBP, or AUD. diff --git a/docs/articles/new-expensify/workspaces/Workspace-Workflows.md b/docs/articles/new-expensify/workspaces/Workspace-Workflows.md index 9e9562abd381..ffca701db491 100644 --- a/docs/articles/new-expensify/workspaces/Workspace-Workflows.md +++ b/docs/articles/new-expensify/workspaces/Workspace-Workflows.md @@ -110,6 +110,21 @@ Only the authorized payer is prompted to pay reports. They'll see a payment remi --- +## How to choose who pays currency conversion fees + +When you reimburse in a different currency, conversion fees may apply to the cross-border conversion. **Currency conversion fees** sets whether your company or the employee pays them. + +1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Workspaces > [Workspace name] > Workflows**. +2. Under **Payments**, click **Currency conversion fees**. +3. Select **Company pays** or **Employee pays**. +4. Click **Save**. + +Selecting an option does not change the setting on its own — the new preference is saved only when you click **Save**. **Save** stays disabled until the option you select is different from the one currently saved, so leaving the page without clicking **Save** keeps your current preference. After you click **Save**, you return to **Workflows** and the **Currency conversion fees** row shows the new value. + +**Currency conversion fees** appears under **Payments** only when **Payments** is enabled with a connected reimbursement bank account, and it is not available on every workspace. + +--- + ## How to automate report compliance on the Advanced tab The **Advanced** tab holds the rules that apply to a whole expense report rather than to a single expense. diff --git a/docs/articles/travel/managing-trips/Canceling-Travel-Arrangements.md b/docs/articles/travel/managing-trips/Canceling-Travel-Arrangements.md index 3a3eabfd12ee..13bfae6a2478 100644 --- a/docs/articles/travel/managing-trips/Canceling-Travel-Arrangements.md +++ b/docs/articles/travel/managing-trips/Canceling-Travel-Arrangements.md @@ -69,6 +69,9 @@ Yes. If you rebook on the same trip, no new trip fee applies. ## What if the cancel option is unavailable? Some bookings must be canceled through or directly with the travel provider. If this applies, you’ll be prompted. +## Where does a canceled booking go on Home? +A canceled booking is removed from the **Upcoming travel** section on Home, which only lists travel you’re still expected to take. The booking stays in the trip, labeled **Canceled**. Learn more about [what the Home page shows](/articles/new-expensify/getting-started/Expensify-Home-Overview). + ## Will I receive a refund or a credit? If the booking is refundable, you’ll receive a refund to your original payment method. If it’s non-refundable, you may receive a travel credit, depending on the provider’s terms. diff --git a/eslint-plugin-local-rules/require-locale-for-localized-date-format.js b/eslint-plugin-local-rules/require-locale-for-localized-date-format.js index 23deccaac0f1..879cfb2f1e12 100644 --- a/eslint-plugin-local-rules/require-locale-for-localized-date-format.js +++ b/eslint-plugin-local-rules/require-locale-for-localized-date-format.js @@ -33,6 +33,16 @@ const LOCALIZED_TOKENS = [ {token: 'eeee', label: 'eeee (weekday name)'}, {token: 'eee', label: 'eee (short weekday)'}, {token: 'do', label: 'do (ordinal day)'}, + // Quarters. `Q` and `QQ` are plain numbers, but everything wider is a locale's own label such as `Q1`, `T1`, + // `K1` or `I kw.` + {token: 'QQQQQ', label: 'QQQQQ (narrow quarter)'}, + {token: 'QQQQ', label: 'QQQQ (quarter name)'}, + {token: 'QQQ', label: 'QQQ (short quarter)'}, + {token: 'Qo', label: 'Qo (ordinal quarter)'}, + {token: 'qqqqq', label: 'qqqqq (standalone narrow quarter)'}, + {token: 'qqqq', label: 'qqqq (standalone quarter name)'}, + {token: 'qqq', label: 'qqq (standalone short quarter)'}, + {token: 'qo', label: 'qo (standalone ordinal quarter)'}, // date-fns' localized date/time formats. These exist precisely to defer the clock convention, ordering and // separators to the locale, so they are meaningless without one. {token: 'PPPP', label: 'PPPP (localized long date with weekday)'}, diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 556074e1ca22..b81afdde235f 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.4.91 + 9.4.92 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.4.91.1 + 9.4.92.0 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index f85db6edaa10..9d0515857b66 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.4.91 + 9.4.92 CFBundleVersion - 9.4.91.1 + 9.4.92.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2cae9404a7e5..a18004fdb309 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2388,7 +2388,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-webview (13.16.0): + - react-native-webview (16.0.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -4056,7 +4056,7 @@ SPEC CHECKSUMS: react-native-skia: b3c25cbcfdc2d4f68b73390ff1b50ef2a9bc5968 react-native-view-shot: 6da2aaa0404b8d617ab1689550acdb251d759af9 react-native-wallet: 69c8fde0b0e16899b95b2689f7cb737f4ec247f9 - react-native-webview: 5233e591e6849d953cea601995eab6a1c91f0819 + react-native-webview: 36fd14012dd0352c90b960ea7860fbc53876593b React-NativeModulesApple: a092d89b58f635ebfab88048b0eda9fb516819fd React-networking: ee8e1504d7c1c73a3655bf545ca1184bebae4c7f React-oscompat: 0b72a7e926954a0415ccd83e0748b6561fe45367 diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index 03cc5d49dd1b..ed0dbfac026a 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.4.91 + 9.4.92 CFBundleVersion - 9.4.91.1 + 9.4.92.0 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index 0d7a33cb028a..dde948ad3931 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.4.91-1", + "version": "9.4.92-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.4.91-1", + "version": "9.4.92-0", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -56,7 +56,7 @@ "@sbaiahmed1/react-native-biometrics": "0.15.0", "@sentry/core": "10.73.0", "@sentry/react-native": "8.25.0", - "@shopify/flash-list": "2.3.0", + "@shopify/flash-list": "2.3.2", "@shopify/react-native-skia": "^2.11.2", "@ua/react-native-airship": "26.7.0", "array.prototype.tosorted": "^1.1.4", @@ -146,7 +146,7 @@ "react-native-view-shot": "5.1.0", "react-native-vision-camera": "^4.7.2", "react-native-web": "0.21.2", - "react-native-webview": "13.16.0", + "react-native-webview": "16.0.0", "react-native-worklets": "0.11.1", "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", @@ -18168,9 +18168,9 @@ } }, "node_modules/@shopify/flash-list": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.3.0.tgz", - "integrity": "sha512-DR7VuN8KJHTYj9zv1/IhpqrMBMQyeeW/DCWCbVQAAkWhHrc6ylIbXOY+qK93CuHABV+dNHXK/3V6p4wCSW/+wA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.3.2.tgz", + "integrity": "sha512-vQnd0y0Ag11yzS30llaeCrtIU3xYTMe7KEOP3dveLImnVjQEUw6BEg1+Ztja6aODlVNz1BpvxtlQ5eZGxiLwKw==", "license": "MIT", "peerDependencies": { "@babel/runtime": "*", @@ -38357,9 +38357,9 @@ "license": "MIT" }, "node_modules/react-native-webview": { - "version": "13.16.0", - "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.0.tgz", - "integrity": "sha512-Nh13xKZWW35C0dbOskD7OX01nQQavOzHbCw9XoZmar4eXCo7AvrYJ0jlUfRVVIJzqINxHlpECYLdmAdFsl9xDA==", + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-16.0.0.tgz", + "integrity": "sha512-KIg5Yhy04lQfHdXC1JQPM+0kftMfPnH6AqDH7v6YskQ9kzfUacooofOFRmbaZHASq/0WY87Fx0peiFETFjOcCg==", "license": "MIT", "dependencies": { "escape-string-regexp": "^4.0.0", diff --git a/package.json b/package.json index 0e4259356074..d3d54cb47f6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.4.91-1", + "version": "9.4.92-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -132,7 +132,7 @@ "@sbaiahmed1/react-native-biometrics": "0.15.0", "@sentry/core": "10.73.0", "@sentry/react-native": "8.25.0", - "@shopify/flash-list": "2.3.0", + "@shopify/flash-list": "2.3.2", "@shopify/react-native-skia": "^2.11.2", "@ua/react-native-airship": "26.7.0", "array.prototype.tosorted": "^1.1.4", @@ -222,7 +222,7 @@ "react-native-view-shot": "5.1.0", "react-native-vision-camera": "^4.7.2", "react-native-web": "0.21.2", - "react-native-webview": "13.16.0", + "react-native-webview": "16.0.0", "react-native-worklets": "0.11.1", "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", @@ -446,6 +446,7 @@ ], "android": { "buildFromSource": [ + "expo-location", "expo-task-manager", "unimodules-app-loader" ] diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+001+fix-horizontal-height-normalization.patch similarity index 100% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+001+fix-horizontal-height-normalization.patch diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+002+skip-layout-when-hidden.patch similarity index 90% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+002+skip-layout-when-hidden.patch index eae8317799bf..881c1fc1d325 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+002+skip-layout-when-hidden.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index 8b75322..dd2d3bc 100644 +index 6799c8a..4324707 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -74,6 +74,10 @@ const RecyclerViewComponent = (props, ref) => { +@@ -75,6 +75,10 @@ const RecyclerViewComponent = (props, ref) => { if (internalViewRef.current && firstChildViewRef.current) { // Measure the outer container size and inner container layout const outerViewSize = measureParentSize(internalViewRef.current); @@ -13,7 +13,7 @@ index 8b75322..dd2d3bc 100644 const firstChildViewLayout = measureFirstChildLayout(firstChildViewRef.current, internalViewRef.current); containerViewSizeRef.current = outerViewSize; // firstChildViewLayout is already relative to the outer container, -@@ -103,6 +107,10 @@ const RecyclerViewComponent = (props, ref) => { +@@ -104,6 +108,10 @@ const RecyclerViewComponent = (props, ref) => { if (pendingChildIds.size > 0) { return; } @@ -25,10 +25,10 @@ index 8b75322..dd2d3bc 100644 const layout = measureItemLayout(viewHolderRef.current, recyclerViewManager.tryGetLayout(index)); // comapre height with stored layout diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 70f856a..9908674 100644 +index 08724d1..bc5b0ae 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -@@ -165,7 +165,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -175,7 +175,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe const { horizontal } = recyclerViewManager.props; if (scrollViewRef.current) { // Adjust offset for RTL layouts in horizontal mode @@ -37,7 +37,7 @@ index 70f856a..9908674 100644 // eslint-disable-next-line no-param-reassign offset = adjustOffsetForRTL(offset, recyclerViewManager.getChildContainerDimensions().width, recyclerViewManager.getWindowSize().width) + -@@ -235,6 +235,9 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -245,6 +245,9 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe * Returns a Promise that resolves when the scroll is complete. */ scrollToIndex: ({ index, animated, viewPosition, viewOffset, }) => { diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+003+fix-inverted-scroll-direction-on-web.patch similarity index 97% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+003+fix-inverted-scroll-direction-on-web.patch index edb436a356b4..355071c7f514 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+003+fix-inverted-scroll-direction-on-web.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index dd2d3bc..d7a3d84 100644 +index 4324707..3deea98 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js @@ -2,8 +2,8 @@ @@ -13,7 +13,7 @@ index dd2d3bc..d7a3d84 100644 import { ErrorMessages } from "../errors/ErrorMessages"; import { WarningMessages } from "../errors/WarningMessages"; import { areDimensionsNotEqual, measureFirstChildLayout, measureItemLayout, measureParentSize, } from "./utils/measureLayout"; -@@ -66,6 +66,66 @@ const RecyclerViewComponent = (props, ref) => { +@@ -67,6 +67,66 @@ const RecyclerViewComponent = (props, ref) => { // Hook to detect when scrolling reaches list bounds const { checkBounds } = useBoundDetection(recyclerViewManager, scrollViewRef); const isHorizontalRTL = I18nManager.isRTL && horizontal; @@ -81,7 +81,7 @@ index dd2d3bc..d7a3d84 100644 * Initialize the RecyclerView by measuring and setting up the window size * This effect runs when the component mounts or when layout changes diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index 34722d4..ea801d2 100644 +index 3c25e7e..42a901f 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx @@ -5,6 +5,7 @@ @@ -100,7 +100,7 @@ index 34722d4..ea801d2 100644 } from "react-native"; import { FlashListRef } from "../FlashListRef"; -@@ -158,6 +160,88 @@ const RecyclerViewComponent = ( +@@ -159,6 +161,88 @@ const RecyclerViewComponent = ( const isHorizontalRTL = I18nManager.isRTL && horizontal; diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+004+fix-inverted-first-item-offset.patch similarity index 91% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+004+fix-inverted-first-item-offset.patch index 98bac124be64..bc0a9a496b1e 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+004+fix-inverted-first-item-offset.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index d7a3d84..ffcdad8 100644 +index 3deea98..bcaa951 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -142,9 +142,11 @@ const RecyclerViewComponent = (props, ref) => { +@@ -143,9 +143,11 @@ const RecyclerViewComponent = (props, ref) => { containerViewSizeRef.current = outerViewSize; // firstChildViewLayout is already relative to the outer container, // so its x/y directly gives the first item offset. @@ -18,10 +18,10 @@ index d7a3d84..ffcdad8 100644 recyclerViewManager.updateLayoutParams({ width: horizontal ? outerViewSize.width : firstChildViewLayout.width, diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index ea801d2..8a7deff 100644 +index 42a901f..a6db0d9 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -259,9 +259,11 @@ const RecyclerViewComponent = ( +@@ -260,9 +260,11 @@ const RecyclerViewComponent = ( // firstChildViewLayout is already relative to the outer container, // so its x/y directly gives the first item offset. diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+005+fix-pending-children-blocking-measurements.patch similarity index 91% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+005+fix-pending-children-blocking-measurements.patch index 6b96845c5875..4cd36710dae9 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+005+fix-pending-children-blocking-measurements.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index ffcdad8..ee42f63 100644 +index bcaa951..88ca8b1 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -166,9 +166,6 @@ const RecyclerViewComponent = (props, ref) => { +@@ -167,9 +167,6 @@ const RecyclerViewComponent = (props, ref) => { // eslint-disable-next-line react-hooks/exhaustive-deps useLayoutEffect(() => { var _a, _b; @@ -12,7 +12,7 @@ index ffcdad8..ee42f63 100644 if (((_a = containerViewSizeRef.current) === null || _a === void 0 ? void 0 : _a.width) === 0 && ((_b = containerViewSizeRef.current) === null || _b === void 0 ? void 0 : _b.height) === 0) { return; -@@ -196,8 +193,17 @@ const RecyclerViewComponent = (props, ref) => { +@@ -197,8 +194,17 @@ const RecyclerViewComponent = (props, ref) => { } if (recyclerViewManager.modifyChildrenLayout(layoutInfo, (_a = data === null || data === void 0 ? void 0 : data.length) !== null && _a !== void 0 ? _a : 0) && !hasExceededMaxRendersWithoutCommit) { @@ -33,10 +33,10 @@ index ffcdad8..ee42f63 100644 else { (_b = viewHolderCollectionRef.current) === null || _b === void 0 ? void 0 : _b.commitLayout(); diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index 8a7deff..b2bd67a 100644 +index a6db0d9..c09ba9b 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -287,9 +287,6 @@ const RecyclerViewComponent = ( +@@ -288,9 +288,6 @@ const RecyclerViewComponent = ( */ // eslint-disable-next-line react-hooks/exhaustive-deps useLayoutEffect(() => { @@ -46,7 +46,7 @@ index 8a7deff..b2bd67a 100644 const layoutInfo = Array.from(refHolder, ([index, viewHolderRef]) => { const layout = measureItemLayout( viewHolderRef.current!, -@@ -323,8 +320,17 @@ const RecyclerViewComponent = ( +@@ -324,8 +321,17 @@ const RecyclerViewComponent = ( recyclerViewManager.modifyChildrenLayout(layoutInfo, data?.length ?? 0) && !hasExceededMaxRendersWithoutCommit ) { diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+006+fix-inverted-mvcp-android.patch similarity index 95% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+006+fix-inverted-mvcp-android.patch index 48a9b893bbfd..cd6f646d1bb3 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+006+fix-inverted-mvcp-android.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 70f856a..52546f7 100644 +index bc5b0ae..fe87b9b 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js @@ -1,5 +1,5 @@ @@ -38,8 +38,8 @@ index 70f856a..52546f7 100644 + } // If we have a tracked first visible item, maintain its position if (firstVisibleItemKey.current) { - const currentIndexOfFirstVisibleItem = (_a = recyclerViewManager -@@ -115,10 +122,31 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + // Try engaged indices first (fast O(n) over rendered items), then +@@ -125,10 +132,31 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe !pauseOffsetCorrection.current && !recyclerViewManager.animationOptimizationsEnabled) { // console.log("diff", diff, firstVisibleItemKey.current); @@ -73,7 +73,7 @@ index 70f856a..52546f7 100644 else { const scrollToParams = horizontal ? { -@@ -162,6 +190,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -172,6 +200,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe * Handles RTL layouts and first item offset adjustments. */ scrollToOffset: ({ offset, animated, skipFirstItemOffset = true, }) => { @@ -87,7 +87,7 @@ index 70f856a..52546f7 100644 const { horizontal } = recyclerViewManager.props; if (scrollViewRef.current) { // Adjust offset for RTL layouts in horizontal mode -@@ -205,6 +240,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -215,6 +250,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe * Scrolls to the end of the list. */ scrollToEnd: async ({ animated } = {}) => { @@ -101,7 +101,7 @@ index 70f856a..52546f7 100644 const { data } = recyclerViewManager.props; if (data && data.length > 0) { const lastIndex = data.length - 1; -@@ -238,6 +280,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -248,6 +290,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe if (!recyclerViewManager.hasLayout()) { return Promise.resolve(); } diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+007+fix-scroll-anchor-unmount-on-ios.patch similarity index 87% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+007+fix-scroll-anchor-unmount-on-ios.patch index 7f1700a548d9..460796111dfd 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+007+fix-scroll-anchor-unmount-on-ios.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index ee42f63..4e8d8c0 100644 +index 88ca8b1..085c819 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -380,16 +380,15 @@ const RecyclerViewComponent = (props, ref) => { +@@ -382,16 +382,15 @@ const RecyclerViewComponent = (props, ref) => { } return onScrollHandler; }, [onScrollHandler, scrollY, stickyHeaders, stickyHeaderUseNativeDriver]); @@ -19,9 +19,9 @@ index ee42f63..4e8d8c0 100644 - }, [maintainVisibleContentPosition, shouldMaintainVisibleContentPosition]); + }, [maintainVisibleContentPosition]); const shouldRenderFromBottom = recyclerViewManager.getDataLength() > 0 && - ((_d = maintainVisibleContentPosition === null || maintainVisibleContentPosition === void 0 ? void 0 : maintainVisibleContentPosition.startRenderingFromBottom) !== null && _d !== void 0 ? _d : false); + ((_e = maintainVisibleContentPosition === null || maintainVisibleContentPosition === void 0 ? void 0 : maintainVisibleContentPosition.startRenderingFromBottom) !== null && _e !== void 0 ? _e : false); // Create view for measuring bounded size -@@ -401,11 +399,11 @@ const RecyclerViewComponent = (props, ref) => { +@@ -403,11 +402,11 @@ const RecyclerViewComponent = (props, ref) => { }, ref: firstChildViewRef })); }, [horizontal, stickyHeaderOffset]); const scrollAnchor = useMemo(() => { @@ -36,10 +36,10 @@ index ee42f63..4e8d8c0 100644 // Render the main RecyclerView structure return (React.createElement(RecyclerViewContextProvider, { value: recyclerViewContext }, diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index b2bd67a..d4bf02d 100644 +index c09ba9b..974b485 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -572,18 +572,15 @@ const RecyclerViewComponent = ( +@@ -575,18 +575,15 @@ const RecyclerViewComponent = ( return onScrollHandler; }, [onScrollHandler, scrollY, stickyHeaders, stickyHeaderUseNativeDriver]); @@ -60,7 +60,7 @@ index b2bd67a..d4bf02d 100644 const shouldRenderFromBottom = recyclerViewManager.getDataLength() > 0 && -@@ -604,7 +600,7 @@ const RecyclerViewComponent = ( +@@ -607,7 +604,7 @@ const RecyclerViewComponent = ( }, [horizontal, stickyHeaderOffset]); const scrollAnchor = useMemo(() => { @@ -69,7 +69,7 @@ index b2bd67a..d4bf02d 100644 return ( ( +@@ -616,7 +613,7 @@ const RecyclerViewComponent = ( ); } return null; diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+008+increase-timeout.patch similarity index 90% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+008+increase-timeout.patch index a76bba73cc2d..73152813f0e6 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+008+increase-timeout.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 51b6f8c..d4ca252 100644 +index fe87b9b..b90f898 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -@@ -507,7 +507,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -520,7 +520,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe setTimeout(() => { recyclerViewManager.isInitialScrollComplete = true; pauseOffsetCorrection.current = false; diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+009+ignore-stale-viewholder-layout.patch similarity index 89% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+009+ignore-stale-viewholder-layout.patch index 928c0d6c28a6..c55eb5a37083 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+009+ignore-stale-viewholder-layout.patch @@ -1,7 +1,8 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +index 085c819..b8ab103 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -316,7 +316,10 @@ function RecyclerView(props) { +@@ -317,7 +317,10 @@ const RecyclerViewComponent = (props, ref) => { */ const validateItemSize = useCallback((index, size) => { var _a, _b, _c, _d; @@ -14,9 +15,11 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js const height = Math.max(Math.min(layout.height, (_c = layout.maxHeight) !== null && _c !== void 0 ? _c : Infinity), (_d = layout.minHeight) !== null && _d !== void 0 ? _d : 0); if (areDimensionsNotEqual(width, size.width) || diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +index 974b485..ea42f73 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -465,6 +465,9 @@ function RecyclerView(props: RecyclerViewProps) { +@@ -466,7 +466,10 @@ const RecyclerViewComponent = ( + */ const validateItemSize = useCallback( (index: number, size: RVDimension) => { - const layout = recyclerViewManager.getLayout(index); diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+010+fix-web-subpixel-rounding.patch similarity index 100% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+010+fix-web-subpixel-rounding.patch diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+011+sort-for-natural-DOM-order.patch similarity index 97% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+011+sort-for-natural-DOM-order.patch index 321a812fa2cc..84d4c41bd393 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+011+sort-for-natural-DOM-order.patch @@ -34,10 +34,10 @@ index 08b83f3..05a64b1 100644 * Scrolls to a specific item in the list. * diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index 4e53325..ef4daf2 100644 +index b8ab103..27d9f87 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -58,7 +58,7 @@ const RecyclerViewComponent = (props, ref) => { +@@ -59,7 +59,7 @@ const RecyclerViewComponent = (props, ref) => { const refHolder = useMemo(() => new Map(), []); // Initialize core RecyclerView manager and content offset management const { recyclerViewManager, velocityTracker } = useRecyclerViewManager(props); @@ -46,7 +46,7 @@ index 4e53325..ef4daf2 100644 // Initialize view holder collection ref const viewHolderCollectionRef = useRef(null); // Hook to handle list loading -@@ -238,12 +238,19 @@ const RecyclerViewComponent = (props, ref) => { +@@ -239,12 +239,19 @@ const RecyclerViewComponent = (props, ref) => { return; } if (isMomentumEnd) { @@ -66,7 +66,7 @@ index 4e53325..ef4daf2 100644 // Update scroll position and trigger re-render if needed if (recyclerViewManager.updateScrollOffset(scrollOffset, velocity)) { setRenderId((prev) => prev + 1); -@@ -266,6 +273,9 @@ const RecyclerViewComponent = (props, ref) => { +@@ -267,6 +274,9 @@ const RecyclerViewComponent = (props, ref) => { computeFirstVisibleIndexForOffsetCorrection, horizontal, isHorizontalRTL, @@ -76,7 +76,7 @@ index 4e53325..ef4daf2 100644 recyclerViewManager, velocityTracker, ]); -@@ -461,7 +471,7 @@ const RecyclerViewComponent = (props, ref) => { +@@ -463,7 +473,7 @@ const RecyclerViewComponent = (props, ref) => { recyclerViewManager.animationOptimizationsEnabled = false; }, CellRendererComponent: CellRendererComponent, ItemSeparatorComponent: ItemSeparatorComponent, isInLastRow: (index) => recyclerViewManager.isInLastRow(index), getChildContainerLayout: () => recyclerViewManager.hasLayout() ? recyclerViewManager.getChildContainerDimensions() @@ -398,7 +398,7 @@ index 62d55cd..b715484 100644 //# sourceMappingURL=useRecyclerViewController.d.ts.map \ No newline at end of file diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 165b080..18e59ce 100644 +index b90f898..48e0bdc 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js @@ -25,6 +25,21 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe @@ -423,7 +423,7 @@ index 165b080..18e59ce 100644 const pendingAndroidInvertedRafId = useRef(null); const skipNextAndroidInvertedCorrection = useRef(false); const lastDataLengthRef = useRef(recyclerViewManager.getDataLength()); -@@ -180,6 +195,33 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -190,6 +205,33 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe updateScrollOffsetWithCallback, computeFirstVisibleIndexForOffsetCorrection, ]); @@ -457,7 +457,7 @@ index 165b080..18e59ce 100644 const handlerMethods = useMemo(() => { return { get props() { -@@ -271,6 +313,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -281,6 +323,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe animated, }); }, @@ -469,7 +469,7 @@ index 165b080..18e59ce 100644 /** * Scrolls to a specific index in the list. * Supports viewPosition and viewOffset for precise positioning. -@@ -292,6 +339,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -302,6 +349,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe // Pause the scroll offset adjustments pauseOffsetCorrection.current = true; recyclerViewManager.setOffsetProjectionEnabled(false); @@ -481,7 +481,7 @@ index 165b080..18e59ce 100644 const getFinalOffset = () => { const layout = recyclerViewManager.getLayout(index); const offset = horizontal ? layout.x : layout.y; -@@ -496,6 +548,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -506,6 +558,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe setTimeout, isUnmounted, updateScrollOffsetWithCallback, @@ -489,7 +489,7 @@ index 165b080..18e59ce 100644 ]); const applyInitialScrollIndex = useCallback(() => { var _a, _b, _c; -@@ -550,6 +603,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -560,6 +613,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex, handlerMethods, diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+012+fix-scrollbar-oscillation-crash.patch similarity index 95% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+012+fix-scrollbar-oscillation-crash.patch index 03a1dbf1249d..8271a322e1b1 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+012+fix-scrollbar-oscillation-crash.patch @@ -16,7 +16,7 @@ index 12375d9..bfc3ee2 100644 /** * LinearLayoutManager implementation that arranges items in a single row or column. * Supports both horizontal and vertical layouts with dynamic item sizing. -@@ -10,6 +19,8 @@ +@@ -10,6 +19,8 @@ export class RVLinearLayoutManagerImpl extends RVLayoutManager { this.hasSize = false; /** Height of the tallest item */ this.tallestItemHeight = 0; @@ -25,7 +25,7 @@ index 12375d9..bfc3ee2 100644 this.boundedSize = this.horizontal ? params.windowSize.height : params.windowSize.width; -@@ -23,9 +34,15 @@ +@@ -23,9 +34,15 @@ export class RVLinearLayoutManagerImpl extends RVLayoutManager { const prevHorizontal = this.horizontal; super.updateLayoutParams(params); const oldBoundedSize = this.boundedSize; @@ -42,10 +42,11 @@ index 12375d9..bfc3ee2 100644 if (oldBoundedSize !== this.boundedSize || prevHorizontal !== this.horizontal) { if (this.layouts.length > 0) { -@@ -36,6 +53,81 @@ +@@ -35,6 +52,81 @@ export class RVLinearLayoutManagerImpl extends RVLayoutManager { + } } } - /** ++ /** + * Web only. Stops the re-layout loop caused by a scrollbar that keeps showing and hiding. + * + * The list measures its width without the scrollbar, so each toggle changes the width, which @@ -120,7 +121,6 @@ index 12375d9..bfc3ee2 100644 + ]; + return smaller; + } -+ /** + /** * Processes layout information for items, updating their dimensions. * For horizontal layouts, also normalizes heights of items. - * @param layoutInfo Array of layout information for items diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+013+improve-scroll-key-handling.patch similarity index 97% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+013+improve-scroll-key-handling.patch index c11114fa1de0..de2c04c71180 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+013+improve-scroll-key-handling.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts -index fa786bf..586014c 100644 +index 5e90a5c..cd99ea1 100644 --- a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts +++ b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts @@ -127,10 +127,12 @@ export interface FlashListProps extends Omit visibleIndices", visibleIndices); @@ -71,7 +71,7 @@ index 3b69234..a9474c1 100644 if (this.hasRenderedProgressively) { this.isFirstLayoutComplete = true; } -@@ -327,9 +354,13 @@ export class RecyclerViewManager { +@@ -326,9 +353,13 @@ export class RecyclerViewManager { // If everything is measured then render stack will be in sync. The buffer items will get rendered in the next update // triggered by the useOnLoad hook. !this.hasRenderedProgressively && @@ -89,7 +89,7 @@ index 3b69234..a9474c1 100644 } getItemType(index) { diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 18e59ce..40bdddb 100644 +index 48e0bdc..dddd8ba 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js @@ -25,6 +25,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe @@ -103,7 +103,7 @@ index 18e59ce..40bdddb 100644 // True while a `scrollToIndex` / `scrollToOffset` smooth scroll is in // flight. Cleared exactly once on `isMomentumEnd` via // `notifyProgrammaticScrollSettled`. -@@ -566,18 +570,55 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -576,18 +580,55 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe }, 500); pauseOffsetCorrection.current = true; const additionalOffset = (_c = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewOffset) !== null && _c !== void 0 ? _c : 0; diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+014+external-window-size.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+014+external-window-size.patch similarity index 95% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+014+external-window-size.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+014+external-window-size.patch index 971a05fa078b..2a0e801fef9e 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+014+external-window-size.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+014+external-window-size.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts -index fa786bf..014eb62 100644 +index cd99ea1..d926c81 100644 --- a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts +++ b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts @@ -99,6 +99,15 @@ export interface FlashListProps extends Omit { - var _a, _b, _c, _d; + var _a, _b, _c, _d, _e; // Destructure props and initialize refs - const { horizontal, renderItem, data, extraData, onLoad, CellRendererComponent, overrideProps, refreshing, onRefresh, progressViewOffset, ListEmptyComponent, ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ItemSeparatorComponent, renderScrollComponent, style, stickyHeaderIndices, maintainVisibleContentPosition, onCommitLayoutEffect, onChangeStickyIndex, stickyHeaderConfig, inverted, ...rest } = props; + const { horizontal, renderItem, data, extraData, onLoad, CellRendererComponent, overrideProps, refreshing, onRefresh, progressViewOffset, ListEmptyComponent, ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ItemSeparatorComponent, renderScrollComponent, style, stickyHeaderIndices, maintainVisibleContentPosition, onCommitLayoutEffect, onChangeStickyIndex, stickyHeaderConfig, inverted, overrideWindowSize, ...rest } = props; const [renderTimeTracker] = useState(() => new RenderTimeTracker()); renderTimeTracker.startTracking(); // Sticky header config -@@ -147,12 +147,15 @@ const RecyclerViewComponent = (props, ref) => { +@@ -148,12 +148,15 @@ const RecyclerViewComponent = (props, ref) => { : horizontal ? firstChildViewLayout.x : firstChildViewLayout.y; @@ -50,7 +50,7 @@ index ef4daf2..063fd1a 100644 ? firstItemOffset - recyclerViewManager.getChildContainerDimensions().width diff --git a/node_modules/@shopify/flash-list/src/FlashListProps.ts b/node_modules/@shopify/flash-list/src/FlashListProps.ts -index 76dd0c8..5a5c0f5 100644 +index 1dba06c..9531d92 100644 --- a/node_modules/@shopify/flash-list/src/FlashListProps.ts +++ b/node_modules/@shopify/flash-list/src/FlashListProps.ts @@ -160,6 +160,16 @@ export interface FlashListProps @@ -71,7 +71,7 @@ index 76dd0c8..5a5c0f5 100644 * Draw distance for advanced rendering (in dp/px) */ diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index bc27739..a7829d5 100644 +index ea42f73..4d6b24e 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx @@ -90,6 +90,7 @@ const RecyclerViewComponent = ( @@ -82,7 +82,7 @@ index bc27739..a7829d5 100644 ...rest } = props; -@@ -265,13 +266,17 @@ const RecyclerViewComponent = ( +@@ -266,13 +267,17 @@ const RecyclerViewComponent = ( ? firstChildViewLayout.x : firstChildViewLayout.y; diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+015+mvcp-header-aware.patch similarity index 97% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+015+mvcp-header-aware.patch index 5d59a58de2ff..52147e9f2820 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+015+mvcp-header-aware.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index 063fd1a..c37ff3a 100644 +index c00fc99..a65347b 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -349,8 +349,27 @@ const RecyclerViewComponent = (props, ref) => { +@@ -350,8 +350,27 @@ const RecyclerViewComponent = (props, ref) => { recyclerViewContext.layout(); } }, [recyclerViewContext, recyclerViewManager]); @@ -32,7 +32,7 @@ index 063fd1a..c37ff3a 100644 recyclerViewManager.getDataLength() > 0) { parentRecyclerViewContext === null || parentRecyclerViewContext === void 0 ? void 0 : parentRecyclerViewContext.markChildLayoutAsPending(recyclerViewId); diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 40bdddb..c505e59 100644 +index dddd8ba..4c3862c 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js @@ -51,6 +51,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe @@ -71,7 +71,7 @@ index 40bdddb..c505e59 100644 } } }, [recyclerViewManager]); -@@ -128,15 +143,21 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -138,15 +153,21 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe : undefined); if (currentIndexOfFirstVisibleItem !== undefined && currentIndexOfFirstVisibleItem >= 0) { @@ -95,7 +95,7 @@ index 40bdddb..c505e59 100644 if (diff !== 0 && !pauseOffsetCorrection.current && !recyclerViewManager.animationOptimizationsEnabled) { -@@ -147,13 +168,16 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -157,13 +178,16 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe } else if (useAndroidInvertedFallback) { if (!shouldSkipAndroidInvertedCorrection) { @@ -114,7 +114,7 @@ index 40bdddb..c505e59 100644 animated: false, }; if (pendingAndroidInvertedRafId.current !== null) { -@@ -169,17 +193,17 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -179,17 +203,17 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe else { const scrollToParams = horizontal ? { @@ -160,7 +160,7 @@ index 4d7a945..26133d6 100644 * Creates the footer component with optional styling. */ diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -index a7829d5..ec5d7b7 100644 +index 4d6b24e..3782805 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx @@ -16,6 +16,7 @@ import React, { @@ -171,7 +171,7 @@ index a7829d5..ec5d7b7 100644 NativeScrollEvent, NativeSyntheticEvent, Platform, -@@ -501,6 +502,31 @@ const RecyclerViewComponent = ( +@@ -502,6 +503,31 @@ const RecyclerViewComponent = ( [recyclerViewContext, recyclerViewManager] ); @@ -203,7 +203,7 @@ index a7829d5..ec5d7b7 100644 // Get secondary props and components const { refreshControl, -@@ -509,7 +535,7 @@ const RecyclerViewComponent = ( +@@ -510,7 +536,7 @@ const RecyclerViewComponent = ( renderEmpty, CompatScrollView, renderStickyHeaderBackdrop, @@ -213,7 +213,7 @@ index a7829d5..ec5d7b7 100644 if ( !recyclerViewManager.getIsFirstLayoutComplete() && diff --git a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx -index 3012391..7375752 100644 +index 3689830..1308c15 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx @@ -57,6 +57,10 @@ export function useRecyclerViewController( @@ -255,7 +255,7 @@ index 3012391..7375752 100644 } } }, [recyclerViewManager]); -@@ -156,15 +174,23 @@ export function useRecyclerViewController( +@@ -166,15 +184,23 @@ export function useRecyclerViewController( currentIndexOfFirstVisibleItem !== undefined && currentIndexOfFirstVisibleItem >= 0 ) { @@ -281,7 +281,7 @@ index 3012391..7375752 100644 if ( diff !== 0 && !pauseOffsetCorrection.current && -@@ -175,20 +201,27 @@ export function useRecyclerViewController( +@@ -185,20 +211,27 @@ export function useRecyclerViewController( // console.log("scrollBy", diff); scrollAnchorRef.current?.scrollBy(diff); } else { diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+016+ignore-stale-viewholder-render-layout.patch similarity index 89% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.2+016+ignore-stale-viewholder-render-layout.patch index 572d6b9790bf..6eb1fa0f749c 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.2+016+ignore-stale-viewholder-render-layout.patch @@ -1,7 +1,8 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +index a65347b..562f92c 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -@@ -467,7 +467,7 @@ function RecyclerView(props) { +@@ -469,7 +469,7 @@ const RecyclerViewComponent = (props, ref) => { isHorizontalRTL && viewToMeasureBoundedSize, renderHeader, !isHorizontalRTL && viewToMeasureBoundedSize, @@ -11,9 +12,10 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js return 0; } diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts +index fd2ff94..f617bd6 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts +++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts -@@ -19,7 +19,7 @@ export interface ViewHolderCollectionProps { +@@ -19,7 +19,7 @@ export interface ViewHolderCollectionProps { index: number; }>; /** Function to get layout information for a specific index */ @@ -23,9 +25,10 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollec viewHolderCollectionRef: React.Ref; /** Map to store refs for each ViewHolder instance */ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js +index e66d406..3d3e4b0 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -@@ -290,6 +290,13 @@ export function ViewHolderCollection(props) { +@@ -290,6 +290,13 @@ export const ViewHolderCollection = (props) => { return (React.createElement(CompatView, { ref: containerRef, style: hasData && containerStyle }, containerLayout && hasData && renderEntriesRef.current.map(([reactKey, { index }]) => { @@ -39,7 +42,7 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollec const item = data[index]; // Suppress separators for items in the last row to prevent // height mismatch. The last data item has no separator (no -@@ -298,7 +305,7 @@ export function ViewHolderCollection(props) { +@@ -298,7 +305,7 @@ export const ViewHolderCollection = (props) => { ? data[index + 1] : undefined; return (React.createElement(ViewHolder, { key: reactKey, index: index, item: item, trailingItem: trailingItem, layout: { @@ -49,9 +52,10 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollec }))); }; diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +index 3782805..cac633c 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx -@@ -705,7 +705,7 @@ function RecyclerView(props: RecyclerViewProps) { +@@ -709,7 +709,7 @@ const RecyclerViewComponent = ( data={data} horizontal={horizontal} renderStack={recyclerViewManager.getRenderStack()} @@ -61,9 +65,10 @@ diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx if (!shouldRenderFromBottom || !recyclerViewManager.hasLayout()) { return 0; diff --git a/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollection.tsx b/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollection.tsx +index a652beb..5af6016 100644 --- a/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollection.tsx +++ b/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollection.tsx -@@ -23,7 +23,7 @@ export interface ViewHolderCollectionProps { +@@ -23,7 +23,7 @@ export interface ViewHolderCollectionProps { /** Map of indices to React keys for each rendered item */ renderStack: Map; /** Function to get layout information for a specific index */ @@ -72,7 +77,7 @@ diff --git a/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollect /** Ref to control layout updates from parent components */ viewHolderCollectionRef: React.Ref; /** Map to store refs for each ViewHolder instance */ -@@ -176,6 +176,13 @@ export function ViewHolderCollection(props: ViewHolderCollectionProps) { +@@ -176,6 +176,13 @@ export const ViewHolderCollection = ( {containerLayout && hasData && Array.from(renderStack.entries(), ([reactKey, { index }]) => { @@ -86,7 +91,7 @@ diff --git a/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollect const item = data[index]; // Suppress separators for items in the last row to prevent // height mismatch. The last data item has no separator (no -@@ -192,7 +199,7 @@ export function ViewHolderCollection(props: ViewHolderCollectionProps) { +@@ -192,7 +199,7 @@ export const ViewHolderCollection = ( item={item} trailingItem={trailingItem} layout={{ diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index 3e4eeab058c3..cf53dd448f9e 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -1,6 +1,6 @@ # `@shopify/flash-list` patches -### [@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch](@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch) +### [@shopify+flash-list+2.3.2+001+fix-horizontal-height-normalization.patch](@shopify+flash-list+2.3.2+001+fix-horizontal-height-normalization.patch) - Reason: Fixes height normalization in horizontal FlashList when items change. `LinearLayoutManager.normalizeLayoutHeights` had three issues: 1. **Screen resize / item shrink**: When items shrink, `tallestItemHeight` was updated prematurely, causing the next cycle to skip re-normalization. Fixed by resetting tallest item tracking when `targetMinHeight === 0` so the next repaint re-detects the tallest item. @@ -10,7 +10,7 @@ - E/App issue: https://github.com/Expensify/App/issues/33725 - PR introducing patch: https://github.com/Expensify/App/pull/81566 -### [@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch](@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch) +### [@shopify+flash-list+2.3.2+002+skip-layout-when-hidden.patch](@shopify+flash-list+2.3.2+002+skip-layout-when-hidden.patch) - Reason: Prevents FlashList from losing its render state when a navigation stack hides the parent container with `display: none`. Four guards in total — two in `RecyclerView` to skip layout processing while hidden, and two in `useRecyclerViewController` to make scroll methods safe while hidden: 1. **First `useLayoutEffect`** in `RecyclerView` (measures parent container): After calling `measureParentSize()`, if both width and height are 0, return early before calling `updateLayoutParams()` or updating `containerViewSizeRef`. This preserves the last known valid window size and prevents the layout manager from receiving zero dimensions. @@ -23,35 +23,35 @@ - E/App issue: https://github.com/Expensify/App/issues/83976 (original), https://github.com/Expensify/App/issues/90756 (scroll-while-hidden follow-up) - PR introducing patch: https://github.com/Expensify/App/pull/84887 -### [@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch](@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch) +### [@shopify+flash-list+2.3.2+003+fix-inverted-scroll-direction-on-web.patch](@shopify+flash-list+2.3.2+003+fix-inverted-scroll-direction-on-web.patch) - Reason: Fixes inverted scroll direction on web. FlashList uses `scaleY: -1` / `scaleX: -1` CSS transform to visually invert the list, but the browser's native wheel scroll doesn't flip accordingly — scrolling down visually scrolls up and vice versa. This patch adds a `useEffect` in `RecyclerView` that attaches a `wheel` event listener on web when `inverted` is true, intercepting the event, negating the scroll delta, and manually adjusting `scrollTop`/`scrollLeft`. Mirrors the same fix applied in react-native-web's `VirtualizedList`. - Upstream PR/issue: TBD - E/App issue: https://github.com/Expensify/App/issues/33725 - PR introducing patch: https://github.com/Expensify/App/pull/85114 -### [@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch](@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch) +### [@shopify+flash-list+2.3.2+004+fix-inverted-first-item-offset.patch](@shopify+flash-list+2.3.2+004+fix-inverted-first-item-offset.patch) - Reason: Fixes inverted lists rendering only a few items with white space on scroll. FlashList's `RecyclerView` measures `firstItemOffset` by calling `measureFirstChildLayout` relative to the outer container. When `inverted` is true, the outer container has `scaleY: -1`, which flips the coordinate system — causing the measured y-offset to equal the container height instead of 0. This makes all scroll offsets negative after adjustment (`adjustedOffset = scrollOffset - firstItemOffset`), so the viewport thinks it's in negative space where no items exist. Only items caught by the draw-distance buffer render. The fix forces `firstItemOffset` to 0 for inverted lists, since the transform already handles visual inversion. - Upstream PR/issue: https://github.com/Shopify/flash-list/pull/2300 - E/App issue: https://github.com/Expensify/App/issues/33725 - PR introducing patch: https://github.com/Expensify/App/pull/85114 -### [@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch](@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch) +### [@shopify+flash-list+2.3.2+005+fix-pending-children-blocking-measurements.patch](@shopify+flash-list+2.3.2+005+fix-pending-children-blocking-measurements.patch) - Reason: Fixes items overlapping on initial load when a list contains nested FlashLists (e.g. a horizontal list inside a chat message). The `RecyclerView` layout measurement `useLayoutEffect` had an early return when `pendingChildIds.size > 0` — while any nested FlashList was still doing its progressive first layout, the parent list skipped ALL measurement processing. This meant newly added items stayed at estimated positions (wrong heights/y-offsets) while being visible (`opacity: 1`), causing overlap. The fix moves the `pendingChildIds` check so that measurements are always collected and processed by the layout manager, but when children are pending, `commitLayout()` is called instead of `setRenderId()`. This updates item positions in `ViewHolderCollection` without triggering a full `RecyclerView` re-render, avoiding the cascading `setState` calls that the original guard was meant to prevent. - Upstream PR/issue: TBD - E/App issue: https://github.com/Expensify/App/issues/33725 - PR introducing patch: https://github.com/Expensify/App/pull/85114 -### [@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch](@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch) +### [@shopify+flash-list+2.3.2+006+fix-inverted-mvcp-android.patch](@shopify+flash-list+2.3.2+006+fix-inverted-mvcp-android.patch) - Reason: Fixes `maintainVisibleContentPosition` not working on Android for inverted lists when items are prepended (e.g. new messages arriving, or `useFlashListScrollKey` switching from sliced to full data). FlashList's offset correction uses a `ScrollAnchor` component — an invisible absolutely-positioned element whose `top` changes to trigger the native `maintainVisibleContentPosition` on the ScrollView. On Android, where inversion uses `rotate: 180deg` (vs `scaleY: -1` on iOS), this mechanism silently fails: the anchor position changes but the native ScrollView does not adjust its scroll offset. The fix detects the specific case (`inverted && Platform.OS === 'android' && hasDataChanged`) and bypasses `ScrollAnchor` in favor of a deferred `scrollTo` via `requestAnimationFrame`, which fires after the native layout has committed the new content size. Non-inverted lists, iOS, web, and layout-only corrections (no data change) are unaffected and continue using the original code paths. - Upstream PR/issue: TBD - E/App issue: https://github.com/Expensify/App/issues/33725 - PR introducing patch: https://github.com/Expensify/App/pull/85114 -### [@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch](@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch) +### [@shopify+flash-list+2.3.2+007+fix-scroll-anchor-unmount-on-ios.patch](@shopify+flash-list+2.3.2+007+fix-scroll-anchor-unmount-on-ios.patch) - Reason: Fixes a scroll position reset on iOS when `maintainVisibleContentPosition.disabled` toggles from `true` to `false` (e.g. when `shouldMaintainVisibleContentPosition` changes based on scroll offset). Root cause: `ScrollAnchor` was conditionally rendered based on `shouldMaintainVisibleContentPosition()`. When MVCP was disabled, the anchor unmounted, which made the native Fabric `_firstVisibleView` weak-ref become nil. When MVCP was re-enabled, the anchor remounted at `top: 1,000,000` (its initial position), but `_prevFirstVisibleFrame` was stale at `1,000,000 + X` from the prior anchor instance. `_adjustForMaintainVisibleContentPosition` then computed `deltaY = 0 - (1,000,000 + X)` — a massive negative offset — causing the list to jump to the start. The fix decouples anchor lifetime from the `disabled` flag: `ScrollAnchor` is now always mounted (and `maintainVisibleContentPositionInternal` always non-null) whenever `maintainVisibleContentPosition` prop is defined. The `disabled` flag continues to gate JS-level `scrollBy` corrections in `applyOffsetCorrection` (via `shouldMaintainVisibleContentPosition()`), so the anchor stays in place when MVCP is logically off — the native side always has a live `_firstVisibleView` and a fresh `_prevFirstVisibleFrame` to diff against. - Files changed: Both `src/recyclerview/RecyclerView.tsx` and `dist/recyclerview/RecyclerView.js`. @@ -59,7 +59,7 @@ - E/App issue: https://github.com/Expensify/App/issues/33725 - PR introducing patch: https://github.com/Expensify/App/pull/88923 -### [@shopify+flash-list+2.3.0+008+increase-timeout.patch](@shopify+flash-list+2.3.0+008+increase-timeout.patch) +### [@shopify+flash-list+2.3.2+008+increase-timeout.patch](@shopify+flash-list+2.3.2+008+increase-timeout.patch) - Reason: Fixes an initial-render scroll jump on iOS for inverted lists using `initialScrollIndex`. The existing 100 ms `pauseOffsetCorrection` window in `applyInitialScrollIndex` wasn't long enough — MVCP resumed before the corrective `scrollToOffset` had settled, exposing the jump. Bumped to 500 ms. - Files changed: `dist/recyclerview/hooks/useRecyclerViewController.js` only. @@ -67,7 +67,7 @@ - E/App issue: https://github.com/Expensify/App/issues/89768 - PR introducing patch: https://github.com/Expensify/App/pull/90218 -### [@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch](@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch) +### [@shopify+flash-list+2.3.2+009+ignore-stale-viewholder-layout.patch](@shopify+flash-list+2.3.2+009+ignore-stale-viewholder-layout.patch) - Reason: Prevents stale `ViewHolder.onLayout` callbacks from crashing FlashList after the list data/layout table has changed. `validateItemSize` previously read the stored layout with `recyclerViewManager.getLayout(index)`, which throws when the callback's render-time index is no longer present in the layout manager. The patch uses `recyclerViewManager.tryGetLayout(index)` and returns early when the layout is missing, so obsolete measurements are ignored while current indexes continue through the existing width/height comparison. - Files changed: Both `src/recyclerview/RecyclerView.tsx` and `dist/recyclerview/RecyclerView.js`. @@ -75,7 +75,7 @@ - E/App issue: https://github.com/Expensify/App/issues/89933 - PR introducing patch: https://github.com/Expensify/App/pull/91248 -### [@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch](@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch) +### [@shopify+flash-list+2.3.2+010+fix-web-subpixel-rounding.patch](@shopify+flash-list+2.3.2+010+fix-web-subpixel-rounding.patch) - Reason: Fixes a "Maximum update depth exceeded" infinite render loop on web (mostly Windows with fractional display scaling). `roundOffPixel` on web was a no-op, so subpixel drift in the child container's `getBoundingClientRect()` width re-triggered `ViewHolderCollection`'s `[fixedContainerSize]` layout effect on every measurement. The patch implements `roundOffPixel` to snap to the device-pixel grid (`Math.round(value * devicePixelRatio) / devicePixelRatio`), matching native `PixelRatio.roundToNearestPixel`. Two measurements that paint the same physical pixel now collapse to the same JS value, breaking the loop. - Files changed: `dist/recyclerview/utils/measureLayout.web.js` only. @@ -84,7 +84,7 @@ - Sentry: https://expensify.sentry.io/issues/APP-DQ2 - PR introducing patch: https://github.com/Expensify/App/pull/91799 -### [@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch](@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch) +### [@shopify+flash-list+2.3.2+011+sort-for-natural-DOM-order.patch](@shopify+flash-list+2.3.2+011+sort-for-natural-DOM-order.patch) - Reason: Fixes scrambled DOM order in virtualized list items on web. FlashList uses `position: absolute` to position items, so visual order is determined by CSS `top`/`left` values rather than DOM order. Due to recycling (reusing ViewHolder components for different data items), the DOM order reflects Map insertion order rather than data index order. This causes three web-specific issues: @@ -130,7 +130,7 @@ - E/App issue: https://github.com/Expensify/App/issues/86126 - PR introducing patch: https://github.com/Expensify/App/pull/85825 -### [@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch](@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch) +### [@shopify+flash-list+2.3.2+012+fix-scrollbar-oscillation-crash.patch](@shopify+flash-list+2.3.2+012+fix-scrollbar-oscillation-crash.patch) - Reason: Fixes a "Maximum update depth exceeded" (#185) infinite render loop on web with classic (non-overlay) scrollbars — i.e. Windows/Linux Chrome and macOS with "Always show scroll bars". @@ -159,7 +159,7 @@ - E/App issue: https://github.com/Expensify/App/issues/91584, https://github.com/Expensify/App/issues/92263, https://github.com/Expensify/App/issues/95719 - PR introducing patch: https://github.com/Expensify/App/pull/92520 (hardened for #95719) -### [@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch](@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch) +### [@shopify+flash-list+2.3.2+013+improve-scroll-key-handling.patch](@shopify+flash-list+2.3.2+013+improve-scroll-key-handling.patch) - Reason: Adds `viewPosition` support to `initialScrollIndexParams` (0 = start, 0.5 = center, 1 = end — same semantics as `scrollToIndex`'s `viewPosition`). Six changes: 1. **`applyInitialScrollIndex`** in `useRecyclerViewController.js`: the corrective scroll for `initialScrollIndex` now shifts the target offset by `(containerSize - itemSize) * viewPosition` (clamped to ≥ 0, and skipped while the container is unmeasured), mirroring `scrollToIndex`'s math. @@ -173,7 +173,7 @@ - E/App issue: https://github.com/Expensify/App/issues/92152 - PR introducing patch: https://github.com/Expensify/App/pull/93403 -### [@shopify+flash-list+2.3.0+014+external-window-size.patch](@shopify+flash-list+2.3.0+014+external-window-size.patch) +### [@shopify+flash-list+2.3.2+014+external-window-size.patch](@shopify+flash-list+2.3.2+014+external-window-size.patch) - Reason: Adds an **`overrideWindowSize`** prop that lets a list declare its visible window (`{width, height}`) instead of deriving it from `measureParentSize(internalViewRef)`. Needed for an *externally-driven* list — one whose `renderScrollComponent` is a non-scrolling `View` that grows to the full content height and receives synthetic scroll events from a parent scroller. Without this, FlashList measures the outer container (as tall as all content) as its viewport and renders every row, defeating virtualization. The change is minimal: `measureParentSize` is still assigned to `outerViewSize` and used for `containerViewSizeRef` (layout-change detection) and the 0×0 hidden-guard from patch 002; only the `windowSize` fed to `updateLayoutParams` is `overrideWindowSize ?? outerViewSize`. Fully backward compatible — when the prop is unset, `windowSize === outerViewSize` and behavior is byte-identical. Used by `MoneyRequestReportView`'s horizontally-scrollable transaction table (`ExternalScrollFlashListTable`), which windows its rows against the unified list's vertical scroll. - Files changed: `src/FlashListProps.ts`, `src/recyclerview/RecyclerView.tsx`, `dist/FlashListProps.d.ts`, `dist/recyclerview/RecyclerView.js`. @@ -181,7 +181,7 @@ - E/App issue: https://github.com/Expensify/App/issues/91425 - PR introducing patch: https://github.com/Expensify/App/pull/91422 -### [@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch](@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch) +### [@shopify+flash-list+2.3.2+015+mvcp-header-aware.patch](@shopify+flash-list+2.3.2+015+mvcp-header-aware.patch) - Reason: Makes `maintainVisibleContentPosition` aware of the `ListHeaderComponent`. Item layouts are header-relative, so MVCP was blind to the header in two symmetric ways: 1. **Header resize was never corrected**: when the header changes height after layout (e.g. a nested virtualized table settling from estimated to measured row heights, ~400px on a 207-row table), every data item shifts on screen but no tracked `x`/`y` changes — the anchored item (e.g. a deep-linked report action positioned via `initialScrollIndex`) drifts out of the viewport with no correction. Fixed by capturing `firstItemOffset` alongside the anchor layout (`firstVisibleItemFirstItemOffset`) and including its delta in the correction diff. On the `ScrollAnchor.scrollBy` path (iOS/Android) the full diff is applied; on the `scrollTo` fallback paths (web, Android inverted) only the layout diff is added, because `getAbsoluteLastScrollOffset()` already reflects the current `firstItemOffset` (the tracker's relative offset only resyncs on scroll events), so the header delta is already embedded in it. @@ -193,7 +193,7 @@ - E/App issue: https://github.com/Expensify/App/issues/91425 - PR introducing patch: https://github.com/Expensify/App/pull/91422 -### [@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch](@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch) +### [@shopify+flash-list+2.3.2+016+ignore-stale-viewholder-render-layout.patch](@shopify+flash-list+2.3.2+016+ignore-stale-viewholder-render-layout.patch) - Reason: Prevents an `index out of bounds, not enough layouts` crash thrown while `ViewHolderCollection` renders. This is the render-path sibling of patch `009`, which only guarded the `validateItemSize` measurement callback. The crash originates in upstream flash-list and reproduces on **every platform** (native crash: `APP-8PG`), not just web. The render stack (`RenderStackManager.keyMap`, returned by `RecyclerViewManager.getRenderStack()`) can hold an entry whose stored `index` exceeds the current `layouts` length when the list `data` shrinks between renders (e.g. deleting a report action, IOU actions being filtered once transactions load, or a Concierge draft being removed). This is a timing gap inside flash-list's own update pipeline: on a data shrink `LayoutManager.modifyLayout` truncates `this.layouts` synchronously (`getLayoutCount()` drops immediately), but the render stack is pruned of the now-out-of-bounds keys only later, when `RenderStackManager.sync()` runs. Any render committed in that gap iterates a `keyMap` still carrying a pre-shrink `index` against the already-shortened `layouts`, so the unguarded `getLayout(index)` wired at `RecyclerView` → `LayoutManager.getLayout` throws. Upstream already guards this same staleness on the measurement path — `modifyLayout` filters stale `layoutInfo` with the comment _"layoutInfo may contain stale indices from ViewHolders that were rendered before the data shrunk"_ — but left the render path unguarded. The patch wires `ViewHolderCollection`'s `getLayout` prop to the bounds-safe `recyclerViewManager.tryGetLayout(index)` and skips (returns `null` for) any render entry whose layout is `undefined`, so a stale index is dropped for that render instead of crashing. Because `keyMap`/`LayoutManager` are shared, platform-agnostic state, the guard applies on both render branches — web's `renderEntriesRef.current.map` and native's `Array.from(renderStack.entries())`. Patch `011` (which introduces web's `renderEntriesRef` copy) only carries the index forward; it is not the source of the stale index. - Files changed: `src/recyclerview/RecyclerView.tsx`, `src/recyclerview/ViewHolderCollection.tsx`, and their `dist` counterparts (`dist/recyclerview/RecyclerView.js`, `dist/recyclerview/ViewHolderCollection.js`, `dist/recyclerview/ViewHolderCollection.d.ts`). diff --git a/patches/expo-location/details.md b/patches/expo-location/details.md new file mode 100644 index 000000000000..d6bcf807f260 --- /dev/null +++ b/patches/expo-location/details.md @@ -0,0 +1,48 @@ +# `expo-location` patches + +### [expo-location+57.0.6+001+guard-failed-last-location-task.patch](expo-location+57.0.6+001+guard-failed-last-location-task.patch) + +- Reason: + + ``` + Fixes a fatal Android crash when the user revokes the location permission while a GPS distance + trip is being tracked and then returns to the app. + + Trigger: the app registers a background location task (`background-location-tracking`) via + `Location.startLocationUpdatesAsync` for GPS distance tracking. Android keeps delivering + location broadcasts to `LocationTaskConsumer.didReceiveBroadcast` after the permission is + revoked. When a broadcast carries no `LocationResult`, the consumer falls back to querying + `mLocationClient.lastLocation`, which now fails with a `SecurityException`. + + Root cause: `didReceiveBroadcast` reads `task.result` inside an `addOnCompleteListener` + callback. `task.result` calls `getResult()`, which rethrows the task's failure as a + `RuntimeExecutionException`. The callback runs asynchronously on the main looper, long after + the enclosing `try` block has returned, so the surrounding `catch (e: SecurityException)` can + never catch it and the exception reaches the main thread unhandled: + + FATAL EXCEPTION: main + com.google.android.gms.tasks.RuntimeExecutionException: java.lang.SecurityException: + uid 10845 does not have any of [ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION]. + at com.google.android.gms.tasks.zzw.getResult(...) + at expo.modules.location.taskConsumers.LocationTaskConsumer.didReceiveBroadcast$lambda$2 + + Nothing on the JS side can prevent this, because the broadcast is delivered to the native + receiver independently of JS and keeps arriving after location updates are declined. + + Patch: check `task.isSuccessful` and bail out with a log line before reading `task.result`. + The existing outer `try/catch` is kept, because `lastLocation` can also throw synchronously. + + Note: `expo-location` ships a prebuilt AAR (see the `publication` entry in its + `expo-module.config.json`), so this source patch only takes effect because `expo-location` was + added to `expo.autolinking.android.buildFromSource` in `package.json`. Do not remove it there + while this patch exists, or the crash silently comes back. + ``` + +- Upstream PR/issue: None. Expo's bug tracker requires a standalone minimal reproduction, and the + faulty branch is only reached by a post-revocation location broadcast that carries no + `LocationResult`. We could not synthesize such a broadcast outside the app, so there is no report + we could file that Expo would accept. The bug is still unpatched on Expo `main`, `sdk-57` and + `expo-location@57.0.15`, so upgrading does not remove the need for this patch — it must stay until + the upstream code changes. +- E/App issue: https://github.com/Expensify/App/issues/99183 +- PR introducing patch: https://github.com/Expensify/App/pull/101091 diff --git a/patches/expo-location/expo-location+57.0.6+001+guard-failed-last-location-task.patch b/patches/expo-location/expo-location+57.0.6+001+guard-failed-last-location-task.patch new file mode 100644 index 000000000000..13a85e2b3809 --- /dev/null +++ b/patches/expo-location/expo-location+57.0.6+001+guard-failed-last-location-task.patch @@ -0,0 +1,17 @@ +diff --git a/node_modules/expo-location/android/src/main/java/expo/modules/location/taskConsumers/LocationTaskConsumer.kt b/node_modules/expo-location/android/src/main/java/expo/modules/location/taskConsumers/LocationTaskConsumer.kt +index b4f6c06..daf10e4 100644 +--- a/node_modules/expo-location/android/src/main/java/expo/modules/location/taskConsumers/LocationTaskConsumer.kt ++++ b/node_modules/expo-location/android/src/main/java/expo/modules/location/taskConsumers/LocationTaskConsumer.kt +@@ -86,6 +86,12 @@ override fun didReceiveBroadcast(intent: Intent) { + } else { + try { + mLocationClient.lastLocation.addOnCompleteListener { task -> ++ // `task.result` rethrows a failed task's exception as a `RuntimeExecutionException`, and ++ // this listener runs after the enclosing `try` returned, so the `catch` never sees it. ++ if (!task.isSuccessful) { ++ Log.e(TAG, "Cannot get last location: " + task.exception?.message) ++ return@addOnCompleteListener ++ } + task.result?.let { handleLocationUpdate(listOf(it)) } + } + } catch (e: SecurityException) { diff --git a/patches/react-native-webview/details.md b/patches/react-native-webview/details.md index 731576f1a4f7..57a9f5a0dda5 100644 --- a/patches/react-native-webview/details.md +++ b/patches/react-native-webview/details.md @@ -1,18 +1,6 @@ # `react-native-webview` patches -### [react-native-webview+13.16.0+001+fix-dataDetectorTypes.patch](react-native-webview+13.16.0+001+fix-dataDetectorTypes.patch) - -- Reason: - - ``` - This patch fixes dataDetectorTypes. - ``` - -- Upstream PR/issue: 🛑, there's no upstream PR/issue found. -- E/App issue: https://github.com/Expensify/App/issues/8503 -- PR introducing patch: https://github.com/Expensify/App/pull/13767 - -### [react-native-webview+13.16.0+002+certificate-pinning.patch](react-native-webview+13.16.0+002+certificate-pinning.patch) +### [react-native-webview+16.0.0+001+certificate-pinning.patch](react-native-webview+16.0.0+001+certificate-pinning.patch) - Reason: @@ -30,7 +18,7 @@ - E/App issue: 🛑 - PR introducing patch: 🛑 -### [react-native-webview+13.16.0+003+certificate-pinning-android.patch](react-native-webview+13.16.0+003+certificate-pinning-android.patch) +### [react-native-webview+16.0.0+002+certificate-pinning-android.patch](react-native-webview+16.0.0+002+certificate-pinning-android.patch) - Reason: diff --git a/patches/react-native-webview/react-native-webview+13.16.0+001+fix-dataDetectorTypes.patch b/patches/react-native-webview/react-native-webview+13.16.0+001+fix-dataDetectorTypes.patch deleted file mode 100644 index 35b0a160b3a2..000000000000 --- a/patches/react-native-webview/react-native-webview+13.16.0+001+fix-dataDetectorTypes.patch +++ /dev/null @@ -1,65 +0,0 @@ -diff --git a/node_modules/react-native-webview/apple/RNCWebView.mm b/node_modules/react-native-webview/apple/RNCWebView.mm -index ec75032..9685c81 100644 ---- a/node_modules/react-native-webview/apple/RNCWebView.mm -+++ b/node_modules/react-native-webview/apple/RNCWebView.mm -@@ -318,24 +318,28 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & - #if !TARGET_OS_OSX - if (oldViewProps.dataDetectorTypes != newViewProps.dataDetectorTypes) { - WKDataDetectorTypes dataDetectorTypes = WKDataDetectorTypeNone; -- if (dataDetectorTypes & RNCWebViewDataDetectorTypes::Address) { -+ for (const auto& type : newViewProps.dataDetectorTypes) { -+ if (type == "address") { - dataDetectorTypes |= WKDataDetectorTypeAddress; -- } else if (dataDetectorTypes & RNCWebViewDataDetectorTypes::Link) { -+ } else if (type == "link") { - dataDetectorTypes |= WKDataDetectorTypeLink; -- } else if (dataDetectorTypes & RNCWebViewDataDetectorTypes::CalendarEvent) { -+ } else if (type == "calendarEvent") { - dataDetectorTypes |= WKDataDetectorTypeCalendarEvent; -- } else if (dataDetectorTypes & RNCWebViewDataDetectorTypes::TrackingNumber) { -+ } else if (type == "trackingNumber") { - dataDetectorTypes |= WKDataDetectorTypeTrackingNumber; -- } else if (dataDetectorTypes & RNCWebViewDataDetectorTypes::FlightNumber) { -+ } else if (type == "flightNumber") { - dataDetectorTypes |= WKDataDetectorTypeFlightNumber; -- } else if (dataDetectorTypes & RNCWebViewDataDetectorTypes::LookupSuggestion) { -+ } else if (type == "lookupSuggestion") { - dataDetectorTypes |= WKDataDetectorTypeLookupSuggestion; -- } else if (dataDetectorTypes & RNCWebViewDataDetectorTypes::PhoneNumber) { -+ } else if (type == "phoneNumber") { - dataDetectorTypes |= WKDataDetectorTypePhoneNumber; -- } else if (dataDetectorTypes & RNCWebViewDataDetectorTypes::All) { -+ } else if (type == "all") { - dataDetectorTypes |= WKDataDetectorTypeAll; -- } else if (dataDetectorTypes & RNCWebViewDataDetectorTypes::None) { -+ break; -+ } else if (type == "none") { - dataDetectorTypes = WKDataDetectorTypeNone; -+ break; -+ } - } - [_view setDataDetectorTypes:dataDetectorTypes]; - } -diff --git a/node_modules/react-native-webview/src/RNCWebViewNativeComponent.ts b/node_modules/react-native-webview/src/RNCWebViewNativeComponent.ts -index 682ab39..09e2bce 100644 ---- a/node_modules/react-native-webview/src/RNCWebViewNativeComponent.ts -+++ b/node_modules/react-native-webview/src/RNCWebViewNativeComponent.ts -@@ -202,17 +202,7 @@ export interface NativeProps extends ViewProps { - 'recommended' - >; - dataDetectorTypes?: WithDefault< -- ReadonlyArray< -- | 'address' -- | 'link' -- | 'calendarEvent' -- | 'trackingNumber' -- | 'flightNumber' -- | 'lookupSuggestion' -- | 'phoneNumber' -- | 'all' -- | 'none' -- >, -+ ReadonlyArray, - 'phoneNumber' - >; - decelerationRate?: Double; diff --git a/patches/react-native-webview/react-native-webview+13.16.0+002+certificate-pinning.patch b/patches/react-native-webview/react-native-webview+16.0.0+001+certificate-pinning.patch similarity index 94% rename from patches/react-native-webview/react-native-webview+13.16.0+002+certificate-pinning.patch rename to patches/react-native-webview/react-native-webview+16.0.0+001+certificate-pinning.patch index 37a3d4d26af4..0d3efbd4e1e1 100644 --- a/patches/react-native-webview/react-native-webview+13.16.0+002+certificate-pinning.patch +++ b/patches/react-native-webview/react-native-webview+16.0.0+001+certificate-pinning.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-native-webview/apple/RNCWebViewImpl.m b/node_modules/react-native-webview/apple/RNCWebViewImpl.m -index 7f5c24d..416cfde 100644 +index bb6f828..bb616d4 100644 --- a/node_modules/react-native-webview/apple/RNCWebViewImpl.m +++ b/node_modules/react-native-webview/apple/RNCWebViewImpl.m @@ -17,6 +17,15 @@ @@ -18,7 +18,7 @@ index 7f5c24d..416cfde 100644 static NSTimer *keyboardTimer; static NSString *const HistoryShimName = @"ReactNativeHistoryShim"; static NSString *const MessageHandlerName = @"ReactNativeWebView"; -@@ -1161,6 +1170,17 @@ RCTAutoInsetsProtocol> +@@ -1131,6 +1140,17 @@ - (void) webView:(WKWebView *)webView completionHandler(NSURLSessionAuthChallengeUseCredential, clientAuthenticationCredential); return; } diff --git a/patches/react-native-webview/react-native-webview+13.16.0+003+certificate-pinning-android.patch b/patches/react-native-webview/react-native-webview+16.0.0+002+certificate-pinning-android.patch similarity index 91% rename from patches/react-native-webview/react-native-webview+13.16.0+003+certificate-pinning-android.patch rename to patches/react-native-webview/react-native-webview+16.0.0+002+certificate-pinning-android.patch index ead73644518b..fead56001538 100644 --- a/patches/react-native-webview/react-native-webview+13.16.0+003+certificate-pinning-android.patch +++ b/patches/react-native-webview/react-native-webview+16.0.0+002+certificate-pinning-android.patch @@ -1,9 +1,8 @@ diff --git a/node_modules/react-native-webview/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewClient.java b/node_modules/react-native-webview/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewClient.java -index 0287e1c..e849278 100644 +index 5dac89e..6798515 100644 --- a/node_modules/react-native-webview/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewClient.java +++ b/node_modules/react-native-webview/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewClient.java -@@ -67,7 +67,18 @@ - reactWebView.callInjectedJavaScript(); +@@ -61,6 +61,17 @@ public class RNCWebViewClient extends WebViewClient { emitFinishEvent(webView, url); } diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ff322477612a..99ea9ee28622 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -705,7 +705,10 @@ const CONST = { // 12-hour clock and its own `μ.μ.` marker. `p` is a date-fns extension, and still needs `{locale}`. LOCAL_TIME_FORMAT: 'p', YEAR_MONTH_FORMAT: 'yyyyMM', - MONTH_FORMAT: 'MMMM', + // `LLLL` is the standalone month, not `MMMM`. Greek and Polish inflect the month name when it accompanies + // a day, so `stycznia` means "of January". A month shown on its own, like a picker list or a statement + // heading, needs the nominative `styczeń` instead. Both uses of this constant are a month standing alone. + MONTH_FORMAT: 'LLLL', WEEKDAY_TIME_FORMAT: 'eeee', MONTH_DAY_ABBR_FORMAT: 'MMM d', SHORT_DATE_FORMAT: 'MM-dd', @@ -1117,6 +1120,7 @@ const CONST = { MERGE_ATS: 'mergeATSConnections', REPORT_MERGE: 'reportMerge', INSIGHTS_PAGE: 'insightsPage', + INSIGHTS_COMPARE: 'insightsCompare', }, BUTTON_STATES: { DEFAULT: 'default', @@ -5363,6 +5367,15 @@ const CONST = { }, CARD_LIST_THRESHOLD: 8, DEFAULT_EXPORT_TYPE: 'default', + + /** + * How a card's export account is resolved. Most integrations point a card's NVP at one entry in a flat account + * list, while Rillet and DualEntry resolve it through a program account that each card feed can override. + */ + EXPORT_RESOLVER: { + SINGLE_ACCOUNT: 'singleAccount', + PROGRAM_ACCOUNT: 'programAccount', + }, EXPORT_CARD_TYPES: { /** * Name of Card NVP for QBO custom export accounts @@ -7267,6 +7280,7 @@ const CONST = { SEARCH: { RESULTS_PAGE_SIZE: 50, + TAG_FILTER_PAGE_SIZE: 200, EXITING_ANIMATION_DURATION: 200, ME: 'me', /** How far the cursor may wander from where it last counted as moving over the advanced filter list and still count as resting */ @@ -8179,7 +8193,11 @@ const CONST = { SAVED_SEARCH_PREFIX: 'savedSearch_', GROUP_PREFIX: 'group_', ANIMATION: { - FADE_DURATION: 200, + FADE_DURATION: 150, + + // How long the results area may keep showing the previous query's results while a new query loads. Past + // this, a slow query gives up the stale results and swaps to the skeleton so the wait is visible. + MAX_STALE_HOLD_DURATION: 500, }, TODO_BADGE_MAX_COUNT: 50, TOP_SEARCH_LIMIT: 10, diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index 62d9f6d2acc2..ddf02fa19546 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -36,6 +36,8 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { const hasRefetchedPublicRoom = useRef(false); const [allReports, allReportsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [reportNameValuePairs, reportNameValuePairsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const reportNameValuePairsRef = useRef(reportNameValuePairs); const [isLoadingApp = true] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [session, sessionMetadata] = useOnyx(ONYXKEYS.SESSION); const [conciergeReportID, conciergeReportIDMetadata] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); @@ -45,6 +47,10 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { const [betas, betasMetadata] = useOnyx(ONYXKEYS.BETAS); const isAuthenticated = useIsAuthenticated(); + useEffect(() => { + reportNameValuePairsRef.current = reportNameValuePairs; + }, [reportNameValuePairs]); + // An anonymous deep link into a public room needs to be re-fetched after OpenApp settles (see the effect // below). Track the pending reportID so both the initial-URL and the url-change paths stay in sync. const trackPendingPublicRoomFromDeepLink = useCallback((url: string, isCurrentlyAuthenticated: boolean) => { @@ -57,7 +63,17 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { }, []); useEffect(() => { - if (isLoadingOnyxValue(allReportsMetadata, sessionMetadata, conciergeReportIDMetadata, introSelectedMetadata, guidedSetupAndTourStatusMetadata, betasMetadata)) { + if ( + isLoadingOnyxValue( + allReportsMetadata, + reportNameValuePairsMetadata, + sessionMetadata, + conciergeReportIDMetadata, + introSelectedMetadata, + guidedSetupAndTourStatusMetadata, + betasMetadata, + ) + ) { return; } @@ -114,6 +130,7 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { guidedSetupAndTourStatus?.isSelfTourViewed, betas, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, + reportNameValuePairsRef.current, ); trackPendingPublicRoomFromDeepLink(url, isCurrentlyAuthenticated); } else { @@ -157,6 +174,7 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { guidedSetupAndTourStatus?.isSelfTourViewed, betas, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, + reportNameValuePairsRef.current, ); trackPendingPublicRoomFromDeepLink(state.url, isCurrentlyAuthenticated); }); @@ -172,6 +190,7 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { introSelected, betas, allReportsMetadata.status, + reportNameValuePairsMetadata.status, sessionMetadata.status, conciergeReportIDMetadata.status, introSelectedMetadata.status, diff --git a/src/GlobalModals.tsx b/src/GlobalModals.tsx index a29dca9321cc..9e6a7b48a156 100644 --- a/src/GlobalModals.tsx +++ b/src/GlobalModals.tsx @@ -9,10 +9,8 @@ import {growlRef} from './libs/Growl'; import * as ReportActionContextMenu from './pages/inbox/report/ContextMenu/ReportActionContextMenu'; const LazyPopoverReportActionContextMenu = React.lazy(() => import('./pages/inbox/report/ContextMenu/PopoverReportActionContextMenu')); -const LazyUpdateAppModal = React.lazy(() => import('./components/UpdateAppModal')); -const LazyScreenShareRequestModal = React.lazy(() => import('./components/ScreenShareRequestModal')); -const LazyProactiveAppReviewModalManager = React.lazy(() => import('./components/ProactiveAppReviewModalManager')); -const LazyTrialPaymentReminderModalManager = React.lazy(() => import('./components/TrialPaymentReminderModalManager')); +// Dynamic rather than static so this module graph is only fetched once the idle callback has fired. +const LazyDeferredGlobalModals = React.lazy(() => import('./components/DeferredGlobalModals')); // Maximum time (ms) the context menu mount can stay deferred before requestIdleCallback forces it to run, // guaranteeing mount even if the main thread never becomes idle. @@ -61,24 +59,9 @@ function GlobalModals() { {/* eslint-disable-next-line react-hooks/refs -- module-level createRef, safe to pass as ref prop */} {shouldRenderDeferredModals && ( - <> - {/* Order matters: BaseModal hardcodes zIndex: 1 on every modal, so DOM source order - determines stacking when modals coincide. UpdateAppModal is last so the forced-update - prompt sits on top if it ever overlaps with the others. */} - - {/* Proactive app review modal shown when user has completed a trigger action */} - - - - - - - - - - - - + + + )} ); diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 297c19736043..9aa01ce62046 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -49,6 +49,12 @@ const ONYXKEYS = { /** Boolean flag set whenever we are searching for reports in the server */ RAM_ONLY_IS_SEARCHING_FOR_REPORTS: 'isSearchingForReports', + /** Pagination state for tag filter search (hasMore, nextCursor). RAM-only so it resets on app restart. */ + RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION: 'searchTagFiltersPagination', + + /** Paginated tag search results. RAM-only so it resets on app restart. */ + RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS: 'searchTagFiltersResults', + /** Boolean flag set whenever we are searching for users in the server */ RAM_ONLY_IS_SEARCHING_FOR_USERS: 'isSearchingForUsers', @@ -125,9 +131,6 @@ const ONYXKEYS = { * select a currency based on the list */ CURRENCY_LIST: 'currencyList', - /** Indicates whether an update is available and ready to be installed. */ - RAM_ONLY_UPDATE_AVAILABLE: 'updateAvailable', - /** Indicates that a request to join a screen share with a GuidesPlus agent was received */ SCREEN_SHARE_REQUEST: 'screenShareRequest', @@ -1645,7 +1648,6 @@ type OnyxValuesMapping = { [ONYXKEYS.PERSONAL_DETAILS_METADATA]: Record; [ONYXKEYS.TASK]: OnyxTypes.Task; [ONYXKEYS.CURRENCY_LIST]: OnyxTypes.CurrencyList; - [ONYXKEYS.RAM_ONLY_UPDATE_AVAILABLE]: boolean; [ONYXKEYS.SCREEN_SHARE_REQUEST]: OnyxTypes.ScreenShareRequest; [ONYXKEYS.COUNTRY_CODE]: number; [ONYXKEYS.COUNTRY]: string; @@ -1782,6 +1784,8 @@ type OnyxValuesMapping = { [ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string; [ONYXKEYS.ONBOARDING_LAST_VISITED_PATH]: string; [ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS]: boolean; + [ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION]: OnyxTypes.SearchTagFiltersPaginationState; + [ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS]: OnyxTypes.SearchTagFilterItem[]; [ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_USERS]: boolean; [ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN]: boolean; [ONYXKEYS.LAST_VISITED_PATH]: string | undefined; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index e83f35059b68..32c01e92884c 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2079,7 +2079,10 @@ const ROUTES = { SEARCH_ADVANCED_FILTERS: 'search/filters', SEARCH_ADVANCED_FILTERS_CONTENT: { route: 'search/filters/:filterKey', - getRoute: (filterKey: SearchFilterKey | UserFriendlyKey) => `search/filters/${filterKey}` as const, + getRoute: (filterKey: SearchFilterKey | UserFriendlyKey, applyDirectly?: boolean) => { + const baseRoute = `search/filters/${filterKey}` as const; + return applyDirectly ? (`${baseRoute}?applyDirectly=true` as const) : baseRoute; + }, }, SEARCH_REPORT: { route: 'search/view/:reportID/:reportActionID?', diff --git a/src/components/AccountSwitcherButton.tsx b/src/components/AccountSwitcherButton.tsx index 876339b65111..e7697eb41cc2 100644 --- a/src/components/AccountSwitcherButton.tsx +++ b/src/components/AccountSwitcherButton.tsx @@ -27,6 +27,8 @@ import {isTrackingSelector} from '@src/selectors/GPSDraftDetails'; import type {PersonalDetails} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; +import type {ComponentRef} from 'react'; + import {canSwitchAccountsSelector} from '@selectors/Account'; import {Str} from 'expensify-common'; import React, {useCallback, useLayoutEffect, useRef, useState} from 'react'; @@ -55,7 +57,7 @@ function AccountSwitcherButton({isScreenFocused}: AccountSwitcherButtonProps) { const styles = useThemeStyles(); const {localeCompare, translate, formatPhoneNumber} = useLocalize(); const {isOffline} = useNetwork(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {shouldUseNarrowLayout, isInLandscapeMode} = useResponsiveLayout(); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [credentials] = useOnyx(ONYXKEYS.CREDENTIALS); const [stashedCredentials = CONST.EMPTY_OBJECT] = useOnyx(ONYXKEYS.STASHED_CREDENTIALS); @@ -69,7 +71,7 @@ function AccountSwitcherButton({isScreenFocused}: AccountSwitcherButtonProps) { const delegators = account?.delegatedAccess?.delegators ?? []; const personalDetailsByLogin = usePersonalDetailsByLogins([delegate, ...delegators.map((delegator) => delegator.email)]); - const buttonRef = useRef(null); + const buttonRef = useRef>(null); const {windowHeight, windowWidth} = useWindowDimensions(); const {calculatePopoverPosition} = usePopoverPosition(); @@ -234,6 +236,11 @@ function AccountSwitcherButton({isScreenFocused}: AccountSwitcherButtonProps) { }); }; + // Right alignment only makes sense while the button is the trailing item of a row, which is the landscape phone + // header. A narrow portrait header stacks and centers the button, so right-aligning there grows the tooltip + // leftward off the screen, and the wide layout puts the button in the top bar. + const isButtonTrailingItemOfRow = shouldUseNarrowLayout && isInLandscapeMode; + const TooltipToRender = shouldShowProductTrainingTooltip ? EducationalTooltip : Tooltip; const tooltipProps = shouldShowProductTrainingTooltip ? { @@ -242,14 +249,16 @@ function AccountSwitcherButton({isScreenFocused}: AccountSwitcherButtonProps) { anchorAlignment: { // Center on the button so the pointer lands on the button's center rather than off by its right edge. // The tooltip is wider than the room left of the window gutter, so the body still gets pulled left and - // only the pointer ends up truly centered. Narrow layouts keep the right alignment they had before. - horizontal: shouldUseNarrowLayout ? CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT : CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.CENTER, + // only the pointer ends up truly centered. + horizontal: isButtonTrailingItemOfRow ? CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT : CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.CENTER, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, }, shiftVertical: variables.accountSwitcherTooltipShiftVertical, // The nudge squares the right-aligned tooltip up against the gutter. Centered alignment subtracts it back // out of the pointer offset, which would drag the pointer off the button's center, so drop it. - shiftHorizontal: shouldUseNarrowLayout ? variables.accountSwitcherTooltipShiftHorizontal : 0, + shiftHorizontal: isButtonTrailingItemOfRow ? variables.accountSwitcherTooltipShiftHorizontal : 0, + // Native ignores the keep-on-screen clamp unless we opt in. + computeHorizontalShiftForNative: true, wrapperStyle: styles.productTrainingTooltipWrapper, onTooltipPress: onPressSwitcher, // The switcher lives in the settings sidebar, which isn't the navigation-focused screen on wide layouts. diff --git a/src/components/AccountingConnectionConfirmationModal.tsx b/src/components/AccountingConnectionConfirmationModal.tsx deleted file mode 100644 index 5565a9c01946..000000000000 --- a/src/components/AccountingConnectionConfirmationModal.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import useLocalize from '@hooks/useLocalize'; - -import CONST from '@src/CONST'; -import type {ConnectionName} from '@src/types/onyx/Policy'; - -import React from 'react'; - -import ConfirmModal from './ConfirmModal'; - -type AccountingConnectionConfirmationModalProps = { - integrationToConnect: ConnectionName; - integrationDisplayName?: string; - onConfirm: () => void; - onCancel: () => void; -}; - -function AccountingConnectionConfirmationModal({integrationToConnect, integrationDisplayName, onCancel, onConfirm}: AccountingConnectionConfirmationModalProps) { - const {translate} = useLocalize(); - const connectionName = integrationDisplayName ?? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integrationToConnect] ?? integrationToConnect; - - return ( - - ); -} - -export default AccountingConnectionConfirmationModal; diff --git a/src/components/ActionSheetAwareScrollView/types.ts b/src/components/ActionSheetAwareScrollView/types.ts index 480dc8731b08..c5ce9708bab5 100644 --- a/src/components/ActionSheetAwareScrollView/types.ts +++ b/src/components/ActionSheetAwareScrollView/types.ts @@ -1,6 +1,6 @@ import type {ActionWithPayload, State} from '@hooks/useWorkletStateMachine'; -import type {Ref} from 'react'; +import type {ComponentRef, Ref} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView, ScrollViewProps} from 'react-native'; import type Reanimated from 'react-native-reanimated'; @@ -27,7 +27,7 @@ type ActionSheetAwareScrollViewActionsContextValue = { type ActionSheetAwareScrollViewContextValue = ActionSheetAwareScrollViewStateContextValue & ActionSheetAwareScrollViewActionsContextValue; -type ActionSheetAwareScrollViewHandle = ScrollView | Reanimated.ScrollView; +type ActionSheetAwareScrollViewHandle = ComponentRef | Reanimated.ScrollView; type ActionSheetAwareScrollViewProps = ScrollViewProps & { ref?: Ref; diff --git a/src/components/AddExistingExpenseFooter.tsx b/src/components/AddExistingExpenseFooter.tsx index 3e5df261535a..2e0a07da4fe1 100644 --- a/src/components/AddExistingExpenseFooter.tsx +++ b/src/components/AddExistingExpenseFooter.tsx @@ -31,7 +31,7 @@ import {usePersonalDetails, useSession} from './OnyxListItemProvider'; type AddExistingExpenseFooterProps = { /** Selected transaction IDs */ - selectedIds: Set; + selectedIds: string[]; /** The report to add expenses to */ report: OnyxEntry; reportToConfirm: OnyxEntry; @@ -64,11 +64,11 @@ function AddExistingExpenseFooter({selectedIds, report, reportToConfirm, policy, const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - const [transactions] = useTransactionsByID([...selectedIds]); + const [transactions] = useTransactionsByID(selectedIds); const reports = useChangeTransactionsReportReports(transactions, reportToConfirm?.reportID); const handleConfirm = () => { - if (selectedIds.size === 0) { + if (selectedIds.length === 0) { setErrorMessage(translate('iou.selectExistingExpense')); return; } @@ -99,7 +99,7 @@ function AddExistingExpenseFooter({selectedIds, report, reportToConfirm, policy, } else { changeTransactionsReport({ isVendorMatchingBetaEnabled, - transactionIDs: [...selectedIds], + transactionIDs: selectedIds, isASAPSubmitBetaEnabled, accountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, email: session?.email ?? '', diff --git a/src/components/AddPaymentMethodMenu.tsx b/src/components/AddPaymentMethodMenu.tsx index 046dffb21b51..3f5f1f91e0f4 100644 --- a/src/components/AddPaymentMethodMenu.tsx +++ b/src/components/AddPaymentMethodMenu.tsx @@ -15,7 +15,7 @@ import type {Report} from '@src/types/onyx'; import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -41,7 +41,7 @@ type AddPaymentMethodMenuProps = { anchorAlignment?: AnchorAlignment; /** Popover anchor ref */ - anchorRef: RefObject; + anchorRef: RefObject | HTMLDivElement | null>; shouldShowPersonalBankAccountOption?: boolean; }; diff --git a/src/components/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx index f8c80d144db4..bb0e131c1d96 100644 --- a/src/components/AddressSearch/index.tsx +++ b/src/components/AddressSearch/index.tsx @@ -22,6 +22,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {Address} from '@src/types/onyx/PrivatePersonalDetails'; +import type {ComponentRef} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import type {GooglePlaceData, GooglePlaceDetail} from 'react-native-google-places-autocomplete'; @@ -144,7 +145,7 @@ function AddressSearch({ const [isListEmpty, setIsListEmpty] = useState(false); const shouldTriggerGeolocationCallbacks = useRef(true); const [shouldHidePredefinedPlaces, setShouldHidePredefinedPlaces] = useState(false); - const containerRef = useRef(null); + const containerRef = useRef>(null); useDebouncedAccessibilityAnnouncement( translate('common.suggestionsAvailableFor', searchValue.trim()), diff --git a/src/components/AddressSearch/types.ts b/src/components/AddressSearch/types.ts index 415749f1e5e3..f7e41070b46d 100644 --- a/src/components/AddressSearch/types.ts +++ b/src/components/AddressSearch/types.ts @@ -3,7 +3,7 @@ import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type {Country} from '@src/CONST'; import type {Address} from '@src/types/onyx/PrivatePersonalDetails'; -import type {ForwardedRef, RefObject} from 'react'; +import type {ComponentRef, ForwardedRef, RefObject} from 'react'; import type {BlurEvent, StyleProp, TextInputProps, View, ViewStyle} from 'react-native'; import type {Place} from 'react-native-google-places-autocomplete'; @@ -100,6 +100,6 @@ type AddressSearchProps = ForwardedFSClassProps & { ref?: ForwardedRef; }; -type IsCurrentTargetInsideContainerType = (event: FocusEvent | BlurEvent, containerRef: RefObject) => boolean; +type IsCurrentTargetInsideContainerType = (event: FocusEvent | BlurEvent, containerRef: RefObject | HTMLElement | null>) => boolean; export type {CurrentLocationButtonProps, AddressSearchProps, IsCurrentTargetInsideContainerType, StreetValue, PredefinedPlace}; diff --git a/src/components/Alert/types.ts b/src/components/Alert/types.ts index 25454abfe8b8..6cd8166c2d37 100644 --- a/src/components/Alert/types.ts +++ b/src/components/Alert/types.ts @@ -1,5 +1,5 @@ -import type {AlertStatic} from 'react-native'; +import type {Alert as AlertRN} from 'react-native'; -type Alert = AlertStatic['alert']; +type Alert = (typeof AlertRN)['alert']; export default Alert; diff --git a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx index e3c8f67654f8..50c48435707d 100644 --- a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx +++ b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.tsx @@ -13,6 +13,7 @@ import {hideContextMenu, showContextMenu} from '@pages/inbox/report/ContextMenu/ import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {Text as RNText} from 'react-native'; @@ -42,7 +43,7 @@ function BaseAnchorForCommentsOnly({ }: BaseAnchorForCommentsOnlyProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const linkRef = useRef(null); + const linkRef = useRef>(null); const flattenStyle = StyleSheet.flatten(style); const isScreenReaderActive = Accessibility.useScreenReaderStatus(); diff --git a/src/components/AnimatedFlatListWithCellRenderer.tsx b/src/components/AnimatedFlatListWithCellRenderer.tsx index 6a37ec2b31d0..4e22f1b82246 100644 --- a/src/components/AnimatedFlatListWithCellRenderer.tsx +++ b/src/components/AnimatedFlatListWithCellRenderer.tsx @@ -3,8 +3,10 @@ * This should be updated when the original implementation updates * Taken from: https://github.com/software-mansion/react-native-reanimated/blob/main/packages/react-native-reanimated/src/component/FlatList.tsx */ +import type {CellRendererProps} from '@src/types/utils/ReactNativeCompat'; + import type {Ref} from 'react'; -import type {FlatListProps, CellRendererProps as RNCellRendererProps} from 'react-native'; +import type {FlatListProps} from 'react-native'; import type {AnimatedProps, ILayoutAnimationBuilder} from 'react-native-reanimated'; import React, {createContext, useContext} from 'react'; @@ -13,7 +15,7 @@ import Animated, {LayoutAnimationConfig} from 'react-native-reanimated'; const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); -type CellRendererComponentProps = React.ComponentType> | null | undefined; +type CellRendererComponentProps = React.ComponentType> | null | undefined; type CellRendererConfig = { itemLayoutAnimation?: ILayoutAnimationBuilder; @@ -27,7 +29,7 @@ const CellRendererConfigContext = createContext({}); * `itemLayoutAnimation` and the optional outer renderer are read from context because * FlatList only passes standard cell props to `CellRendererComponent`. */ -function CellRendererComponentImpl(props: RNCellRendererProps) { +function CellRendererComponentImpl(props: CellRendererProps) { const {itemLayoutAnimation, outerCellRenderer: OuterCellRenderer} = useContext(CellRendererConfigContext); return ( diff --git a/src/components/AnimatedSubmitButton/index.tsx b/src/components/AnimatedSubmitButton/index.tsx index a9b5c4875592..11f038835fd0 100644 --- a/src/components/AnimatedSubmitButton/index.tsx +++ b/src/components/AnimatedSubmitButton/index.tsx @@ -14,6 +14,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportMetadata} from '@src/types/onyx'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -159,7 +160,7 @@ function AnimatedSubmitButton({ {isAnimationRunning && canShow && ( { + ref={(el: ComponentRef | null) => { viewRef.current = el as HTMLElement | null; }} exiting={buttonAnimation} diff --git a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx index b8cb4603a045..3ab9ffb5a3ad 100644 --- a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx @@ -108,7 +108,7 @@ function AttachmentCarouselView({ } const item = entry.item as Attachment; - if (entry.index !== null) { + if (entry.index != null) { setPage(entry.index); setActiveAttachmentID(item.attachmentID ?? item.source); } diff --git a/src/components/Attachments/AttachmentCarousel/types.ts b/src/components/Attachments/AttachmentCarousel/types.ts index 1df610f7338c..c1ce56152ca7 100644 --- a/src/components/Attachments/AttachmentCarousel/types.ts +++ b/src/components/Attachments/AttachmentCarousel/types.ts @@ -2,8 +2,8 @@ import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import type CONST from '@src/CONST'; import type {Report} from '@src/types/onyx'; +import type {ViewToken} from '@src/types/utils/ReactNativeCompat'; -import type {ViewToken} from 'react-native'; import type {ValueOf} from 'type-fest'; type UpdatePageProps = { diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx index 9a90271fe6f3..4f887d35f44b 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx @@ -9,6 +9,7 @@ import CONST from '@src/CONST'; import htmlDivElementRef from '@src/types/utils/htmlDivElementRef'; import viewRef from '@src/types/utils/viewRef'; +import type {ComponentRef} from 'react'; import type {PointerEvent, StyleProp, ViewStyle} from 'react-native'; import React, {useCallback, useMemo, useRef} from 'react'; @@ -26,7 +27,7 @@ type OnPressHandler = PressableProps['onPress']; function TransparentOverlay({onPress: onPressProp, style}: TransparentOverlayProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const dropZone = useRef(null); + const dropZone = useRef>(null); const {isDraggingOver} = useDragAndDrop({ dropZone: htmlDivElementRef(dropZone), diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx index 946d4168bbe6..d83ea511374b 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx @@ -6,6 +6,8 @@ import useWindowDimensionsForAutoCompleteSuggestions from '@hooks/useWindowDimen import variables from '@styles/variables'; +import type {ComponentRef} from 'react'; + import {Portal} from '@gorhom/portal'; import React, {useEffect, useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; @@ -28,7 +30,7 @@ function AutoCompleteSuggestionsPortal({ const StyleUtils = useStyleUtils(); const {height: windowHeight} = useWindowDimensionsForAutoCompleteSuggestions(); const {keyboardHeight} = useKeyboardState(); - const hostFrameRef = useRef(null); + const hostFrameRef = useRef>(null); const [hostBottomInset, setHostBottomInset] = useState(null); diff --git a/src/components/AvatarButtonWithIcon.tsx b/src/components/AvatarButtonWithIcon.tsx index bf809725eaf1..d0def86308b7 100644 --- a/src/components/AvatarButtonWithIcon.tsx +++ b/src/components/AvatarButtonWithIcon.tsx @@ -10,7 +10,7 @@ import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type IconAsset from '@src/types/utils/IconAsset'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -30,7 +30,7 @@ type AvatarButtonWithIconProps = WithSentryLabel & { avatarStyle?: StyleProp; onPress: () => void; - anchorRef?: RefObject; + anchorRef?: RefObject | HTMLDivElement | null>; avatar: React.ReactNode; /** Size of the displayed avatar. Keeps the button footprint even when `avatar` is empty. */ diff --git a/src/components/AvatarCropModal/ImageCropView.tsx b/src/components/AvatarCropModal/ImageCropView.tsx index 9fbc8270cb45..adb26cfd55d4 100644 --- a/src/components/AvatarCropModal/ImageCropView.tsx +++ b/src/components/AvatarCropModal/ImageCropView.tsx @@ -8,6 +8,7 @@ import ControlSelection from '@libs/ControlSelection'; import type IconAsset from '@src/types/utils/IconAsset'; +import type {ComponentRef} from 'react'; import type {PanGesture} from 'react-native-gesture-handler'; import type {SharedValue} from 'react-native-reanimated'; @@ -76,7 +77,7 @@ function ImageCropView({imageUri = '', containerSize = 0, panGesture = Gesture.P return ( ControlSelection.blockElement(el as HTMLElement | null)} + ref={(el: ComponentRef | null) => ControlSelection.blockElement(el as HTMLElement | null)} style={[containerStyle, styles.imageCropContainer]} > ({validationError: null, phraseParam: {}}); const {calculatePopoverPosition} = usePopoverPosition(); - const anchorRef = useRef(null); + const anchorRef = useRef>(null); const {translate} = useLocalize(); const {openCropper} = useAvatarCrop({maskType: editorMaskImage ? 'square' : undefined, onCropped: onImageSelected}); diff --git a/src/components/BulkActionBar/BulkActionBarButton.tsx b/src/components/BulkActionBar/BulkActionBarButton.tsx index 5de4191be6b4..f01abb64e003 100644 --- a/src/components/BulkActionBar/BulkActionBarButton.tsx +++ b/src/components/BulkActionBar/BulkActionBarButton.tsx @@ -10,6 +10,7 @@ import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; import CONST from '@src/CONST'; import type {AnchorPosition} from '@src/styles'; +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import React, {useEffect, useRef, useState} from 'react'; @@ -28,7 +29,7 @@ function BulkActionBarButton({option, onSubItemSelected}: BulkAction const icons = useMemoizedLazyExpensifyIcons(['DownArrow', 'UpArrow']); const {calculatePopoverPosition} = usePopoverPosition(); - const anchorRef = useRef(null); + const anchorRef = useRef | null>(null); const [isMenuVisible, setIsMenuVisible] = useState(false); const [anchorPosition, setAnchorPosition] = useState(defaultPopoverAnchorPosition); diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx index 0b4bc97ce018..cde025fa2bb0 100644 --- a/src/components/BulkActionBar/index.tsx +++ b/src/components/BulkActionBar/index.tsx @@ -24,6 +24,8 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {AnchorPosition} from '@src/styles'; +import type {ComponentRef} from 'react'; + import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withSpring} from 'react-native-reanimated'; @@ -71,7 +73,7 @@ function BulkActionBarContent({ const icons = useMemoizedLazyExpensifyIcons(['Close', 'DownArrow', 'UpArrow']); const {calculatePopoverPosition} = usePopoverPosition(); - const moreAnchorRef = useRef(null); + const moreAnchorRef = useRef | null>(null); const [isMoreMenuVisible, setIsMoreMenuVisible] = useState(false); const [moreMenuAnchorPosition, setMoreMenuAnchorPosition] = useState(defaultPopoverAnchorPosition); diff --git a/src/components/BulkActionBar/types.ts b/src/components/BulkActionBar/types.ts index 41dc6050bb63..586ec4c3d5a7 100644 --- a/src/components/BulkActionBar/types.ts +++ b/src/components/BulkActionBar/types.ts @@ -1,7 +1,7 @@ import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import type {PopoverMenuItem} from '@components/PopoverMenu'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native'; type BulkActionBarProps = { @@ -41,7 +41,7 @@ type BulkActionBarProps = { * Anchor for popovers a caller opens against the bar, such as the KYC wall Search puts behind its pay action. * Attached to the bar itself, since which button opened the flow is not something the bar exposes. */ - barRef?: RefObject; + barRef?: RefObject | null>; /** * Extra styles for the absolutely positioned layer the bar floats in. Pass a `bottom` here to lift the bar above diff --git a/src/components/Button/types.ts b/src/components/Button/types.ts index 29f022ae5812..6fad32beca96 100644 --- a/src/components/Button/types.ts +++ b/src/components/Button/types.ts @@ -3,7 +3,7 @@ import type {ButtonVariant} from '@styles/utils/types'; import type CONST from '@src/CONST'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; import type React from 'react'; import type {AccessibilityState, GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TargetedEvent, View, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -107,7 +107,7 @@ type BaseButtonProps = WithSentryLabel & /** Accessibility state to pass to the pressable */ accessibilityState?: AccessibilityState; - ref?: ForwardedRef; + ref?: ForwardedRef>; }; type ButtonProps = BaseButtonProps & { diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index dac2f080d1aa..842308fd4bd7 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -17,6 +17,7 @@ import mergeRefs from '@libs/mergeRefs'; import CONST from '@src/CONST'; import type {AnchorPosition} from '@src/styles'; +import type {ComponentRef} from 'react'; import type {GestureResponderEvent, StyleProp, TextStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -107,7 +108,7 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM // In tests, skip the popover anchor position calculation. The default values are needed for popover menu to be rendered in tests. const defaultPopoverAnchorPosition = process.env.NODE_ENV === 'test' ? {horizontal: 100, vertical: 100} : null; const [popoverAnchorPosition, setPopoverAnchorPosition] = useState(defaultPopoverAnchorPosition); - const dropdownAnchor = useRef(null); + const dropdownAnchor = useRef | null>(null); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply correct popover styles // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 17f3cd1af072..f5a194d026ad 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -8,7 +8,7 @@ import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type IconAsset from '@src/types/utils/IconAsset'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {GestureResponderEvent, StyleProp, TextStyle, View, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -114,7 +114,7 @@ type ButtonWithDropdownMenuProps = WithSentryLabel & { /** Whether the popover menu should flip to the opposite side of the button when it doesn't fit, instead of being clamped to the window edge */ shouldSwitchPositionIfOverflow?: boolean; - buttonRef?: RefObject; + buttonRef?: RefObject | null>; /** The priority to assign the enter key event listener to buttons. 0 is the highest priority. */ enterKeyEventListenerPriority?: number; diff --git a/src/components/CategoryPicker/CategoryPickerModal.tsx b/src/components/CategoryPicker/CategoryPickerModal.tsx index 0afa23a72485..d87d61c6b721 100644 --- a/src/components/CategoryPicker/CategoryPickerModal.tsx +++ b/src/components/CategoryPicker/CategoryPickerModal.tsx @@ -9,6 +9,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; + import React, {useRef} from 'react'; import {View} from 'react-native'; @@ -51,7 +53,7 @@ function CategoryPickerModal({ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth -- must match PopoverWithMeasuredContent's dock decision (bottom-docked only when isSmallScreenWidth) const {isSmallScreenWidth} = useResponsiveLayout(); const {isKeyboardActive} = useKeyboardState(); - const anchorRef = useRef(null); + const anchorRef = useRef>(null); const handleCategorySelect = (item: ListItem) => { // If clicking the same category that's already selected, treat it as deselection diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 9dec8d814710..eb56969eaaaa 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -1,38 +1,23 @@ import ActivityIndicator from '@components/ActivityIndicator'; import BAR_INNER_PADDING from '@components/Charts/barChartConstants'; -import ChartTooltipLayer from '@components/Charts/components/ChartTooltipLayer'; -import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels'; -import ChartYAxisLabels from '@components/Charts/components/ChartYAxisLabels'; -import type {HitTestArgs} from '@components/Charts/hooks'; -import { - ChartFontsProvider, - useChartFontManager, - useChartInteractions, - useChartLabelFormats, - useChartLabelLayout, - useChartLabelMeasurements, - useDynamicYDomain, - useLabelHitTesting, -} from '@components/Charts/hooks'; -import {calculateMinDomainPadding, getXAxisLabel, getYAxisLabelWidth} from '@components/Charts/utils'; -import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING} from '@components/Charts/VictoryTheme'; +import {ChartFontsProvider, useChartFontManager, useChartLabelFormats, useChartLabelLayout, useChartLabelMeasurements, useDynamicYDomain} from '@components/Charts/hooks'; +import {calculateMinDomainPadding, getVerticalBarPlotBounds, getYAxisLabelWidth} from '@components/Charts/utils'; +import {GLYPH_PADDING} from '@components/Charts/VictoryTheme'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; import type {LayoutChangeEvent} from 'react-native'; -import type {CartesianChartRenderArg, ChartBounds, PointsArray, Scale} from 'victory-native'; import React, {useState} from 'react'; import {View} from 'react-native'; -import {GestureDetector} from 'react-native-gesture-handler'; -import Animated, {useAnimatedStyle, useSharedValue} from 'react-native-reanimated'; -import {Bar, CartesianChart} from 'victory-native'; import type {CartesianChartProps, ChartDataPoint} from '..'; +import HorizontalBarChart from './HorizontalBarChart'; +import VerticalBarChart from './VerticalBarChart'; + /** Extra pixel spacing between the chart boundary and the data range, applied per side (Victory's `domainPadding` prop) * We need bottom: 1 for proper display of the bottom label */ @@ -41,24 +26,18 @@ const BASE_DOMAIN_PADDING = {top: 32, bottom: 1, left: 0, right: 0}; type BarChartProps = CartesianChartProps & { onBarPress?: (dataPoint: ChartDataPoint, index: number) => void; - /** When true, all bars use the same color. When false (default), each bar uses a different color from the palette. */ - useSingleColor?: boolean; + /** Color every bar is drawn in. Left out, each bar takes a different color from the palette by rank. */ + color?: string; }; -function BarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', useSingleColor = false, onBarPress}: BarChartProps) { - const theme = useTheme(); +/** + * Lays the category labels out under vertical bars — side by side, then rotated to 45° — and switches + * to horizontal bars when they still don't fit, so the labels get room to render legibly. + */ +function BarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', color, onBarPress}: BarChartProps) { const styles = useThemeStyles(); const fontManager = useChartFontManager(); const [chartWidth, setChartWidth] = useState(0); - const [barAreaWidth, setBarAreaWidth] = useState(0); - const [boundsLeft, setBoundsLeft] = useState(0); - const [boundsRight, setBoundsRight] = useState(0); - const defaultBarColor = VictoryTheme.colors.default; - - const chartData = data.map((point, index) => ({ - x: index, - y: point.total, - })); const yAxisDomain = useDynamicYDomain(data); @@ -84,154 +63,33 @@ function BarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = 'l return {...BASE_DOMAIN_PADDING, left: horizontalPadding, right: horizontalPadding}; })(); - const totalDomainPadding = domainPadding.left + domainPadding.right; - const paddingScale = barAreaWidth > 0 ? barAreaWidth / (barAreaWidth + totalDomainPadding) : 0; - - const originalLabels = data.map(getXAxisLabel); - - const measurements = useChartLabelMeasurements(data, fontManager, variables.iconSizeExtraSmall); - - const {labelRotation, labelSkipInterval, truncatedLabelWidths, xAxisLabelHeight, regularLabelMaxWidth, firstLabelMaxWidth, lastLabelMaxWidth, ellipsisWidth} = useChartLabelLayout({ - data, - fontManager, - fontSize: variables.iconSizeExtraSmall, - tickSpacing: barAreaWidth > 0 ? barAreaWidth / data.length : 0, - labelAreaWidth: barAreaWidth, - firstTickLeftSpace: boundsLeft + domainPadding.left * paddingScale, - lastTickRightSpace: chartWidth > 0 ? chartWidth - boundsRight + domainPadding.right * paddingScale : 0, - measurements, - }); - const {formatValue} = useChartLabelFormats({ data, unit: yAxisUnit, unitPosition: yAxisUnitPosition, }); - const barWidth = useSharedValue(0); - const chartBottom = useSharedValue(0); - const yZero = useSharedValue(0); + const measurements = useChartLabelMeasurements(data, fontManager, variables.iconSizeExtraSmall); + + // The label layout picks the orientation, so it's based on the vertical chart's plot bounds derived + // from the container width — they stay available while the horizontal chart is the one mounted. + const chartPaddingLeft = getYAxisLabelWidth(data, formatValue, fontManager, variables.iconSizeExtraSmall, BASE_DOMAIN_PADDING) + GLYPH_PADDING; + const plotBounds = getVerticalBarPlotBounds(chartWidth, chartPaddingLeft); + const totalDomainPadding = domainPadding.left + domainPadding.right; + const paddingScale = plotBounds.width > 0 ? plotBounds.width / (plotBounds.width + totalDomainPadding) : 0; - const {isCursorOverLabel, findLabelCursorX, updateTickPositions} = useLabelHitTesting({ + const labelLayout = useChartLabelLayout({ + data, fontManager, fontSize: variables.iconSizeExtraSmall, - truncatedLabelWidths, - labelRotation, - labelSkipInterval, - chartBottom, - }); - - const handleChartBoundsChange = (bounds: ChartBounds) => { - const domainWidth = bounds.right - bounds.left; - const calculatedBarWidth = ((1 - BAR_INNER_PADDING) * domainWidth) / data.length; - barWidth.set(calculatedBarWidth); - yZero.set(0); - setBarAreaWidth(domainWidth); - setBoundsLeft(bounds.left); - setBoundsRight(bounds.right); - }; - - const checkIsOverBar = (args: HitTestArgs) => { - 'worklet'; - - const currentBarWidth = barWidth.get(); - const currentYZero = yZero.get(); - if (currentBarWidth === 0) { - return false; - } - const barLeft = args.targetX - currentBarWidth / 2; - const barRight = args.targetX + currentBarWidth / 2; - - const barTop = Math.min(args.targetY, currentYZero); - const barBottom = Math.max(args.targetY, currentYZero); - - return args.cursorX >= barLeft && args.cursorX <= barRight && args.cursorY >= barTop && args.cursorY <= barBottom; - }; - - const {customGestures, setPointPositions, matchedIndex, isTooltipActive, isCursorOverClickable, initialTooltipPosition} = useChartInteractions({ - handlePress: handleBarPress, - checkIsOver: checkIsOverBar, - isCursorOverLabel, - resolveLabelTouchX: findLabelCursorX, - chartBottom, - yZero, + tickSpacing: plotBounds.width > 0 && data.length > 0 ? plotBounds.width / data.length : 0, + labelAreaWidth: plotBounds.width, + firstTickLeftSpace: plotBounds.left + domainPadding.left * paddingScale, + lastTickRightSpace: chartWidth > 0 ? chartWidth - plotBounds.right + domainPadding.right * paddingScale : 0, + measurements, + canFallBackToHorizontalBars: true, }); - const handleScaleChange = (xScale: Scale, yScale: Scale) => { - yZero.set(yScale(0)); - updateTickPositions(xScale, data.length); - setPointPositions( - chartData.map((point) => xScale(point.x)), - chartData.map((point) => yScale(point.y)), - ); - }; - - const cursorStyle = useAnimatedStyle(() => ({ - cursor: isCursorOverClickable.get() ? 'pointer' : 'auto', - })); - - const renderBar = (point: PointsArray[number], chartBounds: ChartBounds, barCount: number) => { - const dataIndex = point.xValue as number; - const dataPoint = data.at(dataIndex); - const barColor = useSingleColor ? defaultBarColor : VictoryTheme.colors.getColor(dataIndex); - - return ( - - ); - }; - - const renderOutside = (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => { - if (!fontManager || xAxisLabelHeight === undefined) { - return null; - } - - const chartBoundsBottom = args.yScale(Math.min(...args.yTicks)); - chartBottom.set(chartBoundsBottom); - - return ( - <> - - - - ); - }; - - const labelSpace = VictoryTheme.axis.labelGap + (xAxisLabelHeight ?? 0); - const dynamicChartStyle = {height: CHART_CONTENT_MIN_HEIGHT + labelSpace}; - const yAxisLabelWidth = getYAxisLabelWidth(data, formatValue, fontManager, variables.iconSizeExtraSmall, BASE_DOMAIN_PADDING); - const chartPadding = {...VictoryTheme.axis.padding, bottom: labelSpace + VictoryTheme.axis.padding.bottom, left: yAxisLabelWidth + GLYPH_PADDING}; - if (isLoading || !fontManager) { return ( @@ -244,50 +102,38 @@ function BarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = 'l return null; } - return ( - - - {chartWidth > 0 && ( - - {({points, chartBounds}) => points.y.map((point) => renderBar(point, chartBounds, points.y.length))} - - )} - - - + fontManager={fontManager} + formatValue={formatValue} + valueAxisDomain={yAxisDomain} + color={color} + onBarPress={handleBarPress} + labelWidths={measurements.labelWidths} + ellipsisWidth={measurements.ellipsisWidth} + /> + ); + } + + return ( + ); } diff --git a/src/components/Charts/BarChart/HorizontalBarChart.tsx b/src/components/Charts/BarChart/HorizontalBarChart.tsx new file mode 100644 index 000000000000..1920bea20481 --- /dev/null +++ b/src/components/Charts/BarChart/HorizontalBarChart.tsx @@ -0,0 +1,253 @@ +import BAR_INNER_PADDING, {BAR_CORNER_RADIUS, MIN_BAR_ROW_HEIGHT} from '@components/Charts/barChartConstants'; +import ChartTooltipLayer from '@components/Charts/components/ChartTooltipLayer'; +import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels'; +import ChartYAxisLabels from '@components/Charts/components/ChartYAxisLabels'; +import type {HitTestArgs, ResolveTargetIndexArgs} from '@components/Charts/hooks'; +import {findClosestPoint, useChartInteractions} from '@components/Charts/hooks'; +import type {ChartDataPoint} from '@components/Charts/types'; +import {createHorizontalBarPath, getBarColor, getFontLineMetrics, getXAxisLabel, getYAxisLabelWidth, measureTextWidth, truncateLabel} from '@components/Charts/utils'; +import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING, LABEL_ROTATIONS, MAX_Y_AXIS_LABEL_WIDTH} from '@components/Charts/VictoryTheme'; + +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import type {SkTypefaceFontProvider} from '@shopify/react-native-skia'; +import type {LayoutChangeEvent} from 'react-native'; +import type {CartesianChartRenderArg, Scale} from 'victory-native'; + +import {Path} from '@shopify/react-native-skia'; +import React from 'react'; +import {GestureDetector} from 'react-native-gesture-handler'; +import Animated, {useAnimatedStyle, useSharedValue} from 'react-native-reanimated'; +import {CartesianChart} from 'victory-native'; + +/** Largest share of the chart width the category labels may take before they are truncated */ +const MAX_CATEGORY_LABEL_WIDTH_RATIO = 0.4; + +/** Space (px) between the longest bar and the end of the value axis, matching the vertical chart's headroom above its tallest bar */ +const VALUE_AXIS_END_PADDING = 32; + +/** + * Rows are placed on a fixed [0, 1] domain instead of [0, rowCount - 1] because victory-native always + * rounds the y-scale domain out to "nice" values, which would stretch an arbitrary row count to a round + * number and shift the rows off their slots. + */ +const ROW_DOMAIN: [number, number] = [0, 1]; + +type HorizontalBarChartProps = { + /** Chart data points, one bar per point, listed top to bottom */ + data: ChartDataPoint[]; + + /** Measured width of the chart container */ + chartWidth: number; + + /** Reports the chart container's width back to the parent */ + onLayout: (event: LayoutChangeEvent) => void; + + /** Font manager for Paragraph API rendering with multi-font fallback */ + fontManager: SkTypefaceFontProvider; + + /** Formats a value-axis tick for display */ + formatValue: (value: number) => string; + + /** Value-axis domain override (e.g. anchored at zero) */ + valueAxisDomain: [number] | undefined; + + /** Color every bar is drawn in. Left out, each bar takes a different color from the palette by rank. */ + color: string | undefined; + + /** Called with the data index of the pressed bar */ + onBarPress: (index: number) => void; + + /** Pre-measured pixel width of each category label */ + labelWidths: number[]; + + /** Pixel width of the ellipsis, used to truncate long category labels */ + ellipsisWidth: number; +}; + +/** + * Renders the data as horizontal bars with category labels along the y-axis. Used when the + * category labels don't fit under vertical bars, even rotated to 45°. + */ +function HorizontalBarChart({data, chartWidth, onLayout, fontManager, formatValue, valueAxisDomain, color, onBarPress, labelWidths, ellipsisWidth}: HorizontalBarChartProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const fontSize = variables.iconSizeExtraSmall; + const rowCount = data.length; + + /** Center of the row at `index` on the row axis, with the first data point at the top */ + const getRowValue = (index: number) => 1 - (index + 0.5) / rowCount; + + const chartData = data.map((point, index) => ({ + x: point.total, + y: getRowValue(index), + })); + + // Category labels: rendered on the y-axis, truncated so they leave most of the width to the bars. + const categoryLabelMaxWidth = Math.min(MAX_Y_AXIS_LABEL_WIDTH, chartWidth * MAX_CATEGORY_LABEL_WIDTH_RATIO); + const categoryLabels = data.map((point, index) => truncateLabel(getXAxisLabel(point), labelWidths.at(index) ?? 0, categoryLabelMaxWidth, ellipsisWidth)); + const categoryLabelWidth = Math.max(0, ...categoryLabels.map((label) => measureTextWidth(label, fontManager, fontSize))); + const categoryTicks = data.map((_, index) => getRowValue(index)); + + // Value labels: rendered below the plot, centered on each x tick. + const {ascent, descent} = getFontLineMetrics(fontManager, fontSize); + const valueLabelSpace = VictoryTheme.axis.labelGap + ascent + descent; + const valueLabelWidth = getYAxisLabelWidth(data, formatValue, fontManager, fontSize, {top: 0, bottom: 0}); + + const chartPadding = { + ...VictoryTheme.axis.padding, + left: categoryLabelWidth + GLYPH_PADDING + VictoryTheme.axis.labelGap, + // Value labels are centered on their ticks, and the last tick can sit on the plot's right edge. + right: Math.max(VictoryTheme.axis.padding.right, valueLabelWidth / 2), + bottom: VictoryTheme.axis.padding.bottom + valueLabelSpace, + }; + const domainPadding = {top: 0, bottom: 0, left: 0, right: VALUE_AXIS_END_PADDING}; + + // Grow with the number of rows so every category stays legible. The surrounding page scrolls. + const chartHeight = Math.max(CHART_CONTENT_MIN_HEIGHT, rowCount * MIN_BAR_ROW_HEIGHT + chartPadding.top + chartPadding.bottom); + const rowHeight = (chartHeight - chartPadding.top - chartPadding.bottom) / rowCount; + const barThickness = (1 - BAR_INNER_PADDING) * rowHeight; + + const xZero = useSharedValue(0); + + /** Hovering a bar or its category label shows the tooltip */ + const checkIsOverRow = (args: HitTestArgs) => { + 'worklet'; + + const barRight = Math.max(args.targetX, xZero.get()); + return Math.abs(args.cursorY - args.targetY) <= barThickness / 2 && args.cursorX >= 0 && args.cursorX <= barRight; + }; + + /** Only the bar itself is pressable */ + const checkIsOverBar = (args: HitTestArgs) => { + 'worklet'; + + const currentXZero = xZero.get(); + const barLeft = Math.min(args.targetX, currentXZero); + const barRight = Math.max(args.targetX, currentXZero); + return Math.abs(args.cursorY - args.targetY) <= barThickness / 2 && args.cursorX >= barLeft && args.cursorX <= barRight; + }; + + /** Rows are stacked vertically, so match the cursor to the nearest row by Y */ + const findNearestRow = ({cursorY, pointY}: ResolveTargetIndexArgs) => { + 'worklet'; + + return findClosestPoint(pointY, cursorY); + }; + + const {customGestures, setPointPositions, matchedIndex, isTooltipActive, isCursorOverClickable, initialTooltipPosition} = useChartInteractions({ + handlePress: onBarPress, + checkIsOver: checkIsOverRow, + checkIsClickable: checkIsOverBar, + resolveTargetIndex: findNearestRow, + xZero, + tooltipPlacement: 'right', + }); + + const handleScaleChange = (xScale: Scale, yScale: Scale) => { + xZero.set(xScale(0)); + setPointPositions( + chartData.map((point) => xScale(point.x)), + chartData.map((point) => yScale(point.y)), + ); + }; + + const cursorStyle = useAnimatedStyle(() => ({ + cursor: isCursorOverClickable.get() ? 'pointer' : 'auto', + })); + + // Bars are drawn from `data` rather than victory's `points`, because victory sorts numeric x values + // (here: the totals), so `points` would no longer line up with the data indices used for colors. + const renderBars = ({xScale, yScale}: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => + data.map((dataPoint, index) => ( + + )); + + const renderOutside = (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => ( + <> + + args.xScale(args.xTicks.at(index) ?? 0)} + chartBoundsBottom={args.chartBounds.bottom} + /> + + ); + + return ( + + + {chartWidth > 0 && ( + + {renderBars} + + )} + + + + ); +} + +export default HorizontalBarChart; diff --git a/src/components/Charts/BarChart/VerticalBarChart.tsx b/src/components/Charts/BarChart/VerticalBarChart.tsx new file mode 100644 index 000000000000..ce7b8c16fc69 --- /dev/null +++ b/src/components/Charts/BarChart/VerticalBarChart.tsx @@ -0,0 +1,258 @@ +import BAR_INNER_PADDING, {BAR_CORNER_RADIUS} from '@components/Charts/barChartConstants'; +import ChartTooltipLayer from '@components/Charts/components/ChartTooltipLayer'; +import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels'; +import ChartYAxisLabels from '@components/Charts/components/ChartYAxisLabels'; +import type {HitTestArgs, useChartLabelLayout} from '@components/Charts/hooks'; +import {useChartInteractions, useLabelHitTesting} from '@components/Charts/hooks'; +import type {ChartDataPoint} from '@components/Charts/types'; +import {getBarColor, getXAxisLabel} from '@components/Charts/utils'; +import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT} from '@components/Charts/VictoryTheme'; + +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import type {SkTypefaceFontProvider} from '@shopify/react-native-skia'; +import type {LayoutChangeEvent} from 'react-native'; +import type {CartesianChartRenderArg, ChartBounds, PointsArray, Scale} from 'victory-native'; + +import React from 'react'; +import {GestureDetector} from 'react-native-gesture-handler'; +import Animated, {useAnimatedStyle, useSharedValue} from 'react-native-reanimated'; +import {Bar, CartesianChart} from 'victory-native'; + +type VerticalBarChartProps = { + /** Chart data points, one bar per point */ + data: ChartDataPoint[]; + + /** Measured width of the chart container */ + chartWidth: number; + + /** Reports the chart container's width back to the parent */ + onLayout: (event: LayoutChangeEvent) => void; + + /** Font manager for Paragraph API rendering with multi-font fallback */ + fontManager: SkTypefaceFontProvider; + + /** Formats a value-axis tick for display */ + formatValue: (value: number) => string; + + /** Value-axis domain override (e.g. anchored at zero) */ + yAxisDomain: [number] | undefined; + + /** Color every bar is drawn in. Left out, each bar takes a different color from the palette by rank. */ + color: string | undefined; + + /** Called with the data index of the pressed bar */ + onBarPress: (index: number) => void; + + /** X-axis label layout, computed by the parent because it also decides the chart orientation */ + labelLayout: ReturnType; + + /** Pre-measured pixel width of each x-axis label */ + labelWidths: number[]; + + /** Extra pixel spacing between the chart boundary and the data range, applied per side */ + domainPadding: {top: number; bottom: number; left: number; right: number}; + + /** Left chart padding, reserving the gutter for the y-axis labels */ + chartPaddingLeft: number; +}; + +/** Renders the data as vertical bars with category labels along the x-axis (at 0° or 45°). */ +function VerticalBarChart({ + data, + chartWidth, + onLayout, + fontManager, + formatValue, + yAxisDomain, + color, + onBarPress, + labelLayout, + labelWidths, + domainPadding, + chartPaddingLeft, +}: VerticalBarChartProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {labelRotation, labelSkipInterval, truncatedLabelWidths, xAxisLabelHeight, regularLabelMaxWidth, firstLabelMaxWidth, lastLabelMaxWidth, ellipsisWidth} = labelLayout; + + const chartData = data.map((point, index) => ({ + x: index, + y: point.total, + })); + + const originalLabels = data.map(getXAxisLabel); + + const barWidth = useSharedValue(0); + const chartBottom = useSharedValue(0); + const yZero = useSharedValue(0); + + const {isCursorOverLabel, findLabelCursorX, updateTickPositions} = useLabelHitTesting({ + fontManager, + fontSize: variables.iconSizeExtraSmall, + truncatedLabelWidths, + labelRotation, + labelSkipInterval, + chartBottom, + }); + + const handleChartBoundsChange = (bounds: ChartBounds) => { + const domainWidth = bounds.right - bounds.left; + const calculatedBarWidth = ((1 - BAR_INNER_PADDING) * domainWidth) / data.length; + barWidth.set(calculatedBarWidth); + yZero.set(0); + }; + + const checkIsOverBar = (args: HitTestArgs) => { + 'worklet'; + + const currentBarWidth = barWidth.get(); + const currentYZero = yZero.get(); + if (currentBarWidth === 0) { + return false; + } + const barLeft = args.targetX - currentBarWidth / 2; + const barRight = args.targetX + currentBarWidth / 2; + + const barTop = Math.min(args.targetY, currentYZero); + const barBottom = Math.max(args.targetY, currentYZero); + + return args.cursorX >= barLeft && args.cursorX <= barRight && args.cursorY >= barTop && args.cursorY <= barBottom; + }; + + const {customGestures, setPointPositions, matchedIndex, isTooltipActive, isCursorOverClickable, initialTooltipPosition} = useChartInteractions({ + handlePress: onBarPress, + checkIsOver: checkIsOverBar, + isCursorOverLabel, + resolveLabelTouchX: findLabelCursorX, + chartBottom, + yZero, + }); + + const handleScaleChange = (xScale: Scale, yScale: Scale) => { + yZero.set(yScale(0)); + updateTickPositions(xScale, data.length); + setPointPositions( + chartData.map((point) => xScale(point.x)), + chartData.map((point) => yScale(point.y)), + ); + }; + + const cursorStyle = useAnimatedStyle(() => ({ + cursor: isCursorOverClickable.get() ? 'pointer' : 'auto', + })); + + const renderBar = (point: PointsArray[number], chartBounds: ChartBounds, barCount: number) => { + const dataIndex = Number(point.xValue); + const dataPoint = data.at(dataIndex); + + return ( + + ); + }; + + const renderOutside = (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => { + if (xAxisLabelHeight === undefined) { + return null; + } + + const chartBoundsBottom = args.yScale(Math.min(...args.yTicks)); + chartBottom.set(chartBoundsBottom); + + return ( + <> + + + + ); + }; + + const labelSpace = VictoryTheme.axis.labelGap + (xAxisLabelHeight ?? 0); + const dynamicChartStyle = {height: CHART_CONTENT_MIN_HEIGHT + labelSpace}; + const chartPadding = {...VictoryTheme.axis.padding, bottom: labelSpace + VictoryTheme.axis.padding.bottom, left: chartPaddingLeft}; + + return ( + + + {chartWidth > 0 && ( + + {({points, chartBounds}) => points.y.map((point) => renderBar(point, chartBounds, points.y.length))} + + )} + + + + ); +} + +export default VerticalBarChart; diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index b7a89cd62069..f1320357b7c0 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -243,7 +243,10 @@ function LineChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = ' } return ( - + - + ; + + /** Where the tooltip sits relative to `initialTooltipPosition`. Defaults to `above`. */ + placement?: ChartTooltipPlacement; }; function getTooltipContent(label: string, amount: string, percentage?: string): string { @@ -38,9 +43,10 @@ function getTooltipContent(label: string, amount: string, percentage?: string): return `${label} • ${amount} (${percentage})`; } -function ChartTooltip({label, amount, percentage, chartWidth, initialTooltipPosition}: ChartTooltipProps) { +function ChartTooltip({label, amount, percentage, chartWidth, initialTooltipPosition, placement = 'above'}: ChartTooltipProps) { const theme = useTheme(); const styles = useThemeStyles(); + const isRightPlacement = placement === 'right'; /** Shared value to store the measured width of the tooltip container */ const tooltipMeasuredWidth = useSharedValue(0); @@ -52,7 +58,7 @@ function ChartTooltip({label, amount, percentage, chartWidth, initialTooltipPosi * This prevents the "old" dimensions from being used to calculate the position * of "new" content, avoiding visual jumps or "ghosting" effects. */ - const tooltipWrapperRef = useRef(null); + const tooltipWrapperRef = useRef>(null); useLayoutEffect(() => { tooltipWrapperRef.current?.measure((x: number, y: number, width: number) => { @@ -74,10 +80,23 @@ function ChartTooltip({label, amount, percentage, chartWidth, initialTooltipPosi /** * Animated style for the main tooltip container. - * Calculates the clamped center to keep the box within chart boundaries. + * Clamps the box horizontally to keep it within chart boundaries. */ const tooltipStyle = useAnimatedStyle(() => { - const {y} = initialTooltipPosition.get(); + const {x, y} = initialTooltipPosition.get(); + + if (isRightPlacement) { + const width = tooltipMeasuredWidth.get(); + + return { + position: 'absolute', + left: Math.max(0, Math.min(chartWidth - width, x)), + top: y, + /** Start the wrapper at the X point and center it vertically on the Y point */ + transform: [{translateY: '-50%'}], + opacity: width > 0 ? 1 : 0, + }; + } return { position: 'absolute', @@ -87,7 +106,7 @@ function ChartTooltip({label, amount, percentage, chartWidth, initialTooltipPosi transform: [{translateX: '-50%'}, {translateY: '-100%'}], opacity: tooltipMeasuredWidth.get() > 0 ? 1 : 0, }; - }, [initialTooltipPosition]); + }, [initialTooltipPosition, isRightPlacement, chartWidth]); /** * Animated style for the pointer (triangle). @@ -104,36 +123,59 @@ function ChartTooltip({label, amount, percentage, chartWidth, initialTooltipPosi }; }, [initialTooltipPosition]); + const tooltipBox = ( + + + {content} + + + ); + return ( - - - - {content} - + {isRightPlacement ? ( + + + {tooltipBox} + + ) : ( + + {tooltipBox} + - - + )} ); } diff --git a/src/components/Charts/components/ChartTooltipLayer.tsx b/src/components/Charts/components/ChartTooltipLayer.tsx index 35938865812f..d09f0969e36d 100644 --- a/src/components/Charts/components/ChartTooltipLayer.tsx +++ b/src/components/Charts/components/ChartTooltipLayer.tsx @@ -1,5 +1,5 @@ import {useTooltipData} from '@components/Charts/hooks'; -import type {ChartDataPoint} from '@components/Charts/types'; +import type {ChartDataPoint, ChartTooltipPlacement} from '@components/Charts/types'; import type {DerivedValue, SharedValue} from 'react-native-reanimated'; @@ -26,13 +26,16 @@ type ChartTooltipLayerProps = { /** The initial tooltip position (x, y) in canvas coordinates */ initialTooltipPosition: SharedValue<{x: number; y: number}>; + + /** Where the tooltip sits relative to `initialTooltipPosition`. Defaults to `above`. */ + placement?: ChartTooltipPlacement; }; /** * Renders the chart tooltip in an isolated subtree so that hover-driven state changes * (active index, visibility) only re-render this lightweight component, not the chart itself. */ -function ChartTooltipLayer({matchedIndex, isTooltipActive, data, formatValue, chartWidth, initialTooltipPosition}: ChartTooltipLayerProps) { +function ChartTooltipLayer({matchedIndex, isTooltipActive, data, formatValue, chartWidth, initialTooltipPosition, placement}: ChartTooltipLayerProps) { const [activeDataIndex, setActiveDataIndex] = useState(-1); useAnimatedReaction( @@ -68,6 +71,7 @@ function ChartTooltipLayer({matchedIndex, isTooltipActive, data, formatValue, ch percentage={tooltipData.percentage} chartWidth={chartWidth} initialTooltipPosition={initialTooltipPosition} + placement={placement} /> ); diff --git a/src/components/Charts/components/ChartYAxisLabels.tsx b/src/components/Charts/components/ChartYAxisLabels.tsx index f77ba922f13a..78b65fde6f89 100644 --- a/src/components/Charts/components/ChartYAxisLabels.tsx +++ b/src/components/Charts/components/ChartYAxisLabels.tsx @@ -27,15 +27,18 @@ type ChartYAxisLabelsProps = { /** Fill color for the label text. */ labelColor: string; - /** Formats a tick value to its display string. */ - formatValue: (value: number) => string; + /** Formats a tick value to its display string. Ignored when `labels` is provided. */ + formatValue?: (value: number) => string; + + /** Pre-formatted label for each tick, e.g. category names in a horizontal bar chart. */ + labels?: string[]; /** When true, labels are left-aligned starting at the left edge of the chart instead of right-aligned. */ leftAlign?: boolean; }; -function ChartYAxisLabels({yTicks, yScale, chartBounds, fontSize, fontManager, labelColor, formatValue, leftAlign = false}: ChartYAxisLabelsProps) { - const formattedLabels = yTicks.map((tick) => formatValue(tick)); +function ChartYAxisLabels({yTicks, yScale, chartBounds, fontSize, fontManager, labelColor, formatValue, labels, leftAlign = false}: ChartYAxisLabelsProps) { + const formattedLabels = labels ?? yTicks.map((tick) => formatValue?.(tick) ?? String(tick)); const paragraphs = useChartParagraphs(formattedLabels, fontManager, fontSize, labelColor, MAX_Y_AXIS_LABEL_WIDTH); const maxWidth = Math.max(0, ...paragraphs.map((item) => item.width)); diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts index 5fa23151604b..59dd45db23ef 100644 --- a/src/components/Charts/hooks/index.ts +++ b/src/components/Charts/hooks/index.ts @@ -3,7 +3,7 @@ export {default as useChartLabelMeasurements} from './useChartLabelMeasurements' export {default as useChartParagraphs} from './useChartParagraphs'; export {useChartFontManager} from '@components/Charts/context/ChartFontsContext'; export {default as ChartFontsProvider} from '@components/Charts/context/ChartFontsProvider'; -export {useChartInteractions, TOOLTIP_BAR_GAP} from './useChartInteractions'; +export {useChartInteractions, findClosestPoint, TOOLTIP_BAR_GAP} from './useChartInteractions'; export type {HitTestArgs, ResolveTargetIndexArgs} from './useChartInteractions'; export {default as useChartLabelFormats} from './useChartLabelFormats'; export {default as useDynamicYDomain} from './useDynamicYDomain'; diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index 663016c91083..925349a6bcef 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -1,3 +1,5 @@ +import type {ChartTooltipPlacement} from '@components/Charts/types'; + import type {SharedValue} from 'react-native-reanimated'; import {useCallback} from 'react'; @@ -7,7 +9,7 @@ import {scheduleOnRN} from 'react-native-worklets'; import useChartInteractionState from './useChartInteractionState'; -/** Gap between bar top and tooltip bottom */ +/** Gap between the bar end and the tooltip */ const TOOLTIP_BAR_GAP = 8; /** @@ -98,8 +100,17 @@ type UseChartInteractionsProps = { /** Optional shared value containing the y-axis zero position */ yZero?: SharedValue; + /** Optional shared value containing the x-axis zero position (horizontal bar charts) */ + xZero?: SharedValue; + /** Scale applied to the rendered chart container */ coordinateScale?: number; + + /** + * Where the tooltip is anchored relative to the matched point. `above` sits over the top of a + * vertical bar/point; `right` sits past the end of a horizontal bar, vertically centered on it. + */ + tooltipPlacement?: ChartTooltipPlacement; }; function normalizeChartCoordinate(coordinate: number, coordinateScale: number): number { @@ -165,7 +176,9 @@ function useChartInteractions({ resolveLabelTouchX, chartBottom, yZero, + xZero, coordinateScale = 1, + tooltipPlacement = 'above', }: UseChartInteractionsProps) { /** Interaction state compatible with Victory Native's internal logic */ const {state: chartInteractionState} = useChartInteractionState(); @@ -385,13 +398,26 @@ function useChartInteractions({ * compose them into their own useAnimatedStyle. */ const initialTooltipPosition = useDerivedValue(() => { + const targetX = chartInteractionState.x.position.get(); const targetY = chartInteractionState.y.y.position.get(); + + if (tooltipPlacement === 'right') { + const currentXZero = xZero?.get() ?? targetX; + // Position tooltip past the right end of the bar (max of targetX and xZero) + const barRightX = Math.max(targetX, currentXZero); + + return { + x: barRightX + TOOLTIP_BAR_GAP, + y: targetY, + }; + } + const currentYZero = yZero?.get() ?? targetY; // Position tooltip at the top of the bar (min of targetY and yZero) const barTopY = Math.min(targetY, currentYZero); return { - x: chartInteractionState.x.position.get(), + x: targetX, y: barTopY - TOOLTIP_BAR_GAP, }; }); diff --git a/src/components/Charts/hooks/useChartLabelLayout.ts b/src/components/Charts/hooks/useChartLabelLayout.ts index 804cfa0b8070..1b6d5ee51981 100644 --- a/src/components/Charts/hooks/useChartLabelLayout.ts +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -30,6 +30,12 @@ type LabelLayoutConfig = { /** Measurements of the label text. */ measurements: ReturnType; + + /** + * When true, labels that don't fit at 45° request a horizontal bar layout (`shouldUseHorizontalBars`) + * instead of falling back to 90° rotation with skipped labels. + */ + canFallBackToHorizontalBars?: boolean; }; const EMPTY_LAYOUT = { @@ -42,9 +48,19 @@ const EMPTY_LAYOUT = { firstLabelMaxWidth: Infinity, lastLabelMaxWidth: Infinity, ellipsisWidth: 0, + shouldUseHorizontalBars: false, }; -function useChartLabelLayout({data, fontManager, tickSpacing, labelAreaWidth, firstTickLeftSpace = Infinity, lastTickRightSpace = Infinity, measurements}: LabelLayoutConfig) { +function useChartLabelLayout({ + data, + fontManager, + tickSpacing, + labelAreaWidth, + firstTickLeftSpace = Infinity, + lastTickRightSpace = Infinity, + measurements, + canFallBackToHorizontalBars = false, +}: LabelLayoutConfig) { // Phase 1: font/data measurements — stable across geometry-only changes (resize). // Phase 2: layout decisions + label truncation. @@ -61,8 +77,9 @@ function useChartLabelLayout({data, fontManager, tickSpacing, labelAreaWidth, fi const effectiveFirstTickLeftSpace = data.length === 1 ? Infinity : firstTickLeftSpace; const effectiveLastTickRightSpace = data.length === 1 ? Infinity : lastTickRightSpace; - // Pick rotation (prefer 0° → 45° → 90°) + // Pick rotation (prefer 0° → 45° → 90°, or 0° → 45° → horizontal bars when allowed) let rotation: LabelRotation = LABEL_ROTATIONS.VERTICAL; + let shouldUseHorizontalBars = false; const hWidth = effectiveWidth(maxLabelWidth, lineHeight, LABEL_ROTATIONS.HORIZONTAL); const hFitsInTicks = hWidth + LABEL_PADDING <= tickSpacing && maxVisibleCount(labelAreaWidth, hWidth) >= data.length; @@ -87,6 +104,8 @@ function useChartLabelLayout({data, fontManager, tickSpacing, labelAreaWidth, fi if (dFitsInTicks && dEdgeFits) { rotation = LABEL_ROTATIONS.DIAGONAL; + } else if (canFallBackToHorizontalBars) { + shouldUseHorizontalBars = true; } } @@ -130,6 +149,7 @@ function useChartLabelLayout({data, fontManager, tickSpacing, labelAreaWidth, fi firstLabelMaxWidth: labelMaxWidths.at(0) ?? tickMaxWidth, lastLabelMaxWidth: labelMaxWidths.at(lastIndex) ?? tickMaxWidth, ellipsisWidth, + shouldUseHorizontalBars, }; } diff --git a/src/components/Charts/types/index.ts b/src/components/Charts/types/index.ts index f70b26d8c50c..b47fcfed6edc 100644 --- a/src/components/Charts/types/index.ts +++ b/src/components/Charts/types/index.ts @@ -72,4 +72,7 @@ type LabelRotation = ValueOf; type ParagraphWithWidth = {para: SkParagraph | null; width: number}; -export type {ChartDataPoint, ChartProps, CartesianChartProps, LabelRotation, ParagraphWithWidth, PieSlice, UnitPosition, UnitWithFallback}; +/** Where a chart tooltip sits relative to the data point it describes. */ +type ChartTooltipPlacement = 'above' | 'right'; + +export type {CartesianChartProps, ChartDataPoint, ChartProps, ChartTooltipPlacement, LabelRotation, ParagraphWithWidth, PieSlice, UnitPosition, UnitWithFallback}; diff --git a/src/components/Charts/utils/index.ts b/src/components/Charts/utils/index.ts index 692b073cc38a..0c250e4c1374 100644 --- a/src/components/Charts/utils/index.ts +++ b/src/components/Charts/utils/index.ts @@ -3,7 +3,7 @@ import VictoryTheme, {CHART_Y_SCALE_HEIGHT, DIAGONAL_ANGLE_RADIAN_THRESHOLD, ELL import variables from '@styles/variables'; -import type {SkParagraph, SkParagraphBuilder, SkTypefaceFontProvider} from '@shopify/react-native-skia'; +import type {SkParagraph, SkParagraphBuilder, SkPath, SkTypefaceFontProvider} from '@shopify/react-native-skia'; import {FontStyle, FontWeight, Skia} from '@shopify/react-native-skia'; import {scaleLinear} from 'd3-scale'; @@ -452,6 +452,42 @@ function getYAxisLabelWidth( ); } +/** + * Returns the horizontal plot bounds of the vertical bar chart for a given container width, + * without needing the chart to be mounted. + * + * `CartesianChart` reports the same values through `onChartBoundsChange`, but the bar chart + * decides its orientation from these bounds and unmounts the vertical chart when it falls back + * to horizontal bars — so they must be derivable from the container width alone, otherwise the + * chart could never switch back to vertical bars when the container grows. + * + * victory-native lays the x range out as `[padding.left + yLabelWidth + yLabelOffset, width - padding.right]`. + * We draw the y-axis labels ourselves and pass no `font` to the axis, so its `yLabelWidth` is 0 + * and the gutter we reserve for our labels is part of `paddingLeft` instead. + */ +function getVerticalBarPlotBounds(chartWidth: number, paddingLeft: number): {left: number; right: number; width: number} { + const left = paddingLeft + VictoryTheme.axis.labelGap; + const right = Math.max(left, chartWidth - VictoryTheme.axis.padding.right); + return {left, right, width: right - left}; +} + +/** + * Builds the path of a single horizontal bar, extending along the x-axis from `xZero` to `x` + * and centered on `y`. victory-native's `Bar` only draws vertical bars and its `BarGroup` + * paints a whole series in one color, so per-category colored horizontal bars need their own path. + */ +function createHorizontalBarPath(x: number, y: number, xZero: number, thickness: number, cornerRadius: number): SkPath { + const path = Skia.Path.Make(); + const rect = Skia.XYWHRect(Math.min(x, xZero), y - thickness / 2, Math.abs(x - xZero), thickness); + path.addRRect(Skia.RRectXY(rect, cornerRadius, cornerRadius)); + return path; +} + +/** Returns the fill color of the bar at `index`. That is `color` when given, otherwise a distinct palette color per bar. */ +function getBarColor(color: string | undefined, index: number): string { + return color ?? VictoryTheme.colors.getColor(index); +} + export { buildChartParagraph, canFontRenderText, @@ -477,6 +513,9 @@ export { isCursorOverChartLabel, getNiceYAxisTicks, getYAxisLabelWidth, + getVerticalBarPlotBounds, + createHorizontalBarPath, + getBarColor, }; export type {ChartLabelHitTestParams}; diff --git a/src/components/CheckboxWithLabel.tsx b/src/components/CheckboxWithLabel.tsx index 6001b4d719d2..77d0f0c47bc6 100644 --- a/src/components/CheckboxWithLabel.tsx +++ b/src/components/CheckboxWithLabel.tsx @@ -2,7 +2,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; -import type {ComponentType, ForwardedRef} from 'react'; +import type {ComponentRef, ComponentType, ForwardedRef} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import React, {useState} from 'react'; @@ -57,7 +57,7 @@ type CheckboxWithLabelProps = RequiredLabelProps & { shouldSaveDraft?: boolean; accessibilityLabel?: string; - ref?: ForwardedRef; + ref?: ForwardedRef>; }; function CheckboxWithLabel({ diff --git a/src/components/ColumnsSettingsList.tsx b/src/components/ColumnsSettingsList.tsx index 7614de68730a..a065dd38942d 100644 --- a/src/components/ColumnsSettingsList.tsx +++ b/src/components/ColumnsSettingsList.tsx @@ -9,6 +9,8 @@ import {getSearchColumnTranslationKey} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; +import type {ComponentRef} from 'react'; + import React, {useRef, useState} from 'react'; import {View} from 'react-native'; @@ -193,7 +195,7 @@ function ColumnsSettingsList({ const combinedItems = isGrouped ? [...groupColumnsList, ...typeColumnsList] : []; const groupLength = groupColumnsList.length; const disabledIndexes = combinedItems.flatMap((item, index) => (item.isDisabled ? [index] : [])); - const containerRef = useRef(null); + const containerRef = useRef>(null); const {focusedIndex, setFocusedIndex} = useListKeyboardNav({ containerRef, diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index 7962200faf42..391ea22fdf6f 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -20,12 +20,19 @@ import CONST from '@src/CONST'; import type {FileObject} from '@src/types/utils/Attachment'; import type {MarkdownStyle, MarkdownTextInput} from '@expensify/react-native-live-markdown'; -import type {NativeSyntheticEvent, TextInputChangeEvent, TextInputPasteEventData} from 'react-native'; +import type {NativeSyntheticEvent, TextInputChangeEvent} from 'react-native'; import mimeDb from 'mime-db'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import {StyleSheet} from 'react-native'; +type TextInputPasteEvent = NativeSyntheticEvent<{ + items: Array<{ + type: string; + data: string; + }>; +}>; + const excludeNoStyles: Array = []; const excludeReportMentionStyle: Array = ['mentionReport']; @@ -105,7 +112,7 @@ function Composer({ ); const pasteFile = useCallback( - (e: NativeSyntheticEvent) => { + (e: TextInputPasteEvent) => { const clipboardContent = e.nativeEvent.items.at(0); if (clipboardContent?.type === 'text/plain') { return; diff --git a/src/components/ContextMenuItem.tsx b/src/components/ContextMenuItem.tsx index abcaa16ae6e9..0decf9dcba8b 100644 --- a/src/components/ContextMenuItem.tsx +++ b/src/components/ContextMenuItem.tsx @@ -9,6 +9,7 @@ import CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; +import type {ComponentRef} from 'react'; import type {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native'; import React from 'react'; @@ -47,7 +48,7 @@ type ContextMenuItemProps = WithSentryLabel & { shouldPreventDefaultFocusOnPress?: boolean; /** The ref of mini context menu item */ - buttonRef?: React.RefObject; + buttonRef?: React.RefObject | null>; onFocus?: () => void; diff --git a/src/components/CountryPicker/index.tsx b/src/components/CountryPicker/index.tsx index a5cfa603a428..ea81cf27bdeb 100644 --- a/src/components/CountryPicker/index.tsx +++ b/src/components/CountryPicker/index.tsx @@ -1,9 +1,12 @@ -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import useLocalize from '@hooks/useLocalize'; import type {Option} from '@libs/searchOptions'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -36,14 +39,21 @@ function CountryPicker({value, errorText, onInputChange = () => {}}: CountryPick return ( <> - setIsPickerVisible(true)} - brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={errorText} - /> + setIsPickerVisible(true))}> + + {!!errorText && } + + + {!!errorText && ( + + )} + void; - ref?: ForwardedRef; + ref?: ForwardedRef>; }; function CountrySelector({errorText = '', value: countryCode, onInputChange = () => {}, onBlur, ref}: CountrySelectorProps) { diff --git a/src/components/CurrencyPicker.tsx b/src/components/CurrencyPicker.tsx index d6923ca28521..7deb936a838a 100644 --- a/src/components/CurrencyPicker.tsx +++ b/src/components/CurrencyPicker.tsx @@ -4,6 +4,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import type {ReactNode} from 'react'; @@ -15,7 +17,8 @@ import type {CurrencyListItem} from './CurrencySelectionList/types'; import FullPageOfflineBlockingView from './BlockingViews/FullPageOfflineBlockingView'; import CurrencySelectionList from './CurrencySelectionList'; import HeaderWithBackButton from './HeaderWithBackButton'; -import MenuItemWithTopDescription from './MenuItemWithTopDescription'; +import MenuItem from './MenuItem'; +import MenuItemField from './MenuItem/presets/MenuItemField'; import Modal from './Modal'; import ScreenWrapper from './ScreenWrapper'; @@ -60,15 +63,24 @@ function CurrencyPicker({label, value, errorText, headerContent, excludeCurrenci return ( <> - setIsPickerVisible(true)} - brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={errorText} - disabled={disabled} - /> + setIsPickerVisible(true))} + isDisabled={disabled} + > + + {!!errorText && } + + + {!!errorText && ( + + )} + ; + ref: ForwardedRef>; }; function CurrencySelector({ diff --git a/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx b/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx index bdb1545a68f7..1fc0a6dec671 100644 --- a/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx +++ b/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx @@ -11,7 +11,7 @@ import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {Keyboard} from 'react-native'; type MonthPickerModalProps = { @@ -29,21 +29,18 @@ type MonthPickerModalProps = { shouldEnableBackdropInNarrowPane?: boolean; }; -function MonthPickerModal({isVisible, currentMonth = new Date().getMonth(), onMonthChange, onClose, shouldEnableBackdropInNarrowPane = false}: MonthPickerModalProps) { +function MonthPickerModal({isVisible, currentMonth, onMonthChange, onClose, shouldEnableBackdropInNarrowPane = false}: MonthPickerModalProps) { const styles = useThemeStyles(); const {translate, dateFnsLocale} = useLocalize(); const [searchText, setSearchText] = useState(''); + const resolvedCurrentMonth = currentMonth ?? new Date().getMonth(); const monthNames = DateUtils.getMonthNames(dateFnsLocale); - const allMonths = useMemo(() => DateUtils.getFilteredMonthItems(monthNames, currentMonth), [monthNames, currentMonth]); + const allMonths = DateUtils.getFilteredMonthItems(monthNames, resolvedCurrentMonth); - const {data, headerMessage} = useMemo(() => { - const filteredMonths = searchText === '' ? allMonths : allMonths.filter((month) => month.text.toLowerCase().includes(searchText.toLowerCase())); - return { - headerMessage: !filteredMonths.length ? translate('common.noResultsFound') : '', - data: filteredMonths, - }; - }, [allMonths, searchText, translate]); + const filteredMonths = searchText === '' ? allMonths : allMonths.filter((month) => month.text.toLowerCase().includes(searchText.toLowerCase())); + const headerMessage = !filteredMonths.length ? translate('common.noResultsFound') : ''; + const data = filteredMonths; useEffect(() => { if (isVisible) { @@ -52,15 +49,12 @@ function MonthPickerModal({isVisible, currentMonth = new Date().getMonth(), onMo setSearchText(''); }, [isVisible]); - const textInputOptions = useMemo( - () => ({ - label: translate('monthPickerPage.selectMonth'), - value: searchText, - onChangeText: setSearchText, - headerMessage, - }), - [headerMessage, searchText, translate], - ); + const textInputOptions = { + label: translate('monthPickerPage.selectMonth'), + value: searchText, + onChangeText: setSearchText, + headerMessage, + }; return ( { - const yearsList = searchText === '' ? years : years.filter((year) => year.text?.includes(searchText)); - return { - headerMessage: !yearsList.length ? translate('common.noResultsFound') : '', - data: yearsList.sort((a, b) => b.value - a.value), - }; - }, [years, searchText, translate]); + const yearsList = searchText === '' ? years : years.filter((year) => year.text?.includes(searchText)); + const headerMessage = !yearsList.length ? translate('common.noResultsFound') : ''; + // Copy before sorting: with an empty search `yearsList` is the `years` prop itself, and sorting it in place would mutate the caller's state during render. + const data = [...yearsList].sort((a, b) => b.value - a.value); useEffect(() => { if (isVisible) { @@ -46,17 +44,14 @@ function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear setSearchText(''); }, [isVisible]); - const textInputOptions = useMemo( - () => ({ - label: translate('yearPickerPage.selectYear'), - value: searchText, - onChangeText: (text: string) => setSearchText(text.replaceAll(CONST.REGEX.NON_NUMERIC, '').trim()), - headerMessage, - maxLength: 4, - inputMode: CONST.INPUT_MODE.NUMERIC, - }), - [headerMessage, searchText, translate], - ); + const textInputOptions = { + label: translate('yearPickerPage.selectYear'), + value: searchText, + onChangeText: (text: string) => setSearchText(text.replaceAll(CONST.REGEX.NON_NUMERIC, '').trim()), + headerMessage, + maxLength: 4, + inputMode: CONST.INPUT_MODE.NUMERIC, + }; return ( (null); - const monthPressableRef = useRef(null); + const pressableRef = useRef>(null); + const monthPressableRef = useRef>(null); const [currentDateView, setCurrentDateView] = useState(() => getInitialCurrentDateView(value, minDate, maxDate)); const [isYearPickerVisible, setIsYearPickerVisible] = useState(false); const [isMonthPickerVisible, setIsMonthPickerVisible] = useState(false); diff --git a/src/components/DatePicker/DatePickerModal.tsx b/src/components/DatePicker/DatePickerModal.tsx index 5ea4f13a430e..5e202fd4ab51 100644 --- a/src/components/DatePicker/DatePickerModal.tsx +++ b/src/components/DatePicker/DatePickerModal.tsx @@ -8,6 +8,7 @@ import {setDraftValues} from '@userActions/FormActions'; import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import {setYear} from 'date-fns'; @@ -48,7 +49,7 @@ function DatePickerModal({ shouldEnableMonthYearBackdropInNarrowPane = false, }: DatePickerProps) { const [selectedDate, setSelectedDate] = useState(value ?? defaultValue ?? undefined); - const anchorRef = useRef(null); + const anchorRef = useRef>(null); const styles = useThemeStyles(); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to distinguish RHL and narrow layout diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 3cede0b289ea..c228d91bd00f 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -15,6 +15,7 @@ import {setDraftValues} from '@userActions/FormActions'; import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; import type {TextInputKeyPressEvent} from 'react-native'; import {format, setYear} from 'date-fns'; @@ -63,7 +64,7 @@ function DatePicker({ const [selectedDate, setSelectedDate] = useState(() => value ?? defaultValue ?? ''); const [popoverPosition, setPopoverPosition] = useState({horizontal: 0, vertical: 0}); const textInputRef = useRef(null); - const anchorRef = useRef(null); + const anchorRef = useRef>(null); const [isInverted, setIsInverted] = useState(false); // Whether the user currently intends the picker to be open. Lets a deferred measurement skip opening if the // picker was dismissed before it resolved. diff --git a/src/components/DeferredGlobalModals.tsx b/src/components/DeferredGlobalModals.tsx new file mode 100644 index 000000000000..31eb16153bc3 --- /dev/null +++ b/src/components/DeferredGlobalModals.tsx @@ -0,0 +1,33 @@ +import useScreenShareRequestPrompt from '@hooks/useScreenShareRequestPrompt'; + +import React from 'react'; + +import LazyModalSlot from './LazyModalSlot'; + +const LazyProactiveAppReviewModalManager = React.lazy(() => import('./ProactiveAppReviewModalManager')); +const LazyTrialPaymentReminderModalManager = React.lazy(() => import('./TrialPaymentReminderModalManager')); + +/** + * Global modals and prompts that are held back until after startup, so their dependencies and Onyx subscriptions stay + * out of the ManualAppStartup span. `GlobalModals` mounts this only once the idle callback has fired, which is what + * defers the prompt hook below. `useOnyx` cannot skip subscribing, so the call site is the only place to defer. + */ +function DeferredGlobalModals() { + useScreenShareRequestPrompt(); + + return ( + <> + {/* Order matters. Both managers still render their own modals, and BaseModal hardcodes zIndex: 1 on every + one of them, so DOM source order decides which is painted on top when they coincide. Each keeps its own + slot so a chunk-load failure in one cannot tear down the other. */} + + + + + + + + ); +} + +export default DeferredGlobalModals; diff --git a/src/components/DialogLabelContext.tsx b/src/components/DialogLabelContext.tsx index 56851eb61acc..46de37bdc6e4 100644 --- a/src/components/DialogLabelContext.tsx +++ b/src/components/DialogLabelContext.tsx @@ -1,5 +1,6 @@ import isHTMLElement from '@libs/isHTMLElement'; +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import React, {createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore} from 'react'; @@ -7,7 +8,7 @@ import React, {createContext, useCallback, useContext, useLayoutEffect, useMemo, type LabelEntry = {id: number; text: string}; type DialogLabelData = { - containerRef: React.RefObject; + containerRef: React.RefObject | null>; isInsideDialog: boolean; /** * Accessible name for the dialog — applied as a React `aria-label` prop (not via DOM setAttribute). @@ -37,7 +38,7 @@ const DialogLabelActionsContext = createContext({ type DialogLabelProviderProps = { children: React.ReactNode; /** Pass via `useState`/callback-ref so the provider observes node identity changes; a `RefObject` would pin the MutationObserver to the original node across Animated.View remounts. */ - containerNode: View | HTMLElement | null; + containerNode: ComponentRef | HTMLElement | null; /** * When provided, used instead of observing the container's role/aria-modal attributes. * Prefer this in production so dialog semantics stay in React props (resize updates `isSmallScreenWidth`). @@ -50,11 +51,11 @@ function DialogLabelProvider({children, containerNode, hasDialogSemantics: hasDi const nextIdRef = useRef(0); const labelStackRef = useRef([]); const initialFocusClaimedRef = useRef(false); - const containerRef = useRef(null); + const containerRef = useRef | null>(null); const [activeLabel, setActiveLabel] = useState(); useLayoutEffect(() => { - containerRef.current = (containerNode as View | null) ?? null; + containerRef.current = (containerNode as ComponentRef | null) ?? null; }, [containerNode]); const hasDialogSemanticsFromDom = useSyncExternalStore( diff --git a/src/components/DisplayNames/DisplayNamesTooltipItem.tsx b/src/components/DisplayNames/DisplayNamesTooltipItem.tsx index fefdcb66891c..2cd3fc73e91c 100644 --- a/src/components/DisplayNames/DisplayNamesTooltipItem.tsx +++ b/src/components/DisplayNames/DisplayNamesTooltipItem.tsx @@ -6,7 +6,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type {AvatarSource} from '@libs/UserAvatarUtils'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports import type {Text as RNText, StyleProp, TextStyle} from 'react-native'; @@ -33,7 +33,7 @@ type DisplayNamesTooltipItemProps = ForwardedFSClassProps & { textStyles?: StyleProp; /** Refs to all the names which will be used to correct the horizontal position of the tooltip */ - childRefs: RefObject; + childRefs: RefObject>>; }; function DisplayNamesTooltipItem({ diff --git a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx index f56d99f9f08a..28ab5c120ff0 100644 --- a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx +++ b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx @@ -5,6 +5,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {formatReportLastMessageText} from '@libs/ReportUtils'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {Text as RNText} from 'react-native'; @@ -15,7 +16,7 @@ import type DisplayNamesProps from './types'; import DisplayNamesTooltipItem from './DisplayNamesTooltipItem'; -type HTMLElementWithText = HTMLElement & RNText; +type HTMLElementWithText = HTMLElement & ComponentRef; function DisplayNamesWithToolTip({ shouldUseFullTitle, diff --git a/src/components/DragAndDrop/NoDropZone/index.tsx b/src/components/DragAndDrop/NoDropZone/index.tsx index bea7b87d0242..9fb4b571c164 100644 --- a/src/components/DragAndDrop/NoDropZone/index.tsx +++ b/src/components/DragAndDrop/NoDropZone/index.tsx @@ -4,6 +4,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import htmlDivElementRef from '@src/types/utils/htmlDivElementRef'; import viewRef from '@src/types/utils/viewRef'; +import type {ComponentRef} from 'react'; + import React, {useRef} from 'react'; import {View} from 'react-native'; @@ -11,7 +13,7 @@ import type NoDropZoneProps from './types'; function NoDropZone({children}: NoDropZoneProps) { const styles = useThemeStyles(); - const noDropZone = useRef(null); + const noDropZone = useRef | HTMLDivElement>(null); useDragAndDrop({ dropZone: htmlDivElementRef(noDropZone), diff --git a/src/components/DragAndDrop/Provider/index.tsx b/src/components/DragAndDrop/Provider/index.tsx index 21d86b6fc702..0ef05d73f8bb 100644 --- a/src/components/DragAndDrop/Provider/index.tsx +++ b/src/components/DragAndDrop/Provider/index.tsx @@ -6,6 +6,8 @@ import {shouldAcceptDrop} from '@libs/DragAndDropUtils'; import htmlDivElementRef from '@src/types/utils/htmlDivElementRef'; import viewRef from '@src/types/utils/viewRef'; +import type {ComponentRef} from 'react'; + import {PortalHost} from '@gorhom/portal'; import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; @@ -17,7 +19,7 @@ import {DragAndDropActionsContext, DragAndDropStateContext} from './DragAndDropC function DragAndDropProvider({children, isDisabled = false, setIsDraggingOver = () => {}}: DragAndDropProviderProps) { const styles = useThemeStyles(); - const dropZone = useRef(null); + const dropZone = useRef>(null); const dropZoneID = useRef(Str.guid('drag-n-drop')); const onDropHandler = useRef(() => {}); diff --git a/src/components/DraggableList/index.tsx b/src/components/DraggableList/index.tsx index 0919ae2487d6..6c45cfee4cc4 100644 --- a/src/components/DraggableList/index.tsx +++ b/src/components/DraggableList/index.tsx @@ -9,6 +9,7 @@ import DragCursor from '@libs/DragCursor'; import CONST from '@src/CONST'; import type {DragEndEvent, DragStartEvent} from '@dnd-kit/core'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView} from 'react-native'; @@ -43,7 +44,7 @@ function DraggableList({ disableScroll, focusedIndex: controlledFocusedIndex, ref, -}: DraggableListProps & {ref?: React.ForwardedRef}) { +}: DraggableListProps & {ref?: React.ForwardedRef>}) { const styles = useThemeStyles(); const isControlled = controlledFocusedIndex !== undefined; const hasKeyboardNav = !isControlled && !!onSelectRow; diff --git a/src/components/DropZone/DropZoneWrapper.tsx b/src/components/DropZone/DropZoneWrapper.tsx index 8ca25175118b..a2915b4c504d 100644 --- a/src/components/DropZone/DropZoneWrapper.tsx +++ b/src/components/DropZone/DropZoneWrapper.tsx @@ -6,7 +6,7 @@ import {shouldAcceptDrop} from '@libs/DragAndDropUtils'; import htmlDivElementRef from '@src/types/utils/htmlDivElementRef'; import viewRef from '@src/types/utils/viewRef'; -import type {ReactNode} from 'react'; +import type {ComponentRef, ReactNode} from 'react'; import React, {useRef} from 'react'; import {View} from 'react-native'; @@ -20,7 +20,7 @@ type DropZoneWrapperProps = { function DropZoneWrapper({onDrop, children}: DropZoneWrapperProps) { const styles = useThemeStyles(); - const dropZone = useRef(null); + const dropZone = useRef>(null); const {isDraggingOver} = useDragAndDrop({ shouldAcceptDrop, diff --git a/src/components/EmojiPicker/EmojiPicker.tsx b/src/components/EmojiPicker/EmojiPicker.tsx index ec3890b78701..d1ef9f2d451f 100644 --- a/src/components/EmojiPicker/EmojiPicker.tsx +++ b/src/components/EmojiPicker/EmojiPicker.tsx @@ -27,7 +27,7 @@ import {close} from '@userActions/Modal'; import CONST from '@src/CONST'; import KeyboardUtils from '@src/utils/keyboard'; -import type {ForwardedRef, RefObject} from 'react'; +import type {ComponentRef, ForwardedRef, RefObject} from 'react'; import React, {Activity, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import {Dimensions, View} from 'react-native'; @@ -266,7 +266,7 @@ function EmojiPicker({viewportOffsetTop, ref}: EmojiPickerProps) { vertical: emojiPopoverAnchorPosition.vertical, horizontal: emojiPopoverAnchorPosition.horizontal, }} - anchorRef={getEmojiPopoverAnchor() as RefObject} + anchorRef={getEmojiPopoverAnchor() as RefObject | HTMLDivElement>} withoutOverlay={isWithoutOverlay} popoverDimensions={{ width: CONST.EMOJI_PICKER_SIZE.WIDTH, diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx index 10051c97216c..75a683bdc258 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx @@ -20,6 +20,7 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type {ListRenderItem} from '@shopify/flash-list'; +import type {ComponentRef} from 'react'; import lodashDebounce from 'lodash/debounce'; import React, {useCallback, useMemo, useRef, useState} from 'react'; @@ -57,13 +58,13 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro const {paddingLeft: safeAreaPaddingLeft, paddingRight: safeAreaPaddingRight} = StyleUtils.getPlatformSafeAreaPadding(insets); const [searchText, setSearchText] = useState(''); - const headerRefs = useRef>>({}); + const headerRefs = useRef | null>>>({}); const pendingHeaderFocusIndexRef = useRef(null); const [selectedHeaderIndex, setSelectedHeaderIndex] = useState(null); const getHeaderRef = useCallback((index: number) => { if (!headerRefs.current[index]) { - headerRefs.current[index] = React.createRef(); + headerRefs.current[index] = React.createRef>(); } return headerRefs.current[index]; }, []); diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx index 992eb0d447b5..813b5e35cdbb 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx @@ -25,6 +25,7 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type {ListRenderItem} from '@shopify/flash-list'; +import type {ComponentRef} from 'react'; import throttle from 'lodash/throttle'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; @@ -65,13 +66,13 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro spacersIndexes, } = useEmojiPickerMenu(); - const headerRefs = useRef>>({}); + const headerRefs = useRef | null>>>({}); const pendingHeaderFocusIndexRef = useRef(null); const [selectedHeaderIndex, setSelectedHeaderIndex] = useState(null); const getHeaderRef = useCallback((index: number) => { if (!headerRefs.current[index]) { - headerRefs.current[index] = React.createRef(); + headerRefs.current[index] = React.createRef>(); } return headerRefs.current[index]; }, []); diff --git a/src/components/EmojiPicker/EmojiPickerMenuItem/index.native.tsx b/src/components/EmojiPicker/EmojiPickerMenuItem/index.native.tsx index 705f5e97c274..e36bd3f45ad8 100644 --- a/src/components/EmojiPicker/EmojiPickerMenuItem/index.native.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenuItem/index.native.tsx @@ -8,6 +8,7 @@ import getButtonState from '@libs/getButtonState'; import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import React, {useEffect, useRef} from 'react'; @@ -25,7 +26,7 @@ function EmojiPickerMenuItem({ isHighlighted = false, isUsingKeyboardMovement = false, }: EmojiPickerMenuItemProps) { - const ref = useRef(null); + const ref = useRef>(null); const StyleUtils = useStyleUtils(); const themeStyles = useThemeStyles(); diff --git a/src/components/EmojiPicker/EmojiPickerMenuItem/index.tsx b/src/components/EmojiPicker/EmojiPickerMenuItem/index.tsx index 98c30332c473..44467ff5b733 100644 --- a/src/components/EmojiPicker/EmojiPickerMenuItem/index.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenuItem/index.tsx @@ -9,6 +9,7 @@ import getButtonState from '@libs/getButtonState'; import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {Text as RNText, View} from 'react-native'; @@ -27,7 +28,7 @@ function EmojiPickerMenuItem({ isHighlighted = false, }: EmojiPickerMenuItemProps) { const [isHovered, setIsHovered] = useState(false); - const ref = useRef(null); + const ref = useRef | ComponentRef | null>(null); const StyleUtils = useStyleUtils(); const themeStyles = useThemeStyles(); diff --git a/src/components/FeatureTraining/FeatureTrainingCarousel.tsx b/src/components/FeatureTraining/FeatureTrainingCarousel.tsx index efb4849265de..5034899e92ca 100644 --- a/src/components/FeatureTraining/FeatureTrainingCarousel.tsx +++ b/src/components/FeatureTraining/FeatureTrainingCarousel.tsx @@ -2,8 +2,10 @@ import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; +import type {ViewabilityConfig, ViewToken} from '@src/types/utils/ReactNativeCompat'; + import type {ReactElement, ReactNode} from 'react'; -import type {LayoutChangeEvent, FlatList as RNFlatList, ViewabilityConfig, ViewStyle, ViewToken} from 'react-native'; +import type {LayoutChangeEvent, FlatList as RNFlatList, ViewStyle} from 'react-native'; import React, {cloneElement, isValidElement, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {FlatList, Platform, View} from 'react-native'; diff --git a/src/components/FeatureTraining/hooks/useScrollableWrapper.ts b/src/components/FeatureTraining/hooks/useScrollableWrapper.ts index 0db8e9515389..97bed4b85cb4 100644 --- a/src/components/FeatureTraining/hooks/useScrollableWrapper.ts +++ b/src/components/FeatureTraining/hooks/useScrollableWrapper.ts @@ -9,6 +9,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports -- type-only import from react-native import type {LayoutChangeEvent, ScrollView as RNScrollView, StyleProp, ViewStyle} from 'react-native'; @@ -30,7 +31,7 @@ function useScrollableWrapper({shouldUseScrollView: shouldUseScrollViewProp = fa const shouldUseScrollView = shouldUseScrollViewProp || isInLandscapeMode; - const scrollViewRef = useRef(null); + const scrollViewRef = useRef>(null); const [containerHeight, setContainerHeight] = useState(0); const [contentHeight, setContentHeight] = useState(0); diff --git a/src/components/FlatList/FlatList/types.ts b/src/components/FlatList/FlatList/types.ts index f82f932fe9c7..658b09e17fcb 100644 --- a/src/components/FlatList/FlatList/types.ts +++ b/src/components/FlatList/FlatList/types.ts @@ -1,5 +1,7 @@ +import type {CellRendererProps} from '@src/types/utils/ReactNativeCompat'; + import type {ForwardedRef} from 'react'; -import type {CellRendererProps, FlatList, FlatListProps} from 'react-native'; +import type {FlatList, FlatListProps} from 'react-native'; type CustomFlatListProps = Omit, 'CellRendererComponent'> & { ref?: ForwardedRef; diff --git a/src/components/FlatList/hooks/useFlatListHandle.ts b/src/components/FlatList/hooks/useFlatListHandle.ts index 159c8b7b2558..6bafc31df9ed 100644 --- a/src/components/FlatList/hooks/useFlatListHandle.ts +++ b/src/components/FlatList/hooks/useFlatListHandle.ts @@ -1,6 +1,6 @@ import type {FlatListInnerRefType} from '@components/FlatList/types'; -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {FlatList as RNFlatList, ScrollView} from 'react-native'; @@ -31,7 +31,7 @@ function useFlatListHandle({ref, listRef, setCurrentDataId, remainingItemsToD const scrollViewRef = listRef.current?.getNativeScrollRef(); // Try to scroll on underlying scrollView if available, fallback to usual listRef if (scrollViewRef && 'scrollToEnd' in scrollViewRef) { - (scrollViewRef as ScrollView).scrollToEnd({animated: !!params?.animated}); + (scrollViewRef as ComponentRef).scrollToEnd({animated: !!params?.animated}); return; } listRef.current?.scrollToEnd(params); diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx index 9cf7b4840079..2a99bcd71f52 100644 --- a/src/components/FloatingActionButton.tsx +++ b/src/components/FloatingActionButton.tsx @@ -9,7 +9,7 @@ import variables from '@styles/variables'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {GestureResponderEvent, Role, Text as TextType, View as ViewType} from 'react-native'; @@ -34,14 +34,14 @@ type FloatingActionButtonProps = WithSentryLabel & { isActive: boolean; accessibilityLabel: string; role: Role; - ref?: ForwardedRef; + ref?: ForwardedRef | ComponentRef>; }; function FloatingActionButton({onPress, onLongPress, isActive, accessibilityLabel, role, ref, sentryLabel}: FloatingActionButtonProps) { const {buttonDefaultBG, buttonHoveredBG, icon} = useTheme(); const styles = useThemeStyles(); const borderRadius = styles.floatingActionButton.borderRadius; - const fabPressable = useRef(null); + const fabPressable = useRef | ComponentRef | null>(null); const {shouldUseNarrowLayout} = useResponsiveLayout(); const isLHBVisible = !shouldUseNarrowLayout; const {translate} = useLocalize(); diff --git a/src/components/FloatingReceiptButton.tsx b/src/components/FloatingReceiptButton.tsx index e7c2bb4f79e8..3c7ff068d129 100644 --- a/src/components/FloatingReceiptButton.tsx +++ b/src/components/FloatingReceiptButton.tsx @@ -9,6 +9,7 @@ import variables from '@styles/variables'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {GestureResponderEvent, Role, Text, View as ViewType} from 'react-native'; @@ -31,7 +32,7 @@ function FloatingReceiptButton({onPress, accessibilityLabel, role, sentryLabel}: const {successHover, textLight} = useTheme(); const styles = useThemeStyles(); const borderRadius = styles.floatingActionButton.borderRadius; - const fabPressable = useRef(null); + const fabPressable = useRef | ComponentRef | null>(null); const icons = useMemoizedLazyExpensifyIcons(['ReceiptPlus']); const {translate} = useLocalize(); diff --git a/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts b/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts index 14e18b93ab79..63f5023302ab 100644 --- a/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts +++ b/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts @@ -1,11 +1,11 @@ -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; import type {View, ViewProps} from 'react-native'; type FocusTrapContainerElementProps = ViewProps & { /** Callback to register focus trap container element */ onContainerElementChanged?: (element: HTMLElement | null) => void; - ref?: ForwardedRef; + ref?: ForwardedRef>; }; export default FocusTrapContainerElementProps; diff --git a/src/components/FocusTrap/FocusTrapForModal/FocusTrapForModalProps.ts b/src/components/FocusTrap/FocusTrapForModal/FocusTrapForModalProps.ts index 5681af5fb8e6..fe33ad258b96 100644 --- a/src/components/FocusTrap/FocusTrapForModal/FocusTrapForModalProps.ts +++ b/src/components/FocusTrap/FocusTrapForModal/FocusTrapForModalProps.ts @@ -1,5 +1,5 @@ import type {FocusTrapProps} from 'focus-trap-react'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports -- type-only: the launcher union must cover every anchor shape popovers pass, including RN Text anchors import type {Text, View} from 'react-native'; @@ -21,7 +21,7 @@ type FocusTrapForModalProps = { * Deliberately covers every anchor shape in use (`View`, `Text`, DOM element): the trap narrows it with * `instanceof HTMLElement` anyway, so a ref it cannot use is simply ignored rather than rejected. */ - launcherRef?: RefObject; + launcherRef?: RefObject | ComponentRef | HTMLElement | null>; }; export default FocusTrapForModalProps; diff --git a/src/components/FocusableMenuItem.tsx b/src/components/FocusableMenuItem.tsx index 5cc0bd5e1b12..e06de7272ebe 100644 --- a/src/components/FocusableMenuItem.tsx +++ b/src/components/FocusableMenuItem.tsx @@ -1,5 +1,6 @@ import useSyncFocus from '@hooks/useSyncFocus'; +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import React, {useRef} from 'react'; @@ -14,7 +15,7 @@ type FocusableMenuItemProps = MenuItemProps & { }; function FocusableMenuItem({shouldSyncFocus = true, ...props}: FocusableMenuItemProps) { - const ref = useRef(null); + const ref = useRef>(null); // Sync focus on an item useSyncFocus(ref, !!props.focused, shouldSyncFocus); diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx index 2702daf706fd..b6c63efe437e 100644 --- a/src/components/Form/FormWrapper.tsx +++ b/src/components/Form/FormWrapper.tsx @@ -16,7 +16,7 @@ import type {ErrorFields} from '@src/types/onyx/OnyxCommon'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {ForwardedRef, RefObject} from 'react'; +import type {ComponentRef, ForwardedRef, RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView, StyleProp, ViewStyle} from 'react-native'; @@ -114,8 +114,8 @@ function FormWrapper({ submitButtonInnerStyles, }: FormWrapperProps) { const styles = useThemeStyles(); - const formRef = useRef(null); - const formContentRef = useRef(null); + const formRef = useRef>(null); + const formContentRef = useRef>(null); const {getErrorAnnouncementKey, getFallbackAnnouncementMessage} = useContext(FormContext); const errorAnnouncementKey = getErrorAnnouncementKey(); const fallbackAnnouncementMessage = getFallbackAnnouncementMessage(); diff --git a/src/components/FormAlertWithSubmitButton.tsx b/src/components/FormAlertWithSubmitButton.tsx index 0e542d50d0c4..ef7c9afabe65 100644 --- a/src/components/FormAlertWithSubmitButton.tsx +++ b/src/components/FormAlertWithSubmitButton.tsx @@ -8,7 +8,7 @@ import type {ButtonVariant} from '@styles/utils/types'; import CONST from '@src/CONST'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; -import type {Ref} from 'react'; +import type {ComponentRef, Ref} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import React from 'react'; @@ -61,7 +61,7 @@ type FormAlertWithSubmitButtonProps = WithSentryLabel & { isAlertVisible?: boolean; /** React ref being forwarded to the submit button */ - buttonRef?: Ref; + buttonRef?: Ref>; buttonText: string; diff --git a/src/components/FormElement/index.tsx b/src/components/FormElement/index.tsx index 444f75001293..e3e342f1f7d0 100644 --- a/src/components/FormElement/index.tsx +++ b/src/components/FormElement/index.tsx @@ -1,14 +1,14 @@ import {ACCESSIBILITY_ROLE_FORM} from '@libs/ComponentUtils/index'; import mergeRefs from '@libs/mergeRefs'; -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; import type {ViewProps} from 'react-native'; import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; type FormElementProps = ViewProps & { - ref?: ForwardedRef; + ref?: ForwardedRef>; }; const preventFormDefault = (event: SubmitEvent) => { @@ -18,7 +18,7 @@ const preventFormDefault = (event: SubmitEvent) => { }; function FormElement({ref, ...props}: FormElementProps) { - const formRef = useRef(null); + const formRef = useRef>(null); const mergedRef = mergeRefs(formRef, ref); useEffect(() => { diff --git a/src/components/FormScrollView.tsx b/src/components/FormScrollView.tsx index dd0c9574079b..089d2c49e88a 100644 --- a/src/components/FormScrollView.tsx +++ b/src/components/FormScrollView.tsx @@ -1,6 +1,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView, ScrollViewProps} from 'react-native'; @@ -12,7 +12,7 @@ type FormScrollViewProps = ScrollViewProps & { /** Form elements */ children: React.ReactNode; - ref?: ForwardedRef; + ref?: ForwardedRef>; }; function FormScrollView({children, ref, ...rest}: FormScrollViewProps) { diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx index 3470204ae2cf..39b7e656c397 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx @@ -9,6 +9,7 @@ import {canUseTouchScreen as canUseTouchScreenUtil} from '@libs/DeviceCapabiliti import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; import type {View as RNView} from 'react-native'; import React, {useEffect, useRef} from 'react'; @@ -27,7 +28,7 @@ function DesktopVictoryChartExpandedContent({availableSize, layout, isVisible}: const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); - const scrollableRef = useRef(null); + const scrollableRef = useRef & HTMLDivElement>(null); // No headroom (very large displays) means clicking couldn't enlarge anything const canZoom = layout.zoomHeadroom > 1; diff --git a/src/components/Icon/chunks/illustrations.chunk.ts b/src/components/Icon/chunks/illustrations.chunk.ts index bba36412cca5..dde691ee6d16 100644 --- a/src/components/Icon/chunks/illustrations.chunk.ts +++ b/src/components/Icon/chunks/illustrations.chunk.ts @@ -165,6 +165,8 @@ import Hourglass from '@assets/images/simple-illustrations/simple-illustration__ import HouseMoney from '@assets/images/simple-illustrations/simple-illustration__house-money.svg'; import House from '@assets/images/simple-illustrations/simple-illustration__house.svg'; import HouseWithMap from '@assets/images/simple-illustrations/simple-illustration__housewithmap.svg'; +import IceCreamNumberOne from '@assets/images/simple-illustrations/simple-illustration__ice-cream-number-1.svg'; +import IceCreamNumberTwo from '@assets/images/simple-illustrations/simple-illustration__ice-cream-number-2.svg'; import InvoiceBlue from '@assets/images/simple-illustrations/simple-illustration__invoice.svg'; import LifeRing from '@assets/images/simple-illustrations/simple-illustration__lifering.svg'; import Lightbulb from '@assets/images/simple-illustrations/simple-illustration__lightbulb.svg'; @@ -172,6 +174,7 @@ import LockClosed from '@assets/images/simple-illustrations/simple-illustration_ import LockClosedOrange from '@assets/images/simple-illustrations/simple-illustration__lockclosed_orange.svg'; import LockOpen from '@assets/images/simple-illustrations/simple-illustration__lockopen.svg'; import Luggage from '@assets/images/simple-illustrations/simple-illustration__luggage.svg'; +import MagnifyingGlassChart from '@assets/images/simple-illustrations/simple-illustration__magnifyingglass-chart.svg'; import MagnifyingGlassMoney from '@assets/images/simple-illustrations/simple-illustration__magnifyingglass-money.svg'; import MagnifyingGlassReceipt from '@assets/images/simple-illustrations/simple-illustration__magnifyingglass-receipt.svg'; import Mailbox from '@assets/images/simple-illustrations/simple-illustration__mailbox.svg'; @@ -358,6 +361,7 @@ const Illustrations = { HandCard, Hashtag, InvoiceBlue, + MagnifyingGlassChart, MagnifyingGlassMoney, MagnifyingGlassReceipt, MoneyReceipts, @@ -432,6 +436,8 @@ const Illustrations = { House, HouseWithMap, HouseMoney, + IceCreamNumberOne, + IceCreamNumberTwo, LifeRing, Lightbulb, LockClosed, diff --git a/src/components/Image/types.ts b/src/components/Image/types.ts index fe4c3cf22901..6efaec2be6cd 100644 --- a/src/components/Image/types.ts +++ b/src/components/Image/types.ts @@ -3,7 +3,7 @@ import type {FullScreenLoadingIndicatorIconSize} from '@components/FullscreenLoa import type CONST from '@src/CONST'; import type {ImagePrefetchOptions, ImageSource} from 'expo-image'; -import type {ImageRequireSource, ImageResizeMode, ImageStyle, ImageURISource, StyleProp, ViewStyle} from 'react-native'; +import type {ImageProps as RNImageProps, ImageRequireSource, ImageStyle, ImageURISource, StyleProp, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; type ExpoImageSource = ImageSource | number | ImageSource[]; @@ -44,7 +44,7 @@ type ImageOwnProps = BaseImageProps & { isAuthTokenRequired?: boolean; /** How should the image fit within its container */ - resizeMode?: ImageResizeMode; + resizeMode?: RNImageProps['resizeMode']; loadingIconSize?: FullScreenLoadingIndicatorIconSize; loadingIndicatorStyles?: StyleProp; diff --git a/src/components/ImageView/index.tsx b/src/components/ImageView/index.tsx index 33118971cfa1..57440545bbc7 100644 --- a/src/components/ImageView/index.tsx +++ b/src/components/ImageView/index.tsx @@ -17,6 +17,7 @@ import {isLocalFile} from '@libs/fileDownload/FileUtils'; import CONST from '@src/CONST'; import type {Dimensions} from '@src/types/utils/Layout'; +import type {ComponentRef} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import React, {useRef, useState} from 'react'; @@ -36,7 +37,7 @@ function ImageView({isAuthTokenRequired = false, url, fileName, onError}: ImageV const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {isOffline} = useNetwork(); - const scrollableRef = useRef(null); + const scrollableRef = useRef & HTMLDivElement>(null); const canUseTouchScreen = canUseTouchScreenUtil(); const [isLoading, setIsLoading] = useState(true); diff --git a/src/components/ImageWithSizeCalculation.tsx b/src/components/ImageWithSizeCalculation.tsx index eb6f65581361..6b8d7d97336d 100644 --- a/src/components/ImageWithSizeCalculation.tsx +++ b/src/components/ImageWithSizeCalculation.tsx @@ -4,7 +4,7 @@ import Log from '@libs/Log'; import CONST from '@src/CONST'; -import type {ImageResizeMode, ImageSourcePropType, StyleProp, ViewStyle} from 'react-native'; +import type {ImageProps, ImageSourcePropType, StyleProp, ViewStyle} from 'react-native'; import React, {useMemo} from 'react'; @@ -43,7 +43,7 @@ type ImageWithSizeCalculationProps = { /** Callback to be called when the image loads */ onLoad?: (event: {nativeEvent: {width: number; height: number}}) => void; - resizeMode?: ImageResizeMode; + resizeMode?: ImageProps['resizeMode']; /** Low-resolution URI shown as a placeholder while the full image loads */ previewUri?: string; diff --git a/src/components/InteractiveStepWrapper.tsx b/src/components/InteractiveStepWrapper.tsx index 9c0e797e4319..a97c48504e7e 100644 --- a/src/components/InteractiveStepWrapper.tsx +++ b/src/components/InteractiveStepWrapper.tsx @@ -4,7 +4,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import React from 'react'; @@ -46,7 +46,7 @@ type InteractiveStepWrapperProps = { enableEdgeToEdgeBottomSafeAreaPadding?: boolean; onEntryTransitionEnd?: () => void; - ref?: ForwardedRef; + ref?: ForwardedRef>; }; const INPUT_HEADER_HEIGHT = variables.lineHeightXXLarge; diff --git a/src/components/KYCWall/BaseKYCWall.tsx b/src/components/KYCWall/BaseKYCWall.tsx index 16b5a164b235..c6a92cce9238 100644 --- a/src/components/KYCWall/BaseKYCWall.tsx +++ b/src/components/KYCWall/BaseKYCWall.tsx @@ -34,6 +34,7 @@ import type {BankAccountList, PersonalDetailsList, Policy} from '@src/types/onyx import {getEmptyObject} from '@src/types/utils/EmptyObject'; import viewRef from '@src/types/utils/viewRef'; +import type {ComponentRef} from 'react'; import type {EmitterSubscription, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -101,8 +102,8 @@ function KYCWall({ const reportTransactions = useReportTransactions(iouReport?.reportID); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); - const anchorRef = useRef(null); - const transferBalanceButtonRef = useRef(null); + const anchorRef = useRef>(null); + const transferBalanceButtonRef = useRef | null>(null); const [shouldShowAddPaymentMenu, setShouldShowAddPaymentMenu] = useState(false); // Holds the fallback route while the add-payment menu is open. When the user picks "Personal bank account" from the menu, diff --git a/src/components/KYCWall/types.ts b/src/components/KYCWall/types.ts index 4f94b202b356..55973cd139ba 100644 --- a/src/components/KYCWall/types.ts +++ b/src/components/KYCWall/types.ts @@ -4,7 +4,7 @@ import type {Policy, Report} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; -import type {ForwardedRef, RefObject} from 'react'; +import type {ComponentRef, ForwardedRef, RefObject} from 'react'; import type {GestureResponderEvent, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -71,7 +71,7 @@ type KYCWallProps = { onSuccessfulKYC: (iouPaymentType?: PaymentMethodType, currentSource?: Source) => void; /** Children to build the KYC */ - children: (continueAction: (params?: ContinueActionParams) => void, anchorRef: RefObject) => void; + children: (continueAction: (params?: ContinueActionParams) => void, anchorRef: RefObject | null>) => void; /** Reference to the KYCWall component */ ref: ForwardedRef; diff --git a/src/components/LHNOptionsList/OptionRowLHN/OptionRow/Pressable.tsx b/src/components/LHNOptionsList/OptionRowLHN/OptionRow/Pressable.tsx index 9eefb2f89cf6..575048586f30 100644 --- a/src/components/LHNOptionsList/OptionRowLHN/OptionRow/Pressable.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN/OptionRow/Pressable.tsx @@ -23,7 +23,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; -import type {ReactNode} from 'react'; +import type {ComponentRef, ReactNode} from 'react'; import type {GestureResponderEvent, LayoutChangeEvent, View} from 'react-native'; import React, {useRef, useState} from 'react'; @@ -35,7 +35,7 @@ type PressableProps = { /** Whether the row is the currently focused/active option. Drives the focused background and accessibility metadata. */ isOptionFocused: boolean; - onSelectRow: (optionItem: OptionData, popoverAnchor: React.RefObject) => void; + onSelectRow: (optionItem: OptionData, popoverAnchor: React.RefObject | null>) => void; /** Layout handler forwarded to the underlying pressable. */ onLayout?: (event: LayoutChangeEvent) => void; @@ -62,7 +62,7 @@ function Pressable({optionItem, isOptionFocused, onSelectRow, onLayout, onHoverI const {shouldUseNarrowLayout} = useResponsiveLayout(); const {hideProductTrainingTooltip} = useLHNRowProductTrainingTooltip(); - const popoverAnchor = useRef(null); + const popoverAnchor = useRef>(null); const [isContextMenuActive, setIsContextMenuActive] = useState(false); const reportID = optionItem.reportID; diff --git a/src/components/LHNOptionsList/types.ts b/src/components/LHNOptionsList/types.ts index bff7983637e8..c95083c09ce1 100644 --- a/src/components/LHNOptionsList/types.ts +++ b/src/components/LHNOptionsList/types.ts @@ -3,7 +3,7 @@ import type {OptionData} from '@src/libs/ReportUtils'; import type {PersonalDetailsList, Policy, Report} from '@src/types/onyx'; import type {ReportAttributes, ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {LayoutChangeEvent, StyleProp, View, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -18,7 +18,7 @@ type CustomLHNOptionsListProps = { contentContainerStyles?: StyleProp; data: Report[]; - onSelectRow?: (optionItem: OptionData, popoverAnchor: RefObject) => void; + onSelectRow?: (optionItem: OptionData, popoverAnchor: RefObject | null>) => void; /** Toggle between compact and default view of the option */ optionMode: OptionMode; @@ -49,7 +49,7 @@ type OptionRowLHNDataProps = { viewMode?: OptionMode; /** A function that is called when an option is selected. Selected option is passed as a param */ - onSelectRow?: (optionItem: OptionData, popoverAnchor: RefObject) => void; + onSelectRow?: (optionItem: OptionData, popoverAnchor: RefObject | null>) => void; /** Callback to execute when the OptionList lays out */ onLayout?: (event: LayoutChangeEvent) => void; @@ -65,7 +65,7 @@ type OptionRowLHNProps = { isOptionFocused?: boolean; /** A function that is called when an option is selected. Selected option is passed as a param */ - onSelectRow?: (optionItem: OptionData, popoverAnchor: RefObject) => void; + onSelectRow?: (optionItem: OptionData, popoverAnchor: RefObject | null>) => void; /** Toggle between compact and default view */ viewMode?: OptionMode; diff --git a/src/components/MenuItem/MenuItem.tsx b/src/components/MenuItem/MenuItem.tsx index 41f59e68ddb8..35144c0821af 100644 --- a/src/components/MenuItem/MenuItem.tsx +++ b/src/components/MenuItem/MenuItem.tsx @@ -57,7 +57,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; import type {ImageContentFit} from 'expo-image'; -import type {ReactElement, ReactNode, Ref} from 'react'; +import type {ComponentRef, ReactElement, ReactNode, Ref} from 'react'; import type {GestureResponderEvent, Role, StyleProp, TextStyle, ViewStyle} from 'react-native'; import type {AnimatedStyle} from 'react-native-reanimated'; import type {ValueOf} from 'type-fest'; @@ -90,7 +90,7 @@ type NoIcon = { type MenuItemBaseProps = ForwardedFSClassProps & WithSentryLabel & Pick & { - ref?: PressableRef | Ref; + ref?: PressableRef | Ref>; onPress?: (event: GestureResponderEvent | KeyboardEvent) => void | Promise; interactive?: boolean; @@ -573,8 +573,8 @@ function MenuItem({ const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const {isExecuting} = useMenuItemGroupState() ?? {}; const {singleExecution, waitForNavigate} = useMenuItemGroupActions() ?? {}; - const popoverAnchor = useRef(null); - const pressableRef = useRef(null); + const popoverAnchor = useRef>(null); + const pressableRef = useRef>(null); useRemoveNonInteractiveClickHandler(pressableRef, interactive); const deviceHasHoverSupport = hasHoverSupport(); const isCompactMenu = useIsCompactMenu(); diff --git a/src/components/MenuItem/MenuItemSecondaryInteractionContext.tsx b/src/components/MenuItem/MenuItemSecondaryInteractionContext.tsx index b7ba0caf7863..7c5d9741766a 100644 --- a/src/components/MenuItem/MenuItemSecondaryInteractionContext.tsx +++ b/src/components/MenuItem/MenuItemSecondaryInteractionContext.tsx @@ -1,4 +1,4 @@ -import type {Dispatch, SetStateAction} from 'react'; +import type {ComponentRef, Dispatch, SetStateAction} from 'react'; import type {GestureResponderEvent, View} from 'react-native'; import {createContext, useContext, useEffect, useRef, useState} from 'react'; @@ -7,7 +7,7 @@ import {createContext, useContext, useEffect, useRef, useState} from 'react'; * Handles a long press or a right-click on the row. `anchor` is the row's own pressable, handed over * so a sub-component can anchor a popover to the whole row without the call site threading a ref down. */ -type MenuItemSecondaryInteractionHandler = (event: GestureResponderEvent | MouseEvent, anchor: View | null) => void; +type MenuItemSecondaryInteractionHandler = (event: GestureResponderEvent | MouseEvent, anchor: ComponentRef | null) => void; /** Takes over the row's long press / right-click. Returns the callback that gives it back */ type RegisterMenuItemSecondaryInteraction = (handler: MenuItemSecondaryInteractionHandler) => () => void; diff --git a/src/components/MenuItem/hooks/useRemoveNonInteractiveClickHandler/types.ts b/src/components/MenuItem/hooks/useRemoveNonInteractiveClickHandler/types.ts index 83f493b1a326..23c7c9372440 100644 --- a/src/components/MenuItem/hooks/useRemoveNonInteractiveClickHandler/types.ts +++ b/src/components/MenuItem/hooks/useRemoveNonInteractiveClickHandler/types.ts @@ -1,10 +1,10 @@ -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; /** * Clears the `onclick` property React sets on a non-interactive pressable, so screen readers * don't announce it as clickable. No-op on native. */ -type UseRemoveNonInteractiveClickHandler = (ref: RefObject, isInteractive: boolean) => void; +type UseRemoveNonInteractiveClickHandler = (ref: RefObject | null>, isInteractive: boolean) => void; export default UseRemoveNonInteractiveClickHandler; diff --git a/src/components/MenuItem/index.ts b/src/components/MenuItem/index.ts index 27591e2a8037..f1beba807177 100644 --- a/src/components/MenuItem/index.ts +++ b/src/components/MenuItem/index.ts @@ -39,6 +39,7 @@ import MenuItemFieldValue from './leaves/content/MenuItemFieldValue'; import MenuItemLabel from './leaves/content/MenuItemLabel'; import MenuItemTitle from './leaves/content/MenuItemTitle'; import MenuItemIcon from './leaves/leading/MenuItemIcon'; +import MenuItemHelpText from './leaves/MenuItemHelpText'; import MenuItemBrickRoadIndicator from './leaves/trailing/icons/MenuItemBrickRoadIndicator'; import MenuItemChevron from './leaves/trailing/icons/MenuItemChevron'; import MenuItemNewWindowIcon from './leaves/trailing/icons/MenuItemNewWindowIcon'; @@ -66,6 +67,7 @@ const MenuItem = Object.assign(LegacyMenuItem, { RightLabel: MenuItemRightLabel, Copy: MenuItemCopy, ExternalLink: MenuItemExternalLink, + HelpText: MenuItemHelpText, }); export default MenuItem; diff --git a/src/components/MenuItem/layout/MenuItemRoot.tsx b/src/components/MenuItem/layout/MenuItemRoot.tsx index 33234c340910..ec77be902f4c 100644 --- a/src/components/MenuItem/layout/MenuItemRoot.tsx +++ b/src/components/MenuItem/layout/MenuItemRoot.tsx @@ -20,7 +20,7 @@ import CONST from '@src/CONST'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; import type WithTestID from '@src/types/utils/TestID'; -import type {PropsWithChildren} from 'react'; +import type {ComponentRef, PropsWithChildren} from 'react'; import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; import React, {useRef} from 'react'; @@ -45,7 +45,7 @@ type MenuItemRootProps = PropsWithChildren & function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testID, accessibilityLabel}: MenuItemRootProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const pressableRef = useRef(null); + const pressableRef = useRef>(null); const isCompactPopover = useIsCompactPopover(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const isInteractive = !!onPress; diff --git a/src/components/MenuItem/leaves/MenuItemHelpText.tsx b/src/components/MenuItem/leaves/MenuItemHelpText.tsx new file mode 100644 index 000000000000..b21419c0ed30 --- /dev/null +++ b/src/components/MenuItem/leaves/MenuItemHelpText.tsx @@ -0,0 +1,31 @@ +import FormHelpMessage from '@components/FormHelpMessage'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ReactNode} from 'react'; + +import React from 'react'; + +type MenuItemHelpTextProps = { + /** Error or hint text to render under the row. Nothing renders when it is empty */ + message?: string | ReactNode; + + /** Whether the text reads as an error (red) instead of a hint (muted) */ + isError?: boolean; +}; + +/** The trailing help line of a `MenuItem.Root`, rendered under `MenuItem.Row` and inside the row's press target */ +function MenuItemHelpText({message, isError = false}: MenuItemHelpTextProps) { + const styles = useThemeStyles(); + + return ( + + ); +} + +export default MenuItemHelpText; diff --git a/src/components/MenuItem/presets/MenuItemField.tsx b/src/components/MenuItem/presets/MenuItemField.tsx index 0cfc6f51fba8..83022c2bccc3 100644 --- a/src/components/MenuItem/presets/MenuItemField.tsx +++ b/src/components/MenuItem/presets/MenuItemField.tsx @@ -10,9 +10,11 @@ import MenuItemChevron from '@components/MenuItem/leaves/trailing/icons/MenuItem import {callFunctionIfActionIsAllowed} from '@userActions/Session'; +import type {PropsWithChildren} from 'react'; + import React from 'react'; -type MenuItemFieldProps = Omit & { +type MenuItemFieldRowProps = PropsWithChildren<{ /** Name of the field */ name: string; @@ -21,10 +23,38 @@ type MenuItemFieldProps = Omit & { /** How many lines the value may take. Defaults to 1, and `0` lets it grow unbounded */ numberOfLinesValue?: number; -}; +}>; + +type MenuItemFieldProps = Omit & Omit; + +/** + * The line a field preset draws, without a `MenuItem.Root` of its own. Reach for it over the + * `MenuItemField` preset when the row needs siblings inside the same `Root` (an error or a hint + * line under the row). + */ +function MenuItemFieldRow({name, value, numberOfLinesValue, children}: MenuItemFieldRowProps) { + return ( + + + {value ? ( + <> + {name} + {value} + + ) : ( + {name} + )} + + {!!children && {children}} + + ); +} -/** Field preset: a field name plus its value. With no `value` the name takes over the row */ -function MenuItemField({name, value, numberOfLinesValue, children, onPress, isDisabled = false, sentryLabel, testID}: MenuItemFieldProps) { +/** + * Field preset: a field name plus its value. With no `value` the name takes over the row. + * `children` land in the trailing cell, next to the chevron. + */ +function MenuItemFieldPreset({name, value, numberOfLinesValue, children, onPress, isDisabled = false, sentryLabel, testID}: MenuItemFieldProps) { return ( - - - {value ? ( - <> - {name} - {value} - - ) : ( - {name} - )} - + {(!!children || !!onPress) && ( - + <> {children} {!!onPress && } - + )} - + ); } +const MenuItemField = Object.assign(MenuItemFieldPreset, {Row: MenuItemFieldRow}); + export default MenuItemField; diff --git a/src/components/MenuItemList.tsx b/src/components/MenuItemList.tsx index 64d49996a33e..6464b1908312 100644 --- a/src/components/MenuItemList.tsx +++ b/src/components/MenuItemList.tsx @@ -8,6 +8,7 @@ import CONST from '@src/CONST'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type IconAsset from '@src/types/utils/IconAsset'; +import type {ComponentRef} from 'react'; import type {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native'; import {useIsFocused} from '@react-navigation/native'; @@ -49,7 +50,7 @@ type MenuItemListProps = { }; function MenuItemList({menuItems = [], shouldUseSingleExecution = false, wrapperStyle = {}, icon = undefined, iconWidth = undefined, iconHeight = undefined}: MenuItemListProps) { - const popoverAnchor = useRef(null); + const popoverAnchor = useRef>(null); const {isExecuting, singleExecution} = useSingleExecution(); const isFocused = useIsFocused(); diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 12c5ec70c794..ce3ef3f03e80 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -25,6 +25,7 @@ import {areAllModalsHidden, closeTop, onModalDidClose, setCloseModal, setModalCo import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; import type {GestureResponderEvent, LayoutChangeEvent} from 'react-native'; import React, {useCallback, useContext, useEffect, useRef, useState} from 'react'; @@ -112,7 +113,7 @@ function BaseModal({ const shouldCallHideModalOnUnmount = useRef(false); const hideModalCallbackRef = useRef<(callHideCallback: boolean) => void>(undefined); - const bottomDockedDismissButtonRef = useRef(null); + const bottomDockedDismissButtonRef = useRef>(null); const [fallbackModalID] = useState(() => ComposerFocusManager.getId()); const coveringModalID = fallbackModalID; // On Android the hide callback fires from the reanimated exit-animation snapshot taken when the close began, diff --git a/src/components/Modal/Global/ModalContext.tsx b/src/components/Modal/Global/ModalContext.tsx index 664c91757267..2fcd46d7b7cb 100644 --- a/src/components/Modal/Global/ModalContext.tsx +++ b/src/components/Modal/Global/ModalContext.tsx @@ -27,12 +27,14 @@ type ModalContextType = { isCloseable?: boolean; }): Promise; closeModal(data?: ModalStateChangePayload): void; + closeModalByID(id: string, data?: ModalStateChangePayload): void; resolveModal(data?: ModalStateChangePayload): void; }; const ModalContext = React.createContext({ showModal: () => Promise.resolve({action: 'CLOSE'}), closeModal: noop, + closeModalByID: noop, resolveModal: noop, }); @@ -131,11 +133,33 @@ function ModalProvider({children}: {children: React.ReactNode}) { }); }; + // Closes one named entry wherever it sits in the stack, which a caller needs when the thing its modal was asking + // about has gone away and the user never answered. Such a caller cannot use `closeModal`, because by then another + // modal may sit above it and that is the one `closeModal` would take down. + const closeModalByID: ModalContextType['closeModalByID'] = (id, data = {action: ModalActions.CLOSE}) => { + setModalStack((prevState) => { + if (!prevState.modals.some((modal) => modal.id === id)) { + return prevState; + } + + const modalPromise = modalPromisesStack.current?.[id]; + if (modalPromise) { + modalPromise.resolve(data); + delete modalPromisesStack.current[id]; + } + + return { + ...prevState, + modals: prevState.modals.filter((modal) => modal.id !== id), + }; + }); + }; + const modalToRender = modalStack.modals.length > 0 ? modalStack.modals.at(modalStack.modals.length - 1) : null; const ModalComponent = modalToRender?.component; return ( - + {children} {!!ModalComponent && ( (null); const transitionHandleRef = useRef(null); - const containerRef = useRef(null); + const containerRef = useRef | null>(null); const styles = useThemeStyles(); diff --git a/src/components/Modal/ReanimatedModal/types.ts b/src/components/Modal/ReanimatedModal/types.ts index 690154ad81eb..187a39bd9527 100644 --- a/src/components/Modal/ReanimatedModal/types.ts +++ b/src/components/Modal/ReanimatedModal/types.ts @@ -2,7 +2,7 @@ import type {FocusTrapOptions} from '@components/Modal/types'; import type CONST from '@src/CONST'; -import type {ReactNode, Ref, RefObject} from 'react'; +import type {ComponentRef, ReactNode, Ref, RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports -- type-only: the launcher union must cover every anchor shape popovers pass, including RN Text anchors import type {NativeSyntheticEvent, StyleProp, Text, View, ViewProps, ViewStyle} from 'react-native'; import type {SharedValue} from 'react-native-reanimated'; @@ -141,7 +141,7 @@ type ReanimatedModalProps = ViewProps & * The element that opened this modal, a popover's anchor. Used only when nothing held focus at activation time, * which is the case for triggers that blur themselves to avoid a focus ring (the FAB, the composer "+"). */ - launcherRef?: RefObject; + launcherRef?: RefObject | ComponentRef | HTMLElement | null>; shouldIgnoreBackHandlerDuringTransition?: boolean; }; @@ -164,7 +164,7 @@ type BackdropProps = { type ContainerProps = { /** Host node of the modal's content, used to tell whether focus is still inside this modal. */ - ref?: Ref; + ref?: Ref>; /** This function is called by open animation callback */ onOpenCallBack: () => void; diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx index 5a2a87897a58..9a23ab6d200e 100644 --- a/src/components/Modal/index.tsx +++ b/src/components/Modal/index.tsx @@ -61,8 +61,9 @@ function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = ( const [previousStatusBarColor, setPreviousStatusBarColor] = useState(); const webAnimation = getWebModalAnimation(type, isSmallScreenWidth); + const defaultStatusBarColor = theme.appBG; - const setStatusBarColor = (color = theme.appBG) => { + const setStatusBarColor = (color = defaultStatusBarColor) => { if (!fullscreen) { return; } diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index 4b04bfcd7f9c..2c3fc601c056 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -3,7 +3,7 @@ import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type CONST from '@src/CONST'; import type {FocusTrapProps} from 'focus-trap-react'; -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; import type {View, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -114,7 +114,7 @@ type BaseModalProps = Partial & */ shouldDisableBottomSafeAreaPadding?: boolean; - ref?: ForwardedRef; + ref?: ForwardedRef>; /** * Whether the modal should display under the side panel. diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 017af6e8975c..6485bf8e97ab 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -17,7 +17,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import {useRoute} from '@react-navigation/native'; +import {useIsFocused, useRoute} from '@react-navigation/native'; import React, {useEffect} from 'react'; import {View} from 'react-native'; @@ -91,6 +91,7 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt const shouldShowBackButton = shouldDisplayBackButton || shouldUseNarrowLayout; const isMobileSelectionModeEnabled = useMobileSelectionMode(); + const isFocused = useIsFocused(); useEffect(() => { return () => { @@ -99,9 +100,11 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt }, []); if (isMobileSelectionModeEnabled && shouldUseNarrowLayout) { - // If mobile selection mode is enabled but only one or no transactions remain, turn it off + // If mobile selection mode is enabled but only one or no transactions remain, turn it off. The selection mode + // is shared with every screen, so this report only gets to turn it off while it is the focused one. Another + // screen on top of it, such as the add existing expense modal, owns the mode for as long as it is open. const visibleTransactions = transactions.filter((t) => t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline); - if (visibleTransactions.length <= 1) { + if (isFocused && visibleTransactions.length <= 1) { turnOffMobileSelectionMode(); } diff --git a/src/components/MoneyRequestConfirmationList/sections/DescriptionField.tsx b/src/components/MoneyRequestConfirmationList/sections/DescriptionField.tsx index 6c38d0d39377..5c30f6beff69 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DescriptionField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DescriptionField.tsx @@ -24,6 +24,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; +import type {ComponentRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import React, {useRef} from 'react'; @@ -44,7 +45,7 @@ function DescriptionField({isDescriptionRequired, policy}: DescriptionFieldProps const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); // Ref on the field's outer container (the bordered box), so scrolling brings the whole field — including its // top border and label — into view rather than just the inner text area. - const fieldContainerRef = useRef(null); + const fieldContainerRef = useRef>(null); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); diff --git a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx index a209e7fd0c03..3da8267816dd 100644 --- a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx +++ b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx @@ -1,6 +1,7 @@ import ScrollView from '@components/ScrollView'; import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; +import type {ComponentRef} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, ScrollViewProps} from 'react-native'; // Deliberately not the @components/FlashList wrapper: it doesn't type `ref` (this list needs one for layout reads), @@ -51,8 +52,8 @@ type MinimalScrollRef = { scrollTo: () => void; scrollToEnd: () => void; flashScrollIndicators: () => void; - getScrollableNode: () => View | null; - getNativeScrollRef: () => View | null; + getScrollableNode: () => ComponentRef | null; + getNativeScrollRef: () => ComponentRef | null; }; // `store` and `offsetTop` are injected at runtime by FlashList via `overrideProps`, never by FlashList's own typed @@ -79,7 +80,7 @@ type ExternalScrollDriverProps = Omit & { * FlashList memoizes its scroll component on identity. */ function ExternalScrollDriver({store, offsetTop = 0, onScroll, children, style, ref}: ExternalScrollDriverProps) { - const nodeRef = useRef(null); + const nodeRef = useRef>(null); useImperativeHandle( ref, diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index b57e48a0eea7..9043769883ad 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -1,4 +1,3 @@ -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; import useLocalize from '@hooks/useLocalize'; import useMarkAsRead from '@hooks/useMarkAsRead'; @@ -8,10 +7,8 @@ import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useParentReportAction from '@hooks/useParentReportAction'; import useReportIsArchived from '@hooks/useReportIsArchived'; -import useReportScrollManager from '@hooks/useReportScrollManager'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; -import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessageReceived'; import useThemeStyles from '@hooks/useThemeStyles'; import useUnreadMarker from '@hooks/useUnreadMarker'; @@ -21,7 +18,6 @@ import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import REPORT_LINK_ROUTE_PARAMS from '@libs/Navigation/reportLinkRouteParams'; -import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {getFilteredReportActionsForReportView, getLatestConciergeFeedbackActionID, getOneTransactionThreadReportID, hasNextActionMadeBySameActor} from '@libs/ReportActionsUtils'; @@ -36,16 +32,11 @@ import { import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import ConciergeThinkingMessage from '@pages/home/report/ConciergeThinkingMessage'; -import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListContext'; -import {useAgentZeroStatus} from '@pages/inbox/AgentZeroStatusContext'; +import {useActionListRef} from '@pages/inbox/ActionListContext'; import {useConciergeDraft} from '@pages/inbox/ConciergeDraftContext'; import FloatingMessageCounter from '@pages/inbox/report/FloatingMessageCounter'; import {ReportActionPositionContextProvider, ReportActionScrollToNewestContext} from '@pages/inbox/report/ReportActionIndexContext'; import ReportActionsListItemRenderer from '@pages/inbox/report/ReportActionsListItemRenderer'; -import useReportUnreadMessageScrollTracking from '@pages/inbox/report/useReportUnreadMessageScrollTracking'; - -import {openReport} from '@userActions/Report'; -import {subscribeToNewActionEvent} from '@userActions/Report/reportActionSubscribers'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -54,10 +45,9 @@ import {getStableReportSelector} from '@src/selectors/Report'; import {pendingNewTransactionIDsSelector} from '@src/selectors/ReportMetaData'; import type * as OnyxTypes from '@src/types/onyx'; -import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import type {LayoutChangeEvent} from 'react-native'; import {useIsFocused, useRoute} from '@react-navigation/native'; -import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -66,6 +56,7 @@ import MoneyRequestReportEmptyStateView from './MoneyRequestReportEmptyStateView import MoneyRequestReportTransactionList from './MoneyRequestReportTransactionList'; import SelectionToolbar from './SelectionToolbar'; import useMoneyRequestReportPagination from './useMoneyRequestReportPagination'; +import useMoneyRequestReportScroll from './useMoneyRequestReportScroll'; import useMoneyRequestReportVisibleActions from './useMoneyRequestReportVisibleActions'; /** @@ -73,9 +64,6 @@ import useMoneyRequestReportVisibleActions from './useMoneyRequestReportVisibleA */ const EmptyParentReportActionForTransactionThread = undefined; -// Amount of time to wait until all list items should be rendered and scrollToEnd will behave well -const DELAY_FOR_SCROLLING_TO_END = 100; - type MoneyRequestReportListProps = { onLayout?: (event: LayoutChangeEvent) => void; }; @@ -85,29 +73,16 @@ type MoneyRequestReportActionsListContentProps = MoneyRequestReportListProps & { reportIDFromRoute: string | undefined; }; +/** + * Renders the money-request report's unified list (transactions table + report actions). Composes the + * view's data/behavior hooks (`useMoneyRequestReportVisibleActions` / `useMoneyRequestReportPagination` / + * `useMoneyRequestReportScroll`) with the hooks shared with the chat list (`useUnreadMarker` / `useMarkAsRead`). + * Mounted with `key={reportID}` by the wrapper below, so all hook state resets on report switch. + */ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: MoneyRequestReportActionsListContentProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const reportScrollManager = useReportScrollManager(); - // The unified list writes its last item index here (see lastItemIndexRef prop). We jump to the bottom via - // scrollToIndex rather than scrollToEnd: scrollToEnd targets an estimated content-end offset, which on a large - // list (hundreds of transactions + chat) leaves the bottom blank until it renders/corrects. scrollToIndex - // targets the last item directly and renders around it, so the landing is not blank. - const lastItemIndexRef = useRef(0); - const updateLastItemIndex = useCallback((index: number) => { - lastItemIndexRef.current = index; - }, []); - - const scrollToBottom = useCallback(() => { - if (lastItemIndexRef.current < 0) { - return; - } - - reportScrollManager.scrollToIndex(lastItemIndexRef.current, {animated: false, viewPosition: 1}); - }, [reportScrollManager]); - - const didLayout = useRef(false); const isFocused = useIsFocused(); const {shouldUseNarrowLayout} = useResponsiveLayoutOnWideRHP(); // The table is visible whenever it's wide, or — on narrow — only when focused (the RHP has closed). @@ -149,29 +124,22 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon const reportTransactionIDs = useMemo(() => transactions.map((transaction) => transaction.transactionID), [transactions]); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.chatReportID)}`); - // Opened from the "X Replies" link: land on the latest message instead of the default top of the report. + // Set when the report is opened from an "X Replies" link, which should land on the latest message const shouldScrollToLatestOnOpen = route?.params?.[REPORT_LINK_ROUTE_PARAMS.SHOULD_SCROLL_TO_LATEST] === 'true'; const scrolledToLatestOnOpenForReportIDRef = useRef(undefined); const parentReportAction = useParentReportAction(report); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [betas] = useOnyx(ONYXKEYS.BETAS); - const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); - const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], false, reportTransactionIDs); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); - const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const isReportArchived = useReportIsArchived(reportID); const canPerformWriteAction = canUserPerformWriteAction(report, isReportArchived); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${getNonEmptyStringOnyxID(reportID)}`); const shouldShowHarvestCreatedAction = isHarvestCreatedExpenseReport(reportNameValuePairs?.origin, reportNameValuePairs?.originalID); - const [enableScrollToEnd, setEnableScrollToEnd] = useState(false); - const [lastActionEventId, setLastActionEventId] = useState(''); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); const {visibleReportActions, visibleReportActionsNewestFirst, lastAction, firstVisibleReportActionID} = useMoneyRequestReportVisibleActions({ @@ -183,14 +151,9 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon isOffline, }); - const {scrollOffsetRef} = useActionListContext(); const listRef = useActionListRef(); + const didLayout = useRef(false); - const scrollingVerticalBottomOffset = useRef(0); - const stickToBottomRef = useRef(false); - const stickToBottomTimeoutRef = useRef(null); - // Set when the user taps "Latest messages"; the report is marked as read only once the scroll actually reaches the bottom. - const pendingMarkAsReadRef = useRef(false); const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, transactionThreadReport); const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated; @@ -221,16 +184,6 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon }); const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(false); - const listLayoutHeightRef = useRef(0); - const listContentHeightRef = useRef(0); - const listScrollYRef = useRef(0); - - const syncBottomOffset = () => { - const bottomOffset = listContentHeightRef.current - listLayoutHeightRef.current - listScrollYRef.current; - scrollingVerticalBottomOffset.current = bottomOffset; - scrollOffsetRef.current = bottomOffset; - setHasScrolledOverThreshold(bottomOffset >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - }; const {unreadMarkerReportActionID, unreadMarkerReportActionIndex} = useUnreadMarker({ reportID: reportID ?? reportIDFromRoute ?? '', @@ -254,130 +207,41 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon shouldRequireScreenFocus: true, }); - const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ - reportID: reportID ?? reportIDFromRoute ?? '', - currentVerticalScrollingOffsetRef: scrollingVerticalBottomOffset, - onUnreadActionVisible: completeSkippedMarkAsRead, - unreadMarkerReportActionIndex, - isInverted: false, - hasNewerActions, + const { + isFloatingMessageCounterVisible, + trackVerticalScrolling, + onViewableItemsChanged, + scrollToLatestMessages, + onListContentSizeChange, + onListScrollBeginDrag, + updateLastItemIndex, + scrollToBottom, + onListLayout: syncBottomOffsetFromLayout, + } = useMoneyRequestReportScroll({ + reportID, shouldBeAlignedToTop: shouldReportAlignToTop(report, parentReportAction), - onTrackScrolling: (event: NativeSyntheticEvent) => { - const {layoutMeasurement, contentSize, contentOffset} = event.nativeEvent; - - /** - * Count the diff between current scroll position and the bottom of the list. - * Diff == (height of all items in the list) - (height of the layout with the list) - (how far user scrolled) - */ - listContentHeightRef.current = contentSize.height; - listLayoutHeightRef.current = layoutMeasurement.height; - listScrollYRef.current = contentOffset.y; - syncBottomOffset(); - - // Mark the report as read only once the scroll has actually reached the bottom. The jump fired by - // "Latest messages" settles over several frames as deferred items hydrate, so we wait for the real end. - if (pendingMarkAsReadRef.current && scrollingVerticalBottomOffset.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD) { - pendingMarkAsReadRef.current = false; - markNewestActionAsRead(); - } - }, - }); - - useScrollToEndOnNewMessageReceived({ - sizeChangeType: 'grewFromReportActions', - scrollOffsetRef, - lastActionID: lastAction?.reportActionID, - visibleActionsLength: visibleReportActions.length, + resetKey: reportID ?? reportIDFromRoute ?? '', + visibleReportActions, reportActionsLength: reportActions.length, + lastAction, hasNewestReportAction, - setIsFloatingMessageCounterVisible, - scrollToEnd: scrollToBottom, - resetKey: reportID ?? reportIDFromRoute ?? '', + hasNewerActions, + unreadMarkerReportActionIndex, + onScrolledOverThresholdChange: setHasScrolledOverThreshold, + markNewestActionAsRead, + completeSkippedMarkAsRead, }); - // The indicator renders in the list footer, below the row scrollToBottom targets, so only - // scrollToEnd reveals it. This list is not inverted, so nothing sticks to the bottom for us. - const {candidateAgentIDs} = useAgentZeroStatus(); - const isThinkingIndicatorVisible = candidateAgentIDs.length > 0; - // Scroll once per appearance: the label changes many times per run, and re-firing would yank - // the viewport away from a user who has since scrolled up. - const hasScrolledForThinkingIndicatorRef = useRef(false); + // Wait for the actions to arrive before jumping, and clear the route param afterwards so a later remount + // doesn't yank the user back down. useEffect(() => { - if (!isThinkingIndicatorVisible) { - hasScrolledForThinkingIndicatorRef.current = false; - return; - } - if (hasScrolledForThinkingIndicatorRef.current || scrollingVerticalBottomOffset.current >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD) { - return; - } - hasScrolledForThinkingIndicatorRef.current = true; - - // Wait for the footer to lay out, otherwise the content hasn't grown yet and there is - // nothing to scroll to. - const timeoutID = setTimeout(() => { - reportScrollManager.scrollToEnd(); - }, DELAY_FOR_SCROLLING_TO_END); - - return () => clearTimeout(timeoutID); - }, [isThinkingIndicatorVisible, reportScrollManager]); - - const scrollToBottomForCurrentUserAction = useCallback( - (isFromCurrentUser: boolean, reportAction?: OnyxTypes.ReportAction) => { - TransitionTracker.runAfterTransitions({ - callback: () => { - setIsFloatingMessageCounterVisible(false); - // If a new comment is added from the current user, scroll to the bottom, otherwise leave the user position unchanged - if (!isFromCurrentUser || reportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT) { - return; - } - - // We want to scroll to the end of the list where the newest message is. We route through the indexed - // scrollToBottom (scrollToIndex) rather than scrollToEnd because scrollToEnd targets an estimated - // content-end offset that leaves the bottom blank on large transaction+chat lists. We still delay so - // the just-sent item has landed in the data before we jump. - const index = visibleReportActions.findIndex((item) => item.reportActionID === reportAction?.reportActionID); - if (index !== -1) { - setTimeout(() => { - scrollToBottom(); - }, DELAY_FOR_SCROLLING_TO_END); - } else { - setEnableScrollToEnd(true); - setLastActionEventId(reportAction?.reportActionID); - } - }, - }); - }, - [scrollToBottom, setIsFloatingMessageCounterVisible, visibleReportActions], - ); - - useEffect(() => { - if (!report?.reportID) { + if (!shouldScrollToLatestOnOpen || scrolledToLatestOnOpenForReportIDRef.current === reportIDFromRoute || visibleReportActions.length === 0) { return; } - // This callback is triggered when a new action arrives via Pusher and the event is emitted from Report.ts. This allows us to maintain - // a single source of truth for the "new action" event instead of trying to derive that a new action has appeared from looking at props. - const unsubscribe = subscribeToNewActionEvent(report.reportID, scrollToBottomForCurrentUserAction); - - return () => { - if (!unsubscribe) { - return; - } - unsubscribe(); - }; - - // This effect handles subscribing to events, so we only want to run it on mount, and in case reportID changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [report?.reportID]); - - useEffect(() => { - const index = visibleReportActions.findIndex((item) => item.reportActionID === lastActionEventId); - if (enableScrollToEnd && index !== -1) { - setTimeout(() => { - scrollToBottom(); - }, DELAY_FOR_SCROLLING_TO_END); - setEnableScrollToEnd(false); - } - }, [visibleReportActions, lastActionEventId, enableScrollToEnd, scrollToBottom]); + scrolledToLatestOnOpenForReportIDRef.current = reportIDFromRoute; + scrollToLatestMessages(); + Navigation.setParams({[REPORT_LINK_ROUTE_PARAMS.SHOULD_SCROLL_TO_LATEST]: undefined}); + }, [shouldScrollToLatestOnOpen, visibleReportActions.length, scrollToLatestMessages, reportIDFromRoute]); const renderReportAction = useCallback( (reportAction: OnyxTypes.ReportAction, indexWithinReportActions: number) => { @@ -437,92 +301,6 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon [draftReportActionID, isDraftPendingCompletion, latestConciergeFeedbackActionID], ); - const scrollToLatestMessages = useCallback(() => { - setIsFloatingMessageCounterVisible(false); - - stickToBottomRef.current = true; - if (stickToBottomTimeoutRef.current) { - clearTimeout(stickToBottomTimeoutRef.current); - } - // Safety net: stop pinning after deferred content has had time to settle, so a much later - // unrelated layout change doesn't yank the user back down. - stickToBottomTimeoutRef.current = setTimeout(() => { - stickToBottomRef.current = false; - }, 2000); - - if (!hasNewestReportAction) { - openReport({ - reportID, - introSelected, - conciergeChat, - betas, - hasReportActions: true, - currentUserAccountID, - isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, - hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, - }); - scrollToBottom(); - return; - } - - // Defer marking the report as read until the scroll actually reaches the bottom (handled in onTrackScrolling). - pendingMarkAsReadRef.current = true; - scrollToBottom(); - }, [ - setIsFloatingMessageCounterVisible, - hasNewestReportAction, - scrollToBottom, - reportID, - introSelected, - conciergeChat, - betas, - currentUserAccountID, - guidedSetupAndTourStatus?.isSelfTourViewed, - guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, - ]); - - useEffect(() => { - return () => { - if (!stickToBottomTimeoutRef.current) { - return; - } - clearTimeout(stickToBottomTimeoutRef.current); - }; - }, []); - - // When the report is opened from the "X Replies" link, scroll to the latest message once the actions are - // available (this list otherwise opens at the top). scrollToLatestMessages pins to the bottom while the - // deferred content settles, mirroring the floating "new messages" button. We clear the route param afterwards - // so a later re-render or remount doesn't yank the user back down. - useEffect(() => { - if (!shouldScrollToLatestOnOpen || scrolledToLatestOnOpenForReportIDRef.current === reportIDFromRoute || visibleReportActions.length === 0) { - return; - } - scrolledToLatestOnOpenForReportIDRef.current = reportIDFromRoute; - scrollToLatestMessages(); - Navigation.setParams({[REPORT_LINK_ROUTE_PARAMS.SHOULD_SCROLL_TO_LATEST]: undefined}); - }, [shouldScrollToLatestOnOpen, visibleReportActions.length, scrollToLatestMessages, reportIDFromRoute]); - - const onListContentSizeChange = (_width: number, height: number) => { - listContentHeightRef.current = height; - syncBottomOffset(); - - if (!stickToBottomRef.current) { - return; - } - scrollToBottom(); - }; - - const onListScrollBeginDrag = () => { - stickToBottomRef.current = false; - // The user scrolled away before reaching the bottom, so cancel the pending read. - pendingMarkAsReadRef.current = false; - if (stickToBottomTimeoutRef.current) { - clearTimeout(stickToBottomTimeoutRef.current); - stickToBottomTimeoutRef.current = null; - } - }; - /** * Runs when the FlatList finishes laying out */ @@ -537,8 +315,7 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon }, [reportIDFromRoute, report]); const onListLayout = (event: LayoutChangeEvent) => { - listLayoutHeightRef.current = event.nativeEvent.layout.height; - syncBottomOffset(); + syncBottomOffsetFromLayout(event); recordTimeToMeasureItemLayout(); }; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 6963cca5807a..bdafa2236b50 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -22,19 +22,10 @@ import {resolveTransactionCardFields} from '@libs/CardUtils'; import {navigationRef} from '@libs/Navigation/Navigation'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {groupTransactionsByCategory, groupTransactionsByTag} from '@libs/ReportLayoutUtils'; -import { - getActionErrorsByTransaction, - getMoneyRequestSpendBreakdown, - getReportOfflinePendingActionAndErrors, - getTransactionSortValue, - isExpenseReport, - isIOUReport, - isSortableColumnName, -} from '@libs/ReportUtils'; +import {getMoneyRequestSpendBreakdown, getReportOfflinePendingActionAndErrors, getTransactionSortValue, isExpenseReport, isIOUReport, isSortableColumnName} from '@libs/ReportUtils'; import type {SortableColumnName} from '@libs/ReportUtils'; import {compareValues} from '@libs/SearchUIUtils'; import {getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; -import {transactionHasRBR} from '@libs/TransactionPreviewUtils'; import {getTransactionPendingAction, getVisibleTransactionViolations, isTransactionPendingDelete} from '@libs/TransactionUtils'; import isReportOpenInSuperWideRHP from '@navigation/helpers/isReportOpenInSuperWideRHP'; @@ -48,8 +39,9 @@ import SCREENS from '@src/SCREENS'; import type {StableReport} from '@src/selectors/Report'; import type * as OnyxTypes from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {ViewableItemsChanged} from '@src/types/utils/ReactNativeCompat'; -import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native'; +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; import {findFocusedRoute, useFocusEffect} from '@react-navigation/native'; import {personalDetailsLoginSelector} from '@selectors/PersonalDetails'; @@ -201,7 +193,7 @@ type MoneyRequestReportTransactionListProps = { onContentSizeChange: (width: number, height: number) => void; /** FlashList onViewableItemsChanged callback. */ - onViewableItemsChanged: (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => void; + onViewableItemsChanged: ViewableItemsChanged; /** FlashList onEndReached callback. */ onEndReached: () => void; @@ -364,18 +356,13 @@ function MoneyRequestReportTransactionList({ }); const {sortBy, sortOrder} = sortConfig; - const isDefaultSort = sortBy === CONST.SEARCH.TABLE_COLUMNS.DATE && sortOrder === CONST.SEARCH.SORT_ORDER.ASC; - - // In a single pass over reportActions, build: - // - reportActionsMap: keyed by reportActionID for transactionHasRBR. - // - transactionThreadReportIDByTransactionID: transactionID → transaction-thread report ID, so each row can pass it - // to the RBR, letting rows without RBR content early-return instead of mounting the heavy RBR inner (6 Onyx - // subscriptions). Without this, the per-row alternative would re-scan every report action (O(transactions × actions)). - const {reportActionsMap, transactionThreadReportIDByTransactionID} = useMemo(() => { - const actionsMap: Record = {}; + + // Maps each transactionID to its transaction-thread report ID, so each row can pass it to the RBR, letting rows + // without RBR content early-return instead of mounting the heavy RBR inner (6 Onyx subscriptions). Without this, + // the per-row alternative would re-scan every report action (O(transactions x actions)). + const transactionThreadReportIDByTransactionID = useMemo(() => { const threadReportIDByTransactionID = new Map(); for (const action of reportActions) { - actionsMap[action.reportActionID] = action; if (!isMoneyRequestAction(action)) { continue; } @@ -385,49 +372,21 @@ function MoneyRequestReportTransactionList({ threadReportIDByTransactionID.set(iouTransactionID, action.childReportID); } } - return {reportActionsMap: actionsMap, transactionThreadReportIDByTransactionID: threadReportIDByTransactionID}; + return threadReportIDByTransactionID; }, [reportActions]); - // Precompute the set of RBR-flagged transaction IDs - const rbrTransactionIDs = useMemo(() => { - if (!isDefaultSort || !allTransactionViolations) { - return null; - } - const login = currentUserDetails?.login ?? ''; - const accountID = currentUserDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID; - // Precompute report-action errors once so each transaction's RBR check is an O(1) lookup instead of - // re-scanning every report action (O(transactions × actions)). - const actionErrors = getActionErrorsByTransaction(report?.reportID, reportActionsMap); - const ids = new Set(); - for (const transaction of transactions) { - const violations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`] ?? []; - if (transactionHasRBR(transaction, violations, login, accountID, report, ownerLogin, policy, reportActionsMap, actionErrors)) { - ids.add(transaction.transactionID); - } - } - return ids; - }, [isDefaultSort, allTransactionViolations, currentUserDetails?.login, currentUserDetails?.accountID, transactions, report, ownerLogin, policy, reportActionsMap]); - const sortedTransactions: TransactionWithOptionalHighlight[] = useMemo(() => { - return [...transactions].sort((a, b) => { - // When on default sort (Date/ASC), prioritize RBR-flagged transactions - if (rbrTransactionIDs) { - const aHasRBR = rbrTransactionIDs.has(a.transactionID); - const bHasRBR = rbrTransactionIDs.has(b.transactionID); - if (aHasRBR !== bHasRBR) { - return aHasRBR ? -1 : 1; - } - } - return compareValues( + return [...transactions].sort((a, b) => + compareValues( getTransactionSortValue(a, sortBy, report, policy, policyCategories, policyTagLists), getTransactionSortValue(b, sortBy, report, policy, policyCategories, policyTagLists), sortOrder, sortBy, localeCompare, true, - ); - }); - }, [sortBy, sortOrder, transactions, localeCompare, report, policy, policyCategories, policyTagLists, rbrTransactionIDs]); + ), + ); + }, [sortBy, sortOrder, transactions, localeCompare, report, policy, policyCategories, policyTagLists]); const resolvedTransactions = useMemo(() => resolveTransactionCardFields(sortedTransactions, cardList, translate), [sortedTransactions, cardList, translate]); diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index 81368bb1bada..f5fecbe0ce24 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -6,9 +6,10 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import variables from '@styles/variables'; import type * as OnyxTypes from '@src/types/onyx'; +import type {ViewableItemsChanged, ViewToken} from '@src/types/utils/ReactNativeCompat'; import type {FlashListProps, ListRenderItemInfo} from '@shopify/flash-list'; -import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native'; +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; import React, {memo, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -103,7 +104,7 @@ type MoneyRequestReportUnifiedListProps = { onScrollBeginDrag: () => void; onContentSizeChange: (width: number, height: number) => void; - onViewableItemsChanged: (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => void; + onViewableItemsChanged: ViewableItemsChanged; /** Called when the end of the list is reached (older actions). */ onEndReached: () => void; @@ -220,7 +221,7 @@ function MoneyRequestReportUnifiedList({ // The hook compares unreadMarkerReportActionIndex (0-based within visibleReportActions) against // raw FlashList indices. When transactions are present, report actions start at reportActionIndexOffset, // so we shift all viewable indices down before forwarding so the comparison is apples-to-apples. - const onViewableItemsChangedAdjusted = (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { + const onViewableItemsChangedAdjusted = (info: Parameters[0]) => { // Keep the raw array so the new-transaction effect can tell whether the new row is already on screen. viewableItemsRef.current = info.viewableItems; if (reportActionIndexOffset === 0) { @@ -229,7 +230,7 @@ function MoneyRequestReportUnifiedList({ } onViewableItemsChanged({ ...info, - viewableItems: info.viewableItems.map((item) => ({...item, index: item.index !== null ? item.index - reportActionIndexOffset : null})), + viewableItems: info.viewableItems.map((item) => ({...item, index: typeof item.index === 'number' ? item.index - reportActionIndexOffset : item.index})), }); }; diff --git a/src/components/MoneyRequestReportView/useMoneyRequestReportColumns.ts b/src/components/MoneyRequestReportView/useMoneyRequestReportColumns.ts index e186d9dd03db..c7f366959146 100644 --- a/src/components/MoneyRequestReportView/useMoneyRequestReportColumns.ts +++ b/src/components/MoneyRequestReportView/useMoneyRequestReportColumns.ts @@ -74,9 +74,9 @@ function useMoneyRequestReportColumns({report, policy, transactions, reportActio const isExpenseReportViewFromIOUReport = isIOUReport(report); const shouldShowBillableColumn = isBillableEnabledOnPolicy(policy); const shouldShowCommentsColumn = Object.values(reportActions ?? {}).some((action) => (action?.childVisibleActionCount ?? 0) > 0); - // The saved column list is account-wide, so drop the vendor column on reports whose workspace lacks the vendor feature. + // The saved column list is account-wide, so the vendor column only shows on reports whose workspace has the vendor feature. const isVendorColumnAvailable = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)); - const savedColumns = (reportDetailsColumns ?? []).filter(isReportDetailsCustomColumn).filter((column) => isVendorColumnAvailable || column !== CONST.SEARCH.TABLE_COLUMNS.VENDOR); + const savedColumns = (reportDetailsColumns ?? []).filter(isReportDetailsCustomColumn); const columnsToShow = getColumnsToShow({ currentAccountID: currentUserDetails?.accountID, data: transactions, @@ -89,6 +89,7 @@ function useMoneyRequestReportColumns({report, policy, transactions, reportActio shouldShowReimbursableColumn: hasNonReimbursableTransactions(transactions), reportCurrency: report?.currency, isPolicyTaxEnabled: isPolicyTaxEnabled(policy), + isVendorColumnAvailable, }); const isAmountColumnWide = transactions.some((transaction) => isTransactionAmountTooLong(transaction)); diff --git a/src/components/MoneyRequestReportView/useMoneyRequestReportScroll.ts b/src/components/MoneyRequestReportView/useMoneyRequestReportScroll.ts new file mode 100644 index 000000000000..06c4aaaf56c0 --- /dev/null +++ b/src/components/MoneyRequestReportView/useMoneyRequestReportScroll.ts @@ -0,0 +1,364 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useOnyx from '@hooks/useOnyx'; +import useReportScrollManager from '@hooks/useReportScrollManager'; +import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessageReceived'; + +import TransitionTracker from '@libs/Navigation/TransitionTracker'; + +import {useActionListContext} from '@pages/inbox/ActionListContext'; +import {useAgentZeroStatus} from '@pages/inbox/AgentZeroStatusContext'; +import useReportUnreadMessageScrollTracking from '@pages/inbox/report/useReportUnreadMessageScrollTracking'; + +import {openReport} from '@userActions/Report'; +import {subscribeToNewActionEvent} from '@userActions/Report/reportActionSubscribers'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ViewToken} from 'react-native'; + +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; +import {useEffect, useEffectEvent, useRef} from 'react'; + +// Amount of time to wait until all list items should be rendered and scrollToEnd will behave well +const DELAY_FOR_SCROLLING_TO_END = 100; + +// How long to keep pinning the list to the bottom after "Latest messages", long enough for deferred content to +// settle. Past that, an unrelated layout change shouldn't yank the user back down. +const STICK_TO_BOTTOM_DURATION_MS = 2000; + +type UseMoneyRequestReportScrollParams = { + reportID: string | undefined; + + /** Whether the report's list is aligned to the top instead of the bottom */ + shouldBeAlignedToTop: boolean; + + /** Key that resets the new-message autoscroll tracking, usually the reportID */ + resetKey: string; + + /** Actions rendered in the unified list, oldest-first */ + visibleReportActions: OnyxTypes.ReportAction[]; + + /** Number of paginated report actions (pre-filter) */ + reportActionsLength: number; + + /** The newest visible action */ + lastAction: OnyxTypes.ReportAction | undefined; + + /** Whether the newest report action is already loaded */ + hasNewestReportAction: boolean; + + /** Whether newer actions exist beyond the loaded page */ + hasNewerActions: boolean; + + /** Index of the unread marker within the rendered (oldest-first) actions, or -1 */ + unreadMarkerReportActionIndex: number; + + /** Tells the caller whether the list is scrolled further from the bottom than the action-visible threshold */ + onScrolledOverThresholdChange: (isScrolledOverThreshold: boolean) => void; + + /** Marks the newest action as read (from `useMarkAsRead`) */ + markNewestActionAsRead: () => void; + + /** Completes a previously skipped mark-as-read (from `useMarkAsRead`) */ + completeSkippedMarkAsRead: () => void; +}; + +type UseMoneyRequestReportScrollResult = { + /** Whether the "Latest messages" pill should show */ + isFloatingMessageCounterVisible: boolean; + + /** FlashList onScroll handler */ + trackVerticalScrolling: (event: NativeSyntheticEvent | undefined) => void; + + /** FlashList onViewableItemsChanged handler */ + onViewableItemsChanged: (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => void; + + /** "Latest messages" pill click handler. Scrolls to the bottom and marks the report as read once it lands */ + scrollToLatestMessages: () => void; + + /** Jumps the list to its last item, the way action items reach the newest message */ + scrollToBottom: () => void; + + /** FlashList onContentSizeChange handler. Refreshes the bottom offset and keeps the list pinned while stick-to-bottom is active */ + onListContentSizeChange: (width: number, height: number) => void; + + /** List onLayout handler. Refreshes the bottom offset from the laid-out list height */ + onListLayout: (event: LayoutChangeEvent) => void; + + /** FlashList onScrollBeginDrag handler. Cancels stick-to-bottom and any pending mark-as-read */ + onListScrollBeginDrag: () => void; + + /** Receives the unified list's last item index */ + updateLastItemIndex: (index: number) => void; +}; + +/** + * Owns the scroll behavior of the money-request report view's unified list: bottom-offset tracking, the "Latest + * messages" pill, scroll-to-bottom on own/new messages, stick-to-bottom while deferred content settles, and the + * AgentZero thinking-indicator reveal. + * + * Unlike the chat list, this list is not inverted. Everything here is bottom-offset math over a normal list. + */ +function useMoneyRequestReportScroll({ + reportID, + shouldBeAlignedToTop, + resetKey, + visibleReportActions, + reportActionsLength, + lastAction, + hasNewestReportAction, + hasNewerActions, + unreadMarkerReportActionIndex, + onScrolledOverThresholdChange, + markNewestActionAsRead, + completeSkippedMarkAsRead, +}: UseMoneyRequestReportScrollParams): UseMoneyRequestReportScrollResult { + const reportScrollManager = useReportScrollManager(); + const {scrollOffsetRef} = useActionListContext(); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + + // The unified list reports its last item index through updateLastItemIndex + const lastItemIndexRef = useRef(0); + const updateLastItemIndex = (index: number) => { + lastItemIndexRef.current = index; + }; + + // scrollToEnd targets an estimated content-end offset, which on a list this large leaves the bottom blank until + // it renders and corrects. scrollToIndex targets the last item and renders around it, so the landing is never blank. + const scrollToBottom = () => { + if (lastItemIndexRef.current < 0) { + return; + } + + reportScrollManager.scrollToIndex(lastItemIndexRef.current, {animated: false, viewPosition: 1}); + }; + + const listLayoutHeightRef = useRef(0); + const listContentHeightRef = useRef(0); + const listScrollYRef = useRef(0); + const scrollingVerticalBottomOffset = useRef(0); + + const syncBottomOffset = () => { + const bottomOffset = listContentHeightRef.current - listLayoutHeightRef.current - listScrollYRef.current; + scrollingVerticalBottomOffset.current = bottomOffset; + scrollOffsetRef.current = bottomOffset; + onScrolledOverThresholdChange(bottomOffset >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + }; + + const stickToBottomRef = useRef(false); + const stickToBottomTimeoutRef = useRef(null); + // Set when the user taps "Latest messages", cleared once the scroll reaches the bottom or the user scrolls away + const pendingMarkAsReadRef = useRef(false); + + const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ + reportID: resetKey, + currentVerticalScrollingOffsetRef: scrollingVerticalBottomOffset, + onUnreadActionVisible: completeSkippedMarkAsRead, + unreadMarkerReportActionIndex, + isInverted: false, + hasNewerActions, + shouldBeAlignedToTop, + onTrackScrolling: (event: NativeSyntheticEvent) => { + const {layoutMeasurement, contentSize, contentOffset} = event.nativeEvent; + listContentHeightRef.current = contentSize.height; + listLayoutHeightRef.current = layoutMeasurement.height; + listScrollYRef.current = contentOffset.y; + syncBottomOffset(); + + // The jump fired by "Latest messages" settles over several frames as deferred items hydrate, so the + // report is marked as read only once the scroll really lands here. + if (pendingMarkAsReadRef.current && scrollingVerticalBottomOffset.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD) { + pendingMarkAsReadRef.current = false; + markNewestActionAsRead(); + } + }, + }); + + useScrollToEndOnNewMessageReceived({ + sizeChangeType: 'grewFromReportActions', + scrollOffsetRef, + lastActionID: lastAction?.reportActionID, + visibleActionsLength: visibleReportActions.length, + reportActionsLength, + hasNewestReportAction, + setIsFloatingMessageCounterVisible, + scrollToEnd: scrollToBottom, + resetKey, + }); + + // The indicator renders in the list footer, below the row scrollToBottom targets, so only scrollToEnd reveals it + const {candidateAgentIDs} = useAgentZeroStatus(); + const isThinkingIndicatorVisible = candidateAgentIDs.length > 0; + // Scroll once per appearance, because the label changes many times per run and re-firing would yank the viewport + const hasScrolledForThinkingIndicatorRef = useRef(false); + useEffect(() => { + if (!isThinkingIndicatorVisible) { + hasScrolledForThinkingIndicatorRef.current = false; + return; + } + if (hasScrolledForThinkingIndicatorRef.current || scrollingVerticalBottomOffset.current >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD) { + return; + } + hasScrolledForThinkingIndicatorRef.current = true; + + // Wait for the footer to lay out, otherwise there is nothing to scroll to yet + const timeoutID = setTimeout(() => { + reportScrollManager.scrollToEnd(); + }, DELAY_FOR_SCROLLING_TO_END); + + return () => clearTimeout(timeoutID); + }, [isThinkingIndicatorVisible, reportScrollManager]); + + // When the just-sent action hasn't landed in the visible data yet, remember it and scroll once it does + const pendingScrollToActionIDRef = useRef(null); + const ownActionScrollTimeoutRef = useRef(null); + + // Effect Event so the Pusher subscription below can subscribe once per report and still read the latest actions + const onNewActionEvent = useEffectEvent((isFromCurrentUser: boolean, reportAction?: OnyxTypes.ReportAction) => { + TransitionTracker.runAfterTransitions({ + callback: () => { + setIsFloatingMessageCounterVisible(false); + // If a new comment is added from the current user, scroll to the bottom, otherwise leave the user position unchanged + if (!isFromCurrentUser || reportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT) { + return; + } + + // Delay so the just-sent item has landed in the data before we jump + const index = visibleReportActions.findIndex((item) => item.reportActionID === reportAction?.reportActionID); + if (index !== -1) { + if (ownActionScrollTimeoutRef.current) { + clearTimeout(ownActionScrollTimeoutRef.current); + } + ownActionScrollTimeoutRef.current = setTimeout(() => { + ownActionScrollTimeoutRef.current = null; + scrollToBottom(); + }, DELAY_FOR_SCROLLING_TO_END); + } else { + pendingScrollToActionIDRef.current = reportAction?.reportActionID ?? null; + } + }, + }); + }); + + useEffect(() => { + if (!reportID) { + return; + } + // This callback is triggered when a new action arrives via Pusher and the event is emitted from Report.ts. This allows us to maintain + // a single source of truth for the "new action" event instead of trying to derive that a new action has appeared from looking at props. + const unsubscribe = subscribeToNewActionEvent(reportID, onNewActionEvent); + + return () => { + unsubscribe?.(); + }; + }, [reportID]); + + // No per-run cleanup on purpose. Cancelling the pending jump whenever another action lands is what stops the + // list from ever reaching the newest message. + const pendingScrollTimeoutRef = useRef(null); + useEffect(() => { + const pendingActionID = pendingScrollToActionIDRef.current; + if (!pendingActionID) { + return; + } + const index = visibleReportActions.findIndex((item) => item.reportActionID === pendingActionID); + if (index === -1) { + return; + } + pendingScrollToActionIDRef.current = null; + if (pendingScrollTimeoutRef.current) { + clearTimeout(pendingScrollTimeoutRef.current); + } + pendingScrollTimeoutRef.current = setTimeout(() => { + pendingScrollTimeoutRef.current = null; + scrollToBottom(); + }, DELAY_FOR_SCROLLING_TO_END); + }, [visibleReportActions, scrollToBottom]); + + const scrollToLatestMessages = () => { + setIsFloatingMessageCounterVisible(false); + + stickToBottomRef.current = true; + if (stickToBottomTimeoutRef.current) { + clearTimeout(stickToBottomTimeoutRef.current); + } + stickToBottomTimeoutRef.current = setTimeout(() => { + stickToBottomRef.current = false; + }, STICK_TO_BOTTOM_DURATION_MS); + + if (!hasNewestReportAction) { + openReport({ + reportID, + introSelected, + conciergeChat, + betas, + hasReportActions: true, + currentUserAccountID, + isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, + hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, + }); + scrollToBottom(); + return; + } + + // Defer marking the report as read until the scroll actually reaches the bottom (handled in onTrackScrolling). + pendingMarkAsReadRef.current = true; + scrollToBottom(); + }; + + useEffect(() => { + return () => { + for (const timeoutRef of [stickToBottomTimeoutRef, pendingScrollTimeoutRef, ownActionScrollTimeoutRef]) { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + } + }; + }, []); + + const onListContentSizeChange = (_width: number, height: number) => { + listContentHeightRef.current = height; + syncBottomOffset(); + + if (!stickToBottomRef.current) { + return; + } + scrollToBottom(); + }; + + const onListLayout = (event: LayoutChangeEvent) => { + listLayoutHeightRef.current = event.nativeEvent.layout.height; + syncBottomOffset(); + }; + + const onListScrollBeginDrag = () => { + stickToBottomRef.current = false; + // The user scrolled away before reaching the bottom, so cancel the pending read. + pendingMarkAsReadRef.current = false; + if (stickToBottomTimeoutRef.current) { + clearTimeout(stickToBottomTimeoutRef.current); + stickToBottomTimeoutRef.current = null; + } + }; + + return { + isFloatingMessageCounterVisible, + trackVerticalScrolling, + onViewableItemsChanged, + scrollToLatestMessages, + onListContentSizeChange, + onListLayout, + onListScrollBeginDrag, + updateLastItemIndex, + scrollToBottom, + }; +} + +export default useMoneyRequestReportScroll; diff --git a/src/components/OptionRow.tsx b/src/components/OptionRow.tsx index c83b09b3c8e4..04fe1c04dc84 100644 --- a/src/components/OptionRow.tsx +++ b/src/components/OptionRow.tsx @@ -11,6 +11,7 @@ import type {OptionData} from '@libs/ReportUtils'; import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {deepEqual} from 'fast-equals'; @@ -90,7 +91,7 @@ function OptionRow({ const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const {getCurrencyDecimals} = useCurrencyListActions(); const icons = useMemoizedLazyExpensifyIcons(['DotIndicator', 'Checkmark']); - const pressableRef = useRef(null); + const pressableRef = useRef | HTMLDivElement>(null); const [isDisabled, setIsDisabled] = useState(isOptionDisabled); useEffect(() => { diff --git a/src/components/Picker/BasePicker.tsx b/src/components/Picker/BasePicker.tsx index 423fc9fe938b..7122675e9914 100644 --- a/src/components/Picker/BasePicker.tsx +++ b/src/components/Picker/BasePicker.tsx @@ -12,7 +12,7 @@ import {isMobile} from '@libs/Browser'; import CONST from '@src/CONST'; -import type {ReactElement, ReactNode, RefObject} from 'react'; +import type {ComponentRef, ReactElement, ReactNode, RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView} from 'react-native'; @@ -61,7 +61,7 @@ function BasePickerImpl({ const [isHighlighted, setIsHighlighted] = useState(false); // reference to the root View - const root = useRef(null); + const root = useRef>(null); // reference to @react-native-picker/picker const picker = useRef(null); @@ -256,7 +256,7 @@ function BasePickerImpl({ disableHighlight(); }), }} - scrollViewRef={context?.scrollViewRef as RefObject} + scrollViewRef={context?.scrollViewRef as RefObject>} scrollViewContentOffsetY={context?.contentOffsetY} /> diff --git a/src/components/Popover/types.ts b/src/components/Popover/types.ts index 9e1027ecac15..202132fe9735 100644 --- a/src/components/Popover/types.ts +++ b/src/components/Popover/types.ts @@ -5,7 +5,7 @@ import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {Dimensions} from '@src/types/utils/Layout'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports import type {Text, View} from 'react-native'; @@ -13,14 +13,14 @@ type PopoverProps = BaseModalProps & ChildrenProps & { anchorPosition?: PopoverAnchorPosition; anchorAlignment?: AnchorAlignment; - anchorRef: RefObject; + anchorRef: RefObject | HTMLDivElement | ComponentRef | null>; disableAnimation?: boolean; /** Whether we don't want to show overlay */ withoutOverlay?: boolean; popoverDimensions?: Dimensions; - withoutOverlayRef?: RefObject; + withoutOverlayRef?: RefObject | HTMLDivElement>; /** Whether we want to show the popover on the right side of the screen */ fromSidebarMediumScreen?: boolean; diff --git a/src/components/PopoverMenu/index.tsx b/src/components/PopoverMenu/index.tsx index 7be7fea00c5b..47da17331140 100644 --- a/src/components/PopoverMenu/index.tsx +++ b/src/components/PopoverMenu/index.tsx @@ -152,7 +152,7 @@ type PopoverMenuProps = Partial & { /** The horizontal and vertical anchors points for the popover */ anchorPosition: AnchorPosition; - anchorRef: RefObject; + anchorRef: RefObject | HTMLDivElement | null>; /** Where the popover should be positioned relative to the anchor points. */ anchorAlignment?: AnchorAlignment; diff --git a/src/components/PopoverMenu/v2/content/ContentContext.ts b/src/components/PopoverMenu/v2/content/ContentContext.ts index 56ec6c2b3042..92dce7365bd7 100644 --- a/src/components/PopoverMenu/v2/content/ContentContext.ts +++ b/src/components/PopoverMenu/v2/content/ContentContext.ts @@ -1,10 +1,10 @@ import createContextNamespace from '@hooks/createContextNamespace'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; type FocusableItem = { - ref: RefObject; + ref: RefObject | null>; isDisabled: boolean; onActivate: () => void; text?: string; diff --git a/src/components/PopoverMenu/v2/content/useOrderedIDs.ts b/src/components/PopoverMenu/v2/content/useOrderedIDs.ts index 3a44e71b7933..f392c64a2f3f 100644 --- a/src/components/PopoverMenu/v2/content/useOrderedIDs.ts +++ b/src/components/PopoverMenu/v2/content/useOrderedIDs.ts @@ -1,3 +1,4 @@ +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import type {FocusableItem} from './ContentContext'; @@ -11,7 +12,7 @@ function useOrderedIDs(registry: Map): string[] { return [...registry.entries()].sort(([, a], [, b]) => compareNodes(a.ref.current, b.ref.current)).map(([id]) => id); } -function compareNodes(a: View | null, b: View | null): number { +function compareNodes(a: ComponentRef | null, b: ComponentRef | null): number { // Stable-sort fallback: refs may not have attached on the first commit after registration. if (a === null || b === null) { return 0; diff --git a/src/components/PopoverMenu/v2/root/RootContext.ts b/src/components/PopoverMenu/v2/root/RootContext.ts index c604badc59e9..859bb7c27879 100644 --- a/src/components/PopoverMenu/v2/root/RootContext.ts +++ b/src/components/PopoverMenu/v2/root/RootContext.ts @@ -1,9 +1,9 @@ import createContextNamespace from '@hooks/createContextNamespace'; -import type {Dispatch, RefObject, SetStateAction} from 'react'; +import type {ComponentRef, Dispatch, RefObject, SetStateAction} from 'react'; import type {View} from 'react-native'; -type AnchorRef = RefObject; +type AnchorRef = RefObject | null>; type AnchorRect = {x: number; y: number; width: number; height: number}; diff --git a/src/components/PopoverMenu/v2/root/useAnchorOpener.ts b/src/components/PopoverMenu/v2/root/useAnchorOpener.ts index 52723a892355..8427c095fb18 100644 --- a/src/components/PopoverMenu/v2/root/useAnchorOpener.ts +++ b/src/components/PopoverMenu/v2/root/useAnchorOpener.ts @@ -1,3 +1,4 @@ +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import {useRef} from 'react'; @@ -13,7 +14,7 @@ type UseAnchorOpenerResult = { function useAnchorOpener(callerName: string): UseAnchorOpenerResult { const {setIsVisible, setActiveAnchor} = useRootActions(callerName); - const ownRef: AnchorRef = useRef(null); + const ownRef: AnchorRef = useRef | null>(null); const open = (overrideRect?: AnchorRect) => { if (overrideRect) { diff --git a/src/components/PopoverMenu/v2/rows/useFocusableRow.ts b/src/components/PopoverMenu/v2/rows/useFocusableRow.ts index ea2e3f35a9ce..03c3d3cba899 100644 --- a/src/components/PopoverMenu/v2/rows/useFocusableRow.ts +++ b/src/components/PopoverMenu/v2/rows/useFocusableRow.ts @@ -2,13 +2,13 @@ import {useContentFocus, useContentItemActions} from '@components/PopoverMenu/v2 import useSyncFocus from '@hooks/useSyncFocus'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; import {useId, useLayoutEffect, useRef} from 'react'; type FocusableRow = { - ref: RefObject; + ref: RefObject | null>; focused: boolean; onPress: () => void; onFocus: () => void; @@ -29,7 +29,7 @@ function useFocusableRow({ text?: string; }): FocusableRow { const id = useId(); - const ref = useRef(null); + const ref = useRef>(null); const {focusedID} = useContentFocus(componentName); const {registerItem, unregisterItem, setFocusedID} = useContentItemActions(componentName); diff --git a/src/components/PopoverMenu/v2/rows/useSelectableRow.ts b/src/components/PopoverMenu/v2/rows/useSelectableRow.ts index 3661728474a7..61ae439522cd 100644 --- a/src/components/PopoverMenu/v2/rows/useSelectableRow.ts +++ b/src/components/PopoverMenu/v2/rows/useSelectableRow.ts @@ -4,7 +4,7 @@ import {useIsAtActiveLevel} from '@components/PopoverMenu/v2/sub/SubContext'; import {createCustomEvent} from '@libs/CustomEventUtils'; import type {CustomEvent} from '@libs/CustomEventUtils'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; import useFocusableRow from './useFocusableRow'; @@ -14,7 +14,7 @@ const HOOK_NAME = 'useSelectableRow'; type ItemSelectEvent = CustomEvent; type UseSelectableRowResult = { - ref: RefObject; + ref: RefObject | null>; onPress: () => void; onFocus: () => void; focused: boolean; diff --git a/src/components/PopoverMenu/v2/sub/useSubBackButton.ts b/src/components/PopoverMenu/v2/sub/useSubBackButton.ts index f65965f2447f..1bb1dfb6bb40 100644 --- a/src/components/PopoverMenu/v2/sub/useSubBackButton.ts +++ b/src/components/PopoverMenu/v2/sub/useSubBackButton.ts @@ -1,7 +1,7 @@ import {useContentNavigation, useContentSubActions} from '@components/PopoverMenu/v2/content/ContentContext'; import useFocusableRow from '@components/PopoverMenu/v2/rows/useFocusableRow'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; import {useSubContext} from './SubContext'; @@ -9,7 +9,7 @@ import {useSubContext} from './SubContext'; const HOOK_NAME = 'useSubBackButton'; type UseSubBackButtonResult = { - ref: RefObject; + ref: RefObject | null>; onPress: () => void; onFocus: () => void; focused: boolean; diff --git a/src/components/PopoverMenu/v2/sub/useSubTrigger.ts b/src/components/PopoverMenu/v2/sub/useSubTrigger.ts index 27e616dbdd81..b3c12273236c 100644 --- a/src/components/PopoverMenu/v2/sub/useSubTrigger.ts +++ b/src/components/PopoverMenu/v2/sub/useSubTrigger.ts @@ -1,7 +1,7 @@ import {useContentNavigation, useContentSubActions} from '@components/PopoverMenu/v2/content/ContentContext'; import useFocusableRow from '@components/PopoverMenu/v2/rows/useFocusableRow'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; import {useSubContext} from './SubContext'; @@ -9,7 +9,7 @@ import {useSubContext} from './SubContext'; const HOOK_NAME = 'useSubTrigger'; type UseSubTriggerResult = { - ref: RefObject; + ref: RefObject | null>; onPress: () => void; onFocus: () => void; focused: boolean; diff --git a/src/components/PopoverProvider/index.tsx b/src/components/PopoverProvider/index.tsx index d62d3defb351..52601a8fa97a 100644 --- a/src/components/PopoverProvider/index.tsx +++ b/src/components/PopoverProvider/index.tsx @@ -1,4 +1,4 @@ -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports import type {Text, View} from 'react-native'; @@ -14,8 +14,8 @@ type PopoverStateContextType = { type PopoverActionsContextType = { onOpen: (popoverParams: AnchorRef) => void; - close: (anchorRef?: RefObject) => void; - setActivePopoverExtraAnchorRef: (ref?: RefObject) => void; + close: (anchorRef?: RefObject | HTMLElement | ComponentRef | null>) => void; + setActivePopoverExtraAnchorRef: (ref?: RefObject | HTMLDivElement | ComponentRef | null>) => void; }; const defaultPopoverActionsContext: PopoverActionsContextType = { @@ -32,7 +32,7 @@ const PopoverStateContext = createContext({ const PopoverActionsContext = createContext(defaultPopoverActionsContext); -function elementContains(ref: RefObject | undefined, target: EventTarget | null) { +function elementContains(ref: RefObject | HTMLElement | ComponentRef | null> | undefined, target: EventTarget | null) { if (ref?.current && 'contains' in ref.current && ref?.current?.contains(target as never)) { return true; } @@ -46,7 +46,7 @@ function PopoverContextProvider(props: PopoverContextProps) { const [activePopoverAnchor, setActivePopoverAnchor] = useState(null); const [activePopoverExtraAnchorRefs, setActivePopoverExtraAnchorRefs] = useState([]); - const closePopover = useCallback((anchorRef?: RefObject): boolean => { + const closePopover = useCallback((anchorRef?: RefObject | HTMLElement | ComponentRef | null>): boolean => { if (!activePopoverRef.current || (anchorRef && anchorRef !== activePopoverRef.current.anchorRef)) { return false; } @@ -66,7 +66,7 @@ function PopoverContextProvider(props: PopoverContextProps) { } // In case there are any extra anchor refs where the popover should not close on click // for example, the case when the QAB tooltip is clicked it closes the popover this will prevent that - if (activePopoverExtraAnchorRefs?.some((ref: RefObject) => elementContains(ref, e.target))) { + if (activePopoverExtraAnchorRefs?.some((ref: RefObject | HTMLElement | ComponentRef | null>) => elementContains(ref, e.target))) { return; } const ref = activePopoverRef.current?.anchorRef; @@ -147,7 +147,7 @@ function PopoverContextProvider(props: PopoverContextProps) { ); // To set the extra anchor refs for the popover when prop-drilling is not possible - const setActivePopoverExtraAnchorRef = useCallback((extraAnchorRef?: RefObject) => { + const setActivePopoverExtraAnchorRef = useCallback((extraAnchorRef?: RefObject | HTMLDivElement | ComponentRef | null>) => { if (!extraAnchorRef) { return; } diff --git a/src/components/PopoverProvider/types.ts b/src/components/PopoverProvider/types.ts index 6a49012bbc0c..52b28affa54f 100644 --- a/src/components/PopoverProvider/types.ts +++ b/src/components/PopoverProvider/types.ts @@ -1,8 +1,8 @@ -import type {ReactNode, RefObject} from 'react'; +import type {ComponentRef, ReactNode, RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports import type {Text, View} from 'react-native'; -type AnchorType = View | HTMLDivElement | Text | null; +type AnchorType = ComponentRef | HTMLDivElement | ComponentRef | null; type PopoverContextProps = { children: ReactNode; diff --git a/src/components/PopoverWithoutOverlay/types.ts b/src/components/PopoverWithoutOverlay/types.ts index a0ded56a404b..7ed2c90dbd93 100644 --- a/src/components/PopoverWithoutOverlay/types.ts +++ b/src/components/PopoverWithoutOverlay/types.ts @@ -2,7 +2,7 @@ import type BaseModalProps from '@components/Modal/types'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports import type {Text, View} from 'react-native'; @@ -15,13 +15,13 @@ type PopoverWithoutOverlayProps = ChildrenProps & left?: number; }; - anchorRef: RefObject; + anchorRef: RefObject | HTMLDivElement | ComponentRef | null>; /** Time in milliseconds for the modal entering animation */ animationInTiming?: number; disableAnimation?: boolean; - withoutOverlayRef: RefObject; + withoutOverlayRef: RefObject | HTMLDivElement | null>; /** Whether we should display the popover below other modals (e.g. SidePanel, RHP) */ shouldDisplayBelowModals?: boolean; diff --git a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx index 919be79d487a..20a028980b2a 100644 --- a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx @@ -13,6 +13,7 @@ import {notifyPressedTrigger, registerPressable} from '@libs/NavigationFocusRetu import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; import type {GestureResponderEvent, View} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -58,7 +59,7 @@ function GenericPressable({ const [hitSlop, onLayout] = Accessibility.useAutoHitSlop(); const [isHovered, setIsHovered] = useState(false); const isRoleButton = [rest.accessibilityRole, rest.role].includes(CONST.ROLE.BUTTON); - const internalRef = useRef(null); + const internalRef = useRef | null>(null); const composedRef = useMemo(() => mergeRefs(ref, internalRef), [ref]); const routeKey = useRouteKey(); // `||` so empty strings skip — never key off an empty prop. diff --git a/src/components/Pressable/GenericPressable/implementation/index.tsx b/src/components/Pressable/GenericPressable/implementation/index.tsx index 8e16d795f90a..34846d62b19a 100644 --- a/src/components/Pressable/GenericPressable/implementation/index.tsx +++ b/src/components/Pressable/GenericPressable/implementation/index.tsx @@ -2,6 +2,7 @@ import type PressableProps from '@components/Pressable/GenericPressable/types'; import mergeRefs from '@libs/mergeRefs'; +import type {ComponentRef} from 'react'; import type {Role, View} from 'react-native'; import React, {useLayoutEffect, useRef} from 'react'; @@ -14,7 +15,7 @@ function WebGenericPressable({focusable = true, ref, sentryLabel, ...props}: Pre // react-native-web's Pressable always sets aria-disabled from its own `disabled` prop, // overriding any explicit aria-disabled we pass. We pass fullDisabled (not isDisabled) to // preserve interaction/focus behavior, so we must set aria-disabled imperatively instead. - const internalRef = useRef(null); + const internalRef = useRef>(null); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- `||` is intentional so that falsy values like empty string or 0 are treated as not-disabled const isAriaDisabled = props.fullDisabled || props.disabled || props.accessibilityState?.disabled; useLayoutEffect(() => { diff --git a/src/components/Pressable/GenericPressable/types.ts b/src/components/Pressable/GenericPressable/types.ts index ad70b7141b2e..2f34a669a62e 100644 --- a/src/components/Pressable/GenericPressable/types.ts +++ b/src/components/Pressable/GenericPressable/types.ts @@ -160,7 +160,7 @@ type PressableProps = RNPressableProps & ref?: PressableRef; }; -type PressableRef = ForwardedRef; +type PressableRef = ForwardedRef | ComponentRef | undefined>; export default PressableProps; export type {PressableRef}; diff --git a/src/components/Pressable/PressableWithoutFocus.tsx b/src/components/Pressable/PressableWithoutFocus.tsx index 5538261d6fd8..b8788692e93d 100644 --- a/src/components/Pressable/PressableWithoutFocus.tsx +++ b/src/components/Pressable/PressableWithoutFocus.tsx @@ -1,3 +1,4 @@ +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import React, {useRef} from 'react'; @@ -14,7 +15,7 @@ import GenericPressable from './GenericPressable'; * https://github.com/Expensify/App/issues/6806 */ function PressableWithoutFocus({children, onPress, onLongPress, ...rest}: PressableProps) { - const ref = useRef(null); + const ref = useRef>(null); const pressAndBlur = () => { ref?.current?.blur(); diff --git a/src/components/QRShare/QRShareWithDownload/index.native.tsx b/src/components/QRShare/QRShareWithDownload/index.native.tsx index 44d23e58cdfd..19de35171300 100644 --- a/src/components/QRShare/QRShareWithDownload/index.native.tsx +++ b/src/components/QRShare/QRShareWithDownload/index.native.tsx @@ -5,6 +5,8 @@ import useNetwork from '@hooks/useNetwork'; import fileDownload from '@libs/fileDownload'; +import type {ComponentRef} from 'react'; + import React, {useImperativeHandle, useRef} from 'react'; import ViewShot from 'react-native-view-shot'; @@ -16,7 +18,7 @@ function QRShareWithDownload({ref, ...props}: QRShareWithDownloadProps) { const {isOffline} = useNetwork(); const {translate} = useLocalize(); - const qrCodeScreenshotRef = useRef>(null); + const qrCodeScreenshotRef = useRef>(null); useImperativeHandle( ref, diff --git a/src/components/RNMaskedTextInput.tsx b/src/components/RNMaskedTextInput.tsx index b220a7f0d39f..bf9f91be1801 100644 --- a/src/components/RNMaskedTextInput.tsx +++ b/src/components/RNMaskedTextInput.tsx @@ -4,7 +4,7 @@ import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import CONST from '@src/CONST'; -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; import type {MaskedTextInputProps} from 'react-native-advanced-input-mask'; import React from 'react'; @@ -15,7 +15,7 @@ import Animated from 'react-native-reanimated'; // Convert the underlying TextInput into an Animated component so that we can take an animated ref and pass it to a worklet const AnimatedTextInput = Animated.createAnimatedComponent(TextInput); -type AnimatedTextInputRef = typeof AnimatedTextInput & TextInput & HTMLInputElement; +type AnimatedTextInputRef = typeof AnimatedTextInput & ComponentRef & HTMLInputElement; type RNMaskedTextInputWithRefProps = MaskedTextInputProps & ForwardedFSClassProps & { diff --git a/src/components/RNTextInput.tsx b/src/components/RNTextInput.tsx index 5fae3abf56b5..7384ea087d29 100644 --- a/src/components/RNTextInput.tsx +++ b/src/components/RNTextInput.tsx @@ -5,7 +5,7 @@ import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import CONST from '@src/CONST'; -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; import type {TextInputProps} from 'react-native'; import React, {useRef} from 'react'; @@ -15,7 +15,7 @@ import Animated from 'react-native-reanimated'; // Convert the underlying TextInput into an Animated component so that we can take an animated ref and pass it to a worklet const AnimatedTextInput = Animated.createAnimatedComponent(TextInput); -type AnimatedTextInputRef = typeof AnimatedTextInput & TextInput & HTMLInputElement; +type AnimatedTextInputRef = typeof AnimatedTextInput & ComponentRef & HTMLInputElement; type RNTextInputWithRefProps = TextInputProps & ForwardedFSClassProps & { diff --git a/src/components/RadioButtons.tsx b/src/components/RadioButtons.tsx index 677444dd769d..8a9516d26128 100644 --- a/src/components/RadioButtons.tsx +++ b/src/components/RadioButtons.tsx @@ -2,7 +2,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import React, {useState} from 'react'; @@ -35,7 +35,7 @@ type RadioButtonsProps = ForwardedFSClassProps & { /** The checked value, if you're using this component as a controlled input. */ value?: string; - ref?: ForwardedRef; + ref?: ForwardedRef>; }; function RadioButtons({items, onSelect, defaultCheckedValue = '', errorText, onInputChange = () => {}, value, forwardedFSClass, ref}: RadioButtonsProps) { diff --git a/src/components/Reactions/AddReactionBubble.tsx b/src/components/Reactions/AddReactionBubble.tsx index 37583005e661..1eed81300d90 100644 --- a/src/components/Reactions/AddReactionBubble.tsx +++ b/src/components/Reactions/AddReactionBubble.tsx @@ -23,6 +23,8 @@ import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; import type {ReportAction} from '@src/types/onyx'; +import type {ComponentRef} from 'react'; + import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; @@ -57,7 +59,7 @@ function AddReactionBubble({onSelectEmoji, reportAction, onPressOpenPicker, onWi const icons = useMemoizedLazyExpensifyIcons(['AddReaction']); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const ref = useRef(null); + const ref = useRef | HTMLDivElement>(null); const {translate} = useLocalize(); useEffect(() => resetEmojiPopoverAnchor, []); diff --git a/src/components/Reactions/MiniQuickEmojiReactions.tsx b/src/components/Reactions/MiniQuickEmojiReactions.tsx index 3ccf30692e2e..99b3385da9cb 100644 --- a/src/components/Reactions/MiniQuickEmojiReactions.tsx +++ b/src/components/Reactions/MiniQuickEmojiReactions.tsx @@ -23,6 +23,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportActionReactions} from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; +import type {ComponentRef} from 'react'; + import React, {useCallback, useRef} from 'react'; import {View} from 'react-native'; @@ -46,7 +48,7 @@ function MiniQuickEmojiReactions({reportAction, reportActionID, onEmojiSelected, const icons = useMemoizedLazyExpensifyIcons(['AddReaction']); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const ref = useRef(null); + const ref = useRef>(null); const {translate, preferredLocale} = useLocalize(); const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE); const [emojiReactions = getEmptyObject()] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`); diff --git a/src/components/Reactions/QuickEmojiReactions/types.ts b/src/components/Reactions/QuickEmojiReactions/types.ts index b243505d527e..99df9e97a968 100644 --- a/src/components/Reactions/QuickEmojiReactions/types.ts +++ b/src/components/Reactions/QuickEmojiReactions/types.ts @@ -4,11 +4,11 @@ import type {AnchorOrigin} from '@userActions/EmojiPickerAction'; import type {ReportAction, ReportActionReactions} from '@src/types/onyx'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {TextInput, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -type PickerRefElement = RefObject; +type PickerRefElement = RefObject | ComponentRef | null>; type OpenPickerCallback = (element?: PickerRefElement, anchorOrigin?: AnchorOrigin) => void; diff --git a/src/components/ReceiptCropView/index.tsx b/src/components/ReceiptCropView/index.tsx index ed6668f5db80..2c0bbfe62ac5 100644 --- a/src/components/ReceiptCropView/index.tsx +++ b/src/components/ReceiptCropView/index.tsx @@ -11,6 +11,7 @@ import variables from '@styles/variables'; import type {Dimensions} from '@src/types/utils/Layout'; +import type {ComponentRef} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import type {GestureUpdateEvent, PanGestureChangeEventPayload, PanGestureHandlerEventPayload} from 'react-native-gesture-handler'; @@ -607,7 +608,7 @@ function ReceiptCropView({imageUri, onCropChange, initialCrop, isAuthTokenRequir ControlSelection.blockElement(el as HTMLElement | null)} + ref={(el: ComponentRef | null) => ControlSelection.blockElement(el as HTMLElement | null)} > {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-ignores-invert-colors -- Custom Image wrapper does not support this prop. */} diff --git a/src/components/ReceiptHoverZoom/types.ts b/src/components/ReceiptHoverZoom/types.ts index 46a49134918e..85077be17c5f 100644 --- a/src/components/ReceiptHoverZoom/types.ts +++ b/src/components/ReceiptHoverZoom/types.ts @@ -1,4 +1,4 @@ -import type {ReactNode, RefObject} from 'react'; +import type {ComponentRef, ReactNode, RefObject} from 'react'; import type {View} from 'react-native'; /** Shared hover state passed to children so overlays (e.g. the distance e-receipt flip) stay in sync with the zoom. */ @@ -18,7 +18,7 @@ type ReceiptHoverZoomProps = { scale?: number; /** Outer element the listeners should attach to. Lets the zoom stay engaged while the cursor crosses overlay buttons. Falls back to the wrapper element. */ - hoverContainerRef?: RefObject; + hoverContainerRef?: RefObject | null>; }; export default ReceiptHoverZoomProps; diff --git a/src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts b/src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts index 623fc3759efa..bd8da21a3e3c 100644 --- a/src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts +++ b/src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts @@ -1,6 +1,6 @@ import {hasHoverSupport} from '@libs/DeviceCapabilities'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; import {useEffect, useRef, useState} from 'react'; @@ -8,7 +8,7 @@ import {useEffect, useRef, useState} from 'react'; type UseReceiptHoverZoomConfig = { isEnabled: boolean; scale: number; - hoverContainerRef?: RefObject; + hoverContainerRef?: RefObject | null>; }; type UseReceiptHoverZoomResult = { @@ -18,7 +18,7 @@ type UseReceiptHoverZoomResult = { isHovering: boolean; }; -function resolveHoverTarget(wrapper: HTMLDivElement | null, externalRef: RefObject | undefined): HTMLElement | null { +function resolveHoverTarget(wrapper: HTMLDivElement | null, externalRef: RefObject | null> | undefined): HTMLElement | null { if (externalRef) { const external = externalRef.current as unknown as HTMLElement | null; if (external) { diff --git a/src/components/ReceiptImage/index.tsx b/src/components/ReceiptImage/index.tsx index 6664d86f05db..c57348624ce6 100644 --- a/src/components/ReceiptImage/index.tsx +++ b/src/components/ReceiptImage/index.tsx @@ -16,7 +16,7 @@ import type {Transaction} from '@src/types/onyx'; import type {ReceiptSource} from '@src/types/onyx/Transaction'; import type IconAsset from '@src/types/utils/IconAsset'; -import type {ImageResizeMode, ImageStyle, StyleProp, ViewStyle} from 'react-native'; +import type {ImageProps, ImageStyle, StyleProp, ViewStyle} from 'react-native'; import React, {useRef, useState} from 'react'; import {View} from 'react-native'; @@ -116,7 +116,7 @@ type ReceiptImageProps = ( /** Callback to be called when the image fails to load */ onLoadFailure?: () => void; - resizeMode?: ImageResizeMode; + resizeMode?: ImageProps['resizeMode']; style?: StyleProp; /** Low-resolution URI shown as a placeholder while the full image loads */ diff --git a/src/components/ReceiptScanDropZone/ReceiptScanDropTarget.tsx b/src/components/ReceiptScanDropZone/ReceiptScanDropTarget.tsx index e736b6b7514a..79065d83df4a 100644 --- a/src/components/ReceiptScanDropZone/ReceiptScanDropTarget.tsx +++ b/src/components/ReceiptScanDropZone/ReceiptScanDropTarget.tsx @@ -11,6 +11,7 @@ import {shouldAcceptDrop} from '@libs/DragAndDropUtils'; import htmlDivElementRef from '@src/types/utils/htmlDivElementRef'; +import type {ComponentRef} from 'react'; import type {StyleProp, View, ViewStyle} from 'react-native'; import React, {useEffect} from 'react'; @@ -18,7 +19,7 @@ import {View as RNView} from 'react-native'; type ReceiptScanDropTargetProps = { /** Ref to the element the drag events are bound to */ - targetRef: React.RefObject; + targetRef: React.RefObject | HTMLDivElement | null>; dropWrapperStyle?: StyleProp; diff --git a/src/components/ReceiptScanDropZone/index.tsx b/src/components/ReceiptScanDropZone/index.tsx index a7b145724db5..ccb8bc185092 100644 --- a/src/components/ReceiptScanDropZone/index.tsx +++ b/src/components/ReceiptScanDropZone/index.tsx @@ -1,6 +1,6 @@ import {DragAndDropStateContext} from '@components/DragAndDrop/Provider/DragAndDropContext'; -import type {ReactNode, RefObject} from 'react'; +import type {ComponentRef, ReactNode, RefObject} from 'react'; import type {StyleProp, View, ViewStyle} from 'react-native'; import React, {useState} from 'react'; @@ -12,7 +12,7 @@ type ReceiptScanDropZoneProps = { children: ReactNode; /** Ref to the container receipts are dropped onto */ - dropZoneRef: RefObject; + dropZoneRef: RefObject | HTMLDivElement | null>; /** Whether the drop zone is disabled, keeping the scan logic unmounted */ isDisabled?: boolean; diff --git a/src/components/ReportActionAvatars/useReportActionAvatars.ts b/src/components/ReportActionAvatars/useReportActionAvatars.ts index f119f9f0d0be..198e9cb428f2 100644 --- a/src/components/ReportActionAvatars/useReportActionAvatars.ts +++ b/src/components/ReportActionAvatars/useReportActionAvatars.ts @@ -228,7 +228,7 @@ function useReportActionAvatars({ const accountID = reportPreviewSenderID || (actorAccountID ?? CONST.DEFAULT_NUMBER_ID); const {avatar, fallbackIcon, login} = personalDetails?.[delegatePersonalDetails ? delegatePersonalDetails.accountID : accountID] ?? {}; - const defaultDisplayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails, formatPhoneNumber, translate}) ?? ''; + const defaultDisplayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails, formatPhoneNumber, hiddenTranslation: translate('common.hidden')}) ?? ''; const invoiceReport = [iouReport, chatReport, reportChatReport].find((susReport) => isInvoiceReport(susReport) || susReport?.chatType === CONST.REPORT.TYPE.INVOICE); const isNestedInInvoiceReport = !!invoiceReport && !isChatThread(report); const isInvoiceReportActor = isAInvoiceReport && (!actorAccountID || displayAllActors || isAReportPreviewAction); diff --git a/src/components/ReportActionItem/ExportIntegration.tsx b/src/components/ReportActionItem/ExportIntegration.tsx index f5ea8ea1b7dc..8d530edf675d 100644 --- a/src/components/ReportActionItem/ExportIntegration.tsx +++ b/src/components/ReportActionItem/ExportIntegration.tsx @@ -38,7 +38,8 @@ function ExportIntegration({action, originalReport}: ExportIntegrationProps) { const integrationName = getOriginalMessage(action)?.label; if (hasReasoning(action)) { - const message = getExportIntegrationMessageHTML(translate, action, integrationName); + // Omit the period inside the expense link because AskToExplain supplies it. + const message = getExportIntegrationMessageHTML(translate, action, integrationName, true); return ( (null); - const addButtonRef = useRef(null); + const receiptContainerRef = useRef | null>(null); + const addButtonRef = useRef | null>(null); const [isPickerOpen, setIsPickerOpen] = useState(false); const deviceHasHoverSupport = hasHoverSupport(); const lazyIcons = useMemoizedLazyExpensifyIcons(['Expand', 'ReceiptPlus']); diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/usePreviewMessageAnimation.ts b/src/components/ReportActionItem/MoneyRequestReportPreview/usePreviewMessageAnimation.ts index 86eefc95aef0..cb15dcf946c2 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/usePreviewMessageAnimation.ts +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/usePreviewMessageAnimation.ts @@ -118,7 +118,7 @@ function usePreviewMessageAnimation({ accountID: managerID, shouldUseShortForm: true, formatPhoneNumber, - translate, + hiddenTranslation: translate('common.hidden'), }); } @@ -134,7 +134,7 @@ function usePreviewMessageAnimation({ accountID: chatReport?.ownerAccountID, shouldUseShortForm: true, formatPhoneNumber, - translate, + hiddenTranslation: translate('common.hidden'), }); } return translate(paymentVerb, payerOrApproverName); diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx index ede408e2a1b1..43c29b6d0f34 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx @@ -14,9 +14,9 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; import type {Policy, Report, Transaction} from '@src/types/onyx'; +import type {ViewToken} from '@src/types/utils/ReactNativeCompat'; import type {FlashListRef, ListRenderItem, ListRenderItemInfo} from '@shopify/flash-list'; -import type {ViewToken} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; @@ -199,7 +199,7 @@ function useReportPreviewCarousel({ if (typeof newIndex === 'number') { setCurrentIndex(newIndex); } - const viewableItemsIndexes = viewableItems.map((item) => item.index).filter((item): item is number => item !== null); + const viewableItemsIndexes = viewableItems.map((item) => item.index).filter((item): item is number => typeof item === 'number'); setCurrentVisibleItems(viewableItemsIndexes); }, []); diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index c55d923ad880..0b9e9b572788 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -510,6 +510,7 @@ function MoneyRequestView({ (!isPerDiemRequest || canSubmitPerDiemExpenseFromWorkspace(policy) || (isExpenseUnreported && !!perDiemOriginalPolicy)); const policyTagLists = getTagLists(policyTagList); + const policyHasEnabledTags = hasEnabledTags(policyTagLists); const category = transactionCategory ?? ''; const categoryForDisplay = isCategoryMissing(category) ? '' : category; @@ -522,12 +523,12 @@ function MoneyRequestView({ (isExpenseUnreported && (!policyForMovingExpenses || hasEnabledOptions(policyCategories ?? {}))); // transactionTag can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const shouldShowTag = (isPolicyExpenseChat || isExpenseUnreported) && (transactionTag || (canEdit && hasEnabledTags(policyTagLists))); + const shouldShowTag = (isPolicyExpenseChat || isExpenseUnreported) && (transactionTag || (canEdit && policyHasEnabledTags)); // Surface a delete confirmation (like tax) when the value is stale and there's nothing valid to select, instead of // navigating to edit. Categories need at least one, so they only hit this when disabled; tags can be fully emptied, // so also cover "no enabled tags remain". Applies to multi-level tags too - deleting clears the whole tag value. const shouldShowCategoryDisabledAlert = !policy?.areCategoriesEnabled && !!category; - const shouldShowTagDisabledAlert = (!policy?.areTagsEnabled || !hasEnabledTags(policyTagLists)) && !!transactionTag; + const shouldShowTagDisabledAlert = (!policy?.areTagsEnabled || !policyHasEnabledTags) && !!transactionTag; const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!transactionBillable || isBillableEnabledOnPolicy(policy) || !!updatedTransaction?.billable); const isCurrentTransactionReimbursableDifferentFromPolicyDefault = policy?.defaultReimbursable !== undefined && !!(updatedTransaction?.reimbursable ?? transactionReimbursable) !== policy.defaultReimbursable; diff --git a/src/components/ReportActionItem/TaskView.tsx b/src/components/ReportActionItem/TaskView.tsx index e7016e1feb9f..febea1a1cfae 100644 --- a/src/components/ReportActionItem/TaskView.tsx +++ b/src/components/ReportActionItem/TaskView.tsx @@ -89,7 +89,7 @@ function TaskView({report, parentReport, action}: TaskViewProps) { } = useTaskCheckboxAccessibility(isCompletedFromOnyx, taskTitlePlainText); const assigneePersonalDetails = report?.managerID ? personalDetails?.[report.managerID] : undefined; - const assigneeDisplayName = report?.managerID ? getDisplayNameForParticipant({accountID: report.managerID, formatPhoneNumber, translate}) : ''; + const assigneeDisplayName = report?.managerID ? getDisplayNameForParticipant({accountID: report.managerID, formatPhoneNumber, hiddenTranslation: translate('common.hidden')}) : ''; const isOpen = isOpenTaskReport(report); diff --git a/src/components/ReportActionItem/receiptHoverUtils/index.ts b/src/components/ReportActionItem/receiptHoverUtils/index.ts index 6afbeb418a33..b4de0d03644d 100644 --- a/src/components/ReportActionItem/receiptHoverUtils/index.ts +++ b/src/components/ReportActionItem/receiptHoverUtils/index.ts @@ -1,15 +1,15 @@ -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; /** Reset stale button hover/tooltip when file picker opens (browsers don't fire mouseleave). */ -function resetButtonHoverState(addButtonRef: RefObject) { +function resetButtonHoverState(addButtonRef: RefObject | null>) { const buttonEl = addButtonRef.current as unknown as HTMLElement; buttonEl?.dispatchEvent(new PointerEvent('pointerleave')); buttonEl?.dispatchEvent(new MouseEvent('mouseout', {bubbles: true, relatedTarget: document.body})); } /** Check if cursor is over the element (web only). */ -function isElementHovered(ref: RefObject): boolean { +function isElementHovered(ref: RefObject | null>): boolean { return !!(ref.current as unknown as HTMLElement)?.matches?.(':hover'); } diff --git a/src/components/ReportSubmitToPopoverAnchor.tsx b/src/components/ReportSubmitToPopoverAnchor.tsx index 619f87575679..9c4785f74222 100644 --- a/src/components/ReportSubmitToPopoverAnchor.tsx +++ b/src/components/ReportSubmitToPopoverAnchor.tsx @@ -4,7 +4,7 @@ import type {ReportSubmitToPopoverOpenOptions} from '@hooks/useReportSubmitToPop import CONST from '@src/CONST'; import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import React, {createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; @@ -22,11 +22,11 @@ const ReportSubmitToPopoverContext = createContext(() // Default: no provider (tests / edge UI). Opening is a no-op. }); -const ReportSubmitToPopoverAnchorRefContext = createContext | null>(null); +const ReportSubmitToPopoverAnchorRefContext = createContext | null> | null>(null); type ReportSubmitToPopoverHostContextValue = { - registerAnchor: (reportID: string | undefined, anchorRef: RefObject) => () => void; - openReportSubmitToPopover: (reportID: string | undefined, options?: ReportSubmitToPopoverOpenOptions, anchorRef?: RefObject) => void; + registerAnchor: (reportID: string | undefined, anchorRef: RefObject | null>) => () => void; + openReportSubmitToPopover: (reportID: string | undefined, options?: ReportSubmitToPopoverOpenOptions, anchorRef?: RefObject | null>) => void; }; const ReportSubmitToPopoverHostContext = createContext(null); @@ -92,12 +92,12 @@ type ReportSubmitToPopoverHostProps = { * a Modal inside each FlashList cell (iOS only showed the backdrop when the modal lived in a recycled row). */ function ReportSubmitToPopoverHost({children, anchorAlignment}: ReportSubmitToPopoverHostProps) { - const anchorRegistryRef = useRef>>>(new Map()); - const activeAnchorRefRef = useRef | null>(null); + const anchorRegistryRef = useRef | null>>>>(new Map()); + const activeAnchorRefRef = useRef | null> | null>(null); const [activeReportID, setActiveReportID] = useState(); - const pendingOpenRef = useRef<{reportID: string; options?: ReportSubmitToPopoverOpenOptions; anchorRef?: RefObject} | null>(null); + const pendingOpenRef = useRef<{reportID: string; options?: ReportSubmitToPopoverOpenOptions; anchorRef?: RefObject | null>} | null>(null); - const registerAnchor = useCallback((reportID: string | undefined, anchorRef: RefObject) => { + const registerAnchor = useCallback((reportID: string | undefined, anchorRef: RefObject | null>) => { if (!reportID) { return () => {}; } @@ -162,7 +162,7 @@ function ReportSubmitToPopoverHost({children, anchorAlignment}: ReportSubmitToPo ); const openReportSubmitToPopoverForHost = useCallback( - (reportID: string | undefined, options?: ReportSubmitToPopoverOpenOptions, anchorRef?: RefObject) => { + (reportID: string | undefined, options?: ReportSubmitToPopoverOpenOptions, anchorRef?: RefObject | null>) => { if (!reportID) { return; } @@ -236,7 +236,7 @@ function ReportSubmitToPopoverRoot({reportID, onSubmitSuccess, anchorAlignment, } function ReportSubmitToPopoverRootWithHost({reportID, host, children}: {reportID: string | undefined; host: ReportSubmitToPopoverHostContextValue; children: React.ReactNode}) { - const anchorRef = useRef(null); + const anchorRef = useRef>(null); useEffect(() => host.registerAnchor(reportID, anchorRef), [host, reportID]); diff --git a/src/components/SafeTriangle/index.tsx b/src/components/SafeTriangle/index.tsx index bc60f547eeb5..3f85ac8962cc 100644 --- a/src/components/SafeTriangle/index.tsx +++ b/src/components/SafeTriangle/index.tsx @@ -4,6 +4,8 @@ import {isMobile} from '@libs/Browser'; import htmlDivElementRef from '@src/types/utils/htmlDivElementRef'; +import type {ComponentRef} from 'react'; + import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import {Polygon, Svg} from 'react-native-svg'; @@ -13,8 +15,8 @@ import type SafeTriangleProps from './types'; type Point = [number, number]; type SafeTriangleOverlayProps = { - submenuRef: React.RefObject; - containerRef: React.RefObject; + submenuRef: React.RefObject | null>; + containerRef: React.RefObject | null>; }; type Rect = { @@ -176,7 +178,7 @@ function SafeTriangleOverlay({submenuRef, containerRef}: SafeTriangleOverlayProp */ function SafeTriangle({submenuRef, children}: SafeTriangleProps) { const styles = useThemeStyles(); - const containerRef = useRef(null); + const containerRef = useRef>(null); if (isMobile()) { return children; diff --git a/src/components/SafeTriangle/types.ts b/src/components/SafeTriangle/types.ts index 5173cbb3acb7..b97d2400f2e2 100644 --- a/src/components/SafeTriangle/types.ts +++ b/src/components/SafeTriangle/types.ts @@ -1,7 +1,8 @@ +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; type SafeTriangleProps = { - submenuRef: React.RefObject; + submenuRef: React.RefObject | null>; children: React.ReactNode; }; diff --git a/src/components/ScreenShareRequestModal.tsx b/src/components/ScreenShareRequestModal.tsx deleted file mode 100644 index 49c80a311ec5..000000000000 --- a/src/components/ScreenShareRequestModal.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; - -import {clearScreenShareRequest, joinScreenShare} from '@userActions/User'; - -import ONYXKEYS from '@src/ONYXKEYS'; - -import React from 'react'; - -import ConfirmModal from './ConfirmModal'; - -/** - * Component that owns the SCREEN_SHARE_REQUEST Onyx subscription and renders - * the screen-share confirmation modal when a GuidesPlus agent requests it. - */ -function ScreenShareRequestModal() { - const {translate} = useLocalize(); - const [screenShareRequest] = useOnyx(ONYXKEYS.SCREEN_SHARE_REQUEST); - - if (!screenShareRequest) { - return null; - } - - return ( - joinScreenShare(screenShareRequest.accessToken, screenShareRequest.roomName)} - onCancel={clearScreenShareRequest} - prompt={translate('guides.screenShareRequest')} - confirmText={translate('common.join')} - cancelText={translate('common.decline')} - isVisible - /> - ); -} - -export default ScreenShareRequestModal; diff --git a/src/components/ScreenWrapper/ScreenWrapperContainer.tsx b/src/components/ScreenWrapper/ScreenWrapperContainer.tsx index 989fb1bff0c8..43336e3bc074 100644 --- a/src/components/ScreenWrapper/ScreenWrapperContainer.tsx +++ b/src/components/ScreenWrapper/ScreenWrapperContainer.tsx @@ -18,7 +18,7 @@ import toggleTestToolsModal from '@userActions/TestTool'; import CONST from '@src/CONST'; -import type {ForwardedRef, ReactNode} from 'react'; +import type {ComponentRef, ForwardedRef, ReactNode} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import React, {useContext, useEffect, useMemo, useRef} from 'react'; @@ -83,7 +83,7 @@ type ScreenWrapperContainerProps = ForwardedFSClassProps & /** Whether this screen should be hidden from accessibility tree */ shouldHideFromAccessibility?: boolean; - ref?: ForwardedRef; + ref?: ForwardedRef>; }>; function ScreenWrapperContainer({ diff --git a/src/components/ScreenWrapper/index.tsx b/src/components/ScreenWrapper/index.tsx index a68e3037bbb2..a1644c687ecb 100644 --- a/src/components/ScreenWrapper/index.tsx +++ b/src/components/ScreenWrapper/index.tsx @@ -32,7 +32,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {Route} from '@react-navigation/native'; -import type {ReactNode} from 'react'; +import type {ComponentRef, ReactNode} from 'react'; import type {StyleProp, View, ViewStyle} from 'react-native'; import type {EdgeInsets} from 'react-native-safe-area-context'; @@ -120,7 +120,7 @@ function ScreenWrapper({ const navigationFallback = useNavigation>(); const navigation = navigationProp ?? navigationFallback; const isFocused = useIsFocused(); - const screenWrapperRef = useRef(null); + const screenWrapperRef = useRef | HTMLElement>(null); const mergedScreenWrapperRef = mergeRefs(screenWrapperRef, ref); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout for a case where we want to show the offline indicator only on small screens diff --git a/src/components/ScrollView.tsx b/src/components/ScrollView.tsx index 7c4abf7ed09a..ea7a2586e85d 100644 --- a/src/components/ScrollView.tsx +++ b/src/components/ScrollView.tsx @@ -1,6 +1,6 @@ import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; import type {ScrollViewProps as RNScrollViewProps} from 'react-native'; import React from 'react'; @@ -10,7 +10,7 @@ import {ScrollView as RNScrollView} from 'react-native'; type ScrollViewProps = RNScrollViewProps & { addBottomSafeAreaPadding?: boolean; addOfflineIndicatorBottomSafeAreaPadding?: boolean; - ref?: ForwardedRef; + ref?: ForwardedRef>; }; function ScrollView({ diff --git a/src/components/ScrollViewWithContext.tsx b/src/components/ScrollViewWithContext.tsx index fb8930ce3be7..e02405671653 100644 --- a/src/components/ScrollViewWithContext.tsx +++ b/src/components/ScrollViewWithContext.tsx @@ -1,6 +1,6 @@ import CONST from '@src/CONST'; -import type {ForwardedRef, ReactNode} from 'react'; +import type {ComponentRef, ForwardedRef, ReactNode} from 'react'; // eslint-disable-next-line no-restricted-imports import type {NativeScrollEvent, NativeSyntheticEvent, ScrollView as RNScrollView} from 'react-native'; @@ -12,7 +12,7 @@ import ScrollView from './ScrollView'; type ScrollContextValue = { contentOffsetY: number; - scrollViewRef?: ForwardedRef; + scrollViewRef?: ForwardedRef>; }; const ScrollContext = createContext({ @@ -35,7 +35,7 @@ type ScrollViewWithContextProps = Partial & { */ function ScrollViewWithContext({onScroll, scrollEventThrottle, children, ref, ...restProps}: ScrollViewWithContextProps) { const [contentOffsetY, setContentOffsetY] = useState(0); - const defaultScrollViewRef = useRef(null); + const defaultScrollViewRef = useRef>(null); const scrollViewRef = ref ?? defaultScrollViewRef; const setContextScrollPosition = (event: NativeSyntheticEvent) => { diff --git a/src/components/Search/ChartEmptyState.tsx b/src/components/Search/ChartEmptyState.tsx new file mode 100644 index 000000000000..15b4f8035e4d --- /dev/null +++ b/src/components/Search/ChartEmptyState.tsx @@ -0,0 +1,27 @@ +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; + +import HomeSectionEmptyState from '@pages/home/HomeSectionEmptyState'; + +import React from 'react'; + +type ChartEmptyStateProps = { + testID?: string; +}; + +/** Stands in for a chart with too little data to plot. */ +function ChartEmptyState({testID}: ChartEmptyStateProps) { + const {translate} = useLocalize(); + const illustrations = useMemoizedLazyIllustrations(['Chart']); + + return ( + + ); +} + +export default ChartEmptyState; diff --git a/src/components/Search/ChartErrorState.tsx b/src/components/Search/ChartErrorState.tsx new file mode 100644 index 000000000000..a7b12da85391 --- /dev/null +++ b/src/components/Search/ChartErrorState.tsx @@ -0,0 +1,41 @@ +import BlockingView from '@components/BlockingViews/BlockingView'; +import {CHART_CONTENT_MIN_HEIGHT} from '@components/Charts/VictoryTheme'; + +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import React from 'react'; + +type ChartErrorStateProps = { + /** Asks for the chart's data again */ + onRetry: () => void; +}; + +/** Stands in for a chart whose data failed to arrive. */ +function ChartErrorState({onRetry}: ChartErrorStateProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const illustrations = useMemoizedLazyIllustrations(['BrokenMagnifyingGlass']); + + return ( + + ); +} + +export default ChartErrorState; diff --git a/src/components/Search/FilterComponents/AdvancedFilters/AmountFilterContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/AmountFilterContent.tsx index 165b2d6f952f..67423078c2ac 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/AmountFilterContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/AmountFilterContent.tsx @@ -8,6 +8,7 @@ import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useLocalize from '@hooks/useLocalize'; import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus'; +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -31,6 +32,7 @@ type AmountFilterContentProps = { buttonSize?: Exclude, typeof CONST.BUTTON_SIZE.SMALL>; autoFocus?: boolean; style?: StyleProp; + buttonText?: string; onChange: (values: Partial) => void; }; @@ -152,10 +154,11 @@ function AmountBetweenInput({ref, baseFilterKey, greaterThanValue, lessThanValue ); } -function AmountFilterContent({baseFilterKey, value, autoFocus, buttonSize, style, onChange}: AmountFilterContentProps) { +function AmountFilterContent({baseFilterKey, value, autoFocus, buttonSize, style, buttonText, onChange}: AmountFilterContentProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const shouldButtonBeInScrollView = useShouldFooterBeInsideList(); const getInitialSelectedAmountModifier = () => { const hasLessThan = !!value?.[CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN]; @@ -205,6 +208,18 @@ function AmountFilterContent({baseFilterKey, value, autoFocus, buttonSize, style ]; const label = translate(FILTER_VIEW_MAP[baseFilterKey].labelKey); + const button = ( + + ); + return ( ))} + {shouldButtonBeInScrollView && button} - + {!shouldButtonBeInScrollView && button} ); } diff --git a/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx b/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx index 16e8503daa85..f629cdc74189 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx @@ -1,7 +1,6 @@ import Icon from '@components/Icon'; import {PressableWithFeedback} from '@components/Pressable'; import ScrollView from '@components/ScrollView'; -import type {Filter} from '@components/Search/types'; import SpacerView from '@components/SpacerView'; import Text from '@components/Text'; @@ -34,7 +33,6 @@ type FilterItemCallbacks = { type FilterListProps = FilterItemCallbacks & { type: SearchDataTypes | undefined; - policyID: Filter; selectedFilter?: SearchFilter['key']; style?: StyleProp; contentContainerStyle?: StyleProp; @@ -110,9 +108,9 @@ function FilterItem({filterKey, isSelected, type, onPress, onHoverIn, onFocus}: ); } -function FilterList({type, policyID, selectedFilter, style, contentContainerStyle, onHoverIn, onFocus, onPress, onPointerMove, onPointerLeave}: FilterListProps) { +function FilterList({type, selectedFilter, style, contentContainerStyle, onHoverIn, onFocus, onPress, onPointerMove, onPointerLeave}: FilterListProps) { const styles = useThemeStyles(); - const typeFiltersKeys = useAdvancedSearchFilters(type, policyID); + const typeFiltersKeys = useAdvancedSearchFilters(type); return ( (null); const [error, setError] = useState(); - return ( - <> - - {!!selectedField && ( - + ); - onChange(value); - }} - > - - {translate('common.apply')} - - )} - + return ( + ); } diff --git a/src/components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent.tsx index 586835b5a9e8..5abdfd5b75fa 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent.tsx @@ -14,18 +14,20 @@ import type {DateFilterContentProps} from './DateFilterContent'; import type {ReportFieldFilterContentProps} from './ReportFieldFilterContent'; import type {TextInputFilterContentProps} from './TextInputFilterContent'; -type TextInputFilterContentWrapperProps = Pick; -type AmountFilterContentWrapperProps = Pick; -type DateFilterContentWrapperProps = Pick; -type ReportFieldFilterContentWrapperProps = Pick; +type TextInputFilterContentWrapperProps = Pick; +type AmountFilterContentWrapperProps = Pick; +type DateFilterContentWrapperProps = Pick & {buttonText?: string}; +type ReportFieldFilterContentWrapperProps = Pick & {buttonText?: string}; type ListFilterContentWrapperProps = Omit & { onChange: (value: ListFilterContentProps['value'], isNegated: boolean) => void; + buttonText?: string; }; type SearchAdvancedFiltersContentProps = { baseFilterKey: SearchFilter['key']; values: Partial | undefined; ready?: boolean; + buttonText?: string; components: { Text: React.ComponentType; Amount: React.ComponentType; @@ -36,7 +38,7 @@ type SearchAdvancedFiltersContentProps = { onChange: (values: Partial) => void; }; -function SearchAdvancedFiltersContent({baseFilterKey, values, ready, components, onChange}: SearchAdvancedFiltersContentProps) { +function SearchAdvancedFiltersContent({baseFilterKey, values, ready, components, buttonText, onChange}: SearchAdvancedFiltersContentProps) { if (isAmountFilterKey(baseFilterKey)) { const AmountFilter = components.Amount; return ( @@ -48,6 +50,7 @@ function SearchAdvancedFiltersContent({baseFilterKey, values, ready, components, [CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN]: values?.[`${baseFilterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN}`], [CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN]: values?.[`${baseFilterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN}`], }} + buttonText={buttonText} onChange={onChange} /> ); @@ -71,6 +74,7 @@ function SearchAdvancedFiltersContent({baseFilterKey, values, ready, components, [rangeModifier]: values?.[`${baseFilterKey}${rangeModifier}`], }} hasFeed={!!values?.feed} + buttonText={buttonText} onChange={(newValues) => onChange({ [`${baseFilterKey}${onModifier}`]: newValues[onModifier], @@ -88,6 +92,7 @@ function SearchAdvancedFiltersContent({baseFilterKey, values, ready, components, return ( ); @@ -102,6 +107,7 @@ function SearchAdvancedFiltersContent({baseFilterKey, values, ready, components, baseFilterKey={baseFilterKey} value={value} isNegated={isNegated} + buttonText={buttonText} onChange={(newValue, negated) => onChange(getFilterFormValues(baseFilterKey, newValue, negated))} /> ); @@ -117,6 +123,7 @@ function SearchAdvancedFiltersContent({baseFilterKey, values, ready, components, type={values?.type} policyID={getFilterNegatableValue(CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID, values)} ready={ready} + buttonText={buttonText} isNegated={isNegated} onChange={(newValue, negated) => onChange(getFilterFormValues(baseFilterKey, newValue, negated))} /> diff --git a/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx index 5facf618718b..6242f00b45ae 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx @@ -1,4 +1,5 @@ import Button from '@components/Button'; +import ScrollView from '@components/ScrollView'; import NegatableFilter from '@components/Search/FilterComponents/NegatableFilter'; import useTextFilterValidation from '@components/Search/hooks/useTextFilterValidation'; import type {ReportFieldTextKey, SearchTextFilterKeys} from '@components/Search/types'; @@ -7,12 +8,14 @@ import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useThemeStyles from '@hooks/useThemeStyles'; import {FILTER_VIEW_MAP} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; import type {TextInput as RNTextInput, StyleProp, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -26,14 +29,15 @@ type TextInputFilterContentProps = { size?: Exclude, typeof CONST.BUTTON_SIZE.SMALL>; autoFocus?: boolean; style?: StyleProp; + buttonText?: string; onChange: (value: string | undefined, isNegated: boolean) => void; }; -function isTextInput(element: BaseTextInputRef | RNTextInput | null): element is RNTextInput { +function isTextInput(element: BaseTextInputRef | ComponentRef | null): element is ComponentRef { return !!element && 'isFocused' in element; } -function TextInputFilterContent({baseFilterKey, value: initialValue, isNegated: initialIsNegated, autoFocus, size, style, onChange}: TextInputFilterContentProps) { +function TextInputFilterContent({baseFilterKey, value: initialValue, isNegated: initialIsNegated, autoFocus, size, style, buttonText, onChange}: TextInputFilterContentProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [value, setValue] = useState(initialValue); @@ -42,45 +46,56 @@ function TextInputFilterContent({baseFilterKey, value: initialValue, isNegated: const label = translate(FILTER_VIEW_MAP[baseFilterKey].labelKey); const {inputCallbackRef} = useAutoFocusInput(); const error = useTextFilterValidation(baseFilterKey, value); + const shouldButtonBeInScrollView = useShouldFooterBeInsideList(); + + const button = ( + + ); return ( - - { - if (!autoFocus || !isTextInput(ref)) { - return; - } - inputCallbackRef(ref); - }} - placeholder={label} - value={value} - errorText={error} - hasError={!!error} - onChangeText={setValue} - accessibilityLabel={label} - role={CONST.ROLE.PRESENTATION} - containerStyles={[styles.ph5]} - /> - - + + { + if (!autoFocus || !isTextInput(ref)) { + return; + } + inputCallbackRef(ref); + }} + placeholder={label} + value={value} + errorText={error} + hasError={!!error} + onChangeText={setValue} + accessibilityLabel={label} + role={CONST.ROLE.PRESENTATION} + containerStyles={[styles.ph5]} + /> + + {shouldButtonBeInScrollView && button} + + {!shouldButtonBeInScrollView && button} ); } diff --git a/src/components/Search/FilterComponents/BankAccountSelector.tsx b/src/components/Search/FilterComponents/BankAccountSelector.tsx index 4b26b94c6c4b..2c84f984b3a6 100644 --- a/src/components/Search/FilterComponents/BankAccountSelector.tsx +++ b/src/components/Search/FilterComponents/BankAccountSelector.tsx @@ -11,6 +11,7 @@ import useInitialValue from '@hooks/useInitialValue'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -47,6 +48,7 @@ type BankAccountFilterItem = { function BankAccountSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: BankAccountSelectorProps) { const theme = useTheme(); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const styles = useThemeStyles(); const {translate} = useLocalize(); const {isLargeScreenWidth} = useResponsiveLayout(); @@ -188,6 +190,7 @@ function BankAccountSelector({value = [], selectionListTextInputStyle, selection shouldPreventAutoScrollOnSelect style={selectionListStyle} footerContent={footer} + shouldFooterBeInsideList={shouldFooterBeInsideList} /> )} diff --git a/src/components/Search/FilterComponents/CardSelector.tsx b/src/components/Search/FilterComponents/CardSelector.tsx index f49bff3b9ce3..c32e7c149989 100644 --- a/src/components/Search/FilterComponents/CardSelector.tsx +++ b/src/components/Search/FilterComponents/CardSelector.tsx @@ -11,6 +11,7 @@ import useInitialValue from '@hooks/useInitialValue'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useTheme from '@hooks/useTheme'; import useThemeIllustrations from '@hooks/useThemeIllustrations'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -34,6 +35,7 @@ type CardSelectorProps = SearchFilterCommonProps; function CardSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: CardSelectorProps) { const theme = useTheme(); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const styles = useThemeStyles(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); @@ -167,6 +169,7 @@ function CardSelector({value = [], selectionListTextInputStyle, selectionListSty shouldPreventAutoScrollOnSelect style={selectionListStyle} footerContent={footer} + shouldFooterBeInsideList={shouldFooterBeInsideList} /> )} diff --git a/src/components/Search/FilterComponents/InSelector.tsx b/src/components/Search/FilterComponents/InSelector.tsx index 6bfb8b8eb950..332102f6d0d6 100644 --- a/src/components/Search/FilterComponents/InSelector.tsx +++ b/src/components/Search/FilterComponents/InSelector.tsx @@ -13,6 +13,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; import useReportAttributes from '@hooks/useReportAttributes'; +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useSortedReportActionsData from '@hooks/useSortedReportActionsData'; import {searchInServer} from '@libs/actions/Report'; @@ -53,6 +54,7 @@ function getSelectedOptionData(option: Option & Pick): O function InSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, ready = true, footer, onChange}: InSelectorProps) { const {translate, dateFnsLocale} = useLocalize(); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const {convertToDisplayString, convertToDisplayStringWithoutCurrency} = useCurrencyListActions(); const personalDetails = usePersonalDetails(); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); @@ -133,6 +135,7 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle conciergeReportID, isTrackIntentUser, translate, + currentUserAccountID, rules, }, ); @@ -185,6 +188,7 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle convertToDisplayString, selectedOptions, excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, + currentUserAccountID, }, rules, ); @@ -282,6 +286,7 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle shouldShowTextInput style={selectionListStyle} footerContent={footer} + shouldFooterBeInsideList={shouldFooterBeInsideList} /> ); diff --git a/src/components/Search/FilterComponents/MultiSelect.tsx b/src/components/Search/FilterComponents/MultiSelect.tsx index 24db44ce0860..9d10aef4efc1 100644 --- a/src/components/Search/FilterComponents/MultiSelect.tsx +++ b/src/components/Search/FilterComponents/MultiSelect.tsx @@ -8,6 +8,7 @@ import type {TextInputOptions} from '@components/SelectionList/types'; import useDebouncedState from '@hooks/useDebouncedState'; import useInitialValue from '@hooks/useInitialValue'; import useLocalize from '@hooks/useLocalize'; +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -18,7 +19,7 @@ import type {Icon} from '@src/types/onyx/OnyxCommon'; import type {ReactNode} from 'react'; -import React, {useState} from 'react'; +import React, {useEffect, useEffectEvent, useRef, useState} from 'react'; import {View} from 'react-native'; import ListFilterView from './ListFilterViewWrapper'; @@ -49,6 +50,21 @@ type MultiSelectProps = SearchFilterCommonProps>> & loading?: boolean; shouldShowLoadingPlaceholder?: boolean; + + /** Called when the scroll position gets near the end of the list */ + onEndReached?: () => void; + + /** How far from the end the bottom edge of the list must be to trigger onEndReached */ + onEndReachedThreshold?: number; + + /** Called when the debounced search term changes */ + onSearchChange?: (searchTerm: string) => void; + + /** Whether more items are being loaded (shows spinner at bottom of list) */ + isLoadingMore?: boolean; + + /** Whether a server search is in progress (shows a centered spinner in the list area) */ + isSearching?: boolean; }; function MultiSelect({ @@ -65,14 +81,33 @@ function MultiSelect({ autoFocus, footer, onChange, + onEndReached, + onEndReachedThreshold, + onSearchChange, + isLoadingMore, + isSearching, }: MultiSelectProps) { const theme = useTheme(); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const {translate} = useLocalize(); const styles = useThemeStyles(); const [selectedItems, setSelectedItems] = useState(value); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const notifySearchChange = useEffectEvent((term: string) => { + onSearchChange?.(term); + }); + + const isFirstRender = useRef(true); + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + notifySearchChange(debouncedSearchTerm); + }, [debouncedSearchTerm]); + // Snapshot the values selected when the filter first opened so they can be floated to the top of a long list on // first render without repinning rows that are toggled afterwards. // moveInitialSelectionToTop gates on the *unfiltered* items length so the decision doesn't flip as the user types, @@ -81,9 +116,11 @@ function MultiSelect({ const orderedItems = moveInitialSelectionToTop(items, initialSelectedValues); const searchLower = debouncedSearchTerm.toLowerCase(); - const filteredItems = isSearchable - ? orderedItems.filter((item) => item.text.toLowerCase().includes(searchLower) || item.searchableText?.toLowerCase().includes(searchLower)) - : orderedItems; + // When onSearchChange is provided, the parent handles filtering (server-side search) + const filteredItems = + isSearchable && !onSearchChange + ? orderedItems.filter((item) => item.text.toLowerCase().includes(searchLower) || item.searchableText?.toLowerCase().includes(searchLower)) + : orderedItems; const listData: ListItem[] = filteredItems.map((item) => ({ text: item.text, alternateText: item.alternateText, @@ -93,7 +130,8 @@ function MultiSelect({ leftElement: item.leftElement, })); - const headerMessage = isSearchable && listData.length === 0 ? translate('common.noResultsFound') : undefined; + const shouldShowSearchSpinner = isSearching && listData.length === 0; + const headerMessage = isSearchable && listData.length === 0 && !isSearching ? translate('common.noResultsFound') : undefined; const updateSelectedItems = (item: ListItem) => { if (item.isSelected) { @@ -123,6 +161,29 @@ function MultiSelect({ disableAutoFocus: !autoFocus, }; + const renderCenteredSpinner = () => ( + + + + ); + + const footerContent = ( + <> + {!!isLoadingMore && ( + + + + )} + {footer} + + ); + return ( ({ isNegatable={isNegatable} > {loading ? ( - - - + renderCenteredSpinner() ) : ( ({ onSelectRow={updateSelectedItems} textInputOptions={textInputOptions} style={{contentContainerStyle: [styles.pb0], ...selectionListStyle}} - footerContent={footer} + footerContent={footerContent} + shouldFooterBeInsideList={shouldFooterBeInsideList} + onEndReached={onEndReached} + onEndReachedThreshold={onEndReachedThreshold} + listEmptyContent={shouldShowSearchSpinner ? renderCenteredSpinner() : undefined} + shouldShowListEmptyContent={shouldShowSearchSpinner} /> )} diff --git a/src/components/Search/FilterComponents/ReportField/ReportFieldList.tsx b/src/components/Search/FilterComponents/ReportField/ReportFieldList.tsx index d47eb9e9c2c7..743f56f58e64 100644 --- a/src/components/Search/FilterComponents/ReportField/ReportFieldList.tsx +++ b/src/components/Search/FilterComponents/ReportField/ReportFieldList.tsx @@ -7,10 +7,11 @@ import React from 'react'; type ReportFieldListProps = { field: PolicyReportField; value: string | undefined; + footer?: React.ReactNode; onChange: (newValue: string | undefined) => void; }; -function ReportFieldList({field, value, onChange}: ReportFieldListProps) { +function ReportFieldList({field, value, footer, onChange}: ReportFieldListProps) { const items = field.values.map((fieldValue) => ({ value: fieldValue, text: fieldValue, @@ -24,6 +25,7 @@ function ReportFieldList({field, value, onChange}: ReportFieldListProps) { allowDeselect onChange={(item) => onChange(item?.value)} hasHeader + footer={footer} /> ); } diff --git a/src/components/Search/FilterComponents/ReportField/ReportFieldText.tsx b/src/components/Search/FilterComponents/ReportField/ReportFieldText.tsx index 64bae7615f46..fa99a6e5f49f 100644 --- a/src/components/Search/FilterComponents/ReportField/ReportFieldText.tsx +++ b/src/components/Search/FilterComponents/ReportField/ReportFieldText.tsx @@ -1,7 +1,9 @@ +import ScrollView from '@components/ScrollView'; import useTextFilterValidation from '@components/Search/hooks/useTextFilterValidation'; import type {ReportFieldTextKey} from '@components/Search/types'; import TextInput from '@components/TextInput'; +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; @@ -13,15 +15,18 @@ type ReportFieldTextProps = { filterKey: ReportFieldTextKey; field: PolicyReportField; value: string | undefined; + footer?: React.ReactNode; + shouldUseScrollView?: boolean; onChange: (newValue: string) => void; onError: (error: string | undefined) => void; }; -function ReportFieldText({filterKey, field, value, onChange, onError}: ReportFieldTextProps) { +function ReportFieldText({filterKey, field, value, footer, shouldUseScrollView, onChange, onError}: ReportFieldTextProps) { const styles = useThemeStyles(); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const error = useTextFilterValidation(filterKey, value, onError); - return ( + const input = ( ); + + if (!shouldUseScrollView) { + return ( + <> + {input} + {footer} + + ); + } + + return ( + <> + + {input} + {shouldFooterBeInsideList && footer} + + {!shouldFooterBeInsideList && footer} + + ); } export default ReportFieldText; diff --git a/src/components/Search/FilterComponents/ReportField/index.tsx b/src/components/Search/FilterComponents/ReportField/index.tsx index fea784abc2ea..76bb84cd6e2c 100644 --- a/src/components/Search/FilterComponents/ReportField/index.tsx +++ b/src/components/Search/FilterComponents/ReportField/index.tsx @@ -1,3 +1,4 @@ +import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import ScrollView from '@components/ScrollView'; @@ -42,6 +43,8 @@ type ReportFieldBaseProps = { selectedField: PolicyReportField | null; hasFeed: boolean; style?: StyleProp; + footer?: React.ReactNode; + shouldUseScrollView?: boolean; onFieldSelected: (field: PolicyReportField | null) => void; onError: (error: string | undefined) => void; }; @@ -50,6 +53,8 @@ type SelectedReportFieldProps = { ref: React.Ref; field: PolicyReportField; value: string | undefined; + footer?: React.ReactNode; + shouldUseScrollView?: boolean; onError: (error: string | undefined) => void; }; @@ -71,7 +76,7 @@ function getFilterKey(fieldName: string) { return `${CONST.SEARCH.REPORT_FIELD.DEFAULT_PREFIX}${suffix}` as const; } -function SelectedReportField({ref, field, value: initialValue, onError}: SelectedReportFieldProps) { +function SelectedReportField({ref, field, value: initialValue, footer, shouldUseScrollView, onError}: SelectedReportFieldProps) { const [value, setValue] = useState(initialValue); const fieldType = field.type as Exclude, typeof CONST.REPORT_FIELD_TYPES.FORMULA | typeof CONST.REPORT_FIELD_TYPES.DATE>; const filterKey = getFilterKey(field.name); @@ -97,6 +102,8 @@ function SelectedReportField({ref, field, value: initialValue, onError}: Selecte filterKey={filterKey} field={field} value={value} + footer={footer} + shouldUseScrollView={shouldUseScrollView} onChange={setValue} onError={onError} /> @@ -167,7 +174,7 @@ function SelectedDateReportField({ref, field, value: initialValue, selectedDateM ); } -function ReportFieldBase({ref, values: initialValues = {}, selectedField, hasFeed, style, onFieldSelected, onError}: ReportFieldBaseProps) { +function ReportFieldBase({ref, values: initialValues = {}, selectedField, hasFeed, style, footer, shouldUseScrollView, onFieldSelected, onError}: ReportFieldBaseProps) { const {translate, localeCompare, dateFnsLocale} = useLocalize(); const styles = useThemeStyles(); const policyReportFieldsSelector = (policies: OnyxCollection) => createAllPolicyReportFieldsSelector(policies, localeCompare); @@ -235,6 +242,9 @@ function ReportFieldBase({ref, values: initialValues = {}, selectedField, hasFee })); if (selectedField) { + // A list field pads the footer through its SelectionList, the other field types have no such owner and are padded here. + const paddedFooter = selectedField.type === CONST.REPORT_FIELD_TYPES.LIST ? footer : {footer}; + return ( <> {!selectedDateModifier && ( @@ -245,19 +255,24 @@ function ReportFieldBase({ref, values: initialValues = {}, selectedField, hasFee /> )} {selectedField.type === CONST.REPORT_FIELD_TYPES.DATE ? ( - + <> + + {paddedFooter} + ) : ( )} diff --git a/src/components/Search/FilterComponents/SingleSelect.tsx b/src/components/Search/FilterComponents/SingleSelect.tsx index 2b41081692cc..cc1e8cfd602b 100644 --- a/src/components/Search/FilterComponents/SingleSelect.tsx +++ b/src/components/Search/FilterComponents/SingleSelect.tsx @@ -6,6 +6,7 @@ import type {ListItem, TextInputOptions} from '@components/SelectionList/types'; import useDebouncedState from '@hooks/useDebouncedState'; import useInitialValue from '@hooks/useInitialValue'; import useLocalize from '@hooks/useLocalize'; +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useThemeStyles from '@hooks/useThemeStyles'; import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; @@ -72,6 +73,7 @@ function SingleSelectImpl({ onChange, }: SingleSelectProps) { const {translate} = useLocalize(); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const styles = useThemeStyles(); const [selectedItem, setSelectedItem] = useState(value); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); @@ -161,6 +163,7 @@ function SingleSelectImpl({ shouldShowLoadingPlaceholder={!noResultsFound} customListHeaderContent={header} footerContent={footer} + shouldFooterBeInsideList={shouldFooterBeInsideList} /> diff --git a/src/components/Search/FilterComponents/TagSelector.tsx b/src/components/Search/FilterComponents/TagSelector.tsx index bb1ae7eca189..3b2cc07acef3 100644 --- a/src/components/Search/FilterComponents/TagSelector.tsx +++ b/src/components/Search/FilterComponents/TagSelector.tsx @@ -1,20 +1,24 @@ import type {Filter, SearchFilterCommonProps} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import useSearchTagFilters from '@hooks/useSearchTagFilters'; import {getCleanedTagName, getTagNamesFromTagsLists} from '@libs/PolicyUtils'; import {getAllPolicyValues, sortOptionsWithEmptyValue} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PolicyTagLists} from '@src/types/onyx'; +import type {Policy, PolicyTagLists} from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection} from 'react-native-onyx'; import React from 'react'; +import type {MultiSelectItem} from './MultiSelect'; + import MultiSelect from './MultiSelect'; type TagSelectorProps = SearchFilterCommonProps & { @@ -23,17 +27,57 @@ type TagSelectorProps = SearchFilterCommonProps & { function TagSelector({value = [], policyID, selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: TagSelectorProps) { const {translate, localeCompare} = useLocalize(); - const [allPolicyTagLists = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); + const {isOffline} = useNetwork(); + const [policies = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [allPolicyTags = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); + const policyIDs = policyID?.value?.length + ? getAllPolicyValues(policyID, ONYXKEYS.COLLECTION.POLICY, policies) + .map((policy) => policy.id) + .join(',') + : ''; + const {searchResults, isSearching, isLoadingMore, hasMore, loadMore, searchTags, isInitialLoading, searchQuery} = useSearchTagFilters(policyIDs); - const tagItems = [{text: translate('search.noTag'), value: CONST.SEARCH.TAG_EMPTY_VALUE as string}]; - const uniqueTagNames = new Set( - getAllPolicyValues(policyID, ONYXKEYS.COLLECTION.POLICY_TAGS, allPolicyTagLists).flatMap((policyTags) => getTagNamesFromTagsLists(policyTags ?? {})), - ); - tagItems.push( - ...Array.from(uniqueTagNames) - .map((tagName) => ({text: getCleanedTagName(tagName), value: tagName})) - .toSorted((a, b) => sortOptionsWithEmptyValue(a.text.toString(), b.text.toString(), localeCompare)), - ); + const hasSearchResults = !!searchResults && searchResults.length > 0; + const shouldUseOfflineFallback = isOffline && !hasSearchResults; + + const tagItems: Array> = []; + const emptyTagItem = {text: translate('search.noTag'), value: CONST.SEARCH.TAG_EMPTY_VALUE as string}; + const seenTagNames = new Set(); + const lowerSearchQuery = searchQuery.toLowerCase(); + + const addTagName = (tagName: string) => { + if (seenTagNames.has(tagName)) { + return; + } + if (lowerSearchQuery && !tagName.toLowerCase().includes(lowerSearchQuery)) { + return; + } + seenTagNames.add(tagName); + tagItems.push({text: getCleanedTagName(tagName), value: tagName}); + }; + + if (shouldUseOfflineFallback) { + // Fall back to synced workspace tag data when the paginated search cache is empty offline. + const policyTagsLists = getAllPolicyValues(policyID?.value?.length ? policyID : undefined, ONYXKEYS.COLLECTION.POLICY_TAGS, allPolicyTags); + for (const policyTagsList of policyTagsLists) { + for (const tagName of getTagNamesFromTagsLists(policyTagsList)) { + addTagName(tagName); + } + } + tagItems.sort((a, b) => sortOptionsWithEmptyValue(a.text.toString(), b.text.toString(), localeCompare)); + } else { + // Preserve backend order - backend pre-sorts tags across policies alphabetically. + // When offline the API is skipped, so cached results are filtered locally by the search query. + for (const tag of searchResults ?? []) { + addTagName(tag.tagName); + } + } + + const shouldShowEmptyTagOption = !isSearching && !isInitialLoading && (!searchQuery || emptyTagItem.text.toLowerCase().includes(lowerSearchQuery)); + + if (shouldShowEmptyTagOption) { + tagItems.unshift(emptyTagItem); + } const selectedTagsItems = value.map((tag) => { if (tag === CONST.SEARCH.TAG_EMPTY_VALUE) { @@ -42,16 +86,40 @@ function TagSelector({value = [], policyID, selectionListTextInputStyle, selecti return {text: getCleanedTagName(tag), value: tag}; }); + // Selected tags that are not in the current result page stay visible once the search is cleared, so the selection doesn't disappear from the list. + // Prepend missing selected items to the top (after "No tag") so they don't land at the bottom of the paginated list. + // Do not gate on !isSearching so they stay mounted during re-fetches without flashing. + if (!searchQuery) { + const itemValues = new Set(tagItems.map((item) => item.value)); + const missingSelectedItems = selectedTagsItems.filter((selectedItem) => !itemValues.has(selectedItem.value)).toSorted((a, b) => localeCompare(a.text.toString(), b.text.toString())); + + if (missingSelectedItems.length > 0) { + const insertIndex = shouldShowEmptyTagOption ? 1 : 0; + tagItems.splice(insertIndex, 0, ...missingSelectedItems); + } + } + + // Keep search mounted while a query is active or in flight. Clearing results for a new search resets hasMore and tag count. + const shouldEnableSearch = shouldUseOfflineFallback + ? seenTagNames.size >= CONST.STANDARD_LIST_ITEM_LIMIT || !!searchQuery + : hasMore || seenTagNames.size >= CONST.STANDARD_LIST_ITEM_LIMIT || isSearching || !!searchQuery; + return ( = CONST.STANDARD_LIST_ITEM_LIMIT} + isSearchable={shouldEnableSearch} autoFocus={autoFocus} selectionListTextInputStyle={selectionListTextInputStyle} selectionListStyle={selectionListStyle} footer={footer} onChange={(tags) => onChange(tags.map((tag) => tag.value))} + onEndReached={hasMore && !shouldUseOfflineFallback ? loadMore : undefined} + onEndReachedThreshold={0.75} + onSearchChange={searchTags} + loading={isInitialLoading} + isSearching={isSearching} + isLoadingMore={isLoadingMore} /> ); } diff --git a/src/components/Search/FilterComponents/UserSelector.tsx b/src/components/Search/FilterComponents/UserSelector.tsx index b6e516f665dd..79d640f8d8d8 100644 --- a/src/components/Search/FilterComponents/UserSelector.tsx +++ b/src/components/Search/FilterComponents/UserSelector.tsx @@ -8,6 +8,7 @@ import useInitialValue from '@hooks/useInitialValue'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePersonalDetailSearchSelector from '@hooks/usePersonalDetailSearchSelector'; +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useThemeStyles from '@hooks/useThemeStyles'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; @@ -31,6 +32,7 @@ type UserSelectorProps = SearchFilterCommonProps & { function UserSelector({value = [], isNegatable, policyID, selectionListTextInputStyle, selectionListStyle, autoFocus, ready = true, footer, onChange}: UserSelectorProps) { const styles = useThemeStyles(); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const {translate} = useLocalize(); const personalDetails = usePersonalDetails(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -85,7 +87,7 @@ function UserSelector({value = [], isNegatable, policyID, selectionListTextInput return logins.size ? logins : undefined; })(); - const {searchTerm, setSearchTerm, availableOptions, totalOptionsCount, toggleSelection, areOptionsInitialized} = usePersonalDetailSearchSelector({ + const {searchTerm, setSearchTerm, selectedOptions, availableOptions, totalOptionsCount, toggleSelection, areOptionsInitialized} = usePersonalDetailSearchSelector({ selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, initialSelected: initialSelectedAccountIDs, excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, @@ -98,10 +100,14 @@ function UserSelector({value = [], isNegatable, policyID, selectionListTextInput shouldKeepSelectedInAvailableOptions: true, }); - // The current user is excluded from personalDetails, so include it (when present) in the list. moveInitialSelectionToTop - // keys on `value`, so map each option's accountID (keyForList) onto it. Pre-selected rows are moved to the top, + // The current user and selected public-profile users without a login are excluded from personalDetails, so include them in the list when present. + const availablePersonalDetails = availableOptions.currentUserOption ? [availableOptions.currentUserOption, ...availableOptions.personalDetails] : availableOptions.personalDetails; + const availableAccountIDs = new Set(availablePersonalDetails.map((option) => option.accountID)); + const selectedPublicProfileOptions = selectedOptions.filter((option) => !option.login && !availableAccountIDs.has(option.accountID)); + const baseListData = [...selectedPublicProfileOptions, ...availablePersonalDetails]; + + // moveInitialSelectionToTop keys on `value`, so map each option's accountID (keyForList) onto it. Pre-selected rows are moved to the top, // leaving the current user just below them in its natural sorted position. - const baseListData = availableOptions.currentUserOption ? [availableOptions.currentUserOption, ...availableOptions.personalDetails] : availableOptions.personalDetails; const listData = moveInitialSelectionToTop( baseListData.map((option) => ({...option, value: option.keyForList})), initialSelectedValues, @@ -146,6 +152,7 @@ function UserSelector({value = [], isNegatable, policyID, selectionListTextInput shouldShowLoadingPlaceholder={!areOptionsInitialized || !ready} style={{contentContainerStyle: [styles.pb0], ...selectionListStyle}} footerContent={footer} + shouldFooterBeInsideList={shouldFooterBeInsideList} /> ); diff --git a/src/components/Search/FilterComponents/WorkspaceSelector.tsx b/src/components/Search/FilterComponents/WorkspaceSelector.tsx index c7ce27e27941..399686a82b54 100644 --- a/src/components/Search/FilterComponents/WorkspaceSelector.tsx +++ b/src/components/Search/FilterComponents/WorkspaceSelector.tsx @@ -16,6 +16,7 @@ import useInitialValue from '@hooks/useInitialValue'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -103,6 +104,7 @@ function SectionHeader({ function WorkspaceSelector({value = [], selectionListTextInputStyle, selectionListStyle, autoFocus, ready = true, footer, onChange}: WorkspaceSelectorProps) { const {isOffline} = useNetwork(); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); @@ -231,6 +233,7 @@ function WorkspaceSelector({value = [], selectionListTextInputStyle, selectionLi shouldPreventAutoScrollOnSelect style={selectionListStyle} footerContent={footer} + shouldFooterBeInsideList={shouldFooterBeInsideList} /> )} diff --git a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx index 2d1a8006b7bc..5e30d99f5648 100644 --- a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx @@ -17,6 +17,7 @@ import type {SearchDateModifier} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; @@ -46,7 +47,7 @@ function DateSelectPopup({label, value, presets, style, closeOverlay, onChange, const styles = useThemeStyles(); const {windowHeight} = useWindowDimensions(); const searchDatePresetFilterBaseRef = useRef(null); - const scrollViewRef = useRef>(null); + const scrollViewRef = useRef>(null); const [selectedDateModifier, setSelectedDateModifier] = useState(null); const [shouldShowRangeError, setShouldShowRangeError] = useState(false); const [rangeText, setRangeText] = useState(() => diff --git a/src/components/Search/FilterDropdowns/FilterPopupButton.tsx b/src/components/Search/FilterDropdowns/FilterPopupButton.tsx index 9a0e8096edc0..5930b83afce3 100644 --- a/src/components/Search/FilterDropdowns/FilterPopupButton.tsx +++ b/src/components/Search/FilterDropdowns/FilterPopupButton.tsx @@ -15,7 +15,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; import KeyboardUtils from '@src/utils/keyboard'; -import type {ReactNode, RefObject} from 'react'; +import type {ComponentRef, ReactNode, RefObject} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {useIsFocused} from '@react-navigation/core'; @@ -31,7 +31,7 @@ type PopoverComponentProps = { type ButtonComponentProps = { onPress: () => void; - ref: RefObject; + ref: RefObject | null>; isExpanded: boolean; }; @@ -42,6 +42,9 @@ type FilterPopupButtonProps = { popoverAnchorAlignment?: AnchorAlignment; PopoverComponent: (props: PopoverComponentProps) => ReactNode; renderButton: (props: ButtonComponentProps) => ReactNode; + + /** Called instead of opening the popover when device is in landscape mode */ + onLandscapePress?: () => void; }; const ANCHOR_ORIGIN = { @@ -49,18 +52,26 @@ const ANCHOR_ORIGIN = { vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, }; -function FilterPopupButton({viewportOffsetTop, popoverWidth, wrapperStyle, popoverAnchorAlignment: popoverAnchorAlignmentProp, PopoverComponent, renderButton}: FilterPopupButtonProps) { +function FilterPopupButton({ + viewportOffsetTop, + popoverWidth, + wrapperStyle, + popoverAnchorAlignment: popoverAnchorAlignmentProp, + PopoverComponent, + renderButton, + onLandscapePress, +}: FilterPopupButtonProps) { // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to distinguish RHP and narrow layout // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); + const {isSmallScreenWidth, isInLandscapeMode} = useResponsiveLayout(); const isFocused = useIsFocused(); const styles = useThemeStyles(); const {isKeyboardActive} = useKeyboardState(); const bottomSafeAreaPaddingStyle = useBottomSafeSafeAreaPaddingStyle({addBottomSafeAreaPadding: isSmallScreenWidth && !isKeyboardActive}); const StyleUtils = useStyleUtils(); const {windowHeight} = useWindowDimensions(); - const triggerRef = useRef(null); - const anchorRef = useRef(null); + const triggerRef = useRef | null>(null); + const anchorRef = useRef | null>(null); const [isOverlayVisible, setIsOverlayVisible] = useState(false); // Defer mounting the (potentially heavy) popover content until the dropdown is first opened, then keep it // mounted so the close animation and reopening stay instant. The content is otherwise mounted eagerly on @@ -117,7 +128,7 @@ function FilterPopupButton({viewportOffsetTop, popoverWidth, wrapperStyle, popov style={wrapperStyle} > {/* Dropdown Trigger */} - {renderButton({ref: triggerRef, onPress: calculatePopoverPositionAndToggleOverlay, isExpanded: isOverlayVisible})} + {renderButton({ref: triggerRef, onPress: isInLandscapeMode && onLandscapePress ? onLandscapePress : calculatePopoverPositionAndToggleOverlay, isExpanded: isOverlayVisible})} {/* Dropdown overlay. Gated on hasEverExpanded so the (potentially heavy) content subtree isn't mounted until the dropdown is first opened — PopoverWithMeasuredContentBase mounts children even while hidden. */} {isFocused && hasEverExpanded && ( diff --git a/src/components/Search/FilterDropdowns/MultiSelectPopup.tsx b/src/components/Search/FilterDropdowns/MultiSelectPopup.tsx index b798ea48a574..bf403e285763 100644 --- a/src/components/Search/FilterDropdowns/MultiSelectPopup.tsx +++ b/src/components/Search/FilterDropdowns/MultiSelectPopup.tsx @@ -65,5 +65,4 @@ function MultiSelectPopup({showLabel, label, loading, value, i ); } -export type {MultiSelectItem}; export default MultiSelectPopup; diff --git a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx index 62cce1c24439..d44cb288afb1 100644 --- a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx @@ -11,13 +11,15 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import {getFilterNegatableValue, hasFilterContentValuesChanged} from '@libs/SearchUIUtils'; +import {hasFilterContentValuesChanged} from '@libs/SearchUIUtils'; import type {SearchFilter} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import type {ComponentRef} from 'react'; + import React, {Activity, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -109,7 +111,7 @@ function SearchAdvancedFiltersPopup({queryJSON}: SearchAdvancedFiltersPopupProps const StyleUtils = useStyleUtils(); const {windowHeight} = useWindowDimensions(); const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); - const filterContentRef = useRef(null); + const filterContentRef = useRef>(null); const [mountedFilterState, setMountedFilterState] = useState(() => ({ activeFilter: INITIAL_FILTER, mountedFilters: [INITIAL_FILTER], @@ -196,7 +198,6 @@ function SearchAdvancedFiltersPopup({queryJSON}: SearchAdvancedFiltersPopupProps { const currency = item.currency ?? 'USD'; @@ -44,6 +44,7 @@ function SearchBarChart({data, getLabel, getShortLabel, getFilterQuery, onItemPr onBarPress={handleBarPress} yAxisUnit={unit} yAxisUnitPosition={unitPosition} + color={color} /> ); } diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index ce24cf54b352..e3505e458ec3 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -2,10 +2,8 @@ import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; import {sanitizeCurrencyCode} from '@libs/CurrencyUtils'; -import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {formatToParts} from '@libs/NumberFormatUtils'; -import {buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUtils'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; @@ -15,6 +13,7 @@ import React from 'react'; import type {ChartView, GroupedItem, SearchChartProps, SearchGroupBy, SearchQueryJSON} from './types'; +import {buildChartDrillDownQuery} from './chartDrillDown'; import CHART_GROUP_BY_CONFIG from './chartGroupByConfig'; import SearchBarChart from './SearchBarChart'; import {useSearchQueryContext} from './SearchContext'; @@ -34,6 +33,9 @@ type SearchChartViewProps = { data: GroupedItem[]; isLoading?: boolean; + + /** Color every bar is drawn in. Only a bar chart reads it. */ + color?: string; }; /** @@ -49,7 +51,7 @@ const CHART_VIEW_TO_COMPONENT: Record { - const currentQueryString = buildSearchQueryString(queryJSON); - const parsedQueryJSON = buildSearchQueryJSON(`${currentQueryString} ${filterQuery}`); + const query = buildChartDrillDownQuery(queryJSON, filterQuery); - if (!parsedQueryJSON) { - Log.alert('[SearchChartView] Failed to build search query JSON from filter query'); + if (!query) { return; } - const newQueryJSON: SearchQueryJSON = { - ...parsedQueryJSON, - groupBy: undefined, - // `limit` bounds how many bars the chart draws. Without `groupBy` it would cap the rows behind the bar we - // are drilling into, so it is dropped together with `groupBy`. - limit: undefined, - view: CONST.SEARCH.VIEW.TABLE, - sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, - sortOrder: CONST.SEARCH.SORT_ORDER.DESC, - }; - - const newQueryString = buildSearchQueryString(newQueryJSON); - // Drilling into a chart segment stays within the same search, so the key travels with it. - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: newQueryString, searchKey: currentSearchKey})); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query, searchKey: currentSearchKey})); }; const firstItem = data.at(0); @@ -100,6 +87,7 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading}: SearchChar isLoading={isLoading} unit={unit} unitPosition={unitPosition} + color={color} /> ); } diff --git a/src/components/Search/SearchList/BaseSearchList/index.tsx b/src/components/Search/SearchList/BaseSearchList/index.tsx index 5f7273b9f6f8..1fbb0c477c13 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.tsx @@ -16,6 +16,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isModalActiveSelector} from '@src/selectors/Modal'; +import type {ComponentRef} from 'react'; import type {GestureResponderEvent, NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle} from 'react-native'; import {useIsFocused} from '@react-navigation/native'; @@ -29,7 +30,7 @@ import type BaseSearchListProps from './types'; const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList); type CellRendererComponentProps = ViewProps & { - ref?: React.Ref; + ref?: React.Ref>; style?: StyleProp; }; diff --git a/src/components/Search/SearchList/ListItem/GroupHeader.tsx b/src/components/Search/SearchList/ListItem/GroupHeader.tsx index 9ef940c5fb8c..d448f481ac5e 100644 --- a/src/components/Search/SearchList/ListItem/GroupHeader.tsx +++ b/src/components/Search/SearchList/ListItem/GroupHeader.tsx @@ -9,6 +9,7 @@ import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/typ import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useExpandCollapseAnimation from '@hooks/useExpandCollapseAnimation'; +import useIsVendorColumnAvailable from '@hooks/useIsVendorColumnAvailable'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useOnyx from '@hooks/useOnyx'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; @@ -29,6 +30,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportAction, ReportActions} from '@src/types/onyx'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; +import type {ComponentRef} from 'react'; import type {NativeSyntheticEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -106,6 +108,7 @@ function GroupHeader({ const expensifyIcons = useMemoizedLazyExpensifyIcons(['UpArrow', 'DownArrow']); const currentUserDetails = useCurrentUserPersonalDetails(); const {policyForMovingExpensesID} = usePolicyForMovingExpenses(); + const isVendorColumnAvailable = useIsVendorColumnAvailable(); const groupItem = item; const isExpenseReportType = searchType === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; @@ -145,6 +148,7 @@ function GroupHeader({ type: snapshotSearchType, shouldShowViolationsColumn: queryHasViolationFilter(groupItem.transactionsQueryJSON), fallbackPolicyID: policyForMovingExpensesID, + isVendorColumnAvailable, }); } @@ -347,7 +351,7 @@ function GroupHeader({ ); const isLastItemCollapsed = isLastItem && !isExpanded && !isSubHeaderRendered; - const pressableRef = useRef(null); + const pressableRef = useRef>(null); useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus); diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx index 2032218e17bb..8d57e5b0482b 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx @@ -13,6 +13,7 @@ import {useWideRHPActions} from '@components/WideRHPContextProvider'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsVendorColumnAvailable from '@hooks/useIsVendorColumnAvailable'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; @@ -95,6 +96,7 @@ function TransactionGroupListExpandedImpl({ const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector}); + const isVendorColumnAvailable = useIsVendorColumnAvailable(); const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); const [policyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); @@ -147,6 +149,7 @@ function TransactionGroupListExpandedImpl({ type: transactionsSnapshot?.search.type, fallbackPolicyID: policyForMovingExpensesID, shouldShowViolationsColumn: queryHasViolationFilter(transactionsQueryJSON), + isVendorColumnAvailable, }); } } diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index 561d8f646493..77df9159d580 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -30,6 +30,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportAction, ReportActions, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; +import type {ComponentRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {useIsFocused} from '@react-navigation/native'; @@ -222,7 +223,7 @@ function TransactionGroupListItemImpl({ isLastItem && styles.tableBottomRadius, isItemSelected && styles.activeComponentBG, ]; - const pressableRef = useRef(null); + const pressableRef = useRef>(null); useEffect(() => { if (!newTransactionID || !isExpanded) { diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx index 697eed254b32..6e17c5c8e33f 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx @@ -16,6 +16,7 @@ import durationHighlightItem from '@libs/Navigation/helpers/getDurationHighlight import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import React, {useEffect, useRef, useState} from 'react'; @@ -50,7 +51,7 @@ function TransactionListItemNarrow({ const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); - const pressableRef = useRef(null); + const pressableRef = useRef>(null); useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus); const transactionItem = item as unknown as TransactionListItemType; diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx index d94d0c3d3cf4..c1b57ee299db 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx @@ -15,6 +15,7 @@ import useTransactionInlineEdit from '@hooks/useTransactionInlineEdit'; import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import React, {useEffect, useRef, useState} from 'react'; @@ -53,7 +54,7 @@ function TransactionListItemWide({ const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); - const pressableRef = useRef(null); + const pressableRef = useRef>(null); useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus); const transactionItem = item as unknown as TransactionListItemType; diff --git a/src/components/Search/SearchList/SearchSelectAllMenu.tsx b/src/components/Search/SearchList/SearchSelectAllMenu.tsx index dd8ad8a991f4..705a38f1bc90 100644 --- a/src/components/Search/SearchList/SearchSelectAllMenu.tsx +++ b/src/components/Search/SearchList/SearchSelectAllMenu.tsx @@ -12,6 +12,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import React, {useCallback, useMemo, useRef, useState} from 'react'; @@ -31,7 +32,7 @@ function SearchSelectAllMenu({isSelectAllChecked, isIndeterminate, selectedItems const expensifyIcons = useMemoizedLazyExpensifyIcons(['Checkmark', 'CheckSquare']); const {currentSearchResults} = useSearchResultsContext(); const {selectAllMatchingItems} = useSearchSelectionActions(); - const selectAllAnchorRef = useRef(null); + const selectAllAnchorRef = useRef>(null); const [isSelectAllMenuVisible, setIsSelectAllMenuVisible] = useState(false); const [selectAllMenuPosition, setSelectAllMenuPosition] = useState({horizontal: 0, vertical: 0}); const {calculatePopoverPosition} = usePopoverPosition(); diff --git a/src/components/Search/SearchLoadingSkeleton.tsx b/src/components/Search/SearchLoadingSkeleton.tsx index 77908ab0607c..6f40e780df40 100644 --- a/src/components/Search/SearchLoadingSkeleton.tsx +++ b/src/components/Search/SearchLoadingSkeleton.tsx @@ -10,7 +10,7 @@ import CONST from '@src/CONST'; import type {StyleProp, ViewStyle} from 'react-native'; import React from 'react'; -import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; +import {StyleSheet, View} from 'react-native'; type SearchLoadingSkeletonProps = { containerStyle?: StyleProp; @@ -20,10 +20,8 @@ function SearchLoadingSkeleton({containerStyle}: SearchLoadingSkeletonProps) { const styles = useThemeStyles(); return ( - { endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: false}); endNavigateToReportsFirstPaint(CONST.TELEMETRY.NAVIGATE_TO_REPORTS_START_TYPE.COLD); @@ -33,7 +31,7 @@ function SearchLoadingSkeleton({containerStyle}: SearchLoadingSkeletonProps) { shouldAnimate containerStyle={containerStyle} /> - + ); } diff --git a/src/components/Search/SearchPageHeader/SearchActionsBarCreateButton.tsx b/src/components/Search/SearchPageHeader/SearchActionsBarCreateButton.tsx index aa1530a41286..59d21f3d61d9 100644 --- a/src/components/Search/SearchPageHeader/SearchActionsBarCreateButton.tsx +++ b/src/components/Search/SearchPageHeader/SearchActionsBarCreateButton.tsx @@ -31,6 +31,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; +import type {ComponentRef} from 'react'; import type {OnyxCollection} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; @@ -44,7 +45,7 @@ function SearchActionsBarCreateButton() { const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus', 'Location', 'Document', 'Receipt', 'Coins', 'Cash', 'Transfer', 'MoneyCircle']); - const createButtonRef = useRef(null); + const createButtonRef = useRef>(null); const [isCreateMenuActive, setIsCreateMenuActive] = useState(false); const [createMenuPosition, setCreateMenuPosition] = useState<{horizontal: number; vertical: number}>({horizontal: 0, vertical: 0}); const {calculatePopoverPosition} = usePopoverPosition(); diff --git a/src/components/Search/SearchPageHeader/SearchFilterBar.tsx b/src/components/Search/SearchPageHeader/SearchFilterBar.tsx index 4e6a84fc1640..88df128265dc 100644 --- a/src/components/Search/SearchPageHeader/SearchFilterBar.tsx +++ b/src/components/Search/SearchPageHeader/SearchFilterBar.tsx @@ -16,11 +16,11 @@ import React from 'react'; import type {FilterItem} from './useSearchFiltersBar'; -type DropdownProps = Pick & { +type DropdownProps = Pick & { value: SearchFilter['value']; }; -function UserDropdown({label, value, PopoverComponent, sentryLabel, onClosePress}: DropdownProps) { +function UserDropdown({label, value, PopoverComponent, sentryLabel, onClosePress, onLandscapePress}: DropdownProps) { const users = useFilterUserValue(value); return ( ); } -function WorkspaceDropdown({label, value, PopoverComponent, sentryLabel, onClosePress}: DropdownProps) { +function WorkspaceDropdown({label, value, PopoverComponent, sentryLabel, onClosePress, onLandscapePress}: DropdownProps) { const workspaceValue = useFilterWorkspaceValue(value); return ( ); } -function FeedDropdown({label, value, PopoverComponent, sentryLabel, onClosePress}: DropdownProps) { +function FeedDropdown({label, value, PopoverComponent, sentryLabel, onClosePress, onLandscapePress}: DropdownProps) { const feedValue = useFilterFeedValue(value as string[]); return ( ); } -function CardDropdown({label, value, PopoverComponent, sentryLabel, onClosePress}: DropdownProps) { +function CardDropdown({label, value, PopoverComponent, sentryLabel, onClosePress, onLandscapePress}: DropdownProps) { const cardValue = useFilterCardValue(value as string[]); return ( ); } -function BankAccountDropdown({label, value, PopoverComponent, sentryLabel, onClosePress}: DropdownProps) { +function BankAccountDropdown({label, value, PopoverComponent, sentryLabel, onClosePress, onLandscapePress}: DropdownProps) { const bankAccountValue = useFilterBankAccountValue(value); return ( ); } -function TaxRateDropdown({label, value, PopoverComponent, sentryLabel, onClosePress}: DropdownProps) { +function TaxRateDropdown({label, value, PopoverComponent, sentryLabel, onClosePress, onLandscapePress}: DropdownProps) { const taxRateValue = useFilterTaxRateValue(value as string[]); return ( ); } -function ReportDropdown({label, value, PopoverComponent, sentryLabel, onClosePress}: DropdownProps) { +function ReportDropdown({label, value, PopoverComponent, sentryLabel, onClosePress, onLandscapePress}: DropdownProps) { const reportValue = useFilterReportValue(value); return ( ); } @@ -139,6 +146,7 @@ function SearchFilterBar({item}: {item: SearchFilter & FilterItem}) { PopoverComponent={item.PopoverComponent} sentryLabel={item.sentryLabel} onClosePress={item.onClosePress} + onLandscapePress={item.onLandscapePress} /> ); } diff --git a/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx b/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx index fcd0fdb5254c..45db058e99ac 100644 --- a/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx +++ b/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx @@ -23,6 +23,7 @@ import type {SearchFilter} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; import type {SearchAdvancedFiltersKey} from '@src/types/form/SearchAdvancedFiltersForm'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; @@ -37,6 +38,7 @@ import DatePickerFilterPopup from './DatePickerFilterPopup'; type FilterItem = WithSentryLabel & { PopoverComponent: (props: PopoverComponentProps) => ReactNode; onClosePress: (() => void) | undefined; + onLandscapePress?: () => void; }; type UseSearchFiltersBarResult = { @@ -172,6 +174,7 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON): UseSearchFiltersBarRes ), sentryLabel: getFilterSentryLabel(filterKey), + onLandscapePress: () => Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS_CONTENT.getRoute(removeNegation(filterKey), true)), onClosePress: isDefault ? undefined : () => { diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index b5bc1425e206..353f7c2aed7f 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -54,6 +54,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type Report from '@src/types/onyx/Report'; +import type {ComponentRef} from 'react'; import type {TextInputProps} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -80,7 +81,7 @@ type SearchRouterProps = { onRouterClose: (afterClose?: () => void) => void; shouldHideInputCaret?: TextInputProps['caretHidden']; isSearchRouterDisplayed?: boolean; - ref?: React.Ref; + ref?: React.Ref>; }; function searchForReportsAndUsersInServer(searchInput: string) { @@ -330,6 +331,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla reportAttributes, isTrackIntentUser, dateFnsLocale, + currentUserAccountID, convertToDisplayString, convertToDisplayStringWithoutCurrency, rules, diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index 9af0d23a48b6..17f778062bce 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -16,7 +16,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; +import {useSearchTypeMenuSectionsForNavigation} from '@hooks/useSearchTypeMenuSections'; import useThemeStyles from '@hooks/useThemeStyles'; import navigateToDomainRouteWithSidebarSync from '@libs/Navigation/helpers/navigateToDomainRouteWithSidebarSync'; @@ -397,7 +397,7 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); const {clearSelectedTransactions} = useSearchSelectionActions(); - const typeMenuSections = useSearchTypeMenuSections(shouldWatchForApprovals); + const typeMenuSections = useSearchTypeMenuSectionsForNavigation(shouldWatchForApprovals); const {accountMenuItemsData, generalMenuItemsData} = useSettingsNavigationMenuData(); const topLevelItems = buildTopLevelNavigationItems({ diff --git a/src/components/Search/SearchWithNavigationDeferredMount.tsx b/src/components/Search/SearchWithNavigationDeferredMount.tsx index b265db02c924..d4a528095f1e 100644 --- a/src/components/Search/SearchWithNavigationDeferredMount.tsx +++ b/src/components/Search/SearchWithNavigationDeferredMount.tsx @@ -12,10 +12,17 @@ import CONST from '@src/CONST'; import type {ComponentProps} from 'react'; -import React from 'react'; +import React, {useState} from 'react'; +import {StyleSheet, View} from 'react-native'; +import Animated, {FadeIn} from 'react-native-reanimated'; import Search from './index'; +type SearchWithNavigationDeferredMountProps = ComponentProps & { + /** Whether this mount is swapping in over content that is already on screen, rather than filling an empty page. */ + isReplacingContent?: boolean; +}; + function handleSkeletonLayout() { endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true}); endNavigateToReportsFirstPaint(CONST.TELEMETRY.NAVIGATE_TO_REPORTS_START_TYPE.WARM_FIRST); @@ -30,23 +37,34 @@ function handleSkeletonLayout() { } } -function SearchWithNavigationDeferredMount(props: ComponentProps) { +function SearchWithNavigationDeferredMount({isReplacingContent, ...props}: SearchWithNavigationDeferredMountProps) { const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const containerStyle = shouldUseNarrowLayout ? styles.searchListContentContainerStyles(!!props.hasFilterBars) : undefined; + // Read once at mount: the placeholder only ever covers this mount's deferral window, so a later prop change must + // not un-hide it mid-swap. + const [isReplacingContentAtMount] = useState(isReplacingContent); + return ( + + + } > - + + + ); } diff --git a/src/components/Search/chartDrillDown.ts b/src/components/Search/chartDrillDown.ts new file mode 100644 index 000000000000..2f6658e5af1c --- /dev/null +++ b/src/components/Search/chartDrillDown.ts @@ -0,0 +1,35 @@ +import Log from '@libs/Log'; +import {buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUtils'; + +import CONST from '@src/CONST'; + +import type {SearchQueryJSON, SearchQueryString} from './types'; + +/** + * Builds the Spend table query behind a clicked chart point, keeping the filters the chart was plotted with. + * A ranking point adds its own dimension as a filter, a time bucket narrows the date range to the bucket clicked. + */ +function buildChartDrillDownQuery(queryJSON: Readonly, filterQuery: string): SearchQueryString | undefined { + const pointQueryJSON = buildSearchQueryJSON(`${buildSearchQueryString(queryJSON)} ${filterQuery}`); + + if (!pointQueryJSON) { + Log.alert('[chartDrillDown] Failed to build search query JSON from filter query'); + return undefined; + } + + return buildSearchQueryString({ + ...pointQueryJSON, + groupBy: undefined, + limit: undefined, + view: CONST.SEARCH.VIEW.TABLE, + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + }); +} + +/** Builds the Spend table query behind a chart's "View on Spend", which opens the rows the chart plots as they are grouped and sorted, over the whole period. */ +function buildViewOnSpendQuery(queryJSON: Readonly): SearchQueryString { + return buildSearchQueryString({...queryJSON, limit: undefined, view: CONST.SEARCH.VIEW.TABLE}); +} + +export {buildChartDrillDownQuery, buildViewOnSpendQuery}; diff --git a/src/components/Search/hooks/useGroupedItems.ts b/src/components/Search/hooks/useGroupedItems.ts new file mode 100644 index 000000000000..2494ec600430 --- /dev/null +++ b/src/components/Search/hooks/useGroupedItems.ts @@ -0,0 +1,67 @@ +import type {GroupedItem, SearchQueryJSON} from '@components/Search/types'; + +import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; + +import {getSections, getSortedSections, isGroupedItemArray, isGroupEntry} from '@libs/SearchUIUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SearchResults from '@src/types/onyx/SearchResults'; + +import type {OnyxEntry} from 'react-native-onyx'; + +/** Returns the accounts named in the snapshot's groups, so the hook can subscribe to just their personal details. */ +function getGroupedAccountIDs(data: SearchResults['data'] = {}): number[] { + return Object.keys(data).flatMap((key) => (isGroupEntry(key) && 'accountID' in data[key] && data[key].accountID ? [data[key].accountID] : [])); +} + +/** + * Turns a search snapshot into the sorted rows a chart plots, grouped and ordered the way the query asks for. + * Returns nothing until the snapshot holds data, which is what a caller reads as "not loaded yet". + */ +function useGroupedItems(searchResults: OnyxEntry, queryJSON: Readonly | undefined): GroupedItem[] | undefined { + const {translate, localeCompare, formatPhoneNumber, dateFnsLocale} = useLocalize(); + const {convertToDisplayString} = useCurrencyListActions(); + const {accountID, login} = useCurrentUserPersonalDetails(); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const groupBy = queryJSON?.groupBy; + const [onyxPersonalDetailsList] = usePersonalDetailsByIDs(groupBy === CONST.SEARCH.GROUP_BY.FROM ? getGroupedAccountIDs(searchResults?.data) : undefined); + + const sortedSections = + searchResults?.data && queryJSON && groupBy && login + ? getSortedSections( + queryJSON.type, + getSections({ + dateFnsLocale, + type: queryJSON.type, + data: searchResults.data, + groupBy, + queryJSON, + currentAccountID: accountID, + currentUserEmail: login, + translate, + formatPhoneNumber, + bankAccountList: undefined, + rules, + conciergeReportID, + convertToDisplayString, + onyxPersonalDetailsList, + reportAttributesDerivedValue: undefined, + })[0], + localeCompare, + translate, + queryJSON.sortBy, + queryJSON.sortOrder, + groupBy, + ) + : undefined; + + return sortedSections && isGroupedItemArray(sortedSections) ? sortedSections : undefined; +} + +export default useGroupedItems; diff --git a/src/components/Search/hooks/useSearchColumnWidths.ts b/src/components/Search/hooks/useSearchColumnWidths.ts index f399b00b22ab..f395dcaa4e27 100644 --- a/src/components/Search/hooks/useSearchColumnWidths.ts +++ b/src/components/Search/hooks/useSearchColumnWidths.ts @@ -148,12 +148,11 @@ function useSearchColumnWidths({ // It does not have to be exact. The resolved widths are applied as a flex basis that still grows and shrinks, so an // inset a few px out shows as the columns sharing a little more or less room rather than as dead space at the end // of the row. What it does decide is which of the four states the table is in, and "close" is enough for that. + // A table showing enough fixed columns to overrun its own width leaves this at zero or less. That is the scrolling + // case rather than a reason to stop: the columns are still measured, and each one is sized to its content so an + // empty column stays narrow instead of claiming an equal share of a budget that does not exist. const availableWidth = tableWidth - getSearchTableRowInsetWidth(columns.length) - fixedColumnsWidth; - if (availableWidth <= 0) { - return noColumnSizing; - } - const constraints: DynamicColumnConstraints[] = []; const contentWidths: number[] = []; diff --git a/src/components/Search/hooks/useSearchSnapshot.ts b/src/components/Search/hooks/useSearchSnapshot.ts index 6864840739a0..dba8eee36d7e 100644 --- a/src/components/Search/hooks/useSearchSnapshot.ts +++ b/src/components/Search/hooks/useSearchSnapshot.ts @@ -5,6 +5,7 @@ import type {SearchColumnType, SearchData, SearchQueryJSON} from '@components/Se import useActionLoadingReportIDs from '@hooks/useActionLoadingReportIDs'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsVendorColumnAvailable from '@hooks/useIsVendorColumnAvailable'; import useLocalize from '@hooks/useLocalize'; import useMultipleSnapshots from '@hooks/useMultipleSnapshots'; import useNetwork from '@hooks/useNetwork'; @@ -127,6 +128,7 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans const [policyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector}); + const isVendorColumnAvailable = useIsVendorColumnAvailable(); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); // Inject an optimistically-created transaction the server has not indexed yet so its row mounts @@ -397,6 +399,7 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans fallbackPolicyID: policyForMovingExpensesID, sortBy: queryJSON.sortBy, shouldShowViolationsColumn: queryHasViolationFilter(queryJSON), + isVendorColumnAvailable, }); })(); diff --git a/src/components/Search/hooks/useSearchTableWidth/index.native.ts b/src/components/Search/hooks/useSearchTableWidth/index.native.ts index d8a13724bbb8..c905285cd395 100644 --- a/src/components/Search/hooks/useSearchTableWidth/index.native.ts +++ b/src/components/Search/hooks/useSearchTableWidth/index.native.ts @@ -1,3 +1,4 @@ +import type {ComponentRef} from 'react'; import type {LayoutChangeEvent, View} from 'react-native'; import {useRef, useState} from 'react'; @@ -8,7 +9,7 @@ import {useRef, useState} from 'react'; * web, where it is attached to the container to take the synchronous first measurement. */ function useSearchTableWidth() { - const tableWidthRef = useRef(null); + const tableWidthRef = useRef | null>(null); const [tableWidth, setTableWidth] = useState(0); const onTableLayout = (event: LayoutChangeEvent) => { diff --git a/src/components/Search/hooks/useSearchTableWidth/index.ts b/src/components/Search/hooks/useSearchTableWidth/index.ts index 7ee081511e93..dd730f28ee9e 100644 --- a/src/components/Search/hooks/useSearchTableWidth/index.ts +++ b/src/components/Search/hooks/useSearchTableWidth/index.ts @@ -1,3 +1,4 @@ +import type {ComponentRef} from 'react'; import type {LayoutChangeEvent, View} from 'react-native'; import {useLayoutEffect, useRef, useState} from 'react'; @@ -14,7 +15,7 @@ import {useLayoutEffect, useRef, useState} from 'react'; * `onLayout` keeps the width current afterwards (window resize, sidebar collapse), which is also native's only source. */ function useSearchTableWidth() { - const tableWidthRef = useRef(null); + const tableWidthRef = useRef | null>(null); const [tableWidth, setTableWidth] = useState(0); useLayoutEffect(() => { diff --git a/src/components/Search/hooks/useSyncedHorizontalScroll/index.ts b/src/components/Search/hooks/useSyncedHorizontalScroll/index.ts index 64c887f68592..b5b93bd8892d 100644 --- a/src/components/Search/hooks/useSyncedHorizontalScroll/index.ts +++ b/src/components/Search/hooks/useSyncedHorizontalScroll/index.ts @@ -1,3 +1,4 @@ +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView, View as RNView} from 'react-native'; @@ -16,7 +17,7 @@ function asElement(node: unknown): HTMLElement | undefined { } /** React Native types `getScrollableNode` as `any`. On web it resolves to the element that actually scrolls. */ -function getScrollableElement(scrollView: RNScrollView | null): HTMLElement | undefined { +function getScrollableElement(scrollView: ComponentRef | null): HTMLElement | undefined { return asElement(scrollView?.getScrollableNode()); } @@ -38,7 +39,7 @@ const useSyncedHorizontalScroll: UseSyncedHorizontalScroll = (key, isEnabled) => // A ref callback, not an effect, because a group renders collapsed and its rows scroller only mounts on expand, // after this hook's first render. The group header stays mounted across expand and collapse, so an effect keyed // on render values would never re-run to catch it. - const scrollViewRef = (scrollView: RNScrollView | null) => { + const scrollViewRef = (scrollView: ComponentRef | null) => { releaseRef.current?.(); releaseRef.current = undefined; @@ -89,7 +90,7 @@ const useHorizontalScrollFollower: UseHorizontalScrollFollower = (key, isEnabled // A ref callback for the same reason the publisher uses one, plus one of its own. When a group header sticks, // FlashList mounts a second copy of it, and that copy has to pick the group's offset up as it arrives. - return (view: RNView | null) => { + return (view: ComponentRef | null) => { releaseRef.current?.(); releaseRef.current = undefined; diff --git a/src/components/Search/hooks/useSyncedHorizontalScroll/types.ts b/src/components/Search/hooks/useSyncedHorizontalScroll/types.ts index 2a6aa6333b4b..1829e6f23a0f 100644 --- a/src/components/Search/hooks/useSyncedHorizontalScroll/types.ts +++ b/src/components/Search/hooks/useSyncedHorizontalScroll/types.ts @@ -1,10 +1,10 @@ -import type {Ref} from 'react'; +import type {ComponentRef, Ref} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView, ScrollViewProps, View as RNView} from 'react-native'; type SyncedHorizontalScroll = { /** Ref for the horizontal ScrollView that drives the sync. */ - scrollViewRef: Ref; + scrollViewRef: Ref>; /** * Props to spread onto that ScrollView. Empty whenever syncing is off, so a layout that doesn't sync (native, or @@ -31,6 +31,6 @@ type UseSyncedHorizontalScroll = (key: string | undefined, isEnabled: boolean) = * `scrollLeft` keeps it in step with the rows without a visible scrollbar and without ever publishing an offset of * its own. Same key and `isEnabled` contract as `useSyncedHorizontalScroll`. */ -type UseHorizontalScrollFollower = (key: string | undefined, isEnabled: boolean) => Ref; +type UseHorizontalScrollFollower = (key: string | undefined, isEnabled: boolean) => Ref>; export type {SyncedHorizontalScroll, UseHorizontalScrollFollower, UseSyncedHorizontalScroll}; diff --git a/src/components/Search/hooks/useUpdateFilterQuery.tsx b/src/components/Search/hooks/useUpdateFilterQuery.tsx index b53eb7f5f3a3..5752151e3967 100644 --- a/src/components/Search/hooks/useUpdateFilterQuery.tsx +++ b/src/components/Search/hooks/useUpdateFilterQuery.tsx @@ -5,6 +5,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import Navigation from '@libs/Navigation/Navigation'; +import {markQueryAsRefinement} from '@libs/SearchQueryRefinement'; import {buildFilterQueryWithSortDefaults, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {filterValidHasValues} from '@libs/SearchUIUtils'; @@ -51,6 +52,9 @@ function useUpdateFilterQuery(queryJSON: SearchQueryJSON | undefined) { return; } + // Mark the query as a refinement so SearchPage holds the previous results while it loads + markQueryAsRefinement(queryString); + const shouldResetSearchKey = !!values.type && values.type !== searchAdvancedFiltersForm.type; Navigation.setParams({q: queryString, rawQuery: undefined, ...(shouldResetSearchKey && {searchKey: getSearchKeyForQuery(buildSearchQueryJSON(queryString))})}); diff --git a/src/components/Search/primitives/HorizontalTableScroll.tsx b/src/components/Search/primitives/HorizontalTableScroll.tsx index 08492424d323..42816a5ca6a5 100644 --- a/src/components/Search/primitives/HorizontalTableScroll.tsx +++ b/src/components/Search/primitives/HorizontalTableScroll.tsx @@ -8,6 +8,7 @@ import {getTableMinWidth} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {NativeScrollEvent, NativeSyntheticEvent, ScrollView as RNScrollView} from 'react-native'; @@ -73,7 +74,7 @@ function HorizontalTableScroll({children, columns, type, isActionColumnWide, isH const shouldScrollHorizontally = isHeaderVisible && minTableWidth > tableWidth; const contentTableWidth = Math.max(getTableMinWidth(columns, type, isActionColumnWide, columnContentWidths, isSizingColumns), minTableWidth, tableWidth); - const horizontalScrollViewRef = useRef(null); + const horizontalScrollViewRef = useRef>(null); const handleHorizontalScroll = useCallback((event: NativeSyntheticEvent) => { savedHorizontalScrollOffset = event.nativeEvent.contentOffset.x; diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index a4dcb6e5df9b..ada82c5038cb 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -477,6 +477,9 @@ type SearchChartProps = { /** Position of currency symbol relative to value */ unitPosition?: UnitPosition; + + /** Color every bar is drawn in. Only a bar chart reads it. */ + color?: string; }; type SearchFilterCommonProps = { diff --git a/src/components/SelectionButton.tsx b/src/components/SelectionButton.tsx index a4664da47224..09e6f5c91c61 100644 --- a/src/components/SelectionButton.tsx +++ b/src/components/SelectionButton.tsx @@ -9,7 +9,7 @@ import CONST from '@src/CONST'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; -import type {ForwardedRef, MouseEventHandler, KeyboardEvent as ReactKeyboardEvent} from 'react'; +import type {ComponentRef, ForwardedRef, MouseEventHandler, KeyboardEvent as ReactKeyboardEvent} from 'react'; import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; import React from 'react'; @@ -62,7 +62,7 @@ type BaseSelectionButtonProps = Partial & /** Used to locate this view in end-to-end tests. */ testID?: string; - ref?: ForwardedRef; + ref?: ForwardedRef>; tabIndex?: 0 | -1; /** Whether the button is accessible to screen readers */ diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index f8d5b1672829..f1b437a2d83e 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -529,7 +529,8 @@ function BaseSelectionListImpl({ data={data} renderItem={renderItem} ref={listRef} - keyExtractor={(item) => item.keyForList} + // FlashList can pass a stale index here while its render stack syncs after the dataset shrinks, so item can be undefined. + keyExtractor={(item, index) => item?.keyForList ?? index.toString()} extraData={extraData} ListFooterComponent={ isFooterInsideList ? ( diff --git a/src/components/SelectionList/ListItem/UserSelectionListItem.tsx b/src/components/SelectionList/ListItem/UserSelectionListItem.tsx index bf9f22caab17..99153a4d77b0 100644 --- a/src/components/SelectionList/ListItem/UserSelectionListItem.tsx +++ b/src/components/SelectionList/ListItem/UserSelectionListItem.tsx @@ -48,7 +48,7 @@ function UserSelectionListItem({ const userDisplayName = getDisplayNameForParticipant({ accountID: item.accountID ?? CONST.DEFAULT_NUMBER_ID, formatPhoneNumber, - translate, + hiddenTranslation: translate('common.hidden'), }); const icon = item.icons?.at(0); diff --git a/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx b/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx index 54439eabcf00..565cf4cd1199 100644 --- a/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx +++ b/src/components/SelectionList/ListItemComposed/ListItemPressable.tsx @@ -16,6 +16,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import React, {useRef} from 'react'; @@ -61,7 +62,7 @@ function ListItemPressable({ } = useHover(); const {isMouseDownOnInput} = useMouseState(); const {setMouseUp} = useMouseActions(); - const pressableRef = useRef(null); + const pressableRef = useRef>(null); // Sync focus on an item useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus); diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index 237dd3689e26..42040049e946 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -91,6 +91,7 @@ function BaseSelectionListWithSectionsImpl({ shouldHighlightSelectedItem, shouldDisableHoverStyle, selectionButtonPosition, + shouldFooterBeInsideList = false, setShouldDisableHoverStyle = () => {}, }: SelectionListWithSectionsProps) { const styles = useThemeStyles(); @@ -333,6 +334,17 @@ function BaseSelectionListWithSectionsImpl({ } }; + // Footer renders nothing when there is no footer content + const footer = ( + + footerContent={footerContent} + addBottomSafeAreaPadding={addBottomSafeAreaPadding} + /> + ); + + const shouldShowEmptyState = itemsCount === 0 && (shouldShowLoadingPlaceholder || shouldShowListEmptyContent); + const isFooterInsideList = shouldFooterBeInsideList && !shouldShowEmptyState; + return ( {textInputComponent()} {customHeaderContent} - {itemsCount === 0 && (shouldShowLoadingPlaceholder || shouldShowListEmptyContent) ? ( + {shouldShowEmptyState ? ( + {listFooterContent} + {footer} + + ) : ( + listFooterContent + ) + } ListFooterComponentStyle={style?.listFooterContentStyle} style={style?.listStyle} contentContainerStyle={style?.contentContainerStyle} maintainVisibleContentPosition={{disabled: true}} /> )} - {!!footerContent && ( - - footerContent={footerContent} - addBottomSafeAreaPadding={addBottomSafeAreaPadding} - /> - )} + {!isFooterInsideList && footer} ); } diff --git a/src/components/SelectionList/hooks/useScrollToFocusedInput/index.native.ts b/src/components/SelectionList/hooks/useScrollToFocusedInput/index.native.ts index e9d93847eea8..eea70bb1d143 100644 --- a/src/components/SelectionList/hooks/useScrollToFocusedInput/index.native.ts +++ b/src/components/SelectionList/hooks/useScrollToFocusedInput/index.native.ts @@ -2,6 +2,7 @@ import type {MeasurableInput} from '@components/SelectionList/SelectionListWithS import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; import type {EmitterSubscription, NativeScrollEvent, NativeSyntheticEvent, View} from 'react-native'; import {useCallback, useEffect, useRef} from 'react'; @@ -29,7 +30,7 @@ function isMeasurable(node: MeasurableInput): node is MeasurableNode { * so we instead pull the focused input up toward the top of the list area once the keyboard is shown. */ const useScrollToFocusedInput: UseScrollToFocusedInput = (listRef, isKeyboardShown) => { - const containerRef = useRef(null); + const containerRef = useRef | null>(null); const scrollOffsetRef = useRef(0); const isKeyboardShownRef = useRef(isKeyboardShown); const keyboardListenerRef = useRef(null); diff --git a/src/components/SelectionList/hooks/useScrollToFocusedInput/index.ts b/src/components/SelectionList/hooks/useScrollToFocusedInput/index.ts index 36b23364c43a..6ae9ecfdef1e 100644 --- a/src/components/SelectionList/hooks/useScrollToFocusedInput/index.ts +++ b/src/components/SelectionList/hooks/useScrollToFocusedInput/index.ts @@ -1,3 +1,4 @@ +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import {useRef} from 'react'; @@ -11,7 +12,7 @@ const noop = () => {}; * `containerRef` is still returned so the list can attach it harmlessly. */ const useScrollToFocusedInput: UseScrollToFocusedInput = () => { - const containerRef = useRef(null); + const containerRef = useRef | null>(null); return {containerRef, trackScrollOffset: noop, scrollInputIntoView: noop}; }; diff --git a/src/components/SelectionList/hooks/useScrollToFocusedInput/types.ts b/src/components/SelectionList/hooks/useScrollToFocusedInput/types.ts index 1d7010e067e3..c54797dc58ee 100644 --- a/src/components/SelectionList/hooks/useScrollToFocusedInput/types.ts +++ b/src/components/SelectionList/hooks/useScrollToFocusedInput/types.ts @@ -1,12 +1,12 @@ import type {MeasurableInput} from '@components/SelectionList/SelectionListWithSections/types'; import type {FlashListRef} from '@shopify/flash-list'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, View} from 'react-native'; type UseScrollToFocusedInputResult = { /** Attach to the list's outer container; its top is used as a stable anchor to pull focused inputs up to. */ - containerRef: RefObject; + containerRef: RefObject | null>; /** Wire into the list's `onScroll` so we always know the current content offset. */ trackScrollOffset: (event: NativeSyntheticEvent) => void; diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 97d2d6c69737..d8910a404829 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -45,6 +45,10 @@ type BaseSelectionListProps = { onSelectRow: (item: TItem) => void; canSelectMultiple?: boolean; footerContent?: React.ReactNode; + + /** Whether to place the footer in the list so it scrolls with data instead of being fixed to the bottom */ + shouldFooterBeInsideList?: boolean; + listFooterContent?: React.JSX.Element | null | undefined; shouldShowLoadingPlaceholder?: boolean; shouldShowTooltips?: boolean; @@ -162,9 +166,6 @@ type SelectionListProps = Partial & /** Custom accessibility label for the select all checkbox, providing context about what is being selected */ selectAllAccessibilityLabel?: string; - - /** Whether to place the footer (custom footer content or the confirm button) in the list so it scrolls with data instead of being fixed to the bottom */ - shouldFooterBeInsideList?: boolean; }; type SelectionListStyle = { diff --git a/src/components/SettlementButton/AnimatedSettlementButton.tsx b/src/components/SettlementButton/AnimatedSettlementButton.tsx index 68c7404cf2da..96344fa2d930 100644 --- a/src/components/SettlementButton/AnimatedSettlementButton.tsx +++ b/src/components/SettlementButton/AnimatedSettlementButton.tsx @@ -13,6 +13,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportActions, ReportMetadata} from '@src/types/onyx'; +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -140,7 +141,7 @@ function AnimatedSettlementButton({ icon = expensifyIcons.Checkmark; } - const animatedViewRef = (el: View | null) => { + const animatedViewRef = (el: ComponentRef | null) => { if (!el || !isAnimationRunning) { return; } diff --git a/src/components/SingleChoiceQuestion.tsx b/src/components/SingleChoiceQuestion.tsx index 2dc853cb54b5..60aff9f71eea 100644 --- a/src/components/SingleChoiceQuestion.tsx +++ b/src/components/SingleChoiceQuestion.tsx @@ -2,7 +2,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {Text as RNText} from 'react-native'; @@ -19,7 +19,7 @@ type SingleChoiceQuestionProps = ForwardedFSClassProps & { possibleAnswers: Choice[]; currentQuestionIndex: number; onInputChange: (value: string) => void; - ref?: ForwardedRef; + ref?: ForwardedRef>; }; function SingleChoiceQuestion({prompt, errorText, possibleAnswers, currentQuestionIndex, onInputChange, forwardedFSClass, ref}: SingleChoiceQuestionProps) { diff --git a/src/components/Skeletons/UnreportedExpensesSkeleton.tsx b/src/components/Skeletons/UnreportedExpensesSkeleton.tsx index 8f9f1b04af14..acc84dde3944 100644 --- a/src/components/Skeletons/UnreportedExpensesSkeleton.tsx +++ b/src/components/Skeletons/UnreportedExpensesSkeleton.tsx @@ -41,31 +41,44 @@ type UnreportedExpensesSkeletonProps = { function UnreportedExpensesSkeleton({fixedNumberOfItems}: UnreportedExpensesSkeletonProps) { const {onLayout, containerWidth: pageWidth} = useContainerWidth(40); const styles = useThemeStyles(); + // Passed only when the skeleton is the footer appended below rows that are already on screen, so it gets a top + // divider joining it to the row above instead of rounding its own top corners. + const isPaginationFooter = !!fixedNumberOfItems; const skeletonItem = useCallback( (args: {itemIndex: number}) => { return ( <> + + ); }, @@ -78,8 +91,10 @@ function UnreportedExpensesSkeleton({fixedNumberOfItems}: UnreportedExpensesSkel onLayout={onLayout} > {}}: StatePickerPr return ( <> - setIsPickerVisible(true)} - brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={errorText} - /> + setIsPickerVisible(true))}> + + {!!errorText && } + + + {!!errorText && ( + + )} + void; - ref?: ForwardedRef; + ref?: ForwardedRef>; }; function StateSelector({errorText, onBlur, value: stateCode, label, onInputChange, wrapperStyle, ref}: StateSelectorProps) { diff --git a/src/components/TabSelector/TabSelectorBase.tsx b/src/components/TabSelector/TabSelectorBase.tsx index 284888e0cf95..a0aa2bb97d5c 100644 --- a/src/components/TabSelector/TabSelectorBase.tsx +++ b/src/components/TabSelector/TabSelectorBase.tsx @@ -138,6 +138,7 @@ function TabSelectorBase({ badgeText={tab.badgeText} isBadgeCondensed={tab.isBadgeCondensed} badgeStyles={tab.badgeStyles} + isBadgeError={tab.isBadgeError} pendingAction={tab.pendingAction} isDisabled={tab.isDisabled} disabledAction={tab.disabledAction} diff --git a/src/components/TabSelector/TabSelectorContext.tsx b/src/components/TabSelector/TabSelectorContext.tsx index 600cf35b4cce..e05ec4b7271a 100644 --- a/src/components/TabSelector/TabSelectorContext.tsx +++ b/src/components/TabSelector/TabSelectorContext.tsx @@ -1,3 +1,4 @@ +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ScrollView as RNScrollView} from 'react-native'; @@ -12,7 +13,7 @@ const TabSelectorStateContext = createContext(defau const TabSelectorActionsContext = createContext(defaultTabSelectorActionsContextValue); function TabSelectorContextProvider({children, activeTabKey}: TabSelectorContextProviderProps) { - const containerRef = useRef(null); + const containerRef = useRef>(null); const containerLayoutRef = useRef<{x: number; width: number}>({x: 0, width: 0}); const tabsRef = useRef>({}); const lastScrolledToTab = useRef(''); diff --git a/src/components/TabSelector/TabSelectorItem.tsx b/src/components/TabSelector/TabSelectorItem.tsx index 6b09ff9ef6ca..3c424c580709 100644 --- a/src/components/TabSelector/TabSelectorItem.tsx +++ b/src/components/TabSelector/TabSelectorItem.tsx @@ -43,6 +43,7 @@ function TabSelectorItem({ badgeText, isBadgeCondensed = false, badgeStyles, + isBadgeError = false, isDisabled = false, disabledAction, pendingAction, @@ -110,7 +111,8 @@ function TabSelectorItem({ diff --git a/src/components/TabSelector/scrollToTab/types.ts b/src/components/TabSelector/scrollToTab/types.ts index d170ee0a1ea2..2847a054f82c 100644 --- a/src/components/TabSelector/scrollToTab/types.ts +++ b/src/components/TabSelector/scrollToTab/types.ts @@ -1,9 +1,10 @@ +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView} from 'react-native'; type ScrollToTabProps = { animated?: boolean; - containerRef: React.RefObject; + containerRef: React.RefObject | null>; containerX: number; containerWidth: number; tabX: number; diff --git a/src/components/TabSelector/types.context.ts b/src/components/TabSelector/types.context.ts index 18502cb95eb1..79a3a54b1d74 100644 --- a/src/components/TabSelector/types.context.ts +++ b/src/components/TabSelector/types.context.ts @@ -1,8 +1,9 @@ +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ScrollView as RNScrollView} from 'react-native'; type TabSelectorStateContextType = { - containerRef: React.RefObject; + containerRef: React.RefObject | null>; }; type TabSelectorActionsContextType = { diff --git a/src/components/TabSelector/types.ts b/src/components/TabSelector/types.ts index 0679e164c6e1..5b0ed3fdf780 100644 --- a/src/components/TabSelector/types.ts +++ b/src/components/TabSelector/types.ts @@ -7,7 +7,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; import type {MaterialTopTabBarProps} from '@react-navigation/material-top-tabs'; -import type {Ref} from 'react'; +import type {ComponentRef, Ref} from 'react'; // eslint-disable-next-line no-restricted-imports import type {Animated, StyleProp, View, ViewStyle} from 'react-native'; @@ -45,6 +45,9 @@ type TabSelectorBaseItem = WithSentryLabel & { /** Additional styles for the tab's badge. */ badgeStyles?: StyleProp; + /** Whether the tab's badge should render in the error (red) style instead of success (green). */ + isBadgeError?: boolean; + /** Whether this tab is disabled */ isDisabled?: boolean; @@ -54,7 +57,7 @@ type TabSelectorBaseItem = WithSentryLabel & { pendingAction?: PendingAction; /** Optional ref forwarded to this tab's pressable element, e.g. to anchor a popover to this specific tab. */ - tabRef?: Ref; + tabRef?: Ref | HTMLDivElement>; /** * Whether this tab should respond to a long-press (touch) / right-click (web) via `onLongTabPress`. @@ -130,6 +133,9 @@ type TabSelectorItemProps = WithSentryLabel & { /** Additional styles for the tab's badge. */ badgeStyles?: StyleProp; + /** Whether the tab's badge should render in the error (red) style instead of success (green). */ + isBadgeError?: boolean; + /** Whether this tab is disabled */ isDisabled?: boolean; @@ -139,7 +145,7 @@ type TabSelectorItemProps = WithSentryLabel & { pendingAction?: PendingAction; /** Optional ref forwarded to the tab's pressable element. */ - tabRef?: Ref; + tabRef?: Ref | HTMLDivElement>; /** Props for an educational tooltip wrapped around this tab's badge. */ badgeEducationalTooltipProps?: Omit; diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index a05e89e6140a..3c975f59556c 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -9,7 +9,7 @@ import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import {turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import getPlatform from '@libs/getPlatform'; import {canMeasureText} from '@libs/measureTextWidth'; import {acquireBackgroundInputFocusSuppression} from '@libs/ModalFocusManager'; @@ -268,11 +268,14 @@ function Table) { const {translate} = useLocalize(); const isMobileSelectionEnabled = useMobileSelectionMode(); + + const setMobileSelectionModeEnabled = (isEnabled: boolean) => { + if (isEnabled) { + turnOnMobileSelectionMode(); + return; + } + + turnOffMobileSelectionMode(); + }; const icons = useMemoizedLazyExpensifyIcons(['CheckSquare']); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); const bottomSafeAreaPaddingStyle = useBottomSafeSafeAreaPaddingStyle({addBottomSafeAreaPadding: true, addOfflineIndicatorBottomSafeAreaPadding: false}); @@ -304,6 +316,7 @@ function Table({ compareItems, initialSortColumn, + initialSortOrder, narrowLayoutSortColumn, shouldUseNarrowTableLayout, onSortingChange, @@ -322,6 +335,9 @@ function Table | null>; /** Ref for the view wrapping the table list; its top is the anchor used when scrolling a focused input above the keyboard. */ - listContainerRef: React.RefObject; + listContainerRef: React.RefObject | null>; /** Tracks the list scroll offset for the focused-input scroll helper; wired into the list's onScroll. */ trackScrollOffset: (event: NativeSyntheticEvent) => void; @@ -81,6 +84,9 @@ type TableContextValue void; }; @@ -129,6 +138,7 @@ const defaultTableContextValue: TableContextValue = { order: 'asc', }, initialSortColumn: undefined, + initialSortOrder: CONST.SEARCH.SORT_ORDER.ASC, narrowLayoutSortColumn: undefined, activeSearchString: '', tableMethods: {} as TableMethods, diff --git a/src/components/Table/TableFilterBar/TableDisplaySettingsPopoverComponent.tsx b/src/components/Table/TableFilterBar/TableDisplaySettingsPopoverComponent.tsx index 015297a4f182..94cf4372d32c 100644 --- a/src/components/Table/TableFilterBar/TableDisplaySettingsPopoverComponent.tsx +++ b/src/components/Table/TableFilterBar/TableDisplaySettingsPopoverComponent.tsx @@ -93,13 +93,14 @@ export default function TableDisplaySettingsPopoverComponent({closeOverlay}: Tab columns, activeSorting, initialSortColumn, + initialSortOrder, tableMethods: {updateSorting}, } = useTableContext(); const [selectedSetting, setSelectedSetting] = useState<'sortBy' | 'sortOrder' | null>(null); const defaultSorting: ActiveSorting = { columnKey: initialSortColumn, - order: CONST.SEARCH.SORT_ORDER.ASC, + order: initialSortOrder, }; const [pendingSorting, setPendingSorting] = useState>(defaultSorting); @@ -168,7 +169,7 @@ export default function TableDisplaySettingsPopoverComponent({closeOverlay}: Tab ]} value={selectedOrder} label={translate('search.display.sortOrder')} - defaultValue={CONST.SEARCH.SORT_ORDER.ASC} + defaultValue={defaultSorting.order} onBackButtonPress={() => setSelectedSetting('sortBy')} closeOverlay={closeSettingsOverlay} onChange={(item) => { diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index 1c91306ebbf5..768002e2bfc9 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -88,6 +88,8 @@ export default function TableRow({ shouldEnableSelectionInNarrowPaneModal = false, tableListMetadata, dynamicGridTemplateColumns, + listProps, + shouldFooterRenderAsLastRow, } = useTableContext(); const semanticRowID = useTableRowSemanticID(); const semanticTableHasHeader = !tableListMetadata.hasPageHeader || tableListMetadata.shouldRenderStickyHeader; @@ -110,7 +112,9 @@ export default function TableRow({ const isDisabled = !!disabled || isAccessibilityHidden; const isFirstRow = rowIndex === 0; - const isLastRow = rowIndex === rowCount - 1; + // A footer that continues the rows owns the rounded bottom corners instead of the last row. + const doesFooterOwnBottomRadius = !!shouldFooterRenderAsLastRow && !!listProps?.ListFooterComponent; + const isLastRow = rowIndex === rowCount - 1 && !doesFooterOwnBottomRadius; if (selectionEnabled && isSelectionCheckboxVisible) { gridTemplateColumns.unshift(`${variables.tableCheckboxColumnWidth}px`); diff --git a/src/components/Table/calculateDynamicColumnWidths.ts b/src/components/Table/calculateDynamicColumnWidths.ts index 8f81c1ad129f..b9ac9ebb69d7 100644 --- a/src/components/Table/calculateDynamicColumnWidths.ts +++ b/src/components/Table/calculateDynamicColumnWidths.ts @@ -137,7 +137,7 @@ function distributeAvailableWidth(desiredWidths: number[], maxWidths: number[], * fixed-width columns. */ function calculateDynamicColumnWidths(constraints: DynamicColumnConstraints[], availableWidth: number): CalculatedDynamicColumnWidths { - if (constraints.length === 0 || availableWidth <= 0) { + if (constraints.length === 0) { return EQUAL_WIDTHS; } @@ -164,6 +164,11 @@ function calculateDynamicColumnWidths(constraints: DynamicColumnConstraints[], a // 4. Even squeezed to their minimums the columns don't fit, so they stop there and the table scrolls. Rounding up // rather than down, since a column a fraction of a px short would clip a character it is meant to show. + // + // A table whose fixed columns already need more room than it has lands here too, with a budget of zero or less for + // the dynamic ones to share. Sizing them to their own content is what keeps an empty column narrow in that case, + // rather than leaving every column an equal share of room the table never had. Whether the table has been measured + // at all is the caller's question, answered before it works out a budget. const minWidths = constraints.map((constraint, index) => Math.min(constraint.minWidth, maxWidths.at(index) ?? 0)); const totalMinWidth = sum(minWidths); if (totalMinWidth >= availableWidth) { diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index c47b22ca1ad4..4e7b3aceb096 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -1,12 +1,10 @@ import type {TableData, TableRow} from '@components/Table/types'; import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; -import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useShiftRangeSelection from '@hooks/useShiftRangeSelection'; -import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import type {Dispatch, SetStateAction} from 'react'; @@ -34,6 +32,15 @@ type UseSelectionProps = { /** Whether the selection mode should key off the real screen size instead of shouldUseNarrowLayout (for tables inside a narrow pane modal / RHP) */ shouldEnableSelectionInNarrowPaneModal?: boolean; + + /** Whether the mobile selection mode is currently on */ + isSelectionModeEnabled: boolean; + + /** Turns the mobile selection mode on or off */ + setSelectionModeEnabled: (isEnabled: boolean) => void; + + /** Whether the selection survives a change to the search string or the filters */ + shouldPreserveSelectionOnSearchAndFilter?: boolean; }; type SelectionMethods = { @@ -66,6 +73,9 @@ export default function useSelection({ activeSearchString, onRowSelectionChange, shouldEnableSelectionInNarrowPaneModal, + isSelectionModeEnabled, + setSelectionModeEnabled, + shouldPreserveSelectionOnSearchAndFilter, }: UseSelectionProps): UseSelectionResult { // When a table opts into selection inside a narrow pane modal (RHP), the selection-mode auto-sync keys off the real // screen size (isSmallScreenWidth) so it behaves correctly there (shouldUseNarrowLayout is always true in an RHP). @@ -73,7 +83,6 @@ export default function useSelection({ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const selectionUsesNarrowLayout = shouldEnableSelectionInNarrowPaneModal ? isSmallScreenWidth : shouldUseNarrowLayout; - const isSelectionModeEnabled = useMobileSelectionMode(); // When a user long-presses a row on mobile, store the key of the row that will be selected if // the user confirms the selection @@ -103,9 +112,9 @@ export default function useSelection({ } clearSelection(); - turnOffMobileSelectionMode(); + setSelectionModeEnabled(false); return true; - }, [isSelectionModeEnabled, clearSelection]); + }, [isSelectionModeEnabled, clearSelection, setSelectionModeEnabled]); useAndroidBackButtonHandler(androidBackButtonDisableSelectionMode); @@ -116,10 +125,13 @@ export default function useSelection({ const isSelectionModeEnabledWithoutSelectableKeys = isSelectionModeEnabled && !selectableKeys.length && !originalSelectableCount; if (isMobileMissingSelectionMode) { - turnOnMobileSelectionMode(); + setSelectionModeEnabled(true); } else if (isDesktopWithoutSelectableKeys || isSelectionModeEnabledWithoutSelectableKeys) { - turnOffMobileSelectionMode(); + setSelectionModeEnabled(false); } + // setSelectionModeEnabled is omitted from the dependencies below because a caller is free to pass an inline + // callback, and re-running this effect on every render would fight the selection mode it just set. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectionUsesNarrowLayout, isSelectionModeEnabled, selectedKeys.length, originalSelectableCount, selectableKeys.length]); // When selection mode is turned off, clear the list of selected keys, so that re-enabling selection mode doesn't retain rows @@ -133,7 +145,13 @@ export default function useSelection({ }, [isSelectionModeEnabled, selectedKeys.length, clearSelection, wasSelectionModeEnabled]); // When the table filters or the search string change, clear the current selection - useEffect(() => clearSelection(), [currentFilters, activeSearchString, clearSelection]); + useEffect(() => { + if (shouldPreserveSelectionOnSearchAndFilter) { + return; + } + + clearSelection(); + }, [currentFilters, activeSearchString, clearSelection, shouldPreserveSelectionOnSearchAndFilter]); // When the table unmounts, clear the selection. Should only run on unmount // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/components/Table/middlewares/sorting.ts b/src/components/Table/middlewares/sorting.ts index 7a6c33e62249..16a62d8fc07a 100644 --- a/src/components/Table/middlewares/sorting.ts +++ b/src/components/Table/middlewares/sorting.ts @@ -1,4 +1,7 @@ +import CONST from '@src/CONST'; + import type {SetStateAction} from 'react'; +import type {ValueOf} from 'type-fest'; import {useState} from 'react'; @@ -7,7 +10,7 @@ import type {Middleware, MiddlewareHookResult} from './types'; /** * The sort order of a column in the table. */ -type SortOrder = 'asc' | 'desc'; +type SortOrder = ValueOf; /** * The active sorting configuration of the table. @@ -61,6 +64,7 @@ type SortingMethods = { type UseSortingProps = { compareItems?: CompareItemsCallback; initialSortColumn?: ColumnKey; + initialSortOrder?: SortOrder; narrowLayoutSortColumn?: ColumnKey; shouldUseNarrowTableLayout?: boolean; onSortingChange?: (sorting: ActiveSorting) => void; @@ -113,13 +117,14 @@ function resolveActiveSorting( function useSorting({ compareItems, initialSortColumn, + initialSortOrder = CONST.SEARCH.SORT_ORDER.ASC, narrowLayoutSortColumn, shouldUseNarrowTableLayout, onSortingChange, }: UseSortingProps): UseSortingResult { const [userSorting, setUserSorting] = useState>({ columnKey: initialSortColumn, - order: 'asc', + order: initialSortOrder, }); const activeSorting = resolveActiveSorting(shouldUseNarrowTableLayout, narrowLayoutSortColumn, userSorting); diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 4c71345b29d5..af82b04d96f3 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -6,7 +6,7 @@ import type {FilterConfig, FilteringMethods, IsItemInFilterCallback} from './mid import type {HighlightingMethods} from './middlewares/highlight'; import type {IsItemInSearchCallback, SearchingMethods} from './middlewares/searching'; import type {SelectionMethods} from './middlewares/selection'; -import type {ActiveSorting, CompareItemsCallback, SortingMethods} from './middlewares/sorting'; +import type {ActiveSorting, CompareItemsCallback, SortingMethods, SortOrder} from './middlewares/sorting'; /** * Defines the required minimum shape for each row of data in the table @@ -200,6 +200,12 @@ type TableProps>; @@ -219,6 +225,9 @@ type TableProps(); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + const rowData = processedData.at(rowIndex); + const isSelected = !!rowData?.selected; + const isRowDisabled = !!rowData?.disabled; + + const transactionReportID = getNonEmptyStringOnyxID(item.reportID); + const [transactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionReportID}`); + const transactionPolicy = usePolicy(transactionReport?.policyID); + + // Merchant or description doubles as the row's accessible name, matching what the row visibly shows. + const accessibilityLabel = getMerchantName(item, translate) || getDescription(item); + + return ( + { + if (isSmallScreenWidth) { + return; + } + tableMethods.handleSingleRowSelection(item.keyForList); + }} + > + + + + + ); +} + +export default AddExistingExpenseTableRow; diff --git a/src/components/Tables/AddExistingExpenseTable/index.tsx b/src/components/Tables/AddExistingExpenseTable/index.tsx new file mode 100644 index 000000000000..1c6c1f112054 --- /dev/null +++ b/src/components/Tables/AddExistingExpenseTable/index.tsx @@ -0,0 +1,152 @@ +import type {CompareItemsCallback, FilterConfig, IsItemInFilterCallback, IsItemInSearchCallback, TableColumn, TableData, TableProps} from '@components/Table'; +import Table from '@components/Table'; + +import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useLocalize from '@hooks/useLocalize'; + +import tokenizedSearch from '@libs/tokenizedSearch'; +import {getAmount, getCreated, getCurrency, getDescription, getMerchant, isUnreportedTransaction} from '@libs/TransactionUtils'; + +import CONST from '@src/CONST'; +import type Transaction from '@src/types/onyx/Transaction'; + +import type {ListRenderItemInfo} from '@shopify/flash-list'; + +import AddExistingExpenseTableRow from './AddExistingExpenseTableRow'; + +type ExpenseTableColumnKey = 'date' | 'amount'; +type ExpenseTableFilterKey = 'status'; +type UnreportedExpenseTableRowData = Transaction & TableData; + +type AddExistingExpenseTableProps = Pick< + TableProps, + 'onEndReached' | 'onEndReachedThreshold' | 'ListFooterComponent' +> & { + data: UnreportedExpenseTableRowData[]; + selectedKeys: string[]; + onRowSelectionChange: (selectedRowKeys: string[]) => void; +}; + +function AddExistingExpenseTable({data, selectedKeys, onRowSelectionChange, onEndReached, onEndReachedThreshold, ListFooterComponent}: AddExistingExpenseTableProps) { + const {translate, localeCompare} = useLocalize(); + const {convertToDisplayString} = useCurrencyListActions(); + + const columns: Array> = [ + {key: 'date', label: translate('common.date'), sortable: true}, + {key: 'amount', label: translate('iou.amount'), sortable: true}, + ]; + + const filters: FilterConfig = { + status: { + label: translate('common.status'), + filterType: CONST.TABLES.FILTER_TYPE.MULTI_SELECT, + options: [ + {label: translate('common.unreported'), value: CONST.SEARCH.STATUS.EXPENSE.UNREPORTED}, + {label: translate('common.draft'), value: CONST.SEARCH.STATUS.EXPENSE.DRAFTS}, + ], + }, + }; + + const isItemInFilter: IsItemInFilterCallback = (item, values) => { + if (values.length === 0) { + return true; + } + + const includesUnreported = values.includes(CONST.SEARCH.STATUS.EXPENSE.UNREPORTED); + const includesDrafts = values.includes(CONST.SEARCH.STATUS.EXPENSE.DRAFTS); + const isUnreported = isUnreportedTransaction(item); + + if (includesUnreported && isUnreported) { + return true; + } + return includesDrafts && !isUnreported; + }; + + const isItemInSearch: IsItemInSearchCallback = (item, searchValue) => { + const results = tokenizedSearch([item], searchValue, (transaction) => { + const searchableFields: string[] = []; + + const merchant = getMerchant(transaction); + if (merchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && merchant !== CONST.TRANSACTION.DEFAULT_MERCHANT) { + searchableFields.push(merchant); + } + + const description = getDescription(transaction); + if (description.trim()) { + searchableFields.push(description); + } + + const amount = getAmount(transaction); + const currency = getCurrency(transaction); + const formattedAmount = convertToDisplayString(amount, currency); + searchableFields.push(formattedAmount); + + // This allows users to search "2000" and find "$2,000.00" for example + const normalizedAmount = (amount / 100).toString(); + searchableFields.push(normalizedAmount); + + return searchableFields; + }); + + return results.length > 0; + }; + + const compareItems: CompareItemsCallback = (item1, item2, activeSorting) => { + const orderMultiplier = activeSorting.order === 'desc' ? -1 : 1; + + if (activeSorting.columnKey === 'amount') { + return (getAmount(item1) - getAmount(item2)) * orderMultiplier; + } + + // Default: sort by the expense date, matching how Spend > Expenses orders rows by default. The dates are + // compared as strings because an optimistic transaction stores a timestamp while a server one stores a plain + // date, and both formats still order lexicographically. + return localeCompare(getCreated(item1), getCreated(item2)) * orderMultiplier; + }; + + const renderExpenseTableRow = ({item, index}: ListRenderItemInfo) => ( + + ); + + return ( + + data={data} + title={translate('common.expenses')} + columns={columns} + selectionEnabled + shouldEnableSelectionInNarrowPaneModal + // Expenses are confirmed in one batch, so searching or filtering must not drop what is already picked. + shouldPreserveSelectionOnSearchAndFilter + // The skeleton appended while the next page loads continues the rows, so it draws the bottom corners. + shouldFooterRenderAsLastRow + selectedKeys={selectedKeys} + onRowSelectionChange={onRowSelectionChange} + initialSortColumn="date" + initialSortOrder={CONST.SEARCH.SORT_ORDER.DESC} + compareItems={compareItems} + filters={filters} + isItemInFilter={isItemInFilter} + isItemInSearch={isItemInSearch} + renderItem={renderExpenseTableRow} + keyExtractor={(item) => item.keyForList} + onEndReached={onEndReached} + onEndReachedThreshold={onEndReachedThreshold} + maintainVisibleContentPosition={{disabled: true}} + ListFooterComponent={ListFooterComponent} + > + + + + + + + + ); +} + +export default AddExistingExpenseTable; +export type {UnreportedExpenseTableRowData}; diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx index 6e3b79b69747..0d45779dc15c 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx @@ -31,6 +31,7 @@ type WorkspaceCompanyCardTableRowData = TableData & isCardDeleted: boolean; isAssigned: boolean; assignedCard?: Card; + exportAccountTitle?: string; onDismissError?: () => void; }; @@ -45,6 +46,9 @@ type WorkspaceCompanyCardTableRowProps = { /** Whether the current member can edit company cards */ canWriteCompanyCards: boolean; + /** Whether the Export account column is shown, so the row must keep its cell in step with the column */ + shouldShowExportAccountColumn: boolean; + shouldUseNarrowTableLayout: boolean; rowIndex: number; @@ -61,6 +65,7 @@ function WorkspaceCompanyCardTableRow({ feedName, CardFeedIcon, shouldUseNarrowTableLayout, + shouldShowExportAccountColumn, rowIndex, isAssigningCardDisabled, canWriteCompanyCards, @@ -111,7 +116,7 @@ function WorkspaceCompanyCardTableRow({ return Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_COMPANY_CARD_DETAILS.getRoute(feedName, cardID.toString()))); }; - const accessibilityLabel = [memberColumnTitle, formattedCardDetails, formattedCustomCardName].filter(Boolean).join(', '); + const accessibilityLabel = [memberColumnTitle, formattedCardDetails, formattedCustomCardName, item.exportAccountTitle].filter(Boolean).join(', '); return ( ( <> {isAssigned ? ( @@ -142,7 +147,7 @@ function WorkspaceCompanyCardTableRow({ CardFeedIcon )} - + )} + {!shouldUseNarrowTableLayout && shouldShowExportAccountColumn && ( + + + + )} + void; @@ -115,6 +120,8 @@ function WorkspaceCompanyCardsTable({ // again rather than being suppressed forever. const isFeedConnectionBroken = feedName ? cardFeedErrors[feedName]?.shouldPromptBrokenConnection : false; + const policy = usePolicy(policyID); + const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); const [countryByIp] = useOnyx(ONYXKEYS.COUNTRY); const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES); const [selectedCardKeys, setSelectedCardKeys] = useState([]); @@ -183,22 +190,63 @@ function WorkspaceCompanyCardsTable({ const shouldShowGBDisclaimer = isGB && (isNoFeed || hasNoAssignedCard); const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth; - const columns: Array> = [ + // Drives the actions column's dynamic sizing below. Mirrors the row's own Assign button condition rather than + // isAssigningCardDisabled, since a disabled Assign button still renders and needs the same space as an enabled one. + const canAnyCardBeAssigned = canWriteCompanyCards && (companyCardEntries ?? []).some((entry) => !entry.isAssigned); + + // Mirrors the Accounting section's own eligibility check on the card details page, so the column follows the same rules. + const syncingAccountingIntegration = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.find((integration) => integration === connectionSyncProgress?.connectionName); + const connectedIntegration = getConnectedIntegration(policy, CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES) ?? syncingAccountingIntegration; + const cardExportSettings = getPolicyCardExportSettings(connectedIntegration, policyID, translate, policy); + const shouldShowExportAccountColumn = !!cardExportSettings?.shouldShowMenuItem; + + const columns: Array> = [ { key: 'member', label: translate('common.member'), sortable: true, + styling: { + // Cell text never wraps, so without minWidth: 0 the grid track sizes from the full string instead of + // its share and the row overflows the table. + containerStyles: [styles.mnw0], + }, + dynamicSizing: { + // Whichever of the cardholder's name (or the unassigned label) or their login renders wider decides the column's width. + getContentToMeasure: (item) => [ + { + text: item.isAssigned ? Str.removeSMSDomain(item.cardholder?.displayName ?? '') : translate('workspace.moreFeatures.companyCards.unassignedCards'), + fontSize: fontScale.text, + }, + {text: item.isAssigned ? Str.removeSMSDomain(item.cardholder?.login ?? '') : '', fontSize: fontScale.label}, + ], + extraWidth: variables.tableMemberCellAvatarWidth, + }, }, { key: 'card', label: translate('workspace.companyCards.card'), sortable: true, + styling: { + containerStyles: [styles.mnw0], + }, + dynamicSizing: { + getContentToMeasure: (item) => [{text: formatMaskedCardName(item.cardName), fontSize: fontScale.text}], + shouldFitContent: true, + maxWidth: CONST.TABLES.DYNAMIC_COLUMNS.MAX_FREE_TEXT_COLUMN_WIDTH, + }, }, { key: 'customCardName', label: translate('workspace.companyCards.cardName'), sortable: true, + styling: { + containerStyles: [styles.mnw0], + }, + dynamicSizing: { + getContentToMeasure: (item) => (item.customCardName ? [{text: item.customCardName, fontSize: fontScale.text}] : []), + }, }, + ...(shouldShowExportAccountColumn ? [getExportAccountColumn(translate('workspace.moreFeatures.companyCards.exportAccount'), styles)] : []), { key: 'actions', label: '', @@ -206,6 +254,17 @@ function WorkspaceCompanyCardsTable({ styling: { containerStyles: [styles.justifyContentEnd, styles.pr3], }, + // A fixed width would reserve the Assign button's space even when no row can show it, so only measure + // content while at least one row can render the button. + ...(canAnyCardBeAssigned + ? { + dynamicSizing: { + getContentToMeasure: (item) => (!item.isAssigned ? [{text: translate('workspace.companyCards.assign'), fontSize: fontScale.text, fontWeight: '700'}] : []), + shouldFitContent: true, + extraWidth: styles.ph2.paddingHorizontal * 2 + styles.gap3.gap + variables.iconSizeNormal + styles.pr3.paddingRight, + }, + } + : {width: variables.tableCaretColumnWidth}), }, ]; @@ -225,6 +284,8 @@ function WorkspaceCompanyCardsTable({ isAssigned, assignedCard, cardholder, + // Unassigned cards have no details page and so no Accounting section to match, hence no title. + exportAccountTitle: shouldShowExportAccountColumn && assignedCard ? getCardExportAccountTitle(cardExportSettings, assignedCard) : undefined, errors: isFeedConnectionBroken || assignedCard?.pendingFields?.lastScrape ? undefined : assignedCard?.errors, pendingAction: assignedCard?.pendingAction, onDismissError: () => resetFailedWorkspaceCompanyCardUnassignment(domainOrWorkspaceAccountID, bankName, assignedCard?.cardID), @@ -263,7 +324,8 @@ function WorkspaceCompanyCardsTable({ return -1 * orderMultiplier; } - const cardNameSortingResult = localeCompare(a.cardName, b.cardName) * orderMultiplier; + const cardNameComparison = localeCompare(a.cardName, b.cardName); + const cardNameSortingResult = cardNameComparison * orderMultiplier; if (!a.isAssigned && !b.isAssigned) { return cardNameSortingResult; @@ -284,6 +346,13 @@ function WorkspaceCompanyCardsTable({ return localeCompare(a.customCardName ?? '', b.customCardName ?? '') * orderMultiplier; } + if (activeSorting.columnKey === 'exportAccount') { + const exportAccountComparison = localeCompare(a.exportAccountTitle ?? '', b.exportAccountTitle ?? ''); + + // Most cards share the default export account, so ties fall back to the card name for a stable order. + return (exportAccountComparison !== 0 ? exportAccountComparison : cardNameComparison) * orderMultiplier; + } + return 0; }; @@ -360,6 +429,7 @@ function WorkspaceCompanyCardsTable({ isAssigningCardDisabled={isAssigningCardDisabled} canWriteCompanyCards={canWriteCompanyCards} shouldUseNarrowTableLayout={shouldUseNarrowTableLayout} + shouldShowExportAccountColumn={shouldShowExportAccountColumn} /> ); @@ -402,6 +472,7 @@ function WorkspaceCompanyCardsTable({ compareItems={compareItems} isItemInSearch={isItemInSearch} isItemInFilter={isItemInFilter} + shouldUseDynamicColumns initialSortColumn="member" selectionEnabled={showTableControls} selectedKeys={validSelectedCardKeys} diff --git a/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardsTableRow.tsx b/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardsTableRow.tsx index bcc23d2dc620..5db51f66b01d 100644 --- a/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardsTableRow.tsx +++ b/src/components/Tables/WorkspaceExpensifyCardsTable/WorkspaceExpensifyCardsTableRow.tsx @@ -29,9 +29,12 @@ type WorkspaceExpensifyCardsTableRowProps = { item: WorkspaceExpensifyCardTableRowData; rowIndex: number; shouldUseNarrowTableLayout: boolean; + + /** Whether the Export account column is shown, so the row must keep its cell in step with the column */ + shouldShowExportAccountColumn: boolean; }; -export default function WorkspaceExpensifyCardsTableRow({item, rowIndex, shouldUseNarrowTableLayout}: WorkspaceExpensifyCardsTableRowProps) { +export default function WorkspaceExpensifyCardsTableRow({item, rowIndex, shouldUseNarrowTableLayout, shouldShowExportAccountColumn}: WorkspaceExpensifyCardsTableRowProps) { const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'FreezeCard']); const styles = useThemeStyles(); const {translate, formatPhoneNumber, dateFnsLocale} = useLocalize(); @@ -61,7 +64,18 @@ export default function WorkspaceExpensifyCardsTableRow({item, rowIndex, shouldU } } - const accessibilityLabel = [cardholderName, item.name, cardType, limitTypeLabel, item.lastFourPAN, statusLabel, formattedLimit, formattedRemainingLimit, frozenByText] + const accessibilityLabel = [ + cardholderName, + item.name, + cardType, + limitTypeLabel, + item.lastFourPAN, + statusLabel, + item.exportAccountTitle, + formattedLimit, + formattedRemainingLimit, + frozenByText, + ] .filter(Boolean) .join(', '); @@ -100,7 +114,7 @@ export default function WorkspaceExpensifyCardsTableRow({item, rowIndex, shouldU {({hovered}) => ( <> - + )} + {!shouldUseNarrowTableLayout && shouldShowExportAccountColumn && ( + + + + )} + > = [ + const columns: Array> = [ { key: 'name', label: translate('workspace.expensifyCard.name'), sortable: true, styling: { - // Cardholder names and card titles are the longest values in the table, so this column takes the - // space freed up by giving Type, Last 4 and Status fixed widths. Limit type still needs a full share - // to fit its longest value, so this stops at double rather than taking everything. - flex: 2, + // Cell text never wraps, so without minWidth: 0 the grid track sizes from the full string instead of + // its share and the row overflows the table. + containerStyles: [styles.mnw0], + }, + dynamicSizing: { + // Whichever of the cardholder's name or the card's title renders wider decides the column's width. + getContentToMeasure: (item) => [ + {text: temporaryGetDisplayNameOrDefault({passedPersonalDetails: item.cardholder, translate, formatPhoneNumber}), fontSize: fontScale.text}, + {text: item.name, fontSize: fontScale.label}, + ], + extraWidth: variables.tableMemberCellAvatarWidth, }, }, { key: 'type', label: translate('common.type'), sortable: true, - width: variables.tableTypeColumnWidth, styling: { containerStyles: [styles.mnw0], }, + dynamicSizing: { + getContentToMeasure: (item) => [ + {text: item.isVirtual ? translate('workspace.expensifyCard.virtual') : translate('workspace.expensifyCard.physical'), fontSize: fontScale.text}, + ], + shouldFitContent: true, + }, }, { key: 'limitType', label: translate('workspace.card.issueNewCard.limitType'), sortable: true, styling: { - // minWidth: 0 lets the grid track size purely from its 1fr share instead of the cell content, - // so a long limit type value truncates instead of widening the column. containerStyles: [styles.mnw0], }, + dynamicSizing: { + getContentToMeasure: (item) => [{text: translate(getTranslationKeyForLimitType(item.limitType)), fontSize: fontScale.text}], + shouldFitContent: true, + }, }, { key: 'lastFour', label: translate('workspace.expensifyCard.lastFour'), sortable: true, - width: variables.tableLastFourColumnWidth, + dynamicSizing: { + getContentToMeasure: (item) => [{text: item.lastFourPAN, fontSize: fontScale.text}], + shouldFitContent: true, + }, }, { key: 'status', label: translate('common.status'), sortable: true, - width: variables.tableCardStatusColumnWidth, styling: { containerStyles: [styles.mnw0], }, + dynamicSizing: { + getContentToMeasure: (item) => { + const statusTranslationKey = getTranslationKeyForCardStatus(item.card.state, item.isVirtual); + return statusTranslationKey ? [{text: translate(statusTranslationKey), fontSize: fontScale.text}] : []; + }, + shouldFitContent: true, + }, }, + ...(shouldShowExportAccountColumn ? [getExportAccountColumn(translate('workspace.moreFeatures.companyCards.exportAccount'), styles)] : []), { key: 'limit', label: translate('workspace.expensifyCard.limit'), @@ -156,6 +189,10 @@ export default function WorkspaceExpensifyCardsTable({ styling: { containerStyles: [styles.justifyContentEnd], }, + dynamicSizing: { + getContentToMeasure: (item) => [{text: convertToShortDisplayString(item.limit, item.currency), fontSize: fontScale.text}], + shouldFitContent: true, + }, }, { key: 'remainingLimit', @@ -164,6 +201,10 @@ export default function WorkspaceExpensifyCardsTable({ styling: { containerStyles: [styles.justifyContentEnd], }, + dynamicSizing: { + getContentToMeasure: (item) => [{text: convertToShortDisplayString(item.remainingLimit, item.currency), fontSize: fontScale.text}], + shouldFitContent: true, + }, }, { key: 'actions', @@ -208,6 +249,14 @@ export default function WorkspaceExpensifyCardsTable({ return (item1.remainingLimit - item2.remainingLimit) * orderMultiplier; } + if (activeSorting.columnKey === 'exportAccount') { + const exportAccountComparison = localeCompare(item1.exportAccountTitle ?? '', item2.exportAccountTitle ?? ''); + + if (exportAccountComparison !== 0) { + return exportAccountComparison * orderMultiplier; + } + } + const cardholderName1 = item1.cardholder?.displayName ?? item1.cardholder?.login ?? ''; const cardholderName2 = item2.cardholder?.displayName ?? item2.cardholder?.login ?? ''; return localeCompare(cardholderName1, cardholderName2) * orderMultiplier; @@ -220,6 +269,7 @@ export default function WorkspaceExpensifyCardsTable({ item={item} rowIndex={index} shouldUseNarrowTableLayout={shouldUseNarrowTableLayout} + shouldShowExportAccountColumn={shouldShowExportAccountColumn} /> ); @@ -248,6 +298,7 @@ export default function WorkspaceExpensifyCardsTable({ renderItem={renderCardItem} compareItems={compareItems} isItemInSearch={isItemInSearch} + shouldUseDynamicColumns initialSortColumn="name" narrowLayoutSortColumn="name" title={translate('workspace.common.expensifyCard')} diff --git a/src/components/Tables/WorkspaceMembersTable/index.tsx b/src/components/Tables/WorkspaceMembersTable/index.tsx index 8682ff9cd850..f9d2769466b1 100644 --- a/src/components/Tables/WorkspaceMembersTable/index.tsx +++ b/src/components/Tables/WorkspaceMembersTable/index.tsx @@ -53,9 +53,6 @@ type WorkspaceMembersTableProps = { headerComponent?: React.ReactElement; }; -/** Width the member cell's avatar and the space after it take before the name and email start. */ -const MEMBER_CELL_AVATAR_WIDTH = variables.avatarSizeSmall + 12; - const WORKSPACE_MEMBER_FILTER_VALUES = { ADMINS: 'admins', APPROVERS: 'approvers', @@ -94,7 +91,7 @@ export default function WorkspaceMembersTable({ {text: item.name, fontSize: fontScale.text}, {text: item.email, fontSize: fontScale.label}, ], - extraWidth: MEMBER_CELL_AVATAR_WIDTH, + extraWidth: variables.tableMemberCellAvatarWidth, }, }, diff --git a/src/components/TagPicker/TagPickerModal.tsx b/src/components/TagPicker/TagPickerModal.tsx index 992ebedaf201..b1e306085ada 100644 --- a/src/components/TagPicker/TagPickerModal.tsx +++ b/src/components/TagPicker/TagPickerModal.tsx @@ -13,6 +13,8 @@ import type {OptionData} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {ComponentRef} from 'react'; + import React, {useRef} from 'react'; import {View} from 'react-native'; @@ -68,7 +70,7 @@ function TagPickerModal({ const {isSmallScreenWidth} = useResponsiveLayout(); const {isKeyboardActive} = useKeyboardState(); - const anchorRef = useRef(null); + const anchorRef = useRef>(null); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); const tagListName = getTagList(policyTags, 0).name; diff --git a/src/components/Text.tsx b/src/components/Text.tsx index b88e85733599..75f18e0465ae 100644 --- a/src/components/Text.tsx +++ b/src/components/Text.tsx @@ -11,7 +11,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; import type {TextProps as RNTextProps, TextStyle} from 'react-native'; import React, {useContext, useMemo} from 'react'; @@ -39,7 +39,7 @@ type TextProps = RNTextProps & children: React.ReactNode; family?: keyof FontUtilsType['fontFamily']['platform']; shouldUseDefaultLineHeight?: boolean; - ref?: ForwardedRef; + ref?: ForwardedRef>; }; function Text({ diff --git a/src/components/TextInput/BaseTextInput/implementation/index.native.tsx b/src/components/TextInput/BaseTextInput/implementation/index.native.tsx index 60db625bedb9..880b65d742b5 100644 --- a/src/components/TextInput/BaseTextInput/implementation/index.native.tsx +++ b/src/components/TextInput/BaseTextInput/implementation/index.native.tsx @@ -31,6 +31,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; import type {BlurEvent, FocusEvent, GestureResponderEvent, LayoutChangeEvent, StyleProp, TextInput, ViewStyle} from 'react-native'; import {Str} from 'expensify-common'; @@ -126,7 +127,7 @@ function BaseTextInput({ const [isPrefixCharacterPaddingCalculated, setIsPrefixCharacterPaddingCalculated] = useState(() => !prefixCharacter); const labelScale = useSharedValue(initialActiveLabel ? styleConst.ACTIVE_LABEL_SCALE : styleConst.INACTIVE_LABEL_SCALE); const labelTranslateY = useSharedValue(initialActiveLabel ? styleConst.ACTIVE_LABEL_TRANSLATE_Y : styleConst.INACTIVE_LABEL_TRANSLATE_Y); - const input = useRef(null); + const input = useRef | null>(null); const isLabelActive = useRef(initialActiveLabel); const hasLabel = !!label?.length; diff --git a/src/components/TextInput/BaseTextInput/implementation/index.tsx b/src/components/TextInput/BaseTextInput/implementation/index.tsx index a8ad9f2255fd..b8b659058e5c 100644 --- a/src/components/TextInput/BaseTextInput/implementation/index.tsx +++ b/src/components/TextInput/BaseTextInput/implementation/index.tsx @@ -31,7 +31,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {BlurEvent, FocusEvent, GestureResponderEvent, LayoutChangeEvent, StyleProp, TextInput, ViewStyle} from 'react-native'; import {Str} from 'expensify-common'; @@ -130,7 +130,7 @@ function BaseTextInput({ const isLabelActive = useRef(initialActiveLabel); const didScrollToEndRef = useRef(false); - useHtmlPaste(input as RefObject, undefined, isMarkdownEnabled, maxLength); + useHtmlPaste(input as RefObject | null>, undefined, isMarkdownEnabled, maxLength); // AutoFocus which only works on mount: useEffect(() => { diff --git a/src/components/TextInput/TextInputLabel/index.tsx b/src/components/TextInput/TextInputLabel/index.tsx index 5411bab7e4d3..e0c62c150386 100644 --- a/src/components/TextInput/TextInputLabel/index.tsx +++ b/src/components/TextInput/TextInputLabel/index.tsx @@ -3,6 +3,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import textRef from '@src/types/utils/textRef'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {Text} from 'react-native'; @@ -14,7 +15,7 @@ import type TextInputLabelProps from './types'; function TextInputLabel({for: inputId = '', label, labelTranslateY, labelScale, isMultiline, shouldLabelStayOnSingleLine}: TextInputLabelProps) { const shouldClipToSingleLine = !isMultiline || shouldLabelStayOnSingleLine; const styles = useThemeStyles(); - const labelRef = useRef(null); + const labelRef = useRef | HTMLFormElement>(null); useEffect(() => { if (!inputId || !labelRef.current || !('setAttribute' in labelRef.current)) { diff --git a/src/components/TextLink.tsx b/src/components/TextLink.tsx index 76455ec27b03..177fa9b55da5 100644 --- a/src/components/TextLink.tsx +++ b/src/components/TextLink.tsx @@ -35,7 +35,9 @@ type TextLinkProps = (LinkProps | PressProps) & suppressDefaultStyle?: boolean; }; -function TextLink({href, onPress, children, style, onMouseDown = (event) => event.preventDefault(), suppressDefaultStyle = false, ref, ...rest}: TextLinkProps) { +const defaultOnMouseDown: MouseEventHandler = (event) => event.preventDefault(); + +function TextLink({href, onPress, children, style, onMouseDown = defaultOnMouseDown, suppressDefaultStyle = false, ref, ...rest}: TextLinkProps) { const {environmentURL} = useEnvironment(); const styles = useThemeStyles(); diff --git a/src/components/TextPicker/TextSelectorModal.tsx b/src/components/TextPicker/TextSelectorModal.tsx index f8b9b65257d3..9e1ede5d10ec 100644 --- a/src/components/TextPicker/TextSelectorModal.tsx +++ b/src/components/TextPicker/TextSelectorModal.tsx @@ -17,6 +17,7 @@ import {getFieldRequiredErrors} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {ComponentRef} from 'react'; import type {TextInput as TextInputType} from 'react-native'; import {useFocusEffect} from '@react-navigation/native'; @@ -50,10 +51,10 @@ function TextSelectorModal({ const [currentValue, setValue] = useState(value); const [isClosing, setIsClosing] = useState(false); - const inputRef = useRef(null); + const inputRef = useRef | null>(null); const inputValueRef = useRef(value); const focusTimeoutRef = useRef(null); - const inputCallbackRef = (ref: TextInputType | null) => { + const inputCallbackRef = (ref: ComponentRef | null) => { inputRef.current = ref; }; diff --git a/src/components/TextPicker/types.ts b/src/components/TextPicker/types.ts index 41acfadbabe9..258da1e58a7a 100644 --- a/src/components/TextPicker/types.ts +++ b/src/components/TextPicker/types.ts @@ -1,7 +1,7 @@ import type {MenuItemBaseProps} from '@components/MenuItem'; import type {BaseTextInputProps} from '@components/TextInput/BaseTextInput/types'; -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; import type {View} from 'react-native'; type TextProps = Exclude; @@ -78,7 +78,7 @@ type TextPickerProps = { /** Whether to use strict HTML tag validation regex */ shouldUseStrictHtmlTagValidation?: boolean; - ref?: ForwardedRef; + ref?: ForwardedRef>; } & Pick & TextProps; diff --git a/src/components/TextWithMiddleEllipsis/index.tsx b/src/components/TextWithMiddleEllipsis/index.tsx index a5a606907633..01828c894a07 100644 --- a/src/components/TextWithMiddleEllipsis/index.tsx +++ b/src/components/TextWithMiddleEllipsis/index.tsx @@ -3,6 +3,7 @@ import Text from '@components/Text'; import useTextWithMiddleEllipsis from '@hooks/useTextWithMiddleEllipsis'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {Text as RNText, StyleProp, TextStyle, ViewStyle} from 'react-native'; @@ -18,7 +19,7 @@ type TextWithMiddleEllipsisProps = { function TextWithMiddleEllipsis({text, style, textStyle}: TextWithMiddleEllipsisProps) { const styles = useThemeStyles(); - const ref = useRef(null); + const ref = useRef>(null); const displayText = useTextWithMiddleEllipsis({ text, diff --git a/src/components/ThreeDotsMenu/index.tsx b/src/components/ThreeDotsMenu/index.tsx index 4f50116e8a40..955ba916baf0 100644 --- a/src/components/ThreeDotsMenu/index.tsx +++ b/src/components/ThreeDotsMenu/index.tsx @@ -23,6 +23,8 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import KeyboardUtils from '@src/utils/keyboard'; +import type {ComponentRef} from 'react'; + import React, {useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -65,7 +67,7 @@ function ThreeDotsMenu({ const [isPopupMenuVisible, setPopupMenuVisible] = useState(false); const [restoreFocusType, setRestoreFocusType] = useState(); const [position, setPosition] = useState(); - const buttonRef = useRef(null); + const buttonRef = useRef>(null); const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['ThreeDots']); const isBehindModal = modal?.willAlertModalBecomeVisible && !modal?.isPopover && !shouldOverlay; diff --git a/src/components/ThumbnailImage.tsx b/src/components/ThumbnailImage.tsx index 96fccc69e533..18ba94d8279f 100644 --- a/src/components/ThumbnailImage.tsx +++ b/src/components/ThumbnailImage.tsx @@ -11,7 +11,7 @@ import CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; import type {Dimensions} from '@src/types/utils/Layout'; -import type {ImageResizeMode, ImageSourcePropType, StyleProp, ViewStyle} from 'react-native'; +import type {ImageProps, ImageSourcePropType, StyleProp, ViewStyle} from 'react-native'; import React, {useState} from 'react'; import {View} from 'react-native'; @@ -65,7 +65,7 @@ type ThumbnailImageProps = { /** Callback to be called when the image loads */ onLoad?: (event: {nativeEvent: {width: number; height: number}}) => void; - resizeMode?: ImageResizeMode; + resizeMode?: ImageProps['resizeMode']; /** Low-resolution URI shown as a placeholder while the full image loads */ previewUri?: string; diff --git a/src/components/TimeModalPicker.tsx b/src/components/TimeModalPicker.tsx index 5fdee04745a7..9c0078fafab2 100644 --- a/src/components/TimeModalPicker.tsx +++ b/src/components/TimeModalPicker.tsx @@ -5,7 +5,7 @@ import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; import React, {useState} from 'react'; import {View} from 'react-native'; @@ -26,7 +26,7 @@ type TimeModalPickerProps = { errorText?: string; label: string; - ref?: ForwardedRef; + ref?: ForwardedRef>; }; function TimeModalPicker({value, errorText, label, onInputChange = () => {}, ref}: TimeModalPickerProps) { diff --git a/src/components/TimePicker/TimePicker.tsx b/src/components/TimePicker/TimePicker.tsx index 23d4a17d5de0..22725c72cd83 100644 --- a/src/components/TimePicker/TimePicker.tsx +++ b/src/components/TimePicker/TimePicker.tsx @@ -19,7 +19,7 @@ import CONST from '@src/CONST'; import type {GestureResponderEvent, NativeSyntheticEvent} from 'react-native'; import type {TextInput} from 'react-native-gesture-handler'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import setCursorPosition from './setCursorPosition'; @@ -149,36 +149,33 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou const {inputCallbackRef} = useAutoFocusInput(); - const focusMillisecondInputOnFirstCharacter = useCallback(() => setCursorPosition(0, millisecondInputRef, setSelectionMillisecond), []); - const focusSecondInputOnLastCharacter = useCallback(() => setCursorPosition(2, secondInputRef, setSelectionSecond), []); - const focusSecondInputOnFirstCharacter = useCallback(() => setCursorPosition(0, secondInputRef, setSelectionSecond), []); - const focusMinuteInputOnLastCharacter = useCallback(() => setCursorPosition(2, minuteInputRef, setSelectionMinute), []); - const focusMinuteInputOnFirstCharacter = useCallback(() => setCursorPosition(0, minuteInputRef, setSelectionMinute), []); - const focusHourInputOnLastCharacter = useCallback(() => setCursorPosition(2, hourInputRef, setSelectionHour), []); - - const validate = useCallback( - (time: string) => { - if (!shouldValidate) { - return true; - } - const timeString = time || `${hours}:${minutes} ${amPmValue}`; - const [hourStr] = timeString.split(/[:\s]+/); - const hour = parseInt(hourStr, 10); - if (hour === 0) { - setError(true); - setErrorMessage(translate('common.error.invalidTimeRange')); - return false; - } - if (!shouldValidateFutureTime) { - return true; - } - const isValid = DateUtils.isTimeAtLeastOneMinuteInFuture({timeString, dateTimeString: defaultValue}); - setError(!isValid); - setErrorMessage(translate('common.error.invalidTimeShouldBeFuture')); - return isValid; - }, - [shouldValidate, hours, minutes, amPmValue, shouldValidateFutureTime, defaultValue, translate], - ); + const focusMillisecondInputOnFirstCharacter = () => setCursorPosition(0, millisecondInputRef, setSelectionMillisecond); + const focusSecondInputOnLastCharacter = () => setCursorPosition(2, secondInputRef, setSelectionSecond); + const focusSecondInputOnFirstCharacter = () => setCursorPosition(0, secondInputRef, setSelectionSecond); + const focusMinuteInputOnLastCharacter = () => setCursorPosition(2, minuteInputRef, setSelectionMinute); + const focusMinuteInputOnFirstCharacter = () => setCursorPosition(0, minuteInputRef, setSelectionMinute); + const focusHourInputOnLastCharacter = () => setCursorPosition(2, hourInputRef, setSelectionHour); + + const validate = (time: string) => { + if (!shouldValidate) { + return true; + } + const timeString = time || `${hours}:${minutes} ${amPmValue}`; + const [hourStr] = timeString.split(/[:\s]+/); + const hour = parseInt(hourStr, 10); + if (hour === 0) { + setError(true); + setErrorMessage(translate('common.error.invalidTimeRange')); + return false; + } + if (!shouldValidateFutureTime) { + return true; + } + const isValid = DateUtils.isTimeAtLeastOneMinuteInFuture({timeString, dateTimeString: defaultValue}); + setError(!isValid); + setErrorMessage(translate('common.error.invalidTimeShouldBeFuture')); + return isValid; + }; const resetHours = () => { setHours('00'); @@ -558,63 +555,59 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou * Update amount with number or Backspace pressed for BigNumberPad. * Validate new amount with decimal number regex up to 6 digits and 2 decimal digit to enable Next button */ - const updateAmountNumberPad = useCallback( - (key: string) => { - const isHourFocused = hourInputRef.current?.isFocused(); - const isMinuteFocused = minuteInputRef.current?.isFocused(); - const isSecondFocused = secondInputRef.current?.isFocused(); - const isMillisecondFocused = millisecondInputRef.current?.isFocused(); - if (showFullFormat && !isHourFocused && !isMinuteFocused && !isSecondFocused && !isMillisecondFocused) { - millisecondInputRef.current?.focus(); - } else if (!showFullFormat && !isHourFocused && !isMinuteFocused) { - minuteInputRef.current?.focus(); - } - - if (key === '.') { - return; - } - if (key === '<' || key === 'Backspace') { - if (isHourFocused) { - clearSelectedValue(hours, selectionHour, setHours, setSelectionHour); - } else if (isMinuteFocused) { - if (selectionMinute.start === 0 && selectionMinute.end === 0) { - focusHourInputOnLastCharacter(); - return; - } - - clearSelectedValue(minutes, selectionMinute, setMinutes, setSelectionMinute); - } else if (isSecondFocused) { - if (selectionSecond.start === 0 && selectionSecond.end === 0) { - focusMinuteInputOnLastCharacter(); - return; - } - - clearSelectedValue(seconds, selectionSecond, setSeconds, setSelectionSecond); - } else if (isMillisecondFocused) { - if (selectionMillisecond.start === 0 && selectionMillisecond.end === 0) { - focusSecondInputOnLastCharacter(); - return; - } - - clearSelectedValue(milliseconds, selectionMillisecond, setMilliseconds, setSelectionMillisecond, 3); - } - return; - } - const trimmedKey = key.replaceAll(/[^0-9]/g, ''); + const updateAmountNumberPad = (key: string) => { + const isHourFocused = hourInputRef.current?.isFocused(); + const isMinuteFocused = minuteInputRef.current?.isFocused(); + const isSecondFocused = secondInputRef.current?.isFocused(); + const isMillisecondFocused = millisecondInputRef.current?.isFocused(); + if (showFullFormat && !isHourFocused && !isMinuteFocused && !isSecondFocused && !isMillisecondFocused) { + millisecondInputRef.current?.focus(); + } else if (!showFullFormat && !isHourFocused && !isMinuteFocused) { + minuteInputRef.current?.focus(); + } + if (key === '.') { + return; + } + if (key === '<' || key === 'Backspace') { if (isHourFocused) { - handleHourChange(insertAtPosition(hours, trimmedKey, selectionHour.start, selectionHour.end)); + clearSelectedValue(hours, selectionHour, setHours, setSelectionHour); } else if (isMinuteFocused) { - handleMinutesChange(insertAtPosition(minutes, trimmedKey, selectionMinute.start, selectionMinute.end)); + if (selectionMinute.start === 0 && selectionMinute.end === 0) { + focusHourInputOnLastCharacter(); + return; + } + + clearSelectedValue(minutes, selectionMinute, setMinutes, setSelectionMinute); } else if (isSecondFocused) { - handleSecondsChange(insertAtPosition(seconds, trimmedKey, selectionSecond.start, selectionSecond.end)); + if (selectionSecond.start === 0 && selectionSecond.end === 0) { + focusMinuteInputOnLastCharacter(); + return; + } + + clearSelectedValue(seconds, selectionSecond, setSeconds, setSelectionSecond); } else if (isMillisecondFocused) { - handleMillisecondsChange(insertAtPosition(milliseconds, trimmedKey, selectionMillisecond.start, selectionMillisecond.end)); + if (selectionMillisecond.start === 0 && selectionMillisecond.end === 0) { + focusSecondInputOnLastCharacter(); + return; + } + + clearSelectedValue(milliseconds, selectionMillisecond, setMilliseconds, setSelectionMillisecond, 3); } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [minutes, hours, seconds, milliseconds, selectionMinute, selectionHour, selectionSecond, selectionMillisecond], - ); + return; + } + const trimmedKey = key.replaceAll(/[^0-9]/g, ''); + + if (isHourFocused) { + handleHourChange(insertAtPosition(hours, trimmedKey, selectionHour.start, selectionHour.end)); + } else if (isMinuteFocused) { + handleMinutesChange(insertAtPosition(minutes, trimmedKey, selectionMinute.start, selectionMinute.end)); + } else if (isSecondFocused) { + handleSecondsChange(insertAtPosition(seconds, trimmedKey, selectionSecond.start, selectionSecond.end)); + } else if (isMillisecondFocused) { + handleMillisecondsChange(insertAtPosition(milliseconds, trimmedKey, selectionMillisecond.start, selectionMillisecond.end)); + } + }; useEffect(() => { // we implement this to ensure the hour input focuses on the first character upon initial focus @@ -622,94 +615,69 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou setSelectionHour({start: 0, end: 0}); }, []); - const arrowConfig = useMemo( - () => ({ - shouldPreventDefault: false, - }), - [], - ); + const arrowConfig = { + shouldPreventDefault: false, + }; - const arrowLeftCallback = useCallback( - (e?: GestureResponderEvent | KeyboardEvent) => { - if (minuteInputRef.current?.isFocused() && selectionMinute.start === 0) { - // Check e to be truthy to avoid crashing on Android (e is undefined there) - e?.preventDefault(); - focusHourInputOnLastCharacter(); - } - if (secondInputRef.current?.isFocused() && selectionSecond.start === 0) { - // Check e to be truthy to avoid crashing on Android (e is undefined there) - e?.preventDefault(); - focusMinuteInputOnLastCharacter(); - } - if (millisecondInputRef.current?.isFocused() && selectionMillisecond.start === 0) { - // Check e to be truthy to avoid crashing on Android (e is undefined there) - e?.preventDefault(); - focusSecondInputOnLastCharacter(); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [selectionHour, selectionMinute.start], - ); - const arrowRightCallback = useCallback( - (e?: GestureResponderEvent | KeyboardEvent) => { - if (hourInputRef.current?.isFocused() && selectionHour.start === 2) { - // Check e to be truthy to avoid crashing on Android (e is undefined there) - e?.preventDefault(); - focusMinuteInputOnFirstCharacter(); - } - if (minuteInputRef.current?.isFocused() && selectionMinute.start === 2) { - // Check e to be truthy to avoid crashing on Android (e is undefined there) - e?.preventDefault(); - focusSecondInputOnFirstCharacter(); - } - if (secondInputRef.current?.isFocused() && selectionSecond.start === 2) { - // Check e to be truthy to avoid crashing on Android (e is undefined there) - e?.preventDefault(); - focusMillisecondInputOnFirstCharacter(); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [selectionHour.start, selectionMinute.start, selectionSecond.start, selectionMillisecond], - ); + const arrowLeftCallback = (e?: GestureResponderEvent | KeyboardEvent) => { + if (minuteInputRef.current?.isFocused() && selectionMinute.start === 0) { + // Check e to be truthy to avoid crashing on Android (e is undefined there) + e?.preventDefault(); + focusHourInputOnLastCharacter(); + } + if (secondInputRef.current?.isFocused() && selectionSecond.start === 0) { + // Check e to be truthy to avoid crashing on Android (e is undefined there) + e?.preventDefault(); + focusMinuteInputOnLastCharacter(); + } + if (millisecondInputRef.current?.isFocused() && selectionMillisecond.start === 0) { + // Check e to be truthy to avoid crashing on Android (e is undefined there) + e?.preventDefault(); + focusSecondInputOnLastCharacter(); + } + }; + const arrowRightCallback = (e?: GestureResponderEvent | KeyboardEvent) => { + if (hourInputRef.current?.isFocused() && selectionHour.start === 2) { + // Check e to be truthy to avoid crashing on Android (e is undefined there) + e?.preventDefault(); + focusMinuteInputOnFirstCharacter(); + } + if (minuteInputRef.current?.isFocused() && selectionMinute.start === 2) { + // Check e to be truthy to avoid crashing on Android (e is undefined there) + e?.preventDefault(); + focusSecondInputOnFirstCharacter(); + } + if (secondInputRef.current?.isFocused() && selectionSecond.start === 2) { + // Check e to be truthy to avoid crashing on Android (e is undefined there) + e?.preventDefault(); + focusMillisecondInputOnFirstCharacter(); + } + }; useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT, arrowLeftCallback, arrowConfig); useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT, arrowRightCallback, arrowConfig); - const handleFocusOnBackspace = useCallback( - (e: NativeSyntheticEvent) => { - if (e.nativeEvent.key !== 'Backspace') { - return; - } - if (minuteInputRef.current?.isFocused() && selectionMinute.start === 0 && selectionMinute.end === 0) { - e.preventDefault(); - focusHourInputOnLastCharacter(); - } - if (secondInputRef.current?.isFocused() && selectionSecond.start === 0 && selectionSecond.end === 0) { - e.preventDefault(); - focusMinuteInputOnLastCharacter(); - } - if (millisecondInputRef.current?.isFocused() && selectionMillisecond.start === 0 && selectionMillisecond.end === 0) { - e.preventDefault(); - focusSecondInputOnLastCharacter(); - } - }, - - [ - selectionMinute.start, - selectionMinute.end, - selectionSecond.start, - selectionSecond.end, - selectionMillisecond.start, - selectionMillisecond.end, - focusHourInputOnLastCharacter, - focusMinuteInputOnLastCharacter, - focusSecondInputOnLastCharacter, - ], - ); + const handleFocusOnBackspace = (e: NativeSyntheticEvent) => { + if (e.nativeEvent.key !== 'Backspace') { + return; + } + if (minuteInputRef.current?.isFocused() && selectionMinute.start === 0 && selectionMinute.end === 0) { + e.preventDefault(); + focusHourInputOnLastCharacter(); + } + if (secondInputRef.current?.isFocused() && selectionSecond.start === 0 && selectionSecond.end === 0) { + e.preventDefault(); + focusMinuteInputOnLastCharacter(); + } + if (millisecondInputRef.current?.isFocused() && selectionMillisecond.start === 0 && selectionMillisecond.end === 0) { + e.preventDefault(); + focusSecondInputOnLastCharacter(); + } + }; const {styleForAM, styleForPM} = StyleUtils.getStatusAMandPMButtonStyle(amPmValue); - const numberPad = useCallback(() => { + const numberPad = () => { if (!canUseTouchScreen) { return null; } @@ -720,7 +688,7 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou isLongPressDisabled /> ); - }, [canUseTouchScreen, updateAmountNumberPad]); + }; useEffect(() => { onInputChange(showFullFormat ? `${hours}:${minutes}:${seconds}.${milliseconds} ${amPmValue}` : `${hours}:${minutes} ${amPmValue}`); @@ -736,38 +704,35 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shou } }; - const renderedAmPmButtons = useMemo( - () => ( - - - - - ), - [styles, styleForAM, styleForPM, translate, setAmPmValue], + const renderedAmPmButtons = ( + + + + ); return ( diff --git a/src/components/Tooltip/BaseGenericTooltip/index.native.tsx b/src/components/Tooltip/BaseGenericTooltip/index.native.tsx index aa8e56683c96..c33bcf2c84cc 100644 --- a/src/components/Tooltip/BaseGenericTooltip/index.native.tsx +++ b/src/components/Tooltip/BaseGenericTooltip/index.native.tsx @@ -6,6 +6,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; import type {LayoutChangeEvent, View as RNView} from 'react-native'; import {Portal} from '@gorhom/portal'; @@ -55,7 +56,7 @@ function BaseGenericTooltip({ // The height of tooltip's wrapper. const [wrapperMeasuredHeightState, setWrapperMeasuredHeight] = useState(); const wrapperMeasuredHeightAnimated = useSharedValue(0); - const rootWrapper = useRef(null); + const rootWrapper = useRef>(null); const StyleUtils = useStyleUtils(); const {rootWrapperStyle, textStyle, pointerWrapperStyle, pointerStyle} = useMemo( diff --git a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx index 7d3a9374d396..ca84fc416897 100644 --- a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx +++ b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx @@ -9,7 +9,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; -import type {LayoutRectangle, NativeMethods, NativeSyntheticEvent} from 'react-native'; +import type {HostInstance, LayoutRectangle, NativeSyntheticEvent} from 'react-native'; import {NavigationContext, useIsFocused} from '@react-navigation/native'; import React, {memo, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState} from 'react'; @@ -34,7 +34,7 @@ function BaseEducationalTooltip({ }: EducationalTooltipProps) { const shouldShowTooltip = shouldDisplayTooltip ?? shouldRender; const genericTooltipStateRef = useRef(undefined); - const tooltipElementRef = useRef | undefined>(undefined); + const tooltipElementRef = useRef | undefined>(undefined); const [shouldMeasure, setShouldMeasure] = useState(false); const show = useRef<(() => void) | undefined>(undefined); diff --git a/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.android.ts b/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.android.ts index 91d685a0d8b3..c1f914f83358 100644 --- a/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.android.ts +++ b/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.android.ts @@ -1,13 +1,13 @@ -import type {LayoutRectangle, NativeMethods} from 'react-native'; +import type {HostInstance, LayoutRectangle} from 'react-native'; -export default function measureTooltipCoordinate(target: Readonly, updateTargetBounds: (rect: LayoutRectangle) => void, showTooltip: () => void) { +export default function measureTooltipCoordinate(target: Readonly, updateTargetBounds: (rect: LayoutRectangle) => void, showTooltip: () => void) { return target?.measure((x, y, width, height, px, py) => { updateTargetBounds({height, width, x: px, y: py}); showTooltip(); }); } -function getTooltipCoordinates(target: Readonly, callback: (rect: LayoutRectangle) => void) { +function getTooltipCoordinates(target: Readonly, callback: (rect: LayoutRectangle) => void) { return target?.measure((x, y, width, height, px, py) => { callback({height, width, x: px, y: py}); }); diff --git a/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.ts b/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.ts index a9ca7db3cab0..71eb9746a435 100644 --- a/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.ts +++ b/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.ts @@ -1,13 +1,13 @@ -import type {LayoutRectangle, NativeMethods} from 'react-native'; +import type {HostInstance, LayoutRectangle} from 'react-native'; -export default function measureTooltipCoordinate(target: Readonly, updateTargetBounds: (rect: LayoutRectangle) => void, showTooltip: () => void) { +export default function measureTooltipCoordinate(target: Readonly, updateTargetBounds: (rect: LayoutRectangle) => void, showTooltip: () => void) { return target?.measureInWindow((x, y, width, height) => { updateTargetBounds({height, width, x, y}); showTooltip(); }); } -function getTooltipCoordinates(target: Readonly, callback: (rect: LayoutRectangle) => void) { +function getTooltipCoordinates(target: Readonly, callback: (rect: LayoutRectangle) => void) { return target?.measureInWindow((x, y, width, height) => { callback({height, width, x, y}); }); diff --git a/src/components/Tooltip/GenericTooltip.tsx b/src/components/Tooltip/GenericTooltip.tsx index 45c97176c4ea..03b0011e48f7 100644 --- a/src/components/Tooltip/GenericTooltip.tsx +++ b/src/components/Tooltip/GenericTooltip.tsx @@ -12,7 +12,7 @@ import callOrReturn from '@src/types/utils/callOrReturn'; import type {LayoutRectangle} from 'react-native'; -import React, {memo, useCallback, useEffect, useState} from 'react'; +import React, {memo, useEffect, useState} from 'react'; import {cancelAnimation, useSharedValue, withDelay, withTiming} from 'react-native-reanimated'; import type {GenericTooltipProps} from './types'; @@ -24,10 +24,12 @@ import TooltipSense from './TooltipSense'; * The generic tooltip implementation, exposing the tooltip's state * while leaving the tooltip's target bounds computation to its parent. */ +const defaultTooltipMaxWidth = variables.sideBarWidth - 2 * variables.uploadViewMargin; + function GenericTooltip({ children, numberOfLines = CONST.TOOLTIP_MAX_LINES, - maxWidth = variables.sideBarWidth - 2 * variables.uploadViewMargin, + maxWidth = defaultTooltipMaxWidth, minWidth, text = '', renderTooltipContent, @@ -86,7 +88,7 @@ function GenericTooltip({ /** * Display the tooltip in an animation. */ - const showTooltip = useCallback(() => { + const showTooltip = () => { setIsRendered(true); setIsVisible(true); @@ -113,7 +115,7 @@ function GenericTooltip({ ); } TooltipSense.activate(); - }, [animation, isAnimationCanceled, isTooltipSenseInitiator, shouldForceAnimate]); + }; // eslint-disable-next-line rulesdir/prefer-early-return useEffect(() => { @@ -141,7 +143,7 @@ function GenericTooltip({ /** * Hide the tooltip in an animation. */ - const hideTooltip = useCallback(() => { + const hideTooltip = () => { cancelAnimation(animation); if (TooltipSense.isActive() && !isTooltipSenseInitiator.get()) { @@ -153,15 +155,15 @@ function GenericTooltip({ } TooltipSense.deactivate(); setIsVisible(false); - }, [animation, isTooltipSenseInitiator]); + }; - const onPressOverlay = useCallback(() => { + const onPressOverlay = () => { if (!shouldUseOverlay) { return; } setShouldUseOverlay(false); hideTooltip(); - }, [shouldUseOverlay, hideTooltip]); + }; // Skip the tooltip and return the children if the text is empty, we don't have a render function. if (StringUtils.isEmptyString(text) && renderTooltipContent == null) { diff --git a/src/components/TransactionItemRow/DataCells/MerchantCell.tsx b/src/components/TransactionItemRow/DataCells/MerchantCell.tsx index e0a7242f70e2..a44e2d0341e7 100644 --- a/src/components/TransactionItemRow/DataCells/MerchantCell.tsx +++ b/src/components/TransactionItemRow/DataCells/MerchantCell.tsx @@ -14,6 +14,7 @@ import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; import type {TextInput as RNTextInput} from 'react-native'; import React, {useMemo, useRef} from 'react'; @@ -28,7 +29,7 @@ type MerchantOrDescriptionCellProps = { function MerchantOrDescriptionCell({merchantOrDescription, shouldShowTooltip, shouldUseNarrowLayout, isDescription, canEdit, onSave}: MerchantOrDescriptionCellProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const inputRef = useRef(null); + const inputRef = useRef | null>(null); const text = useMemo(() => { if (!isDescription) { @@ -59,7 +60,7 @@ function MerchantOrDescriptionCell({merchantOrDescription, shouldShowTooltip, sh }; const handleRef = (element: BaseTextInputRef | null) => { - inputRef.current = element as RNTextInput | null; + inputRef.current = element as ComponentRef | null; }; // Multiline TextInputs with autoFocus default cursor to the beginning; manually position it at the end on focus diff --git a/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx b/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx index 42a5a1ebae91..495137a77e81 100644 --- a/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx +++ b/src/components/TransactionItemRow/DataCells/ReceiptCell.tsx @@ -17,6 +17,7 @@ import variables from '@styles/variables'; import type {Transaction} from '@src/types/onyx'; +import type {ComponentRef} from 'react'; import type {ViewStyle} from 'react-native'; import {Str} from 'expensify-common'; @@ -48,7 +49,7 @@ function ReceiptCell({ // ReceiptPreview handles its own visibility via debounced state, so keeping it // mounted avoids re-creating the portal and reloading images on subsequent hovers. const [shouldMountPreview, setShouldMountPreview] = useState(false); - const cellRef = useRef(null); + const cellRef = useRef>(null); // The preview is a document.body portal, so it needs the hovered cell's window position to // anchor itself beside the row instead of sitting fixed in the upper-left corner. const [previewAnchor, setPreviewAnchor] = useState(); diff --git a/src/components/TransactionItemRow/EditableCell/EditableCell.tsx b/src/components/TransactionItemRow/EditableCell/EditableCell.tsx index 75e920d42996..d76913848ded 100644 --- a/src/components/TransactionItemRow/EditableCell/EditableCell.tsx +++ b/src/components/TransactionItemRow/EditableCell/EditableCell.tsx @@ -11,7 +11,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import type {ReactNode, RefObject} from 'react'; +import type {ComponentRef, ReactNode, RefObject} from 'react'; import React, {useDeferredValue, useEffect, useId, useState} from 'react'; import {View} from 'react-native'; @@ -44,7 +44,7 @@ type EditableCellProps = { onStartEditing: () => void; /** Ref attached to the cell wrapper — used as popover anchor for date/category pickers */ - anchorRef?: RefObject; + anchorRef?: RefObject | null>; /** Which side should show the edit icon in display mode */ editIconPosition?: EditIconPosition; diff --git a/src/components/TransactionItemRow/EditableCell/usePopoverEditState.ts b/src/components/TransactionItemRow/EditableCell/usePopoverEditState.ts index 5b267dcf3bce..1a3bc0424e6d 100644 --- a/src/components/TransactionItemRow/EditableCell/usePopoverEditState.ts +++ b/src/components/TransactionItemRow/EditableCell/usePopoverEditState.ts @@ -2,6 +2,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -51,7 +52,7 @@ function usePopoverEditStateImpl({ anchorEdge = CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, }: UsePopoverEditStateOptions) { const {windowHeight} = useWindowDimensions(); - const anchorRef = useRef(null); + const anchorRef = useRef>(null); const [isEditing, setIsEditing] = useState(false); const [isPopoverVisible, setIsPopoverVisible] = useState(false); const [popoverPosition, setPopoverPosition] = useState({horizontal: 0, vertical: 0}); diff --git a/src/components/UpdateAppModal.tsx b/src/components/UpdateAppModal.tsx deleted file mode 100755 index 342f47f87bc7..000000000000 --- a/src/components/UpdateAppModal.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; - -import ONYXKEYS from '@src/ONYXKEYS'; - -import React, {useState} from 'react'; - -import ConfirmModal from './ConfirmModal'; - -function UpdateAppModal() { - const [isModalOpen, setIsModalOpen] = useState(true); - const {translate} = useLocalize(); - const [updateAvailable] = useOnyx(ONYXKEYS.RAM_ONLY_UPDATE_AVAILABLE); - - if (!updateAvailable) { - return null; - } - - const handleClose = () => { - setIsModalOpen(false); - }; - - return ( - - ); -} - -export default UpdateAppModal; diff --git a/src/components/ValidateCodeInput.tsx b/src/components/ValidateCodeInput.tsx index db490a368f4c..65a2d3513766 100644 --- a/src/components/ValidateCodeInput.tsx +++ b/src/components/ValidateCodeInput.tsx @@ -9,7 +9,7 @@ import {isNumeric} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; -import type {ForwardedRef, KeyboardEvent} from 'react'; +import type {ComponentRef, ForwardedRef, KeyboardEvent} from 'react'; import type {FocusEvent, TextInput as RNTextInput, TextInputKeyPressEvent} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -40,7 +40,7 @@ const useValidateCodePaste = (inputRef: React.RefObject return; } - const isFocused = (inputRef.current as RNTextInput)?.isFocused?.() ?? false; + const isFocused = (inputRef.current as ComponentRef)?.isFocused?.() ?? false; if (!isFocused) { return; } diff --git a/src/components/ValuePicker/types.ts b/src/components/ValuePicker/types.ts index 2c800ebf0d3f..ef8266e5ad9e 100644 --- a/src/components/ValuePicker/types.ts +++ b/src/components/ValuePicker/types.ts @@ -2,7 +2,7 @@ import type {ListItem} from '@components/SelectionList/types'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; -import type {ForwardedRef, ReactNode} from 'react'; +import type {ComponentRef, ForwardedRef, ReactNode} from 'react'; import type {StyleProp, TextStyle, View} from 'react-native'; type ValuePickerListItem = ListItem & { @@ -84,7 +84,7 @@ type ValuePickerProps = ForwardedFSClassProps & { /** Whether to show the selector modal */ shouldShowModal?: boolean; - ref: ForwardedRef; + ref: ForwardedRef>; addBottomSafeAreaPadding?: boolean; disableKeyboardShortcuts?: boolean; diff --git a/src/components/VideoPlayer/BaseVideoPlayer.tsx b/src/components/VideoPlayer/BaseVideoPlayer.tsx index 0ab5651b86dd..b754c46b210c 100644 --- a/src/components/VideoPlayer/BaseVideoPlayer.tsx +++ b/src/components/VideoPlayer/BaseVideoPlayer.tsx @@ -19,7 +19,7 @@ import {canUseTouchScreen as canUseTouchScreenLib} from '@libs/DeviceCapabilitie import CONST from '@src/CONST'; import type {MutedChangeEventPayload, PlayingChangeEventPayload, StatusChangeEventPayload, TimeUpdateEventPayload, VideoPlayer} from 'expo-video'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import {useEvent, useEventListener} from 'expo'; import {useVideoPlayer, VideoView} from 'expo-video'; @@ -144,9 +144,9 @@ function BaseVideoPlayer(props: BaseVideoPlayerProps) { }, [isLoading, isVideoOffline, isOffline]); const videoViewRef = useRef(null); - const videoPlayerElementParentRef = useRef(null); - const videoPlayerElementRef = useRef(null); - const sharedVideoPlayerParentRef = useRef(null); + const videoPlayerElementParentRef = useRef | HTMLDivElement | null>(null); + const videoPlayerElementRef = useRef | HTMLDivElement | null>(null); + const sharedVideoPlayerParentRef = useRef | HTMLDivElement | null>(null); const isReadyForDisplayRef = useRef(false); const savedCurrentTimeRef = useRef(0); const shouldUseSharedVideoElementRef = useRef(shouldUseSharedVideoElement); @@ -588,7 +588,7 @@ function BaseVideoPlayer(props: BaseVideoPlayerProps) { {shouldUseSharedVideoElement ? ( <> } + ref={sharedVideoPlayerParentRef as RefObject | null>} style={[styles.flex1]} /> {/* We are adding transparent absolute View between appended video component and control buttons to enable @@ -604,7 +604,7 @@ function BaseVideoPlayer(props: BaseVideoPlayerProps) { if (!el) { return; } - const elHTML = el as View | HTMLDivElement; + const elHTML = el as ComponentRef | HTMLDivElement; if ('childNodes' in elHTML && elHTML.childNodes[0]) { videoPlayerElementRef.current = elHTML.childNodes[0] as HTMLDivElement; } diff --git a/src/components/VideoPlayer/IconButton.tsx b/src/components/VideoPlayer/IconButton.tsx index a7ee36fc035a..2e26623fe4b4 100644 --- a/src/components/VideoPlayer/IconButton.tsx +++ b/src/components/VideoPlayer/IconButton.tsx @@ -11,7 +11,7 @@ import CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; -import type {Ref} from 'react'; +import type {ComponentRef, Ref} from 'react'; import type {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native'; import React from 'react'; @@ -26,7 +26,7 @@ type IconButtonProps = WithSentryLabel & { small?: boolean; shouldForceRenderingTooltipBelow?: boolean; /** Forwarded to the underlying pressable so callers can use the button as a popover anchor. */ - ref?: PressableRef | Ref; + ref?: PressableRef | Ref>; }; function IconButton({src, fill = 'white', onPress, style, hoverStyle, tooltipText = '', small = false, shouldForceRenderingTooltipBelow = false, sentryLabel, ref}: IconButtonProps) { diff --git a/src/components/VideoPlayerContexts/PlaybackContext/index.tsx b/src/components/VideoPlayerContexts/PlaybackContext/index.tsx index ff6f9cd4a3f6..2a40345435b1 100644 --- a/src/components/VideoPlayerContexts/PlaybackContext/index.tsx +++ b/src/components/VideoPlayerContexts/PlaybackContext/index.tsx @@ -5,6 +5,7 @@ import Navigation from '@navigation/Navigation'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {VideoPlayer, VideoPlayerStatus, VideoView} from 'expo-video'; +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import React, {useCallback, useContext, useEffect, useRef, useState} from 'react'; @@ -90,8 +91,8 @@ function PlaybackContextProvider({children}: ChildrenProps) { ( videoPlayerRef: VideoPlayer | null, videoViewRef: VideoView | null, - parent: View | HTMLDivElement | null, - child: View | HTMLDivElement | null, + parent: ComponentRef | HTMLDivElement | null, + child: ComponentRef | HTMLDivElement | null, shouldNotAutoPlay: boolean, {shouldUseSharedVideoElement, url, reportID}, ) => { diff --git a/src/components/VideoPlayerContexts/PlaybackContext/types.ts b/src/components/VideoPlayerContexts/PlaybackContext/types.ts index 3921a11196e7..b6fe71bc5c6b 100644 --- a/src/components/VideoPlayerContexts/PlaybackContext/types.ts +++ b/src/components/VideoPlayerContexts/PlaybackContext/types.ts @@ -1,7 +1,7 @@ import type {Report} from '@src/types/onyx'; import type {VideoPlayer, VideoPlayerStatus, VideoView} from 'expo-video'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -20,7 +20,7 @@ type StopVideo = () => void; * Represents the original parent container of the video element. * Can be a React Native View, an HTML div, or null if not set. */ -type OriginalParent = View | HTMLDivElement | null; +type OriginalParent = ComponentRef | HTMLDivElement | null; /** * Metadata describing a video element instance. @@ -53,12 +53,12 @@ type PlaybackStateContextValues = { /** * The original parent container of the shared video element. */ - originalParent: View | HTMLDivElement | null; + originalParent: ComponentRef | HTMLDivElement | null; /** * The shared video element container, if one exists. */ - sharedElement: View | HTMLDivElement | null; + sharedElement: ComponentRef | HTMLDivElement | null; /** * Array of currently mounted Video Player instances @@ -101,8 +101,8 @@ type PlaybackActionsContextValues = { shareVideoPlayerElements: ( playerRef: VideoPlayer | null, viewRef: VideoView | null, - parent: View | HTMLDivElement | null, - child: View | HTMLDivElement | null, + parent: ComponentRef | HTMLDivElement | null, + child: ComponentRef | HTMLDivElement | null, isUploading: boolean, videoElementData: VideoElementData, ) => void; diff --git a/src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx b/src/components/WidgetHeaderMenu.tsx similarity index 92% rename from src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx rename to src/components/WidgetHeaderMenu.tsx index 9036991e7b94..393dd7bdd939 100644 --- a/src/pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu.tsx +++ b/src/components/WidgetHeaderMenu.tsx @@ -1,6 +1,3 @@ -import type {PopoverMenuItem} from '@components/PopoverMenu'; -import ThreeDotsMenu from '@components/ThreeDotsMenu'; - import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; @@ -9,6 +6,10 @@ import CONST from '@src/CONST'; import React from 'react'; +import type {PopoverMenuItem} from './PopoverMenu'; + +import ThreeDotsMenu from './ThreeDotsMenu'; + const ANCHOR_ALIGNMENT = { horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, diff --git a/src/components/WorkspaceConfirmationForm.tsx b/src/components/WorkspaceConfirmationForm.tsx index 98fcf838b969..b4e7e372eb3a 100644 --- a/src/components/WorkspaceConfirmationForm.tsx +++ b/src/components/WorkspaceConfirmationForm.tsx @@ -26,6 +26,7 @@ import type {PolicyType} from '@src/types/form/WorkspaceConfirmationForm'; import INPUT_IDS from '@src/types/form/WorkspaceConfirmationForm'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports -- Type import needed for ref typing; no wrapper available import type {ScrollView as RNScrollView} from 'react-native'; @@ -85,7 +86,7 @@ function WorkspaceConfirmationForm({ const styles = useThemeStyles(); const {translate} = useLocalize(); const {inputCallbackRef} = useAutoFocusInput(); - const scrollViewRef = useRef(null); + const scrollViewRef = useRef>(null); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const isApprovedAccountant = !!account?.isApprovedAccountant; diff --git a/src/components/WorkspaceListLayout.tsx b/src/components/WorkspaceListLayout.tsx index 20d42c53b4b0..ce180ea0d91d 100644 --- a/src/components/WorkspaceListLayout.tsx +++ b/src/components/WorkspaceListLayout.tsx @@ -7,6 +7,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; +import useDomainsTabBadge from '@pages/home/ForYouSection/useDomainsTabBadge'; + import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; @@ -41,6 +43,7 @@ function WorkspaceListHeaderContent({activeTabKey, headerButton, shouldShowHeade const styles = useThemeStyles(); const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Globe', 'Building']); + const {badgeText: domainsBadgeText, hasDomainErrors} = useDomainsTabBadge(); const navigationOptions = [ { key: 'workspaces', @@ -55,6 +58,9 @@ function WorkspaceListHeaderContent({activeTabKey, headerButton, shouldShowHeade icon: icons.Globe, route: ROUTES.DOMAINS_LIST.getRoute(), screenName: SCREENS.DOMAINS_LIST, + badgeText: domainsBadgeText, + isBadgeCondensed: true, + isBadgeError: hasDomainErrors, }, ]; diff --git a/src/hooks/useAdvancedSearchFilters.ts b/src/hooks/useAdvancedSearchFilters.ts index 303a83dce0bc..54ff2aa0531a 100644 --- a/src/hooks/useAdvancedSearchFilters.ts +++ b/src/hooks/useAdvancedSearchFilters.ts @@ -1,12 +1,9 @@ -import type {Filter} from '@components/Search/types'; - import {isFilterableBankAccount} from '@libs/BankAccountUtils'; import {isPolicyFeatureEnabled} from '@libs/PolicyUtils'; -import {getAllPolicyValues} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {CardList, Policy, PolicyTagLists} from '@src/types/onyx'; +import type {CardList, Policy} from '@src/types/onyx'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; @@ -293,14 +290,6 @@ function advancedSearchPoliciesSelector(policies: OnyxCollection): OnyxC * Checks whether a single policy's tag lists contain at least one tag. * Short-circuits on the first tag found. */ -const policyTagListHasTags = (policyTagList: PolicyTagLists | undefined) => Object.values(policyTagList ?? {}).some((tagList) => Object.keys(tagList.tags ?? {}).length > 0); - -/** - * Selector that checks if any tags exist across all policy tag lists. - * Returns a boolean with early exit on first tag found. - */ -const hasTagsSelector = (allPolicyTagLists: OnyxCollection) => Object.values(allPolicyTagLists ?? {}).some(policyTagListHasTags); - function useAdvancedSearchFiltersWorkspaces(policies: OnyxCollection, searchTerm?: string) { const {localeCompare} = useLocalize(); const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); @@ -322,19 +311,15 @@ function shouldDisplayCardFilterSelector(cardList: OnyxEntry) { return shouldDisplayFilter(Object.keys(filterCardsHiddenFromSearch(cardList)).length, true); } -function useAdvancedSearchFilters(type: SearchDataTypes | undefined, policyID: Filter | undefined) { +function useAdvancedSearchFilters(type: SearchDataTypes | undefined) { const [shouldDisplayCardFilter] = useOnyx(ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST, {selector: shouldDisplayCardFilterSelector}); const [policies = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: advancedSearchPoliciesSelector}); const [policyDerived] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: policyDerivedSelector}); - const [allPolicyTagLists = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); - const selectedPolicyTagLists = policyID?.value?.length ? getAllPolicyValues(policyID, ONYXKEYS.COLLECTION.POLICY_TAGS, allPolicyTagLists) : []; - const [hasTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {selector: hasTagsSelector}); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const {workspaces} = useAdvancedSearchFiltersWorkspaces(policies); - const hasSelectedPolicyTags = selectedPolicyTagLists.some(policyTagListHasTags); - const shouldDisplayTagFilter = shouldDisplayFilter(hasTags ? 1 : 0, policyDerived?.areTagsEnabled ?? false, hasSelectedPolicyTags); + const shouldDisplayTagFilter = !!policyDerived?.areTagsEnabled; // Count business accounts that aren't partially set up, mirroring BankAccountSelector so the row never shows above an empty picker. const hasFilterableBankAccount = Object.values(bankAccountList ?? {}).some(isFilterableBankAccount); const shouldDisplayBankAccountFilter = shouldDisplayFilter(hasFilterableBankAccount ? 1 : 0, true); diff --git a/src/hooks/useAutoFocusInput.ts b/src/hooks/useAutoFocusInput.ts index 0462aed410e3..4a3acee32f45 100644 --- a/src/hooks/useAutoFocusInput.ts +++ b/src/hooks/useAutoFocusInput.ts @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {useSplashScreenState} from '@src/SplashScreenStateContext'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {TextInput} from 'react-native'; import {useFocusEffect, useNavigation} from '@react-navigation/native'; @@ -23,8 +23,8 @@ import useOnyx from './useOnyx'; import useSidePanelState from './useSidePanelState'; type UseAutoFocusInput = { - inputCallbackRef: (ref: TextInput | null) => void; - inputRef: RefObject; + inputCallbackRef: (ref: ComponentRef | null) => void; + inputRef: RefObject | null>; cancelAutoFocus: () => void; }; @@ -39,7 +39,7 @@ export default function useAutoFocusInput(isMultiline = false): UseAutoFocusInpu const {splashScreenState} = useSplashScreenState(); const navigation = useNavigation>(); - const inputRef = useRef(null); + const inputRef = useRef | null>(null); const transitionEndTimeoutRef = useRef(null); const isAutoFocusCancelledRef = useRef(false); @@ -150,7 +150,7 @@ export default function useAutoFocusInput(isMultiline = false): UseAutoFocusInpu } } - const inputCallbackRef = (ref: TextInput | null) => { + const inputCallbackRef = (ref: ComponentRef | null) => { inputRef.current = ref; if (isInputInitialized) { return; diff --git a/src/hooks/useClickZoomPan/types.ts b/src/hooks/useClickZoomPan/types.ts index 0979fd4b1945..771ba5af3d8a 100644 --- a/src/hooks/useClickZoomPan/types.ts +++ b/src/hooks/useClickZoomPan/types.ts @@ -1,11 +1,11 @@ import type {Dimensions} from '@src/types/utils/Layout'; -import type {RefObject, SyntheticEvent} from 'react'; +import type {ComponentRef, RefObject, SyntheticEvent} from 'react'; import type {GestureResponderEvent, View} from 'react-native'; type UseClickZoomPanParams = { /** The scrollable element the zoomed content overflows into */ - scrollableRef: RefObject<(View & HTMLDivElement) | null>; + scrollableRef: RefObject<(ComponentRef & HTMLDivElement) | null>; /** Size of the visible scroll area, used to center the clicked point after zooming */ containerSize: Dimensions; diff --git a/src/hooks/useConfirmModal.ts b/src/hooks/useConfirmModal.ts index 7fa66961cfb8..8975f50da8cc 100644 --- a/src/hooks/useConfirmModal.ts +++ b/src/hooks/useConfirmModal.ts @@ -23,6 +23,7 @@ const useConfirmModal = () => { return { ...context, closeModal: () => context.closeModal(), + closeModalByID: (id: string) => context.closeModalByID(id), showConfirmModal, }; }; diff --git a/src/hooks/useDeleteTransactions.ts b/src/hooks/useDeleteTransactions.ts index cae2fad00050..11c2571a92f1 100644 --- a/src/hooks/useDeleteTransactions.ts +++ b/src/hooks/useDeleteTransactions.ts @@ -29,7 +29,6 @@ import type {SplitExpense} from '@src/types/onyx/IOU'; import type {OnyxCollection} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import {useCallback} from 'react'; import type {CurrencyListActionsContextType} from './useCurrencyList'; @@ -113,34 +112,28 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac const {isOffline} = useNetwork(); const {formatPhoneNumber} = useLocalize(); - const getSplitExpenseEditTransactionOnDelete = useCallback( - (transactionIDs: string[]): Transaction | undefined => { - if (transactionIDs.length !== 1) { - return undefined; - } + const getSplitExpenseEditTransactionOnDelete = (transactionIDs: string[]): Transaction | undefined => { + if (transactionIDs.length !== 1) { + return undefined; + } - const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionIDs.at(0)}`]; + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionIDs.at(0)}`]; - if (!transaction) { - return undefined; - } + if (!transaction) { + return undefined; + } - const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.comment?.originalTransactionID}`]; - const isSelfDMSplit = isSelfDM(report) || (!!selfDMReportID && transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID); - const hasMultipleSplits = getChildTransactions(allTransactions, originalTransaction?.transactionID).length > 1; - if (!shouldRedirectDeleteToSplitExpenseEdit(transaction, originalTransaction, isSelfDMSplit) || (!hasMultipleSplits && isPerDiemRequestTransactionUtils(originalTransaction))) { - return undefined; - } + const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.comment?.originalTransactionID}`]; + const isSelfDMSplit = isSelfDM(report) || (!!selfDMReportID && transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID); + const hasMultipleSplits = getChildTransactions(allTransactions, originalTransaction?.transactionID).length > 1; + if (!shouldRedirectDeleteToSplitExpenseEdit(transaction, originalTransaction, isSelfDMSplit) || (!hasMultipleSplits && isPerDiemRequestTransactionUtils(originalTransaction))) { + return undefined; + } - return transaction; - }, - [allTransactions, report, selfDMReportID], - ); + return transaction; + }; - const shouldOpenSplitExpenseEditFlowOnDelete = useCallback( - (transactionIDs: string[]): boolean => !!getSplitExpenseEditTransactionOnDelete(transactionIDs), - [getSplitExpenseEditTransactionOnDelete], - ); + const shouldOpenSplitExpenseEditFlowOnDelete = (transactionIDs: string[]): boolean => !!getSplitExpenseEditTransactionOnDelete(transactionIDs); /** * Delete transactions by IDs @@ -152,316 +145,279 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac * @param fullyDeletedGroupKeys - Grouped-search group rows this delete wipes out entirely, keyed by transaction ID * @returns Result describing whether the delete redirected or deleted transaction threads */ - const deleteTransactions = useCallback( - ( - transactionIDs: string[], - duplicateTransactions: OnyxCollection, - duplicateTransactionViolations: OnyxCollection, - currentSearchHash?: number, - isSingleTransactionView?: boolean, - fullyDeletedGroupKeys?: Record, - ): DeleteTransactionsResult => { - if (!transactionIDs.length) { - return { - action: 'deleted', - deletedTransactionThreadReportIDs: [], - }; - } - - const splitExpenseEditTransaction = getSplitExpenseEditTransactionOnDelete(transactionIDs); - - if (splitExpenseEditTransaction) { - const transactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${splitExpenseEditTransaction.reportID}`]; - const selfDMReport = isSelfDM(report) ? report : allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`]; - const splitExpenseEditTransactionReport = transactionReport ?? selfDMReport; - // We intentionally don't pass `searchSnapshotPolicy` here (unlike the on-page split flow in - // `useSplitEffectivePolicy`). This preserves the pre-refactor behavior: `initSplitExpense` resolved - // the policy from the report policy and the transaction's customUnit only, never from a search-results - // snapshot. The snapshot-aware branch lives on the split pages, where the mileage rate is rendered. - const splitEffectivePolicy = getSplitEffectivePolicy({ - policy, - transaction: splitExpenseEditTransaction, - policyForCustomUnit: findSplitPolicyForCustomUnit(allPolicies, splitExpenseEditTransaction), - fallbackPolicy: policyForMovingExpenses, - }); - initSplitExpense( - splitExpenseEditTransaction, - splitExpenseEditTransactionReport, - splitEffectivePolicy, - selfDMReportID, - restrictedActionPolicyID, - personalPolicy?.outputCurrency, - getCurrencyDecimals, - getCurrencySymbol, - { - navigateToEditSplitExpense: true, - }, - ); - return { - action: 'redirected', - }; - } - - const iouActions = reportActions.filter((action) => isMoneyRequestAction(action)); - - const transactionsWithActions = transactionIDs.map((transactionID) => ({ - transactionID, - transaction: allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`], - action: iouActions.find((action) => { - const IOUTransactionID = getOriginalMessage(action)?.IOUTransactionID; - return transactionID === IOUTransactionID; - }), - })); - const deletedTransactionIDs: string[] = []; - const deletedTransactionThreadReportIDs = new Set(); - const {splitTransactionsByOriginalTransactionID, nonSplitTransactions} = transactionsWithActions.reduce( - (acc, item) => { - const {transaction} = item; - const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.comment?.originalTransactionID}`]; - const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction); - const originalTransactionID = transaction?.comment?.originalTransactionID; - - const transactionCurrentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`]; - const isMovedExpenseSplitChild = - !isExpenseSplit && !!originalTransactionID && isSplitChildTransaction(transaction) && !originalTransaction?.comment?.splits && isIOUReport(transactionCurrentReport); - - if ((isExpenseSplit || isMovedExpenseSplitChild) && originalTransactionID) { - acc.splitTransactionsByOriginalTransactionID[originalTransactionID] ??= []; - acc.splitTransactionsByOriginalTransactionID[originalTransactionID].push(item); - } else { - acc.nonSplitTransactions.push(item); - } - - return acc; - }, - {splitTransactionsByOriginalTransactionID: {}, nonSplitTransactions: []} as { - splitTransactionsByOriginalTransactionID: Record; - nonSplitTransactions: TransactionWithAction[]; + const deleteTransactions = ( + transactionIDs: string[], + duplicateTransactions: OnyxCollection, + duplicateTransactionViolations: OnyxCollection, + currentSearchHash?: number, + isSingleTransactionView?: boolean, + fullyDeletedGroupKeys?: Record, + ): DeleteTransactionsResult => { + if (!transactionIDs.length) { + return { + action: 'deleted', + deletedTransactionThreadReportIDs: [], + }; + } + + const splitExpenseEditTransaction = getSplitExpenseEditTransactionOnDelete(transactionIDs); + + if (splitExpenseEditTransaction) { + const transactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${splitExpenseEditTransaction.reportID}`]; + const selfDMReport = isSelfDM(report) ? report : allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`]; + const splitExpenseEditTransactionReport = transactionReport ?? selfDMReport; + // We intentionally don't pass `searchSnapshotPolicy` here (unlike the on-page split flow in + // `useSplitEffectivePolicy`). This preserves the pre-refactor behavior: `initSplitExpense` resolved + // the policy from the report policy and the transaction's customUnit only, never from a search-results + // snapshot. The snapshot-aware branch lives on the split pages, where the mileage rate is rendered. + const splitEffectivePolicy = getSplitEffectivePolicy({ + policy, + transaction: splitExpenseEditTransaction, + policyForCustomUnit: findSplitPolicyForCustomUnit(allPolicies, splitExpenseEditTransaction), + fallbackPolicy: policyForMovingExpenses, + }); + initSplitExpense( + splitExpenseEditTransaction, + splitExpenseEditTransactionReport, + splitEffectivePolicy, + selfDMReportID, + restrictedActionPolicyID, + personalPolicy?.outputCurrency, + getCurrencyDecimals, + getCurrencySymbol, + { + navigateToEditSplitExpense: true, }, ); - - for (const transactionID of Object.keys(splitTransactionsByOriginalTransactionID)) { - const splitIDs = new Set((splitTransactionsByOriginalTransactionID[transactionID] ?? []).map((transaction) => transaction.transactionID)); - const allChildTransactions = getChildTransactions(allTransactions, transactionID); - const childTransactions = allChildTransactions.filter((transaction) => !splitIDs.has(transaction?.transactionID ?? String(CONST.DEFAULT_NUMBER_ID))); - - if (childTransactions.length === 0) { - nonSplitTransactions.push(...splitTransactionsByOriginalTransactionID[transactionID]); - continue; - } - const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - const originalTransactionIouActions = getIOUActionForTransactions([transactionID], allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`]); - const iouReportID = isMoneyRequestAction(originalTransactionIouActions.at(0)) ? originalTransactionIouActions.at(0)?.reportID : undefined; - const candidateIOUReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]; - // For self-DM tracks and split bills, action.reportID resolves to a chat report, not an IOU/expense report. - const iouReport = isIOUReport(candidateIOUReport) || isExpenseReport(candidateIOUReport) ? candidateIOUReport : undefined; - const splitExpensesTotal = allChildTransactions.reduce((total, childTransaction) => { - const transactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${childTransaction?.reportID}`]; - return total + initSplitExpenseItemData(childTransaction, transactionReport, {getCurrencyDecimals}).amount; - }, 0); - const policyRecentlyUsedCategories = - allPolicyRecentlyUsedCategories?.[ - `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${getNonEmptyStringOnyxID(getIOURequestPolicyID(originalTransaction, report))}` - ] ?? []; - - const hasEditableSplitExpensesLeft = childTransactions.some((childTransaction) => { - const currentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${childTransaction?.reportID}`]; - return (currentReport?.statusNum ?? 0) < CONST.REPORT.STATUS_NUM.SUBMITTED; - }); - - if (!hasEditableSplitExpensesLeft) { - nonSplitTransactions.push(...splitTransactionsByOriginalTransactionID[transactionID]); - continue; + return { + action: 'redirected', + }; + } + + const iouActions = reportActions.filter((action) => isMoneyRequestAction(action)); + + const transactionsWithActions = transactionIDs.map((transactionID) => ({ + transactionID, + transaction: allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`], + action: iouActions.find((action) => { + const IOUTransactionID = getOriginalMessage(action)?.IOUTransactionID; + return transactionID === IOUTransactionID; + }), + })); + const deletedTransactionIDs: string[] = []; + const deletedTransactionThreadReportIDs = new Set(); + const {splitTransactionsByOriginalTransactionID, nonSplitTransactions} = transactionsWithActions.reduce( + (acc, item) => { + const {transaction} = item; + const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.comment?.originalTransactionID}`]; + const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction); + const originalTransactionID = transaction?.comment?.originalTransactionID; + + const transactionCurrentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`]; + const isMovedExpenseSplitChild = + !isExpenseSplit && !!originalTransactionID && isSplitChildTransaction(transaction) && !originalTransaction?.comment?.splits && isIOUReport(transactionCurrentReport); + + if ((isExpenseSplit || isMovedExpenseSplitChild) && originalTransactionID) { + if (acc.splitTransactionsByOriginalTransactionID[originalTransactionID] == null) { + acc.splitTransactionsByOriginalTransactionID[originalTransactionID] = []; + } + acc.splitTransactionsByOriginalTransactionID[originalTransactionID].push(item); + } else { + acc.nonSplitTransactions.push(item); } - const remainingSplitExpenses = childTransactions.map((childTransaction) => { - // For selfDM splits, childTransaction.reportID is UNREPORTED_REPORT_ID ('0'), which does not map to a - // real report. Resolve it to the actual selfDM report ID (same approach as initSplitExpense) so that - // updateSplitTransactions can route the restored transaction to the correct report instead of ending - // up with an undefined reportID downstream. - const resolvedReportID = childTransaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID ? selfDMReportID : childTransaction?.reportID; - const transactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${resolvedReportID}`]; - return initSplitExpenseItemData(childTransaction, transactionReport, {reportID: resolvedReportID, getCurrencyDecimals}); - }); - const remainingSplitExpensesTotal = remainingSplitExpenses.reduce((total, splitExpense) => total + splitExpense.amount, 0); - const updatedRemainingSplitExpenses = - originalTransaction && isPerDiemRequestTransactionUtils(originalTransaction) && remainingSplitExpenses.length > 0 && remainingSplitExpensesTotal !== splitExpensesTotal - ? redistributeRemainingPerDiemSplitExpenses(remainingSplitExpenses, splitExpensesTotal, originalTransaction.currency ?? CONST.CURRENCY.USD, getCurrencyDecimals) - : remainingSplitExpenses; - - const parentTransactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; - const expenseReport = report?.type === CONST.REPORT.TYPE.EXPENSE ? report : parentTransactionReport; - const activeGroupSearchHashes = - currentSearchHash !== undefined && currentSearchHash >= 0 ? getActiveGroupSearchHashes(currentSearchResults?.data, currentSearchQueryJSON) : []; - - updateSplitTransactions({ - isVendorMatchingBetaEnabled, - getCurrencyDecimals, - getCurrencySymbol, - allTransactionsList: allTransactions, - allReportsList: allReports, - allReportActionsList: allReportActions, - allReportNameValuePairsList: allReportNameValuePairs, - allSnapshots, - allPolicyTags, - transactionData: { - reportID: report?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), - originalTransactionID: transactionID, - splitExpenses: updatedRemainingSplitExpenses, - splitExpensesTotal, - }, - searchContext: { - currentSearchHash, - activeGroupSearchHashes, - }, - policyCategories, - policy, - policyRecentlyUsedCategories, - iouReport, - firstIOU: originalTransactionIouActions.at(0), - isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), - currentUserPersonalDetails, - transactionViolations, - policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], - quickAction, - personalDetails, - transactionReport: report, - expenseReport, - isOffline, - delegateAccountID, - isTrackIntentUser, - formatPhoneNumber, - rules, - }); + return acc; + }, + {splitTransactionsByOriginalTransactionID: {}, nonSplitTransactions: []} as { + splitTransactionsByOriginalTransactionID: Record; + nonSplitTransactions: TransactionWithAction[]; + }, + ); + + for (const transactionID of Object.keys(splitTransactionsByOriginalTransactionID)) { + const splitIDs = new Set((splitTransactionsByOriginalTransactionID[transactionID] ?? []).map((transaction) => transaction.transactionID)); + const allChildTransactions = getChildTransactions(allTransactions, transactionID); + const childTransactions = allChildTransactions.filter((transaction) => !splitIDs.has(transaction?.transactionID ?? String(CONST.DEFAULT_NUMBER_ID))); + + if (childTransactions.length === 0) { + nonSplitTransactions.push(...splitTransactionsByOriginalTransactionID[transactionID]); + continue; + } + const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const originalTransactionIouActions = getIOUActionForTransactions([transactionID], allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`]); + const iouReportID = isMoneyRequestAction(originalTransactionIouActions.at(0)) ? originalTransactionIouActions.at(0)?.reportID : undefined; + const candidateIOUReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]; + // For self-DM tracks and split bills, action.reportID resolves to a chat report, not an IOU/expense report. + const iouReport = isIOUReport(candidateIOUReport) || isExpenseReport(candidateIOUReport) ? candidateIOUReport : undefined; + const splitExpensesTotal = allChildTransactions.reduce((total, childTransaction) => { + const transactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${childTransaction?.reportID}`]; + return total + initSplitExpenseItemData(childTransaction, transactionReport, {getCurrencyDecimals}).amount; + }, 0); + const policyRecentlyUsedCategories = + allPolicyRecentlyUsedCategories?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${getNonEmptyStringOnyxID(getIOURequestPolicyID(originalTransaction, report))}`] ?? + []; + + const hasEditableSplitExpensesLeft = childTransactions.some((childTransaction) => { + const currentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${childTransaction?.reportID}`]; + return (currentReport?.statusNum ?? 0) < CONST.REPORT.STATUS_NUM.SUBMITTED; + }); + + if (!hasEditableSplitExpensesLeft) { + nonSplitTransactions.push(...splitTransactionsByOriginalTransactionID[transactionID]); + continue; } - for (const {transactionID, action} of nonSplitTransactions) { - if (!action) { - continue; - } - const iouReportID = isMoneyRequestAction(action) ? action?.reportID : undefined; - const candidateIOUReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]; - // For self-DM tracks and split bills, action.reportID resolves to a chat report, not an IOU/expense report. - // Invoice reports also carry the money request action; without them the optimistic delete would run with an - // undefined iouReport, so the invoice/preview would not be marked deleted (visible until a server response, - // or indefinitely offline). Its chatReportID points to the invoice room. See issue #97399. - const iouReport = isIOUReport(candidateIOUReport) || isExpenseReport(candidateIOUReport) || isInvoiceReport(candidateIOUReport) ? candidateIOUReport : undefined; - const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReport?.chatReportID}`]; - const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${action?.childReportID}`]; - - // A self-DM expense is a tracked expense: it has no IOU report to key the cleanup on, so it goes - // through the track-expense flow, which resolves the self-DM report actions and the whisper. - if (isSelfDM(candidateIOUReport) && isTrackExpenseAction(action)) { - // The Onyx collection can be missing the self-DM actions when the delete comes from Search, where - // they are read from the search snapshot instead, so the ones passed in fill the gaps. Actionable - // track expense whispers are matched on their own since they are built without a `reportID`. - const selfDMReportActions: ReportActions = { - ...Object.fromEntries( - reportActions - .filter((chatAction) => chatAction.reportID === candidateIOUReport?.reportID || isActionableTrackExpense(chatAction)) - .map((chatAction) => [chatAction.reportActionID, chatAction]), - ), - ...allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${candidateIOUReport?.reportID}`], - }; - - deleteTrackExpense({ - chatReportID: candidateIOUReport?.reportID, - chatReport: candidateIOUReport, - chatReportActions: selfDMReportActions, - transactionThreadReportActions: allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${action?.childReportID}`], - transactionID, - reportAction: action, - iouReport: undefined, - chatIOUReport: undefined, - transactions: duplicateTransactions, - violations: duplicateTransactionViolations, - isSingleTransactionView, - isChatReportArchived: isArchivedReport(allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${candidateIOUReport?.reportID}`]), - isChatIOUReportArchived: false, - allTransactionViolationsParam: transactionViolations, - currentUserAccountID: currentUserPersonalDetails.accountID, - currentUserEmail: currentUserPersonalDetails.email ?? '', - policy: undefined, - getCurrencyDecimals, - }); - deletedTransactionIDs.push(transactionID); - if (action.childReportID) { - deletedTransactionThreadReportIDs.add(action.childReportID); - } - continue; - } + const remainingSplitExpenses = childTransactions.map((childTransaction) => { + // For selfDM splits, childTransaction.reportID is UNREPORTED_REPORT_ID ('0'), which does not map to a + // real report. Resolve it to the actual selfDM report ID (same approach as initSplitExpense) so that + // updateSplitTransactions can route the restored transaction to the correct report instead of ending + // up with an undefined reportID downstream. + const resolvedReportID = childTransaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID ? selfDMReportID : childTransaction?.reportID; + const transactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${resolvedReportID}`]; + return initSplitExpenseItemData(childTransaction, transactionReport, {reportID: resolvedReportID, getCurrencyDecimals}); + }); + const remainingSplitExpensesTotal = remainingSplitExpenses.reduce((total, splitExpense) => total + splitExpense.amount, 0); + const updatedRemainingSplitExpenses = + originalTransaction && isPerDiemRequestTransactionUtils(originalTransaction) && remainingSplitExpenses.length > 0 && remainingSplitExpensesTotal !== splitExpensesTotal + ? redistributeRemainingPerDiemSplitExpenses(remainingSplitExpenses, splitExpensesTotal, originalTransaction.currency ?? CONST.CURRENCY.USD, getCurrencyDecimals) + : remainingSplitExpenses; + + const parentTransactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; + const expenseReport = report?.type === CONST.REPORT.TYPE.EXPENSE ? report : parentTransactionReport; + const activeGroupSearchHashes = currentSearchHash !== undefined && currentSearchHash >= 0 ? getActiveGroupSearchHashes(currentSearchResults?.data, currentSearchQueryJSON) : []; + + updateSplitTransactions({ + isVendorMatchingBetaEnabled, + getCurrencyDecimals, + getCurrencySymbol, + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportActionsList: allReportActions, + allReportNameValuePairsList: allReportNameValuePairs, + allSnapshots, + allPolicyTags, + transactionData: { + reportID: report?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), + originalTransactionID: transactionID, + splitExpenses: updatedRemainingSplitExpenses, + splitExpensesTotal, + }, + searchContext: { + currentSearchHash, + activeGroupSearchHashes, + }, + policyCategories, + policy, + policyRecentlyUsedCategories, + iouReport, + firstIOU: originalTransactionIouActions.at(0), + isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), + currentUserPersonalDetails, + transactionViolations, + policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], + quickAction, + personalDetails, + transactionReport: report, + expenseReport, + isOffline, + delegateAccountID, + isTrackIntentUser, + formatPhoneNumber, + rules, + }); + } + + for (const {transactionID, action} of nonSplitTransactions) { + if (!action) { + continue; + } + const iouReportID = isMoneyRequestAction(action) ? action?.reportID : undefined; + const candidateIOUReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]; + // For self-DM tracks and split bills, action.reportID resolves to a chat report, not an IOU/expense report. + // Invoice reports also carry the money request action; without them the optimistic delete would run with an + // undefined iouReport, so the invoice/preview would not be marked deleted (visible until a server response, + // or indefinitely offline). Its chatReportID points to the invoice room. See issue #97399. + const iouReport = isIOUReport(candidateIOUReport) || isExpenseReport(candidateIOUReport) || isInvoiceReport(candidateIOUReport) ? candidateIOUReport : undefined; + const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReport?.chatReportID}`]; + const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${action?.childReportID}`]; + + // A self-DM expense is a tracked expense: it has no IOU report to key the cleanup on, so it goes + // through the track-expense flow, which resolves the self-DM report actions and the whisper. + if (isSelfDM(candidateIOUReport) && isTrackExpenseAction(action)) { + // The Onyx collection can be missing the self-DM actions when the delete comes from Search, where + // they are read from the search snapshot instead, so the ones passed in fill the gaps. Actionable + // track expense whispers are matched on their own since they are built without a `reportID`. + const selfDMReportActions: ReportActions = { + ...Object.fromEntries( + reportActions + .filter((chatAction) => chatAction.reportID === candidateIOUReport?.reportID || isActionableTrackExpense(chatAction)) + .map((chatAction) => [chatAction.reportActionID, chatAction]), + ), + ...allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${candidateIOUReport?.reportID}`], + }; - const chatIOUReportID = chatReport?.reportID; - const isChatIOUReportArchived = isArchivedReport(allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${chatIOUReportID}`]); - const iouPolicy = iouReport?.policyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport.policyID}`] : undefined; - deleteMoneyRequest({ + deleteTrackExpense({ + chatReportID: candidateIOUReport?.reportID, + chatReport: candidateIOUReport, + chatReportActions: selfDMReportActions, + transactionThreadReportActions: allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${action?.childReportID}`], transactionID, reportAction: action, - transactionThreadReport, - transactionThreadReportActions: allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`], + iouReport: undefined, + chatIOUReport: undefined, transactions: duplicateTransactions, violations: duplicateTransactionViolations, - iouReport, - chatReport, - isChatIOUReportArchived, isSingleTransactionView, - transactionIDsPendingDeletion: deletedTransactionIDs, - selectedTransactionIDs: transactionIDs, - searchHash: currentSearchHash, - fullyDeletedGroupKey: fullyDeletedGroupKeys?.[transactionID], + isChatReportArchived: isArchivedReport(allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${candidateIOUReport?.reportID}`]), + isChatIOUReportArchived: false, allTransactionViolationsParam: transactionViolations, currentUserAccountID: currentUserPersonalDetails.accountID, currentUserEmail: currentUserPersonalDetails.email ?? '', - policy: iouPolicy, + policy: undefined, getCurrencyDecimals, }); deletedTransactionIDs.push(transactionID); if (action.childReportID) { deletedTransactionThreadReportIDs.add(action.childReportID); } + continue; } - return { - action: 'deleted', - deletedTransactionThreadReportIDs: Array.from(deletedTransactionThreadReportIDs), - }; - }, - [ - isVendorMatchingBetaEnabled, - allPolicyRecentlyUsedCategories, - allReportNameValuePairs, - allReports, - allReportActions, - allSnapshots, - allTransactions, - currentUserPersonalDetails, - currentSearchQueryJSON, - currentSearchResults?.data, - isBetaEnabled, - policy, - policyCategories, - policyRecentlyUsedCurrencies, - quickAction, - report, - reportActions, - transactionViolations, - allPolicyTags, - personalDetails, - selfDMReportID, - allPolicies, - policyForMovingExpenses, - restrictedActionPolicyID, - getSplitExpenseEditTransactionOnDelete, - isOffline, - personalPolicy?.outputCurrency, - delegateAccountID, - isTrackIntentUser, - formatPhoneNumber, - getCurrencyDecimals, - getCurrencySymbol, - rules, - ], - ); + const chatIOUReportID = chatReport?.reportID; + const isChatIOUReportArchived = isArchivedReport(allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${chatIOUReportID}`]); + const iouPolicy = iouReport?.policyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport.policyID}`] : undefined; + deleteMoneyRequest({ + transactionID, + reportAction: action, + transactionThreadReport, + transactionThreadReportActions: allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`], + transactions: duplicateTransactions, + violations: duplicateTransactionViolations, + iouReport, + chatReport, + isChatIOUReportArchived, + isSingleTransactionView, + transactionIDsPendingDeletion: deletedTransactionIDs, + selectedTransactionIDs: transactionIDs, + searchHash: currentSearchHash, + fullyDeletedGroupKey: fullyDeletedGroupKeys?.[transactionID], + allTransactionViolationsParam: transactionViolations, + currentUserAccountID: currentUserPersonalDetails.accountID, + currentUserEmail: currentUserPersonalDetails.email ?? '', + policy: iouPolicy, + getCurrencyDecimals, + }); + deletedTransactionIDs.push(transactionID); + if (action.childReportID) { + deletedTransactionThreadReportIDs.add(action.childReportID); + } + } + + return { + action: 'deleted', + deletedTransactionThreadReportIDs: Array.from(deletedTransactionThreadReportIDs), + }; + }; return { deleteTransactions, diff --git a/src/hooks/useDialogContainerFocus/types.ts b/src/hooks/useDialogContainerFocus/types.ts index c02c82ba87dc..a34fe421fdb9 100644 --- a/src/hooks/useDialogContainerFocus/types.ts +++ b/src/hooks/useDialogContainerFocus/types.ts @@ -1,6 +1,6 @@ -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; -type UseDialogContainerFocus = (ref: RefObject, isReady: boolean, claimInitialFocus?: () => boolean, skipDialogContainerFocus?: boolean) => void; +type UseDialogContainerFocus = (ref: RefObject | null>, isReady: boolean, claimInitialFocus?: () => boolean, skipDialogContainerFocus?: boolean) => void; export default UseDialogContainerFocus; diff --git a/src/hooks/useFilterFormValues.tsx b/src/hooks/useFilterFormValues.tsx index ffbecb9e2d51..e72d1abf66db 100644 --- a/src/hooks/useFilterFormValues.tsx +++ b/src/hooks/useFilterFormValues.tsx @@ -8,7 +8,7 @@ import {buildFilterFormValuesFromQuery} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; -import type {Policy, PolicyCategories, PolicyTagLists} from '@src/types/onyx'; +import type {Policy, PolicyCategories} from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection} from 'react-native-onyx'; @@ -52,28 +52,6 @@ function policyCategoriesSelector(categories: OnyxCollection): return result; } -function policyTagsSelector(tags: OnyxCollection): OnyxCollection { - if (!tags) { - return tags; - } - const result: OnyxCollection = {}; - for (const [collectionKey, policyTagLists] of Object.entries(tags)) { - if (!policyTagLists) { - continue; - } - const minimalTagLists: PolicyTagLists = {}; - for (const [listKey, tagList] of Object.entries(policyTagLists)) { - const minimalTags: Record = {}; - for (const [tagKey, tag] of Object.entries(tagList.tags ?? {})) { - minimalTags[tagKey] = {name: tag.name}; - } - minimalTagLists[listKey] = {tags: minimalTags} as PolicyTagLists[string]; - } - result[collectionKey] = minimalTagLists; - } - return result; -} - const useFilterFormValues = (queryJSON?: SearchQueryJSON) => { const personalDetails = usePersonalDetails(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -84,7 +62,6 @@ const useFilterFormValues = (queryJSON?: SearchQueryJSON) => { // Subscribe to the report collection directly. buildFilterFormValuesFromQuery only does keyed existence // lookups (`in:` filter), so projecting via a selector would just waste an O(n) pass over every report. const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); - const [policyTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {selector: policyTagsSelector}); const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, {selector: policyCategoriesSelector}); const [workspaceCardFeeds] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); @@ -98,7 +75,6 @@ const useFilterFormValues = (queryJSON?: SearchQueryJSON) => { ? buildFilterFormValuesFromQuery( queryJSON, policyCategories, - policyTagsLists, currencyList, personalDetails, allCards, @@ -114,4 +90,4 @@ const useFilterFormValues = (queryJSON?: SearchQueryJSON) => { }; export default useFilterFormValues; -export {policiesSelector, policyCategoriesSelector, policyTagsSelector}; +export {policiesSelector, policyCategoriesSelector}; diff --git a/src/hooks/useFindLastAccessedReport.ts b/src/hooks/useFindLastAccessedReport.ts new file mode 100644 index 000000000000..c371cb4d6ac0 --- /dev/null +++ b/src/hooks/useFindLastAccessedReport.ts @@ -0,0 +1,30 @@ +import shouldOpenOnAdminRoom from '@libs/Navigation/helpers/shouldOpenOnAdminRoom'; +import {findLastAccessedReport} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import useOnyx from './useOnyx'; +import usePermissions from './usePermissions'; + +type UseFindLastAccessedReportOptions = { + // Prefer the policy admins room when one exists. Defaults to the openOnAdminRoom URL param. + openOnAdminRoom?: boolean; + // When false, skip the lookup and return undefined without scanning reports. + enabled?: boolean; +}; + +// Resolves the last accessed report for navigation fallbacks from a view-scoped subscription. +function useFindLastAccessedReport({openOnAdminRoom, enabled = true}: UseFindLastAccessedReportOptions = {}) { + const {isBetaEnabled} = usePermissions(); + const ignoreDomainRooms = !isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS); + const shouldPreferAdminRoom = openOnAdminRoom ?? shouldOpenOnAdminRoom(); + const [guideAccountIDs] = useOnyx(ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS); + const [reportNameValuePairs, reportNameValuePairsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const [lastAccessedReportID, reportsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { + selector: (reports) => (enabled ? findLastAccessedReport(ignoreDomainRooms, guideAccountIDs, shouldPreferAdminRoom, undefined, reportNameValuePairs, reports)?.reportID : undefined), + }); + return {lastAccessedReportID, reportsMetadata, reportNameValuePairsMetadata}; +} + +export default useFindLastAccessedReport; diff --git a/src/hooks/useHtmlPaste/types.ts b/src/hooks/useHtmlPaste/types.ts index c8d8e9e6ba9b..8add6a319001 100644 --- a/src/hooks/useHtmlPaste/types.ts +++ b/src/hooks/useHtmlPaste/types.ts @@ -1,8 +1,8 @@ -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {TextInput} from 'react-native'; type UseHtmlPaste = ( - textInputRef: RefObject<(HTMLTextAreaElement & TextInput) | TextInput | null>, + textInputRef: RefObject<(HTMLTextAreaElement & ComponentRef) | ComponentRef | null>, preHtmlPasteCallback?: (event: ClipboardEvent) => boolean, isActive?: boolean, maxLength?: number, // Maximum length of the text input value after pasting diff --git a/src/hooks/useIsVendorColumnAvailable.ts b/src/hooks/useIsVendorColumnAvailable.ts new file mode 100644 index 000000000000..c9669c0548bc --- /dev/null +++ b/src/hooks/useIsVendorColumnAvailable.ts @@ -0,0 +1,27 @@ +import {hasVendorFeatureOnAnyPolicy} from '@libs/PolicyUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; + +import {useCallback} from 'react'; + +import useOnyx from './useOnyx'; +import usePermissions from './usePermissions'; + +/** + * Whether the Vendor column can be shown on Search. Search spans every workspace, so the column is available as soon as + * any workspace has the vendor feature. Saved column lists outlive the feature, so every reader of the saved list + * re-checks this, not only the column picker. + */ +function useIsVendorColumnAvailable(): boolean { + const {isBetaEnabled} = usePermissions(); + const isVendorMatchingBetaEnabled = isBetaEnabled(CONST.BETAS.VENDOR_MATCHING); + const selector = useCallback((allPolicies: OnyxCollection) => hasVendorFeatureOnAnyPolicy(allPolicies, isVendorMatchingBetaEnabled), [isVendorMatchingBetaEnabled]); + const [isVendorColumnAvailable = false] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector}); + return isVendorColumnAvailable; +} + +export default useIsVendorColumnAvailable; diff --git a/src/hooks/useListKeyboardNav.ts b/src/hooks/useListKeyboardNav.ts index f9291d7adf84..fa670c5a318a 100644 --- a/src/hooks/useListKeyboardNav.ts +++ b/src/hooks/useListKeyboardNav.ts @@ -1,3 +1,4 @@ +import type {ComponentRef} from 'react'; import type React from 'react'; import type {View} from 'react-native'; @@ -12,7 +13,7 @@ type FocusableContainer = { contains: HTMLElement['contains']; }; -type UseListKeyboardNavConfig = { +type UseListKeyboardNavConfig | HTMLElement> = { isActive: boolean; itemKeys: string[]; disabledIndexes: readonly number[]; @@ -25,7 +26,7 @@ type UseListKeyboardNavConfig = { * items, so the browser handles Enter natively and SortableItem forwards it to the * inner pressable. Space is reserved for dnd-kit drag initiation. */ -function useListKeyboardNav({isActive, itemKeys, disabledIndexes, containerRef}: UseListKeyboardNavConfig) { +function useListKeyboardNav | HTMLElement>({isActive, itemKeys, disabledIndexes, containerRef}: UseListKeyboardNavConfig) { const isFocused = useIsFocused(); const [hasFocus, setHasFocus] = useState(false); const [hasBeenFocused, setHasBeenFocused] = useState(false); diff --git a/src/hooks/useOutstandingBalanceGuard.tsx b/src/hooks/useOutstandingBalanceGuard.tsx index 76c7649300cd..ff12abefe991 100644 --- a/src/hooks/useOutstandingBalanceGuard.tsx +++ b/src/hooks/useOutstandingBalanceGuard.tsx @@ -1,12 +1,13 @@ -import ConfirmModal from '@components/ConfirmModal'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import Navigation from '@libs/Navigation/Navigation'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import React, {useCallback, useMemo, useState} from 'react'; +import {useCallback} from 'react'; +import useConfirmModal from './useConfirmModal'; import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; @@ -19,46 +20,36 @@ import useOnyx from './useOnyx'; * @param onModalDismissed - called when the modal is dismissed (either by settling the balance or cancelling) * @returns shouldBlockDeletion - function that checks and shows the modal if needed (returns true if blocked) * @returns wouldBlockDeletion - pre-computed boolean for popover/menu configuration - * @returns outstandingBalanceModal - React element to render in the page */ function useOutstandingBalanceGuard(ownedPaidPoliciesCount: number, onModalDismissed?: () => void) { - const [isModalOpen, setIsModalOpen] = useState(false); const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const wouldBlockDeletion = (amountOwed ?? 0) > 0 && ownedPaidPoliciesCount === 1; const shouldBlockDeletion = useCallback(() => { - if (wouldBlockDeletion) { - setIsModalOpen(true); - return true; + if (!wouldBlockDeletion) { + return false; } - return false; - }, [wouldBlockDeletion]); - const outstandingBalanceModal = useMemo( - () => ( - { - setIsModalOpen(false); - Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION.route); - onModalDismissed?.(); - }} - onCancel={() => { - setIsModalOpen(false); - onModalDismissed?.(); - }} - prompt={translate('workspace.common.outstandingBalanceWarning')} - confirmText={translate('workspace.common.settleBalance')} - cancelText={translate('common.cancel')} - /> - ), - [isModalOpen, translate, onModalDismissed], - ); + showConfirmModal({ + title: translate('workspace.common.delete'), + prompt: translate('workspace.common.outstandingBalanceWarning'), + confirmText: translate('workspace.common.settleBalance'), + cancelText: translate('common.cancel'), + }).then((result) => { + if (result.action === ModalActions.CONFIRM) { + Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION.route); + } - return {shouldBlockDeletion, wouldBlockDeletion, outstandingBalanceModal}; + onModalDismissed?.(); + }); + + return true; + }, [onModalDismissed, showConfirmModal, translate, wouldBlockDeletion]); + + return {shouldBlockDeletion, wouldBlockDeletion}; } export default useOutstandingBalanceGuard; diff --git a/src/hooks/usePolicyIndicatorChecks.ts b/src/hooks/usePolicyIndicatorChecks.ts index 60354921ecee..50b686f25642 100644 --- a/src/hooks/usePolicyIndicatorChecks.ts +++ b/src/hooks/usePolicyIndicatorChecks.ts @@ -1,6 +1,6 @@ import {isConnectionInProgress} from '@libs/actions/connections'; import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; -import {hasDomainErrors, hasPendingDomainAdminRequestsToReview} from '@libs/DomainUtils'; +import {getDomainsWithErrors, hasPendingDomainAdminRequestsToReview} from '@libs/DomainUtils'; import {isMergeHRCompleteSetupNeeded, shouldShowHRConnectionError} from '@libs/merge/HRUtils'; import { getUberConnectionErrorDirectlyFromPolicy, @@ -79,14 +79,7 @@ function usePolicyIndicatorChecks(): PolicyIndicatorChecksResult { const policyInfoChecks: Array<[IndicatorStatus, Policy | undefined]> = [ [CONST.INDICATOR_STATUS.HAS_MERGE_HR_SETUP_NEEDED, cleanPolicies.find((policy) => isPolicyAdmin(policy) && isMergeHRCompleteSetupNeeded(policy))], ]; - const domainChecks: Array<[IndicatorStatus, boolean]> = [ - [ - CONST.INDICATOR_STATUS.HAS_DOMAIN_ERRORS, - Object.entries(allDomainErrors ?? {}).some(([key, domainErrors]) => - hasDomainErrors(domainErrors, allDomains?.[key.replace(ONYXKEYS.COLLECTION.DOMAIN_ERRORS, ONYXKEYS.COLLECTION.DOMAIN)]), - ), - ], - ]; + const domainChecks: Array<[IndicatorStatus, boolean]> = [[CONST.INDICATOR_STATUS.HAS_DOMAIN_ERRORS, getDomainsWithErrors(allDomainErrors, allDomains).length > 0]]; const domainInfoChecks: Array<[IndicatorStatus, boolean]> = [[CONST.INDICATOR_STATUS.HAS_PENDING_DOMAIN_ADMIN_REQUESTS, hasPendingDomainAdminRequests]]; const activePolicyErrorCheck = policyErrorChecks.find(([, value]) => value); diff --git a/src/hooks/usePopoverPosition.ts b/src/hooks/usePopoverPosition.ts index c14a70ad7d57..0bbb924a550f 100644 --- a/src/hooks/usePopoverPosition.ts +++ b/src/hooks/usePopoverPosition.ts @@ -4,7 +4,7 @@ import CONST from '@src/CONST'; import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; import type {Dimensions} from '@src/types/utils/Layout'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; import useResponsiveLayout from './useResponsiveLayout'; @@ -15,7 +15,7 @@ const defaultAnchorAlignment = { }; /** Web callers may hold an `HTMLDivElement` ref; the runtime `measureInWindow` check guards the cross-platform shape. */ -type MeasurableRef = RefObject; +type MeasurableRef = RefObject | HTMLDivElement | null>; type Rect = {x: number; y: number} & Dimensions; diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index b899c31b1312..f363e5ed78f8 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -21,8 +21,9 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; +import type {ViewableItemsChanged} from '@src/types/utils/ReactNativeCompat'; -import type {NativeScrollEvent, NativeSyntheticEvent, ViewToken} from 'react-native'; +import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useRoute} from '@react-navigation/native'; @@ -99,7 +100,7 @@ type UseReportActionsScrollResult = { trackVerticalScrolling: (event: NativeSyntheticEvent | undefined) => void; /** Viewability handler that drives the floating counter and badge visibility */ - onViewableItemsChanged: (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => void; + onViewableItemsChanged: ViewableItemsChanged; /** Whether the floating "new messages" counter is visible */ isFloatingMessageCounterVisible: boolean; diff --git a/src/hooks/useReportScrollManager/index.native.ts b/src/hooks/useReportScrollManager/index.native.ts index 97c9a92cf1f5..756ae8fc3b76 100644 --- a/src/hooks/useReportScrollManager/index.native.ts +++ b/src/hooks/useReportScrollManager/index.native.ts @@ -1,5 +1,6 @@ import {useActionListContext} from '@pages/inbox/ActionListContext'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView} from 'react-native'; @@ -43,7 +44,7 @@ function useReportScrollManager(): ReportScrollManagerData { return; } - const scrollViewRef = listRef.current.getNativeScrollRef?.() as ScrollView | undefined; + const scrollViewRef = listRef.current.getNativeScrollRef?.() as ComponentRef | undefined; // Try to scroll on underlying scrollView if available, fallback to usual listRef if (scrollViewRef && typeof scrollViewRef.scrollToEnd === 'function') { scrollViewRef.scrollToEnd({animated: false}); diff --git a/src/hooks/useReportSubmitToPopover.tsx b/src/hooks/useReportSubmitToPopover.tsx index 4f051f086de6..cac1d4186eac 100644 --- a/src/hooks/useReportSubmitToPopover.tsx +++ b/src/hooks/useReportSubmitToPopover.tsx @@ -11,7 +11,7 @@ import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; import calculateModalHeightInLandscapeMode from '@src/utils/calculateModalHeightInLandscapeMode'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import {willAlertModalBecomeVisibleSelector} from '@selectors/Modal'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; @@ -51,7 +51,7 @@ type UseReportSubmitToPopoverParams = { onSubmitSuccess?: () => void; anchorAlignment?: AnchorAlignment; /** When provided, resolves the anchor at open time (used by the shared Search host). */ - getAnchorRef?: () => RefObject | null; + getAnchorRef?: () => RefObject | null> | null; }; function useReportSubmitToPopover({reportID, onSubmitSuccess, anchorAlignment = DEFAULT_ANCHOR_ALIGNMENT, getAnchorRef}: UseReportSubmitToPopoverParams) { @@ -77,7 +77,7 @@ function useReportSubmitToPopover({reportID, onSubmitSuccess, anchorAlignment = return Math.min(popoverDimensions.height, contentHeightLandscapeMode); }, [isInLandscapeMode, windowHeight, keyboardActiveHeight, topSafeAreaInset]); - const anchorRef = useRef(null); + const anchorRef = useRef>(null); const oneShotOnSubmitSuccessRef = useRef<(() => void) | undefined>(undefined); const onSubmitWithManagerEmailRef = useRef(undefined); const canSubmitRef = useRef(true); diff --git a/src/hooks/useScreenShareRequestPrompt.ts b/src/hooks/useScreenShareRequestPrompt.ts new file mode 100644 index 000000000000..8317870482dc --- /dev/null +++ b/src/hooks/useScreenShareRequestPrompt.ts @@ -0,0 +1,95 @@ +import {ModalActions} from '@components/Modal/Global/ModalContext'; + +import {clearScreenShareRequest, joinScreenShare} from '@userActions/User'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import {useEffect, useRef} from 'react'; + +import useConfirmModal from './useConfirmModal'; +import useLocalize from './useLocalize'; +import useOnyx from './useOnyx'; + +// Names this hook's entry on the global modal stack so the effect below can take that one entry down, rather than +// whatever modal happens to be on top when the request disappears. +const SCREEN_SHARE_REQUEST_MODAL_ID = 'screenShareRequest'; + +/** + * Owns the SCREEN_SHARE_REQUEST Onyx subscription and shows the screen-share confirmation prompt on the global modal + * stack when a GuidesPlus agent requests one. + * + * Call this from a component that is mounted only after startup: the subscription is what has to be kept out of the + * ManualAppStartup span, and `useOnyx` has no way to skip subscribing, so deferring the call site is the only lever. + */ +function useScreenShareRequestPrompt() { + const {translate} = useLocalize(); + const {showConfirmModal, closeModalByID} = useConfirmModal(); + const [screenShareRequest] = useOnyx(ONYXKEYS.SCREEN_SHARE_REQUEST); + + // Keeps a re-render from stacking a second prompt on top of the one that is already open. Both branches below clear + // SCREEN_SHARE_REQUEST, so clearing the key is what makes the next request eligible to show a prompt again. + const isPromptShownRef = useRef(false); + + // Tells "the user answered" apart from "the request vanished on its own", which is the only reason to take the + // entry down by hand. `isPromptShownRef` cannot answer that: it stays true from the moment the prompt is shown + // until the key is cleared, which is after the user has answered and the entry is already gone. + const isPromptOpenRef = useRef(false); + + // The request is read when the user answers rather than when the prompt is shown, so a request replaced while the + // prompt is open is still joined with the token that is current. + const screenShareRequestRef = useRef(screenShareRequest); + + useEffect(() => { + screenShareRequestRef.current = screenShareRequest; + + if (!screenShareRequest) { + isPromptShownRef.current = false; + + // The modal stack is imperative, so the entry has to be taken down by hand when something other than the + // two branches below clears the key. Logout calling `Onyx.clear()` is the concrete case. Without this the + // prompt stays up over the signed-out state, and a later request stacks a second prompt that uncovers this + // stale one again once it is answered. + if (isPromptOpenRef.current) { + isPromptOpenRef.current = false; + closeModalByID(SCREEN_SHARE_REQUEST_MODAL_ID); + } + return; + } + + if (isPromptShownRef.current) { + return; + } + isPromptShownRef.current = true; + isPromptOpenRef.current = true; + + showConfirmModal({ + id: SCREEN_SHARE_REQUEST_MODAL_ID, + title: translate('guides.screenShare'), + prompt: translate('guides.screenShareRequest'), + confirmText: translate('common.join'), + cancelText: translate('common.decline'), + }).then((result) => { + // The entry is off the stack by the time this resolves, however it was closed, so nothing below may try + // to take it down again. + isPromptOpenRef.current = false; + + const request = screenShareRequestRef.current; + + // The key is already gone when the effect above closed this prompt because the request disappeared on its + // own, so there is nothing left to join and nothing left to clear. + if (!request) { + return; + } + + if (result.action !== ModalActions.CONFIRM) { + clearScreenShareRequest(); + return; + } + + // `joinScreenShare` clears SCREEN_SHARE_REQUEST itself. + joinScreenShare(request.accessToken, request.roomName); + }); + }, [closeModalByID, screenShareRequest, showConfirmModal, translate]); +} + +export default useScreenShareRequestPrompt; diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index b5ad786b678e..8d5d87ae7166 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -140,6 +140,7 @@ import useDefaultExpensePolicy from './useDefaultExpensePolicy'; import useDelegateAccountID from './useDelegateAccountID'; import useDeleteTransactions from './useDeleteTransactions'; import useDuplicateTransactionsAndViolations from './useDuplicateTransactionsAndViolations'; +import useIsVendorColumnAvailable from './useIsVendorColumnAvailable'; import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; import useLocalize from './useLocalize'; import useNetwork from './useNetwork'; @@ -200,6 +201,24 @@ function isGroupSelection(key: string, transaction: SelectedTransactions[string] return key.startsWith(CONST.SEARCH.GROUP_PREFIX) || (!!transaction.isSelectedViaGroup && !!transaction.groupKey); } +/** + * How a selection on a grouped search has to be exported. + * + * Selecting a group stores its loaded children with isSelectedViaGroup. The group key is only stored when no children are loaded. + * Use isGroupSelection rather than the key prefix so exports include every item matching the group filter. + * + * `transactionIDList` drops the rows covered by that filter and keeps the rows the user ticked individually + * alongside them. + */ +function getGroupExportScope(queryJSON: SearchQueryJSON | undefined, selectedTransactions: SelectedTransactions): {isGroupExport: boolean; transactionIDList: string[]} { + const selectedTransactionsKeys = Object.keys(selectedTransactions); + const isGroupExport = !!queryJSON?.groupBy && Object.entries(selectedTransactions).some(([key, transaction]) => isGroupSelection(key, transaction)); + return { + isGroupExport, + transactionIDList: isGroupExport ? selectedTransactionsKeys.filter((key) => !isGroupSelection(key, selectedTransactions[key])) : selectedTransactionsKeys, + }; +} + /** * The group rows a selection covers in full. * @@ -558,6 +577,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const [csvExportLayouts] = useOnyx(ONYXKEYS.NVP_CSV_EXPORT_LAYOUTS); const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector}); + const isVendorColumnAvailable = useIsVendorColumnAvailable(); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const {isBetaEnabled} = usePermissions(); const [personalDetails] = useAllPersonalDetails(); @@ -959,19 +979,20 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { true, ); } else { - const isGroupExport = !!queryJSON?.groupBy && selectedTransactionsKeys.some((key) => key.startsWith(CONST.SEARCH.GROUP_PREFIX)); + const {isGroupExport, transactionIDList} = getGroupExportScope(queryJSON, selectedTransactions); queueExportSearchWithTemplate( { templateName, templateType, - jsonQuery: isGroupExport - ? serializeQueryJSONForBackend( - addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data), - getGroupExportExactMatchFilterKeys(queryJSON.groupBy), - ) - : '{}', + jsonQuery: + isGroupExport && queryJSON + ? serializeQueryJSONForBackend( + addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data), + getGroupExportExactMatchFilterKeys(queryJSON.groupBy), + ) + : '{}', reportIDList: isGroupExport ? [] : selectedTransactionReportIDs, - transactionIDList: isGroupExport ? [] : selectedTransactionsKeys, + transactionIDList, policyID, exportName, }, @@ -992,7 +1013,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { currentSearchResults?.data, queryJSON, selectedTransactionReportIDs, - selectedTransactionsKeys, selectAllMatchingItems, clearSelectedTransactions, ], @@ -1021,7 +1041,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { if (groupBy) { const expensePermittedColumns: string[] = Object.values(CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE); - const expenseColumns: SearchColumnType[] = (visibleColumns ?? []).filter((column) => expensePermittedColumns.includes(column)); + const expenseColumns: SearchColumnType[] = (visibleColumns ?? []).filter( + (column) => expensePermittedColumns.includes(column) && (isVendorColumnAvailable || column !== CONST.SEARCH.TABLE_COLUMNS.VENDOR), + ); columnsToExport = [CONST.SEARCH.TABLE_COLUMNS.TYPE, ...(expenseColumns.length > 0 ? expenseColumns : Object.values(CONST.SEARCH.TYPE_DEFAULT_COLUMNS.EXPENSE))]; // Grouped export skips getColumnsToShow(), so inject Violations when the query asks for it @@ -1040,6 +1062,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { fallbackPolicyID: policyForMovingExpensesID, sortBy: queryJSON?.sortBy, shouldShowViolationsColumn: queryHasViolationFilter(queryJSON), + isVendorColumnAvailable, }); } @@ -1060,7 +1083,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { exportColumnLabels: JSON.stringify(exportColumnLabels), }; }, - [accountID, currentSearchKey, exportSearchData, exportSearchType, policyForMovingExpensesID, queryJSON, translate, visibleColumns], + [accountID, currentSearchKey, exportSearchData, exportSearchType, isVendorColumnAvailable, policyForMovingExpensesID, queryJSON, translate, visibleColumns], ); const handleCSVExport = useCallback( @@ -1104,8 +1127,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } - const isGroupExport = !!queryJSON?.groupBy && Object.entries(selectedTransactions).some(([key, transaction]) => isGroupSelection(key, transaction)); - const transactionIDList = isGroupExport ? selectedTransactionsKeys.filter((key) => !isGroupSelection(key, selectedTransactions[key])) : selectedTransactionsKeys; + const {isGroupExport, transactionIDList} = getGroupExportScope(queryJSON, selectedTransactions); let didFail = false; const reportIDList = selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs; const queryJSONToExport = isGroupExport && queryJSON ? addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data) : queryJSON; @@ -1930,7 +1952,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const includeReportLevelExport = ((isExpenseReportType || typeInvoice) && areFullReportsSelected) || (typeExpense && !isExpenseReportType && isAllOneTransactionReport); - const isGroupedSearch = !!getValidGroupBy(queryJSON?.groupBy); + const groupBy = getValidGroupBy(queryJSON?.groupBy); + const isGroupedSearch = !!groupBy; const policy = selectedPolicyIDs.length === 1 ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedPolicyIDs.at(0)}`] : undefined; // The export templates available to the user, pre-grouped and sorted alphabetically. The basic export is part of the default group so it's sorted alongside @@ -1958,6 +1981,14 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const isReportsTab = isExpenseReportType; const includesGroupExport = isGroupedSearch && Object.entries(selectedTransactions).some(([key, selectedTransaction]) => isGroupSelection(key, selectedTransaction)); + // Group selections are sent as query filters, not report or transaction IDs, so template exports are usually unavailable. + // Reconciliation - All Expenses is supported for card-grouped searches because selected card groups become a `cardID:` + // filter, which template reports can use to scope card spend. + const includesCardGroupExport = includesGroupExport && groupBy === CONST.SEARCH.GROUP_BY.CARD; + const customTemplatesToShow = includesCardGroupExport ? [] : availableCustomTemplates; + const defaultTemplatesToShow = includesCardGroupExport + ? availableDefaultTemplates.filter((template) => template.templateName === CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES) + : availableDefaultTemplates; const canReportBeExported = (report: (typeof selectedReports)[0], exportOption: ValueOf) => { if (!report.reportID) { @@ -2216,7 +2247,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { addSeparatorBefore: true, }); - if (!allSelectedAreDeleted && !includesGroupExport) { + if (!allSelectedAreDeleted && (!includesGroupExport || includesCardGroupExport)) { // Builds a single export sub-menu item for a template. `isDefaultTemplate` picks the icon and `addSeparatorBefore` draws the divider at the top of each group. const buildExportOption = (template: ExportTemplate, isDefaultTemplate: boolean, addSeparatorBefore: boolean): ExportMenuItem => { // The basic export is a plain CSV download, so it uses its own handler rather than the template export flow @@ -2240,10 +2271,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }; // Add each group's templates separately so the icon and the group-boundary divider come from the group itself, not from an index into a combined list. - for (const [index, template] of availableCustomTemplates.entries()) { + for (const [index, template] of customTemplatesToShow.entries()) { exportOptions.push(buildExportOption(template, false, index === 0)); } - for (const [index, template] of availableDefaultTemplates.entries()) { + for (const [index, template] of defaultTemplatesToShow.entries()) { exportOptions.push(buildExportOption(template, true, index === 0)); } } else if (!isGroupedSearch) { @@ -2315,8 +2346,54 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { onSelected: () => onBulkPaySelected(undefined), }; + const isExpenseReportSearch = isExpenseReportType || searchResults?.search.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + + const downloadPDFOption: DropdownOption = { + icon: expensifyIcons.Download, + text: translate('common.downloadReport', {count: selectedReportIDs.length}), + value: CONST.SEARCH.BULK_ACTION_TYPES.DOWNLOAD_PDF, + shouldCloseModalOnSelect: true, + onSelected: async () => { + if (isOffline) { + setIsOfflineModalVisible(true); + return; + } + // In "Select all" mode the matching reports aren't enumerated on the client (results are paged), + // so send the search query to the backend and let it resolve the reports. The searchKey changes what + // the backend query matches, so it must be sent exactly as search() does or the exported set differs from the viewed set. + if (areAllMatchingItemsSelected) { + const serializedQuery = queryJSON ? serializeQueryJSONForBackend({...queryJSON, searchKey: currentSearchKey}) : JSON.stringify(queryJSON); + exportReportsToPDF([], serializedQuery); + + // Clear the selection now that the export has started. The ExportDownloadStatusManager shows the modal. + selectAllMatchingItems(false); + clearSelectedTransactions(undefined, true); + return; + } + if (selectedReportIDs.length === 1) { + const reportIDForPDF = selectedReportIDs.at(0); + if (!reportIDForPDF) { + return; + } + await exportReportToPDF({reportID: reportIDForPDF}); + setPdfReportID(reportIDForPDF); + setIsPdfModalVisible(true); + return; + } + exportReportsToPDF(selectedReportIDs); + + // Clear the selection now that the export has started. The ExportDownloadStatusManager shows the modal. + selectAllMatchingItems(false); + clearSelectedTransactions(undefined, true); + }, + }; + if (areAllMatchingItemsSelected) { - return buildResult(shouldShowPayOption ? [payButtonOption, exportButtonOption] : [exportButtonOption]); + const selectAllOptions = shouldShowPayOption ? [payButtonOption, exportButtonOption] : [exportButtonOption]; + if (isExpenseReportSearch) { + selectAllOptions.push(downloadPDFOption); + } + return buildResult(selectAllOptions); } if (allSelectedAreDeleted) { @@ -2354,7 +2431,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return buildResult(deletedTransactionOptions); } - const isExpenseReportSearch = isExpenseReportType || searchResults?.search.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; const selectedTransactionsList = Object.values(selectedTransactions) .map((transaction) => transaction.transaction) .filter((transaction): transaction is Transaction => !!transaction); @@ -2640,33 +2716,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { options.push(exportButtonOption); if (isExpenseReportSearch && selectedReportIDs.length > 0) { - options.push({ - icon: expensifyIcons.Download, - text: translate('common.downloadReport', {count: selectedReportIDs.length}), - value: CONST.SEARCH.BULK_ACTION_TYPES.DOWNLOAD_PDF, - shouldCloseModalOnSelect: true, - onSelected: async () => { - if (isOffline) { - setIsOfflineModalVisible(true); - return; - } - if (selectedReportIDs.length === 1) { - const reportIDForPDF = selectedReportIDs.at(0); - if (!reportIDForPDF) { - return; - } - await exportReportToPDF({reportID: reportIDForPDF}); - setPdfReportID(reportIDForPDF); - setIsPdfModalVisible(true); - return; - } - exportReportsToPDF(selectedReportIDs); - - // Clear the selection now that the export has started. The ExportDownloadStatusManager shows the modal. - selectAllMatchingItems(false); - clearSelectedTransactions(undefined, true); - }, - }); + options.push(downloadPDFOption); } const selectedReportReceiptCount = Object.values(allTransactions ?? {}).filter( @@ -2994,7 +3044,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { hash, selectedTransactions, excludedTransactions, - queryJSON?.type, + queryJSON, expensifyIcons, translate, areAllMatchingItemsSelected, @@ -3073,7 +3123,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { selectAllMatchingItems, allReportsShouldMarkAsDone, noReportsShouldMarkAsDone, - queryJSON?.groupBy, currentUserPersonalDetails.accountID, delegateAccountID, currentSearchQueryJSON, diff --git a/src/hooks/useSearchOverlay.tsx b/src/hooks/useSearchOverlay.tsx index f3996aef63d3..3918cef3e85c 100644 --- a/src/hooks/useSearchOverlay.tsx +++ b/src/hooks/useSearchOverlay.tsx @@ -17,6 +17,7 @@ import type {StyleProp, ViewStyle} from 'react-native'; import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useState} from 'react'; +import useIsVendorColumnAvailable from './useIsVendorColumnAvailable'; import useOnyx from './useOnyx'; import usePolicyForMovingExpenses from './usePolicyForMovingExpenses'; @@ -67,6 +68,7 @@ function useSearchOverlay({ const session = useSession(); const accountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector}); + const isVendorColumnAvailable = useIsVendorColumnAvailable(); const {policyForMovingExpensesID} = usePolicyForMovingExpenses(); const [isSearchReady, setIsSearchReady] = useState(() => !hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH) && !Navigation.getIsFullscreenPreInsertedUnderRHP()); @@ -124,6 +126,7 @@ function useSearchOverlay({ fallbackPolicyID: policyForMovingExpensesID, sortBy: queryJSON.sortBy, shouldShowViolationsColumn: queryHasViolationFilter(queryJSON), + isVendorColumnAvailable, }); })(); diff --git a/src/hooks/useSearchTagFilters.ts b/src/hooks/useSearchTagFilters.ts new file mode 100644 index 000000000000..574bd2a6ebe9 --- /dev/null +++ b/src/hooks/useSearchTagFilters.ts @@ -0,0 +1,207 @@ +import {clearSearchTagFiltersState, openSearchTagFiltersPage, setSearchTagFiltersPagination} from '@libs/actions/Search'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; + +import {useEffect, useRef, useState} from 'react'; + +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; + +/** + * Hook for managing paginated tag filter search. + */ +type UseSearchTagFiltersResult = { + /** The paginated tag search results from Onyx */ + searchResults: OnyxTypes.SearchTagFilterItem[] | null | undefined; + + /** Whether a new search request is in flight */ + isSearching: boolean; + + /** Whether the next page of results is loading */ + isLoadingMore: boolean; + + /** Whether there are more pages to load */ + hasMore: boolean; + + /** Load the next page of results */ + loadMore: () => void; + + /** Start a new tag search with the given query */ + searchTags: (query: string) => void; + + /** Whether the first fetch is still in flight with no results to show yet */ + isInitialLoading: boolean; + + /** The search query of the currently displayed results */ + searchQuery: string; +}; + +/** + * Hook for managing paginated tag filter search. + * Handles API calls, pagination state, and Onyx data subscription. + * Pagination state is persisted in Onyx (RAM-only) to survive component remounts. + * Accepts a comma-separated list of policy IDs to scope results to specific workspaces. + */ +function useSearchTagFilters(policyIDs: string): UseSearchTagFiltersResult { + const {isOffline} = useNetwork(); + const [searchResults] = useOnyx(ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS); + const [paginationState] = useOnyx(ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION); + const [isSearching, setIsSearching] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + + // Derive pagination state from Onyx + const hasMore = paginationState?.hasMore ?? false; + const nextCursor = paginationState?.nextCursor ?? ''; + const searchQuery = paginationState?.searchQuery ?? ''; + + // Track if we have cached data to avoid showing loading state on remount + const hasCachedData = !!searchResults && searchResults.length > 0; + const [prevPolicyIDs, setPrevPolicyIDs] = useState(policyIDs); + const [isFilteringLocally, setIsFilteringLocally] = useState(false); + + if (prevPolicyIDs !== policyIDs) { + setPrevPolicyIDs(policyIDs); + setIsFilteringLocally(false); + } + + // Only treat the cache as complete when the empty-query dataset is fully loaded. + // A finished server search for a non-empty term can still be a partial result set. + const hasCompleteEmptyQueryCache = hasCachedData && !hasMore && (searchQuery === '' || isFilteringLocally); + + // Keep ref updated with latest values for use in stable callbacks + const stateRef = useRef({hasMore, nextCursor, searchQuery, searchResults, hasCachedData, hasCompleteEmptyQueryCache, isSearching, isLoadingMore}); + useEffect(() => { + stateRef.current = {hasMore, nextCursor, searchQuery, searchResults, hasCachedData, hasCompleteEmptyQueryCache, isSearching, isLoadingMore}; + }, [hasMore, nextCursor, searchQuery, searchResults, hasCachedData, hasCompleteEmptyQueryCache, isSearching, isLoadingMore]); + + // Incremented on every new search so a cancelled request doesn't clear the loading state of its successor + const requestSeqRef = useRef(0); + + // Gates the full-list spinner to the initial load. Once any search settles, the list stays mounted + // so the search input keeps focus while the user types, even when the latest results are empty. + const [hasCompletedSearch, setHasCompletedSearch] = useState(hasCachedData); + + const prevWasOfflineRef = useRef(isOffline); + + const loadMore = () => { + const { + hasMore: currentHasMore, + nextCursor: currentCursor, + searchQuery: currentQuery, + searchResults: currentResults, + isSearching: currentIsSearching, + isLoadingMore: currentIsLoadingMore, + } = stateRef.current; + if (currentIsSearching || currentIsLoadingMore || !currentHasMore || isOffline) { + return; + } + const requestSeq = requestSeqRef.current; + setIsLoadingMore(true); + openSearchTagFiltersPage({searchQuery: currentQuery, cursor: currentCursor, limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE, policyIDs}, false, currentResults ?? []) + .then(({hasMore: newHasMore, nextCursor: newCursor}) => { + setSearchTagFiltersPagination(newHasMore, newCursor, currentQuery); + }) + // Failures are already logged by the network Logging middleware. Cancelled requests are expected when a newer search supersedes them. + .catch(() => {}) + .finally(() => { + if (requestSeq !== requestSeqRef.current) { + return; + } + setIsLoadingMore(false); + }); + }; + + const searchTags = (query: string) => { + if (isOffline) { + // When offline, update the search query so TagSelector can filter cached results locally + setSearchTagFiltersPagination(stateRef.current.hasMore, stateRef.current.nextCursor, query); + return; + } + + const {hasCompleteEmptyQueryCache: currentHasCompleteEmptyQueryCache} = stateRef.current; + + // When the full empty-query dataset is already cached, filter locally instead of hitting the server on every keystroke. + if (currentHasCompleteEmptyQueryCache) { + setIsFilteringLocally(true); + setSearchTagFiltersPagination(false, '', query); + setHasCompletedSearch(true); + return; + } + + setIsFilteringLocally(false); + const requestSeq = ++requestSeqRef.current; + + // Reset pagination state immediately so loadMore doesn't fire with stale query/cursor + setSearchTagFiltersPagination(false, '', query); + + // A new search cancels any in-flight request, so it owns the loading state from here on. + // The cancelled loadMore skips its own reset when it sees the bumped sequence, so clear its spinner here. + setIsLoadingMore(false); + // The collection is cleared by openSearchTagFiltersPage, so there is no cached data to show silently. + setIsSearching(true); + + openSearchTagFiltersPage({searchQuery: query, cursor: '', limit: CONST.SEARCH.TAG_FILTER_PAGE_SIZE, policyIDs}, true) + .then(({hasMore: newHasMore, nextCursor: newCursor}) => { + setSearchTagFiltersPagination(newHasMore, newCursor, query); + }) + // Failures are already logged by the network Logging middleware. Cancelled requests are expected when a newer search supersedes them. + .catch(() => {}) + .finally(() => { + if (requestSeq !== requestSeqRef.current) { + return; + } + setHasCompletedSearch(true); + setIsSearching(false); + }); + }; + + // Clear persisted pagination and cached pages when the filter closes so a fresh open re-fetches with valid hasMore. + useEffect(() => { + return () => { + clearSearchTagFiltersState(); + }; + }, []); + + // Fetch the first page on mount, when the workspace scope changes, and on reconnect. + // Skips the fetch while offline and re-fetches on reconnect, matching useLoadSearchCategoryData. + // Reconnect uses the active search query so results stay in sync with the search input. + // searchTags is not memoized, so it cannot be in the dependency array. It would fire on every render. + // searchTags reads latest state via refs, so the closure captured here is safe to call. + useEffect(() => { + let isCancelled = false; + + if (isOffline) { + prevWasOfflineRef.current = true; + return; + } + + const wasOffline = prevWasOfflineRef.current; + prevWasOfflineRef.current = false; + + Promise.resolve().then(() => { + if (isCancelled) { + return; + } + + if (wasOffline) { + searchTags(stateRef.current.searchQuery); + return; + } + + searchTags(''); + }); + + return () => { + isCancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [policyIDs, isOffline]); + + const isInitialLoading = isSearching && !hasCompletedSearch; + + return {searchResults, isSearching, isLoadingMore, hasMore, loadMore, searchTags, isInitialLoading, searchQuery}; +} + +export default useSearchTagFilters; diff --git a/src/hooks/useSearchTypeMenuSections.ts b/src/hooks/useSearchTypeMenuSections.ts index d6e4d2e81403..499204402ded 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -1,5 +1,7 @@ -import {createTypeMenuSections} from '@libs/SearchUIUtils'; +import type {SearchKey} from '@libs/SearchKeyUtils'; +import {createTypeMenuSections, SPEND_INSIGHT_KEYS} from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isTrackIntentUserSelector} from '@src/selectors/Onboarding'; import type {Policy, Session} from '@src/types/onyx'; @@ -16,6 +18,7 @@ import useHasReportAwaitingApproval from './useHasReportAwaitingApproval'; import useMappedPolicies from './useMappedPolicies'; import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; +import usePermissions from './usePermissions'; const policyMapper = (policy: OnyxEntry): OnyxEntry => policy && { @@ -141,4 +144,24 @@ const useSearchTypeMenuSections = (isScreenFocused = true) => { return typeMenuSections; }; +const spendInsightKeys = new Set(SPEND_INSIGHT_KEYS); + +const useSearchTypeMenuSectionsForNavigation = (isScreenFocused = true) => { + const typeMenuSections = useSearchTypeMenuSections(isScreenFocused); + const {isBetaEnabled} = usePermissions(); + + if (!isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE)) { + return typeMenuSections; + } + + return typeMenuSections.flatMap((section) => { + const menuItems = section.menuItems.filter((item) => !spendInsightKeys.has(item.key)); + if (menuItems.length === section.menuItems.length) { + return section; + } + return menuItems.length > 0 ? {...section, menuItems} : []; + }); +}; + export default useSearchTypeMenuSections; +export {useSearchTypeMenuSectionsForNavigation}; diff --git a/src/hooks/useShouldFooterBeInsideList.ts b/src/hooks/useShouldFooterBeInsideList.ts new file mode 100644 index 000000000000..876d96675bcb --- /dev/null +++ b/src/hooks/useShouldFooterBeInsideList.ts @@ -0,0 +1,16 @@ +import useIsInLandscapeMode from './useIsInLandscapeMode'; +import useKeyboardState from './useKeyboardState'; + +/** + * A screen that renders a selection list usually shows its confirm button as the list footer. In landscape mode the open + * keyboard leaves very little vertical space, so a footer fixed to the bottom eats most of what is left for the list. + * In that case the footer is moved into the list, so it scrolls with the data instead. + */ +function useShouldFooterBeInsideList(): boolean { + const isInLandscapeMode = useIsInLandscapeMode(); + const {isKeyboardActive} = useKeyboardState(); + + return isInLandscapeMode && isKeyboardActive; +} + +export default useShouldFooterBeInsideList; diff --git a/src/hooks/useSyncFocus/useSyncFocusImplementation.ts b/src/hooks/useSyncFocus/useSyncFocusImplementation.ts index 2d79742956f8..026611cfc8e1 100644 --- a/src/hooks/useSyncFocus/useSyncFocusImplementation.ts +++ b/src/hooks/useSyncFocus/useSyncFocusImplementation.ts @@ -1,6 +1,6 @@ import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; import {useContext, useLayoutEffect} from 'react'; @@ -9,14 +9,14 @@ import {useContext, useLayoutEffect} from 'react'; // It enables external actions like calling .focus() from outside this hook, // as demonstrated in this PR: https://github.com/Expensify/App/pull/59206 // eslint-disable-next-line import/no-mutable-exports -let focusedItemRef: View | HTMLElement | null; +let focusedItemRef: ComponentRef | HTMLElement | null; /** * Custom React hook created to handle sync of focus on an element when the user navigates through the app with keyboard. * When the user navigates through the app using the arrows and then the tab button, the focus on the element and the native focus of the browser differs. * To maintain consistency when an element is focused in the app, the focus() method is additionally called on the focused element to eliminate the difference between native browser focus and application focus. */ -const useSyncFocusImplementation = (ref: RefObject, isFocused: boolean, shouldSyncFocus = true) => { +const useSyncFocusImplementation = (ref: RefObject | HTMLElement | null>, isFocused: boolean, shouldSyncFocus = true) => { // this hook can be used outside ScreenWrapperStatusContext (eg. in Popovers). So we to check if the context is present. // If we are outside context we don't have to look at transition status const contextValue = useContext(ScreenWrapperStatusContext); diff --git a/src/hooks/useTextWithMiddleEllipsis.ts b/src/hooks/useTextWithMiddleEllipsis.ts index 9b3a90219704..5a5984ed7066 100644 --- a/src/hooks/useTextWithMiddleEllipsis.ts +++ b/src/hooks/useTextWithMiddleEllipsis.ts @@ -1,4 +1,4 @@ -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports import type {Text as RNText} from 'react-native'; @@ -11,7 +11,7 @@ type TruncateProps = { /** The text string that may need truncation */ text: string; - ref: RefObject; + ref: RefObject | null>; }; type FontStyle = { diff --git a/src/languages/de.ts b/src/languages/de.ts index 5edd0f5b4d2a..79cd291116b5 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -624,10 +624,6 @@ const translations: TranslationDeepObject = { commentExceededMaxLength: (formattedMaxLength: string) => `Die maximale Kommentarlänge beträgt ${formattedMaxLength} Zeichen.`, taskTitleExceededMaxLength: (formattedMaxLength: string) => `Die maximale Aufgaben-Titellänge beträgt ${formattedMaxLength} Zeichen.`, }, - baseUpdateAppModal: { - updateApp: 'App aktualisieren', - updatePrompt: 'Eine neue Version dieser App ist verfügbar.\nAktualisiere jetzt oder starte die App später neu, um die neuesten Änderungen herunterzuladen.', - }, deeplinkWrapper: { launching: 'Expensify wird gestartet', expired: 'Ihre Sitzung ist abgelaufen.', @@ -1082,6 +1078,10 @@ const translations: TranslationDeepObject = { one: 'Überprüfen Sie 1 Ausgabe', other: `Überprüfen Sie ${count} Ausgaben`, }), + reviewDomainAdminRequests: ({count}: {count: number}) => ({ + one: 'Prüfen Sie 1 Domain-Admin-Anfrage', + other: `Prüfen Sie ${count} Domain-Admin-Anfragen`, + }), }, upcomingTravel: 'Bevorstehende Reisen', upcomingTravelSection: { @@ -1166,6 +1166,11 @@ const translations: TranslationDeepObject = { inputPlaceholderMobile: 'Stellen Sie Concierge eine Frage', }, }, + insightsPage: { + viewOnSpend: 'In Ausgaben anzeigen', + emptyState: {title: 'Nichts anzuzeigen', subtitle: 'Versuchen Sie, Ihre Kriterien oben anzupassen'}, + noExpensesState: {title: 'Sehen Sie, wohin Ihr Geld fließt', subtitle: 'Sobald Sie Ausgaben haben, finden Sie Ausgabentrends, Top-Händler und mehr.'}, + }, allSettingsScreen: { subscription: 'Abonnement', domains: 'Domains', @@ -2190,7 +2195,7 @@ const translations: TranslationDeepObject = { profileAvatar: 'Profil-Avatar', customInstructions: 'Benutzerdefinierte Anweisungen', copilotIntoAccount: 'Copilot in Konto', - viewUserHistory: 'Nutzerverlauf anzeigen', + viewMemberHistory: 'Mitgliedsverlauf anzeigen', viewAgentHistory: 'Agentenverlauf anzeigen', publicSection: { title: 'Öffentlich', @@ -3342,7 +3347,7 @@ ${amount} für ${merchant} – ${date}`, agentSignInBlocked: 'Agent-Konten können nicht direkt verwendet werden. Um ein Agent-Konto zu nutzen, melden Sie sich mit Ihrem eigenen Konto an und greifen Sie über Copilot darauf zu.', }, - cannotGetAccountDetails: 'Kontodetails konnten nicht abgerufen werden. Bitte melde dich erneut an.', + cannotGetAccountDetails: 'Kontodaten konnten nicht abgerufen werden. Bitte warten Sie ein paar Minuten und versuchen Sie es erneut.', loginForm: 'Anmeldeformular', notYou: (user: string) => `Nicht ${user}?`, }, @@ -6644,6 +6649,7 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU expensifyCardBannerLearnMoreButton: 'Mehr erfahren', statementCloseDateTitle: 'Abrechnungsschlussdatum', statementCloseDateDescription: 'Teile uns mit, wann dein Kreditkartenkontoauszug abgeschlossen wird, und wir erstellen einen passenden Auszug in Expensify.', + exportAccount: 'Konto exportieren', }, workflows: { title: 'Workflows', @@ -6900,6 +6906,7 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU other: (count: number) => `${count} Tags`, }), showTagGLCodes: 'Kontenplan-Codes beim Auswählen eines Tags anzeigen', + showTagGLCodesSubtitle: 'Wenn ein Kontenplan-Code nicht angezeigt wird, ist er für dieses Tag in Ihrem Buchhaltungssystem nicht verfügbar.', }, taxes: { subtitle: 'Steuernamen und -sätze hinzufügen und Standardwerte festlegen.', @@ -8783,6 +8790,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc subsidiarySelectDescription: 'Wählen Sie die Tochtergesellschaft in Campfire aus, von der Sie Daten importieren möchten.', noSubsidiariesFound: 'Keine Tochtergesellschaften gefunden', noSubsidiariesFoundDescription: 'Bitte fügen Sie eine Entität in Campfire hinzu und synchronisieren Sie die Verbindung erneut', + noVendorsFound: 'Keine Anbieter gefunden', + noVendorsFoundDescription: 'Bitte fügen Sie Lieferanten in Campfire hinzu und synchronisieren Sie die Verbindung erneut', importDescription: 'Wählen Sie aus, welche Kodierungskonfigurationen aus Campfire importiert werden sollen.', accountTypesDescription: 'Ihre Campfire-Konten werden als Kategorien importiert.', enableNewAccountsTitle: 'Neu importierte Konten aktivieren', @@ -9631,6 +9640,11 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc bulkActions: { editMultiple: 'Mehrere bearbeiten', editMultipleTitle: 'Mehrere Ausgaben bearbeiten', + editFinalizedExpensesTitle: 'Abgeschlossene Ausgaben bearbeiten?', + editFinalizedExpensesConfirmation: ({count, total}: {count: number; total: number}) => ({ + one: `1 der ${total} von Ihnen ausgewählten Ausgaben befindet sich in einem genehmigten oder bezahlten Bericht. Sie sind dabei, eine abgeschlossene Ausgabe zu bearbeiten. Sind Sie sicher?`, + other: `${count} der ${total} von Ihnen ausgewählten Ausgaben befinden sich in genehmigten oder bezahlten Berichten. Sie sind dabei, abgeschlossene Ausgaben zu bearbeiten. Sind Sie sicher?`, + }), editMultipleDescription: 'Änderungen werden für alle ausgewählten Ausgaben übernommen und überschreiben alle zuvor festgelegten Werte.', approve: 'Genehmigen', pay: 'Bezahlen', diff --git a/src/languages/el.ts b/src/languages/el.ts index 455e6da4334a..ae536a8fc430 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -628,10 +628,6 @@ const translations: TranslationDeepObject = { commentExceededMaxLength: (formattedMaxLength: string) => `Το μέγιστο μήκος σχολίου είναι ${formattedMaxLength} χαρακτήρες.`, taskTitleExceededMaxLength: (formattedMaxLength: string) => `Το μέγιστο μήκος τίτλου εργασίας είναι ${formattedMaxLength} χαρακτήρες.`, }, - baseUpdateAppModal: { - updateApp: 'Ενημέρωση εφαρμογής', - updatePrompt: 'Μια νέα έκδοση αυτής της εφαρμογής είναι διαθέσιμη. \nΕνημερώστε τώρα ή επανεκκινήστε την εφαρμογή αργότερα για να γίνει λήψη των πιο πρόσφατων αλλαγών.', - }, deeplinkWrapper: { launching: 'Εκκίνηση του Expensify', expired: 'Η συνεδρία σας έχει λήξει.', @@ -1098,6 +1094,10 @@ const translations: TranslationDeepObject = { one: 'Ελέγξτε 1 δαπάνη', other: `Ελέγξτε ${count} δαπάνες`, }), + reviewDomainAdminRequests: ({count}: {count: number}) => ({ + one: 'Ελέγξτε 1 αίτημα διαχειριστή τομέα', + other: `Ελέγξτε ${count} αιτήματα διαχειριστή τομέα`, + }), submit: ({count}: {count: number}) => ({ one: 'Υποβολή 1 αναφοράς', other: `Υποβολή ${count} αναφορών`, @@ -1215,6 +1215,11 @@ const translations: TranslationDeepObject = { inputPlaceholderMobile: 'Ρωτήστε το Concierge οτιδήποτε', }, }, + insightsPage: { + viewOnSpend: 'Προβολή στο Spend', + emptyState: {title: 'Τίποτα προς εμφάνιση', subtitle: 'Δοκιμάστε να προσαρμόσετε τα κριτήριά σας παραπάνω'}, + noExpensesState: {title: 'Δείτε πού πηγαίνουν τα χρήματά σας', subtitle: 'Μόλις έχετε δαπάνες, θα βλέπετε τάσεις δαπανών, κορυφαίους εμπόρους και άλλα.'}, + }, allSettingsScreen: { subscription: 'Συνδρομή', domains: 'Τομείς', @@ -2239,7 +2244,7 @@ const translations: TranslationDeepObject = { profileAvatar: 'Εικόνα προφίλ', customInstructions: 'Προσαρμοσμένες οδηγίες', copilotIntoAccount: 'Οδηγός εντός λογαριασμού', - viewUserHistory: 'Προβολή ιστορικού χρήστη', + viewMemberHistory: 'Προβολή ιστορικού μέλους', viewAgentHistory: 'Προβολή ιστορικού αντιπροσώπου', publicSection: { title: 'Δημόσιο', @@ -3394,7 +3399,7 @@ ${amount} για ${merchant} - ${date}`, agentSignInBlocked: 'Οι λογαριασμοί agent δεν μπορούν να συνδεθούν απευθείας. Για να χρησιμοποιήσετε έναν agent, συνδεθείτε με τον δικό σας λογαριασμό και αποκτήστε πρόσβαση σε αυτόν μέσω Copilot.', }, - cannotGetAccountDetails: 'Δεν ήταν δυνατή η ανάκτηση των στοιχείων του λογαριασμού. Παρακαλούμε δοκιμάστε να συνδεθείτε ξανά.', + cannotGetAccountDetails: 'Δεν ήταν δυνατή η ανάκτηση των στοιχείων του λογαριασμού. Παρακαλούμε περιμένετε μερικά λεπτά και προσπαθήστε ξανά.', loginForm: 'Φόρμα σύνδεσης', notYou: (user: string) => `Δεν είστε ο/η ${user};`, }, @@ -6768,6 +6773,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ expensifyCardBannerLearnMoreButton: 'Μάθετε περισσότερα', statementCloseDateTitle: 'Ημερομηνία λήξης κατάστασης λογαριασμού', statementCloseDateDescription: 'Ενημερώστε μας πότε κλείνει το αντίγραφο κίνησης της κάρτας σας και θα δημιουργήσουμε ένα αντίστοιχο αντίγραφο κίνησης στο Expensify.', + exportAccount: 'Εξαγωγή λογαριασμού', }, workflows: { title: 'Ροές εργασιών', @@ -7027,6 +7033,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ other: (count: number) => `${count} ετικέτες`, }), showTagGLCodes: 'Εμφάνιση κωδικών Γ.Λ. κατά την επιλογή ετικέτας', + showTagGLCodesSubtitle: 'Αν δεν εμφανίζεται ένας κωδικός ΓΚ, τότε δεν είναι διαθέσιμος για αυτήν την ετικέτα στο λογιστικό σας σύστημα.', }, taxes: { subtitle: 'Προσθέστε ονόματα φόρων, συντελεστές και ορίστε προεπιλογές.', @@ -8949,6 +8956,8 @@ ${reportName}`, subsidiarySelectDescription: 'Επιλέξτε τη θυγατρική στο Campfire από την οποία θέλετε να εισαγάγετε δεδομένα.', noSubsidiariesFound: 'Δεν βρέθηκαν θυγατρικές', noSubsidiariesFoundDescription: 'Παρακαλούμε προσθέστε μια οντότητα στο Campfire και συγχρονίστε ξανά τη σύνδεση', + noVendorsFound: 'Δεν βρέθηκαν προμηθευτές', + noVendorsFoundDescription: 'Παρακαλούμε προσθέστε προμηθευτές στο Campfire και συγχρονίστε ξανά τη σύνδεση', importDescription: 'Επιλέξτε ποιες ρυθμίσεις κωδικοποίησης θέλετε να εισαγάγετε από το Campfire.', accountTypesDescription: 'Οι λογαριασμοί Campfire σας θα εισαχθούν ως κατηγορίες.', enableNewAccountsTitle: 'Ενεργοποίηση νέων εισαγόμενων λογαριασμών', @@ -9839,6 +9848,11 @@ ${reportName}`, bulkActions: { editMultiple: 'Επεξεργασία πολλών', editMultipleTitle: 'Επεξεργασία πολλών εξόδων', + editFinalizedExpensesTitle: 'Επεξεργασία οριστικοποιημένων δαπανών;', + editFinalizedExpensesConfirmation: ({count, total}: {count: number; total: number}) => ({ + one: `1 από τις ${total} δαπάνες που επιλέξατε βρίσκεται σε εγκεκριμένη ή εξοφλημένη αναφορά. Πρόκειται να επεξεργαστείτε μια οριστικοποιημένη δαπάνη. Είστε σίγουροι;`, + other: `${count} από τις ${total} δαπάνες που επιλέξατε βρίσκονται σε εγκεκριμένες ή εξοφλημένες αναφορές. Πρόκειται να επεξεργαστείτε οριστικοποιημένες δαπάνες. Είστε σίγουροι;`, + }), editMultipleDescription: 'Οι αλλαγές θα εφαρμοστούν σε όλες τις επιλεγμένες δαπάνες και θα αντικαταστήσουν τυχόν προηγουμένως ορισμένες τιμές.', approve: 'Έγκριση', pay: 'Πληρωμή', diff --git a/src/languages/en.ts b/src/languages/en.ts index 618fc7841cc1..2fe68412d409 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -669,10 +669,6 @@ const translations = { commentExceededMaxLength: (formattedMaxLength: string) => `The maximum comment length is ${formattedMaxLength} characters.`, taskTitleExceededMaxLength: (formattedMaxLength: string) => `The maximum task title length is ${formattedMaxLength} characters.`, }, - baseUpdateAppModal: { - updateApp: 'Update app', - updatePrompt: 'A new version of this app is available.\nUpdate now or restart the app later to download the latest changes.', - }, deeplinkWrapper: { launching: 'Launching Expensify', expired: 'Your session has expired.', @@ -1167,6 +1163,10 @@ const translations = { one: 'Export 1 report', other: `Export ${count} reports`, }), + reviewDomainAdminRequests: ({count}: {count: number}) => ({ + one: 'Review 1 domain admin request', + other: `Review ${count} domain admin requests`, + }), begin: 'Begin', emptyStateMessages: { thumbsUpStarsTitle: "You're done!", @@ -1261,6 +1261,17 @@ const translations = { notEnoughData: 'We don’t have enough data to populate this chart yet', }, }, + insightsPage: { + viewOnSpend: 'View on Spend', + emptyState: { + title: 'Nothing to show', + subtitle: 'Try adjusting your criteria above', + }, + noExpensesState: { + title: 'See where your money goes', + subtitle: 'Once you have expenses, you’ll find spending trends, top merchants, and more.', + }, + }, allSettingsScreen: { subscription: 'Subscription', domains: 'Domains', @@ -2293,7 +2304,7 @@ const translations = { profileAvatar: 'Profile avatar', customInstructions: 'Custom instructions', copilotIntoAccount: 'Copilot into account', - viewUserHistory: 'View user history', + viewMemberHistory: 'View member history', viewAgentHistory: 'View agent history', publicSection: { title: 'Public', @@ -3441,7 +3452,7 @@ const translations = { invalidFormatEmailLogin: 'The email entered is invalid. Please fix the format and try again.', agentSignInBlocked: "Agent accounts can't be signed into directly. To use an agent, sign in with your own account and access it via Copilot.", }, - cannotGetAccountDetails: "Couldn't retrieve account details. Please try to sign in again.", + cannotGetAccountDetails: "Couldn't retrieve account details. Please wait a few minutes and try again.", loginForm: 'Login form', notYou: (user: string) => `Not ${user}?`, }, @@ -6219,6 +6230,8 @@ const translations = { subsidiarySelectDescription: "Choose the subsidiary in Campfire that you'd like to import data from.", noSubsidiariesFound: 'No subsidiaries found', noSubsidiariesFoundDescription: 'Please add an entity in Campfire and sync the connection again', + noVendorsFound: 'No vendors found', + noVendorsFoundDescription: 'Please add vendors in Campfire and sync the connection again', importDescription: 'Choose which coding configurations to import from Campfire.', accountTypesDescription: 'Your Campfire accounts will import as categories.', enableNewAccountsTitle: 'Enable newly imported accounts', @@ -6759,6 +6772,7 @@ const translations = { neverUpdated: 'Never', noAccountsFound: 'No accounts found', defaultCard: 'Default card', + exportAccount: 'Export account', downgradeTitle: `Can't downgrade workspace`, downgradeSubTitle: `This workspace can't be downgraded because multiple card feeds are connected (excluding Expensify Cards). Please keep only one card feed to proceed.`, noAccountsFoundDescription: (connection: string) => `Please add the account in ${connection} and sync the connection again`, @@ -6946,6 +6960,7 @@ const translations = { tagName: 'Tag name', requiresTag: 'Members must tag all expenses', showTagGLCodes: 'Show GL codes when selecting a tag', + showTagGLCodesSubtitle: "If a GL code isn't displayed, it isn't available for that tag in your accounting system.", trackBillable: 'Track billable expenses', customTagName: 'Custom tag name', enableTag: 'Enable tag', @@ -9783,6 +9798,11 @@ const translations = { bulkActions: { editMultiple: 'Edit multiple', editMultipleTitle: 'Edit multiple expenses', + editFinalizedExpensesTitle: 'Edit finalized expenses?', + editFinalizedExpensesConfirmation: ({count, total}: {count: number; total: number}) => ({ + one: `1 of the ${total} expenses you selected is on an approved or paid report. You’re about to edit a finalized expense. Are you sure?`, + other: `${count} of the ${total} expenses you selected are on approved or paid reports. You’re about to edit finalized expenses. Are you sure?`, + }), // cspell:disable editMultipleDescription: 'Changes will be set for all selected expenses and will override any previously set values.', approve: 'Approve', diff --git a/src/languages/es.ts b/src/languages/es.ts index b5fe0e47fe52..660eaf22f012 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -614,10 +614,6 @@ const translations: TranslationDeepObject = { commentExceededMaxLength: (formattedMaxLength) => `El comentario debe tener máximo ${formattedMaxLength} caracteres.`, taskTitleExceededMaxLength: (formattedMaxLength) => `La longitud máxima del título de una tarea es de ${formattedMaxLength} caracteres.`, }, - baseUpdateAppModal: { - updateApp: 'Actualizar app', - updatePrompt: 'Existe una nueva versión de esta aplicación.\nActualiza ahora or reinicia la aplicación más tarde para recibir la última versión.', - }, deeplinkWrapper: { launching: 'Cargando Expensify', expired: 'Tu sesión ha expirado.', @@ -1099,6 +1095,10 @@ const translations: TranslationDeepObject = { one: 'Revisa 1 gasto', other: `Revisa ${count} gastos`, }), + reviewDomainAdminRequests: ({count}: {count: number}) => ({ + one: 'Revisa 1 solicitud de administrador de dominio', + other: `Revisa ${count} solicitudes de administrador de dominio`, + }), }, gettingStartedSection: { title: 'Primeros pasos', @@ -1165,6 +1165,11 @@ const translations: TranslationDeepObject = { inputPlaceholderMobile: 'Pregunta a Concierge cualquier cosa', }, }, + insightsPage: { + viewOnSpend: 'Ver en Gastos', + emptyState: {title: 'Nada que mostrar', subtitle: 'Prueba a ajustar tus criterios de arriba'}, + noExpensesState: {title: 'Ve adónde va tu dinero', subtitle: 'Una vez que tengas gastos, encontrarás tendencias de gasto, principales comercios y mucho más.'}, + }, allSettingsScreen: { subscription: 'Suscripcion', domains: 'Dominios', @@ -2111,7 +2116,7 @@ const translations: TranslationDeepObject = { profileAvatar: 'Perfil avatar', customInstructions: 'Instrucciones personalizadas', copilotIntoAccount: 'Copilot a la cuenta', - viewUserHistory: 'Ver historial del usuario', + viewMemberHistory: 'Ver historial del miembro', viewAgentHistory: 'Ver historial del agente', publicSection: { title: 'Público', @@ -3248,7 +3253,7 @@ ${amount} para ${merchant} - ${date}`, agentSignInBlocked: 'No se puede iniciar sesión directamente en las cuentas de agente. Para usar un agente, inicia sesión con tu propia cuenta y accede a él a través de Copilot.', }, - cannotGetAccountDetails: 'No se pudieron cargar los detalles de tu cuenta. Por favor, intenta iniciar sesión de nuevo.', + cannotGetAccountDetails: 'No se pudieron obtener los detalles de la cuenta. Espera unos minutos e inténtalo de nuevo.', loginForm: 'Formulario de inicio de sesión', notYou: (user) => `¿No eres ${user}?`, }, @@ -6521,6 +6526,7 @@ ${amount} para ${merchant} - ${date}`, expensifyCardBannerLearnMoreButton: 'Más información', statementCloseDateTitle: 'Fecha de cierre del estado de cuenta', statementCloseDateDescription: 'Indícanos cuándo cierra el estado de cuenta de tu tarjeta y crearemos uno correspondiente en Expensify.', + exportAccount: 'Exportar cuenta', }, workflows: { title: 'Flujos de trabajo', @@ -6775,6 +6781,7 @@ ${amount} para ${merchant} - ${date}`, other: (count: number) => `${count} etiquetas`, }), showTagGLCodes: 'Mostrar códigos del libro mayor al seleccionar una etiqueta', + showTagGLCodesSubtitle: 'Si no se muestra un código del libro mayor, no está disponible para esa etiqueta en tu sistema contable.', }, taxes: { subtitle: 'Añade nombres, tasas y establezca valores por defecto para los impuestos.', @@ -8618,6 +8625,8 @@ ${reportName}`, subsidiarySelectDescription: 'Elige la filial en Campfire de la que te gustaría importar datos.', noSubsidiariesFound: 'No se han encontrado filiales', noSubsidiariesFoundDescription: 'Por favor, añade una entidad en Campfire y sincroniza la conexión de nuevo', + noVendorsFound: 'No se encontraron proveedores', + noVendorsFoundDescription: 'Por favor, añade proveedores en Campfire y sincroniza la conexión de nuevo', importDescription: 'Elige qué configuraciones de codificación quieres importar desde Campfire.', accountTypesDescription: 'Tus cuentas de Campfire se importarán como categorías.', enableNewAccountsTitle: 'Habilitar cuentas recién importadas', @@ -9495,6 +9504,11 @@ ${reportName}`, bulkActions: { editMultiple: 'Editar múltiples', editMultipleTitle: 'Editar múltiples gastos', + editFinalizedExpensesTitle: '¿Editar gastos finalizados?', + editFinalizedExpensesConfirmation: ({count, total}: {count: number; total: number}) => ({ + one: `1 de los ${total} gastos que has seleccionado está en un informe aprobado o pagado. Estás a punto de editar un gasto finalizado. ¿Estás seguro?`, + other: `${count} de los ${total} gastos que has seleccionado están en informes aprobados o pagados. Estás a punto de editar gastos finalizados. ¿Estás seguro?`, + }), editMultipleDescription: 'Los cambios se aplicarán a todos los gastos seleccionados y sustituirán cualquier valor establecido previamente.', approve: 'Aprobar', pay: 'Pagar', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 102b1093d072..f88f2b2d5242 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -624,11 +624,6 @@ const translations: TranslationDeepObject = { commentExceededMaxLength: (formattedMaxLength: string) => `La longueur maximale du commentaire est de ${formattedMaxLength} caractères.`, taskTitleExceededMaxLength: (formattedMaxLength: string) => `La longueur maximale du titre de la tâche est de ${formattedMaxLength} caractères.`, }, - baseUpdateAppModal: { - updateApp: 'Mettre à jour l’application', - updatePrompt: - 'Une nouvelle version de cette application est disponible.\nMettez-la à jour maintenant ou redémarrez l’application plus tard pour télécharger les dernières modifications.', - }, deeplinkWrapper: { launching: 'Lancement d’Expensify', expired: 'Votre session a expiré.', @@ -1085,6 +1080,10 @@ const translations: TranslationDeepObject = { one: `Examiner ${count} dépense`, other: `Examiner ${count} dépenses`, }), + reviewDomainAdminRequests: ({count}: {count: number}) => ({ + one: `Examiner ${count} demande d'administrateur de domaine`, + other: `Examiner ${count} demandes d'administrateur de domaine`, + }), }, upcomingTravel: 'Voyages à venir', upcomingTravelSection: { @@ -1169,6 +1168,14 @@ const translations: TranslationDeepObject = { inputPlaceholderMobile: 'Demander n’importe quoi à Concierge', }, }, + insightsPage: { + viewOnSpend: 'Voir dans Dépenses', + emptyState: {title: 'Rien à afficher', subtitle: 'Essayez de modifier vos critères ci-dessus'}, + noExpensesState: { + title: 'Voyez où va votre argent', + subtitle: 'Une fois que vous aurez des dépenses, vous verrez des tendances de dépenses, les principaux marchands et plus encore.', + }, + }, allSettingsScreen: { subscription: 'Abonnement', domains: 'Domaines', @@ -2194,7 +2201,7 @@ const translations: TranslationDeepObject = { profileAvatar: 'Avatar de profil', customInstructions: 'Instructions personnalisées', copilotIntoAccount: 'Copilot dans le compte', - viewUserHistory: 'Voir l’historique de l’utilisateur', + viewMemberHistory: 'Voir l’historique du membre', viewAgentHistory: 'Voir l’historique de l’agent', publicSection: { title: 'Public', @@ -3347,7 +3354,7 @@ ${amount} pour ${merchant} - ${date}`, 'Les comptes d’agent ne permettent pas de se connecter directement. Pour utiliser un agent, connectez-vous avec votre propre compte et accédez-y via Copilot.', invalidFormatEmailLogin: 'L’adresse e-mail saisie est invalide. Veuillez corriger le format et réessayer.', }, - cannotGetAccountDetails: 'Impossible de récupérer les détails du compte. Veuillez essayer de vous reconnecter.', + cannotGetAccountDetails: 'Impossible de récupérer les détails du compte. Veuillez patienter quelques minutes, puis réessayer.', loginForm: 'Formulaire de connexion', notYou: (user: string) => `Pas ${user} ?`, }, @@ -6659,6 +6666,7 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. expensifyCardBannerLearnMoreButton: 'En savoir plus', statementCloseDateTitle: 'Date de clôture du relevé', statementCloseDateDescription: 'Indiquez-nous la date de clôture de votre relevé de carte, et nous créerons un relevé correspondant dans Expensify.', + exportAccount: 'Exporter le compte', }, workflows: { title: 'Workflows', @@ -6918,6 +6926,7 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. other: (count: number) => `${count} tags`, }), showTagGLCodes: 'Afficher les codes GL lors de la sélection d’un tag', + showTagGLCodesSubtitle: 'Si un code GL n’est pas affiché, il n’est pas disponible pour ce tag dans votre système comptable.', }, taxes: { subtitle: 'Ajoutez des noms de taxes, des taux et définissez des valeurs par défaut.', @@ -8799,6 +8808,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e subsidiarySelectDescription: 'Choisissez la filiale dans Campfire à partir de laquelle vous souhaitez importer des données.', noSubsidiariesFound: 'Aucune filiale trouvée', noSubsidiariesFoundDescription: 'Veuillez ajouter une entité dans Campfire et synchroniser à nouveau la connexion', + noVendorsFound: 'Aucun fournisseur trouvé', + noVendorsFoundDescription: 'Veuillez ajouter des fournisseurs dans Campfire et synchroniser de nouveau la connexion', importDescription: 'Choisissez les configurations de codage à importer depuis Campfire.', accountTypesDescription: 'Vos comptes Campfire seront importés en tant que catégories.', enableNewAccountsTitle: 'Activer les nouveaux comptes importés', @@ -9650,6 +9661,11 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e bulkActions: { editMultiple: 'Modifier plusieurs', editMultipleTitle: 'Modifier plusieurs dépenses', + editFinalizedExpensesTitle: 'Modifier des dépenses finalisées ?', + editFinalizedExpensesConfirmation: ({count, total}: {count: number; total: number}) => ({ + one: `1 des ${total} dépenses que vous avez sélectionnées figure sur une note de frais approuvée ou payée. Vous êtes sur le point de modifier une dépense finalisée. Êtes-vous sûr ?`, + other: `${count} des ${total} dépenses que vous avez sélectionnées figurent sur des notes de frais approuvées ou payées. Vous êtes sur le point de modifier des dépenses finalisées. Êtes-vous sûr ?`, + }), editMultipleDescription: 'Les modifications seront définies pour toutes les dépenses sélectionnées et remplaceront toutes les valeurs définies précédemment.', approve: 'Approuver', pay: 'Payer', diff --git a/src/languages/it.ts b/src/languages/it.ts index b354da589cb7..507af047487e 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -623,10 +623,6 @@ const translations: TranslationDeepObject = { commentExceededMaxLength: (formattedMaxLength: string) => `La lunghezza massima del commento è di ${formattedMaxLength} caratteri.`, taskTitleExceededMaxLength: (formattedMaxLength: string) => `La lunghezza massima del titolo dell'attività è di ${formattedMaxLength} caratteri.`, }, - baseUpdateAppModal: { - updateApp: 'Aggiorna app', - updatePrompt: 'È disponibile una nuova versione di questa app.\nAggiorna ora o riavvia l’app più tardi per scaricare le modifiche più recenti.', - }, deeplinkWrapper: { launching: 'Avvio di Expensify', expired: 'La tua sessione è scaduta.', @@ -1082,6 +1078,10 @@ const translations: TranslationDeepObject = { one: 'Esamina 1 spesa', other: `Esamina ${count} spese`, }), + reviewDomainAdminRequests: ({count}: {count: number}) => ({ + one: 'Esamina 1 richiesta di amministratore del dominio', + other: `Esamina ${count} richieste di amministratore del dominio`, + }), }, upcomingTravel: 'Prossimi viaggi', upcomingTravelSection: { @@ -1166,6 +1166,11 @@ const translations: TranslationDeepObject = { inputPlaceholderMobile: 'Chiedi qualsiasi cosa a Concierge', }, }, + insightsPage: { + viewOnSpend: 'Visualizza in Spese', + emptyState: {title: 'Niente da mostrare', subtitle: 'Prova a modificare i criteri qui sopra'}, + noExpensesState: {title: 'Scopri dove va il tuo denaro', subtitle: 'Una volta che avrai delle spese, troverai tendenze di spesa, principali esercenti e altro ancora.'}, + }, allSettingsScreen: { subscription: 'Abbonamento', domains: 'Domini', @@ -2184,7 +2189,7 @@ const translations: TranslationDeepObject = { profileAvatar: 'Avatar profilo', customInstructions: 'Istruzioni personalizzate', copilotIntoAccount: "Copilot nell'account", - viewUserHistory: 'Visualizza cronologia utente', + viewMemberHistory: 'Visualizza cronologia membro', viewAgentHistory: 'Visualizza cronologia agente', publicSection: { title: 'Pubblico', @@ -3329,7 +3334,7 @@ ${amount} per ${merchant} - ${date}`, agentSignInBlocked: 'Non puoi accedere direttamente agli account agente. Per usare un agente, accedi con il tuo account e raggiungilo tramite Copilot.', invalidFormatEmailLogin: 'L’email inserita non è valida. Correggi il formato e riprova.', }, - cannotGetAccountDetails: 'Impossibile recuperare i dettagli dell’account. Prova ad accedere di nuovo.', + cannotGetAccountDetails: 'Impossibile recuperare i dettagli dell’account. Attendi qualche minuto e riprova.', loginForm: 'Modulo di accesso', notYou: (user: string) => `Non sei ${user}?`, }, @@ -6609,6 +6614,7 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. expensifyCardBannerLearnMoreButton: 'Scopri di più', statementCloseDateTitle: 'Data di chiusura dell’estratto conto', statementCloseDateDescription: 'Facci sapere quando si chiude l’estratto conto della tua carta e creeremo un estratto conto corrispondente in Expensify.', + exportAccount: 'Esporta conto', }, workflows: { title: 'Flussi di lavoro', @@ -6863,6 +6869,7 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. other: (count: number) => `${count} tag`, }), showTagGLCodes: 'Mostra i codici GL quando selezioni un tag', + showTagGLCodesSubtitle: 'Se un codice GL non viene visualizzato, non è disponibile per quel tag nel tuo sistema contabile.', }, taxes: { subtitle: 'Aggiungi nomi e aliquote delle imposte e imposta i valori predefiniti.', @@ -8730,6 +8737,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, subsidiarySelectDescription: 'Scegli la consociata in Campfire da cui vuoi importare i dati.', noSubsidiariesFound: 'Nessuna consociata trovata', noSubsidiariesFoundDescription: "Aggiungi un'entità in Campfire e sincronizza di nuovo la connessione", + noVendorsFound: 'Nessun fornitore trovato', + noVendorsFoundDescription: 'Aggiungi fornitori in Campfire e sincronizza di nuovo la connessione', importDescription: 'Scegli quali configurazioni di codifica importare da Campfire.', accountTypesDescription: 'I tuoi account Campfire verranno importati come categorie.', enableNewAccountsTitle: 'Abilita i conti appena importati', @@ -9587,6 +9596,11 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, bulkActions: { editMultiple: 'Modifica multipli', editMultipleTitle: 'Modifica più spese', + editFinalizedExpensesTitle: 'Modificare le spese finalizzate?', + editFinalizedExpensesConfirmation: ({count, total}: {count: number; total: number}) => ({ + one: `1 delle ${total} spese che hai selezionato si trova in un report approvato o pagato. Stai per modificare una spesa finalizzata. Sei sicuro?`, + other: `${count} delle ${total} spese che hai selezionato si trovano in report approvati o pagati. Stai per modificare spese finalizzate. Sei sicuro?`, + }), editMultipleDescription: 'Le modifiche verranno applicate a tutte le spese selezionate e sovrascriveranno qualsiasi valore impostato in precedenza.', approve: 'Approva', pay: 'Paga', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 2adaa3aa2dbe..71b009a75342 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -625,10 +625,6 @@ const translations: TranslationDeepObject = { commentExceededMaxLength: (formattedMaxLength: string) => `コメントの最大文字数は${formattedMaxLength}文字です。`, taskTitleExceededMaxLength: (formattedMaxLength: string) => `タスクタイトルの最大文字数は${formattedMaxLength}文字です。`, }, - baseUpdateAppModal: { - updateApp: 'アプリを更新', - updatePrompt: 'このアプリの新しいバージョンが利用可能です。\n今すぐアップデートするか、後でアプリを再起動して最新の変更をダウンロードしてください。', - }, deeplinkWrapper: { launching: 'Expensify を起動中', expired: 'セッションの有効期限が切れました。', @@ -1069,6 +1065,10 @@ const translations: TranslationDeepObject = { one: `${count} 件の経費を確認`, other: `${count} 件の経費を確認`, }), + reviewDomainAdminRequests: ({count}: {count: number}) => ({ + one: `${count} 件のドメイン管理者リクエストを確認`, + other: `${count} 件のドメイン管理者リクエストを確認`, + }), }, upcomingTravel: '今後の出張', upcomingTravelSection: { @@ -1153,6 +1153,11 @@ const translations: TranslationDeepObject = { inputPlaceholderMobile: 'Concierge に何でも聞いてください', }, }, + insightsPage: { + viewOnSpend: '支出で表示', + emptyState: {title: '表示するものはありません', subtitle: '上の条件を調整してみてください'}, + noExpensesState: {title: 'お金の使い道を確認する', subtitle: '経費が登録されると、支出の傾向や上位の加盟店など、さまざまな情報を確認できるようになります。'}, + }, allSettingsScreen: { subscription: 'サブスクリプション', domains: 'ドメイン', @@ -2165,7 +2170,7 @@ const translations: TranslationDeepObject = { profileAvatar: 'プロフィールアバター', customInstructions: 'カスタム指示', copilotIntoAccount: 'アカウントにCopilot', - viewUserHistory: 'ユーザー履歴を表示', + viewMemberHistory: 'メンバー履歴を表示', viewAgentHistory: 'エージェント履歴を表示', publicSection: { title: '公開', @@ -3300,7 +3305,7 @@ ${date} の ${merchant} への ${amount}`, agentSignInBlocked: 'エージェントアカウントには直接サインインすることはできません。エージェントを利用するには、ご自身のアカウントでサインインし、Copilot 経由でアクセスしてください。', }, - cannotGetAccountDetails: 'アカウントの詳細を取得できませんでした。もう一度サインインしてください。', + cannotGetAccountDetails: 'アカウントの詳細を取得できませんでした。数分待ってから、もう一度お試しください。', loginForm: 'ログインフォーム', notYou: (user: string) => `${user}ではありませんか?`, }, @@ -6521,6 +6526,7 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO expensifyCardBannerLearnMoreButton: '詳細はこちら', statementCloseDateTitle: '取引明細書の締め日', statementCloseDateDescription: 'カード明細の締め日を教えていただければ、Expensify 内に対応する明細を作成します。', + exportAccount: 'アカウントを書き出す', }, workflows: { title: 'ワークフロー', @@ -6777,6 +6783,7 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO other: (count: number) => `${count} 件のタグ`, }), showTagGLCodes: 'タグ選択時にGLコードを表示する', + showTagGLCodesSubtitle: '総勘定元帳コードが表示されていない場合、お使いの会計システムではそのタグに利用できません。', }, taxes: { subtitle: '税名と税率を追加し、デフォルトを設定します。', @@ -8620,6 +8627,8 @@ ${reportName}`, subsidiarySelectDescription: 'データを取り込みたい Campfire 内の子会社を選択してください。', noSubsidiariesFound: '子会社が見つかりません', noSubsidiariesFoundDescription: 'Campfire でエンティティを追加して、接続をもう一度同期してください', + noVendorsFound: '取引先が見つかりません', + noVendorsFoundDescription: 'Campfire にベンダーを追加して、もう一度接続を同期してください', importDescription: 'Campfire からインポートするコーディング設定を選択してください。', accountTypesDescription: 'Campfire のアカウントはカテゴリとしてインポートされます。', enableNewAccountsTitle: '新しくインポートされた口座を有効にする', @@ -9459,6 +9468,11 @@ ${reportName}`, bulkActions: { editMultiple: '複数を編集', editMultipleTitle: '複数の経費を編集', + editFinalizedExpensesTitle: '確定済みの経費を編集しますか?', + editFinalizedExpensesConfirmation: ({count, total}: {count: number; total: number}) => ({ + one: `選択した${total}件の経費のうち1件が、承認済みまたは支払済みのレポートに含まれています。確定済みの経費を編集しようとしています。よろしいですか?`, + other: `選択した${total}件の経費のうち${count}件が、承認済みまたは支払済みのレポートに含まれています。確定済みの経費を編集しようとしています。よろしいですか?`, + }), editMultipleDescription: '変更は選択されたすべての経費に適用され、以前に設定された値は上書きされます。', approve: '承認', pay: '支払う', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 3fe7553d4d2b..bbcb15b2b75f 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -623,10 +623,6 @@ const translations: TranslationDeepObject = { commentExceededMaxLength: (formattedMaxLength: string) => `De maximale lengte van een opmerking is ${formattedMaxLength} tekens.`, taskTitleExceededMaxLength: (formattedMaxLength: string) => `De maximale lengte van een taaknaam is ${formattedMaxLength} tekens.`, }, - baseUpdateAppModal: { - updateApp: 'App bijwerken', - updatePrompt: 'Er is een nieuwe versie van deze app beschikbaar.\nWerk nu bij of start de app later opnieuw om de nieuwste wijzigingen te downloaden.', - }, deeplinkWrapper: { launching: 'Expensify wordt gestart', expired: 'Je sessie is verlopen.', @@ -1081,6 +1077,10 @@ const translations: TranslationDeepObject = { one: 'Beoordeel 1 uitgave', other: `Beoordeel ${count} uitgaven`, }), + reviewDomainAdminRequests: ({count}: {count: number}) => ({ + one: 'Beoordeel 1 domeinbeheerdersverzoek', + other: `Beoordeel ${count} domeinbeheerdersverzoeken`, + }), }, upcomingTravel: 'Aankomende reizen', upcomingTravelSection: { @@ -1165,6 +1165,11 @@ const translations: TranslationDeepObject = { inputPlaceholderMobile: 'Stel Concierge alles gerust een vraag', }, }, + insightsPage: { + viewOnSpend: 'Bekijken in Uitgaven', + emptyState: {title: 'Niets om weer te geven', subtitle: 'Probeer je criteria hierboven aan te passen'}, + noExpensesState: {title: 'Zie waar je geld naartoe gaat', subtitle: 'Zodra je uitgaven hebt, zie je bestedingspatronen, topverkopers en meer.'}, + }, allSettingsScreen: { subscription: 'Abonnement', domains: 'Domeinen', @@ -2181,7 +2186,7 @@ const translations: TranslationDeepObject = { profileAvatar: 'Profielavatar', customInstructions: 'Aangepaste instructies', copilotIntoAccount: 'Copilot naar account', - viewUserHistory: 'Gebruikersgeschiedenis bekijken', + viewMemberHistory: 'Ledengeschiedenis bekijken', viewAgentHistory: 'Agentgeschiedenis bekijken', publicSection: { title: 'Openbaar', @@ -3331,7 +3336,7 @@ ${amount} voor ${merchant} - ${date}`, invalidFormatEmailLogin: 'Het ingevoerde e-mailadres is ongeldig. Corrigeer de notatie en probeer het opnieuw.', agentSignInBlocked: 'Je kunt niet rechtstreeks inloggen op agent-accounts. Log in met je eigen account en gebruik de agent via Copilot.', }, - cannotGetAccountDetails: 'Accountgegevens konden niet worden opgehaald. Probeer opnieuw in te loggen.', + cannotGetAccountDetails: 'Accountgegevens ophalen is mislukt. Wacht een paar minuten en probeer het opnieuw.', loginForm: 'Aanmeldformulier', notYou: (user: string) => `Niet ${user}?`, }, @@ -6593,6 +6598,7 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ expensifyCardBannerLearnMoreButton: 'Meer informatie', statementCloseDateTitle: 'Sluitingsdatum afschrift', statementCloseDateDescription: 'Laat ons weten wanneer je creditcardafschrift wordt afgesloten, dan maken wij een bijpassend afschrift in Expensify aan.', + exportAccount: 'Account exporteren', }, workflows: { title: 'Workflows', @@ -6847,6 +6853,7 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ other: (count: number) => `${count} tags`, }), showTagGLCodes: 'GL-codes tonen bij het selecteren van een tag', + showTagGLCodesSubtitle: 'Als een GL-code niet wordt weergegeven, is deze niet beschikbaar voor die tag in je boekhoudsysteem.', }, taxes: { subtitle: 'Belastingnamen en -tarieven toevoegen en standaarden instellen.', @@ -8706,6 +8713,8 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, subsidiarySelectDescription: 'Kies de dochteronderneming in Campfire waarvan je gegevens wilt importeren.', noSubsidiariesFound: 'Geen dochterondernemingen gevonden', noSubsidiariesFoundDescription: 'Voeg alsjeblieft een entiteit toe in Campfire en synchroniseer de verbinding opnieuw', + noVendorsFound: 'Geen leveranciers gevonden', + noVendorsFoundDescription: 'Voeg leveranciers toe in Campfire en synchroniseer de verbinding opnieuw', importDescription: 'Kies welke boekhoudconfiguraties je wilt importeren uit Campfire.', accountTypesDescription: 'Je Campfire-accounts worden geïmporteerd als categorieën.', enableNewAccountsTitle: 'Nieuw geïmporteerde rekeningen inschakelen', @@ -9555,6 +9564,11 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, bulkActions: { editMultiple: 'Meerdere bewerken', editMultipleTitle: 'Meerdere uitgaven bewerken', + editFinalizedExpensesTitle: 'Afgeronde uitgaven bewerken?', + editFinalizedExpensesConfirmation: ({count, total}: {count: number; total: number}) => ({ + one: `1 van de ${total} uitgaven die je hebt geselecteerd staat op een goedgekeurd of betaald rapport. Je staat op het punt een afgeronde uitgave te bewerken. Weet je het zeker?`, + other: `${count} van de ${total} uitgaven die je hebt geselecteerd staan op goedgekeurde of betaalde rapporten. Je staat op het punt afgeronde uitgaven te bewerken. Weet je het zeker?`, + }), editMultipleDescription: 'Wijzigingen worden toegepast op alle geselecteerde uitgaven en overschrijven eerder ingestelde waarden.', approve: 'Goedkeuren', pay: 'Betalen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index d3cf4c25e63c..77d4ac489fb4 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -626,10 +626,6 @@ const translations: TranslationDeepObject = { commentExceededMaxLength: (formattedMaxLength: string) => `Maksymalna długość komentarza to ${formattedMaxLength} znaków.`, taskTitleExceededMaxLength: (formattedMaxLength: string) => `Maksymalna długość tytułu zadania to ${formattedMaxLength} znaków.`, }, - baseUpdateAppModal: { - updateApp: 'Zaktualizuj aplikację', - updatePrompt: 'Nowa wersja tej aplikacji jest dostępna.\nZaktualizuj ją teraz lub uruchom ponownie później, aby pobrać najnowsze zmiany.', - }, deeplinkWrapper: { launching: 'Uruchamianie Expensify', expired: 'Twoja sesja wygasła.', @@ -1098,6 +1094,12 @@ const translations: TranslationDeepObject = { many: `Przejrzyj ${count} wydatków`, other: `Przejrzyj ${count} wydatku`, }), + reviewDomainAdminRequests: ({count}: {count: number}) => ({ + one: 'Przejrzyj 1 prośbę administratora domeny', + few: `Przejrzyj ${count} prośby administratora domeny`, + many: `Przejrzyj ${count} próśb administratora domeny`, + other: `Przejrzyj ${count} prośby administratora domeny`, + }), }, upcomingTravel: 'Nadchodząca podróż', upcomingTravelSection: { @@ -1184,6 +1186,11 @@ const translations: TranslationDeepObject = { inputPlaceholderMobile: 'Zapytaj Concierge o cokolwiek', }, }, + insightsPage: { + viewOnSpend: 'Zobacz w Wydatkach', + emptyState: {title: 'Nic do wyświetlenia', subtitle: 'Spróbuj zmienić kryteria powyżej'}, + noExpensesState: {title: 'Zobacz, na co idą twoje pieniądze', subtitle: 'Gdy będziesz mieć wydatki, zobaczysz trendy w wydawaniu, najważniejszych sprzedawców i więcej.'}, + }, allSettingsScreen: { subscription: 'Subskrypcja', domains: 'Domeny', @@ -2215,7 +2222,7 @@ const translations: TranslationDeepObject = { profileAvatar: 'Awatar profilu', customInstructions: 'Niestandardowe instrukcje', copilotIntoAccount: 'Copilot do konta', - viewUserHistory: 'Zobacz historię użytkownika', + viewMemberHistory: 'Zobacz historię członka', viewAgentHistory: 'Zobacz historię agenta', publicSection: { title: 'Public', @@ -3359,7 +3366,7 @@ ${amount} dla ${merchant} - ${date}`, invalidFormatEmailLogin: 'Wprowadzony adres e-mail jest nieprawidłowy. Popraw jego format i spróbuj ponownie.', agentSignInBlocked: 'Na konta agenta nie można logować się bezpośrednio. Żeby korzystać z agenta, zaloguj się na własne konto i uzyskaj do niego dostęp przez Copilota.', }, - cannotGetAccountDetails: 'Nie można pobrać szczegółów konta. Spróbuj zalogować się ponownie.', + cannotGetAccountDetails: 'Nie udało się pobrać szczegółów konta. Poczekaj kilka minut i spróbuj ponownie.', loginForm: 'Formularz logowania', notYou: (user: string) => `To nie ${user}?`, }, @@ -6617,6 +6624,7 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy expensifyCardBannerLearnMoreButton: 'Dowiedz się więcej', statementCloseDateTitle: 'Data zamknięcia zestawienia', statementCloseDateDescription: 'Daj nam znać, kiedy kończy się okres rozliczeniowy Twojej karty, a utworzymy w Expensify pasujące zestawienie.', + exportAccount: 'Eksportuj konto', }, workflows: { title: 'Przepływy pracy', @@ -6871,6 +6879,7 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy other: (count: number) => `${count} tagi`, }), showTagGLCodes: 'Pokaż kody GL przy wybieraniu tagu', + showTagGLCodesSubtitle: 'Jeśli kod GL nie jest wyświetlany, nie jest dostępny dla tego taga w twoim systemie księgowym.', }, taxes: { subtitle: 'Dodaj nazwy podatków, stawki i ustaw domyślne.', @@ -8727,6 +8736,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, subsidiarySelectDescription: 'Wybierz spółkę zależną w Campfire, z której chcesz zaimportować dane.', noSubsidiariesFound: 'Nie znaleziono żadnych spółek zależnych', noSubsidiariesFoundDescription: 'Dodaj proszę jednostkę w Campfire i zsynchronizuj połączenie ponownie', + noVendorsFound: 'Nie znaleziono dostawców', + noVendorsFoundDescription: 'Dodaj proszę dostawców w Campfire i zsynchronizuj połączenie ponownie', importDescription: 'Wybierz, które konfiguracje kodowania zaimportować z Campfire.', accountTypesDescription: 'Twoje konta Campfire zostaną zaimportowane jako kategorie.', enableNewAccountsTitle: 'Włącz nowo zaimportowane konta', @@ -9571,6 +9582,13 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, bulkActions: { editMultiple: 'Edytuj wiele', editMultipleTitle: 'Edytuj wiele wydatków', + editFinalizedExpensesTitle: 'Edytować sfinalizowane wydatki?', + editFinalizedExpensesConfirmation: ({count, total}: {count: number; total: number}) => ({ + one: `1 z ${total} wybranych wydatków znajduje się w zatwierdzonym lub opłaconym raporcie. Zamierzasz edytować sfinalizowany wydatek. Czy na pewno?`, + few: `${count} z ${total} wybranych wydatków znajdują się w zatwierdzonych lub opłaconych raportach. Zamierzasz edytować sfinalizowane wydatki. Czy na pewno?`, + many: `${count} z ${total} wybranych wydatków znajduje się w zatwierdzonych lub opłaconych raportach. Zamierzasz edytować sfinalizowane wydatki. Czy na pewno?`, + other: `${count} z ${total} wybranych wydatków znajduje się w zatwierdzonych lub opłaconych raportach. Zamierzasz edytować sfinalizowane wydatki. Czy na pewno?`, + }), editMultipleDescription: 'Zmiany zostaną ustawione dla wszystkich wybranych wydatków i nadpiszą wszystkie wcześniej ustawione wartości.', approve: 'Zatwierdź', pay: 'Zapłać', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 2d1d9852dd58..0ea2dab0cb5f 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -622,10 +622,6 @@ const translations: TranslationDeepObject = { commentExceededMaxLength: (formattedMaxLength: string) => `O tamanho máximo do comentário é de ${formattedMaxLength} caracteres.`, taskTitleExceededMaxLength: (formattedMaxLength: string) => `O tamanho máximo do título da tarefa é de ${formattedMaxLength} caracteres.`, }, - baseUpdateAppModal: { - updateApp: 'Atualizar app', - updatePrompt: 'Uma nova versão deste app está disponível. \nAtualize agora ou reinicie o app mais tarde para baixar as alterações mais recentes.', - }, deeplinkWrapper: { launching: 'Iniciando Expensify', expired: 'Sua sessão expirou.', @@ -1081,6 +1077,10 @@ const translations: TranslationDeepObject = { one: `Revisar ${count} despesa`, other: `Revisar ${count} despesas`, }), + reviewDomainAdminRequests: ({count}: {count: number}) => ({ + one: `Revisar ${count} solicitação de administrador de domínio`, + other: `Revisar ${count} solicitações de administrador de domínio`, + }), }, upcomingTravel: 'Próximas viagens', upcomingTravelSection: { @@ -1165,6 +1165,11 @@ const translations: TranslationDeepObject = { inputPlaceholderMobile: 'Pergunte qualquer coisa ao Concierge', }, }, + insightsPage: { + viewOnSpend: 'Ver em Gastos', + emptyState: {title: 'Nada para mostrar', subtitle: 'Tente ajustar seus critérios acima'}, + noExpensesState: {title: 'Veja para onde vai o seu dinheiro', subtitle: 'Quando você tiver despesas, vai encontrar tendências de gastos, principais estabelecimentos e muito mais.'}, + }, allSettingsScreen: { subscription: 'Assinatura', domains: 'Domínios', @@ -2175,7 +2180,7 @@ const translations: TranslationDeepObject = { profileAvatar: 'Avatar do perfil', customInstructions: 'Instruções personalizadas', copilotIntoAccount: 'Copilot na conta', - viewUserHistory: 'Ver histórico do usuário', + viewMemberHistory: 'Ver histórico do membro', viewAgentHistory: 'Ver histórico do agente', publicSection: { title: 'Público', @@ -3320,7 +3325,7 @@ ${amount} para ${merchant} - ${date}`, invalidFormatEmailLogin: 'O e-mail inserido é inválido. Corrija o formato e tente novamente.', agentSignInBlocked: 'Contas de agente não podem ser acessadas diretamente. Para usar um agente, entre com a sua própria conta e acesse-o via Copilot.', }, - cannotGetAccountDetails: 'Não foi possível recuperar os detalhes da conta. Tente entrar novamente.', + cannotGetAccountDetails: 'Não foi possível obter os detalhes da conta. Aguarde alguns minutos e tente novamente.', loginForm: 'Formulário de login', notYou: (user: string) => `Não é ${user}?`, }, @@ -6596,6 +6601,7 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS expensifyCardBannerLearnMoreButton: 'Saiba mais', statementCloseDateTitle: 'Data de fechamento do extrato', statementCloseDateDescription: 'Informe quando o seu fechamento da fatura do cartão ocorrer e criaremos uma fatura correspondente no Expensify.', + exportAccount: 'Exportar conta', }, workflows: { title: 'Fluxos de trabalho', @@ -6850,6 +6856,7 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS other: (count: number) => `${count} Tags`, }), showTagGLCodes: 'Mostrar códigos GL ao selecionar uma tag', + showTagGLCodesSubtitle: 'Se um código GL não for exibido, ele não está disponível para essa tag no seu sistema contábil.', }, taxes: { subtitle: 'Adicione nomes de impostos, taxas e defina padrões.', @@ -8712,6 +8719,8 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, subsidiarySelectDescription: 'Escolha a subsidiária no Campfire da qual você gostaria de importar dados.', noSubsidiariesFound: 'Nenhuma subsidiária encontrada', noSubsidiariesFoundDescription: 'Adicione uma entidade no Campfire e sincronize a conexão novamente', + noVendorsFound: 'Nenhum fornecedor encontrado', + noVendorsFoundDescription: 'Adicione fornecedores no Campfire e sincronize a conexão novamente', importDescription: 'Escolha quais configurações de codificação importar do Campfire.', accountTypesDescription: 'Suas contas do Campfire serão importadas como categorias.', enableNewAccountsTitle: 'Ativar contas recém-importadas', @@ -9553,6 +9562,11 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, bulkActions: { editMultiple: 'Editar múltiplos', editMultipleTitle: 'Editar múltiplas despesas', + editFinalizedExpensesTitle: 'Editar despesas finalizadas?', + editFinalizedExpensesConfirmation: ({count, total}: {count: number; total: number}) => ({ + one: `1 das ${total} despesas que você selecionou está em um relatório aprovado ou pago. Você está prestes a editar uma despesa finalizada. Tem certeza?`, + other: `${count} das ${total} despesas que você selecionou estão em relatórios aprovados ou pagos. Você está prestes a editar despesas finalizadas. Tem certeza?`, + }), editMultipleDescription: 'As alterações serão aplicadas a todas as despesas selecionadas e substituirão quaisquer valores definidos anteriormente.', approve: 'Aprovar', pay: 'Pagar', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 53cef71131a2..717f28e6b9c5 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -620,10 +620,6 @@ const translations: TranslationDeepObject = { commentExceededMaxLength: (formattedMaxLength: string) => `评论的最大长度为 ${formattedMaxLength} 个字符。`, taskTitleExceededMaxLength: (formattedMaxLength: string) => `任务标题最长为 ${formattedMaxLength} 个字符。`, }, - baseUpdateAppModal: { - updateApp: '更新应用', - updatePrompt: '此应用有新版本可用。\n现在更新,或稍后重启应用以下载最新更改。', - }, deeplinkWrapper: { launching: '正在启动 Expensify', expired: '您的会话已过期。', @@ -1044,6 +1040,10 @@ const translations: TranslationDeepObject = { one: `审核 ${count} 笔费用`, other: `审核 ${count} 笔费用`, }), + reviewDomainAdminRequests: ({count}: {count: number}) => ({ + one: `审核 ${count} 个域管理员请求`, + other: `审核 ${count} 个域管理员请求`, + }), }, upcomingTravel: '即将出行', upcomingTravelSection: { @@ -1119,6 +1119,11 @@ const translations: TranslationDeepObject = { inputPlaceholderMobile: '向 Concierge 提问任何问题', }, }, + insightsPage: { + viewOnSpend: '在支出中查看', + emptyState: {title: '没有可显示的内容', subtitle: '请尝试调整上面的条件'}, + noExpensesState: {title: '查看你的资金流向', subtitle: '添加报销后,您就能查看消费趋势、主要商家等更多信息。'}, + }, allSettingsScreen: { subscription: '订阅', domains: '域名', @@ -2106,7 +2111,7 @@ const translations: TranslationDeepObject = { profileAvatar: '个人头像', customInstructions: '自定义指令', copilotIntoAccount: 'Copilot 到账户', - viewUserHistory: '查看用户历史记录', + viewMemberHistory: '查看成员历史记录', viewAgentHistory: '查看代理历史记录', publicSection: { title: '公开', @@ -3216,7 +3221,7 @@ ${amount},商户:${merchant} - 日期:${date}`, invalidFormatEmailLogin: '输入的邮箱无效。请修正格式后重试。', agentSignInBlocked: '代理帐户无法直接登录。要使用代理,请先登录您自己的帐户,然后通过 Copilot 访问该代理。', }, - cannotGetAccountDetails: '无法获取账户详情。请尝试重新登录。', + cannotGetAccountDetails: '无法获取账户详情。请稍等几分钟后重试。', loginForm: '登录表单', notYou: (user: string) => `不是 ${user} 吗?`, }, @@ -6360,6 +6365,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM expensifyCardBannerLearnMoreButton: '了解详情', statementCloseDateTitle: '账单结算日', statementCloseDateDescription: '请告诉我们您的信用卡账单结算日期,我们会在 Expensify 中创建一份相应的对账单。', + exportAccount: '导出账户', }, workflows: { title: '工作流程', @@ -6607,6 +6613,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM other: (count: number) => `${count} 个标签`, }), showTagGLCodes: '在选择标签时显示总账科目代码', + showTagGLCodesSubtitle: '如果某个总账科目代码未显示,则说明此标签在您的会计系统中不可用。', }, taxes: { subtitle: '添加税种名称、税率,并设置默认值。', @@ -8395,6 +8402,8 @@ ${reportName}`, subsidiarySelectDescription: '选择要从中导入数据的 Campfire 子公司。', noSubsidiariesFound: '未找到子公司', noSubsidiariesFoundDescription: '请在 Campfire 中添加一个实体,然后再次同步连接', + noVendorsFound: '未找到供应商', + noVendorsFoundDescription: '请在 Campfire 中添加供应商,然后再次同步连接', importDescription: '选择要从 Campfire 导入的编码配置。', accountTypesDescription: '您的 Campfire 账户将会作为类别导入。', enableNewAccountsTitle: '启用新导入的账户', @@ -9207,6 +9216,11 @@ ${reportName}`, bulkActions: { editMultiple: '批量编辑', editMultipleTitle: '编辑多个费用', + editFinalizedExpensesTitle: '编辑已完成的费用?', + editFinalizedExpensesConfirmation: ({count, total}: {count: number; total: number}) => ({ + one: `您选择的 ${total} 笔费用中有 1 笔位于已批准或已支付的报表中。您即将编辑已完成的费用。您确定吗?`, + other: `您选择的 ${total} 笔费用中有 ${count} 笔位于已批准或已支付的报表中。您即将编辑已完成的费用。您确定吗?`, + }), editMultipleDescription: '更改将应用于所有选中的报销,并覆盖任何先前设置的值。', approve: '批准', pay: '支付', diff --git a/src/libs/API/parameters/ExportReportsToPDFParams.ts b/src/libs/API/parameters/ExportReportsToPDFParams.ts index b591329a41f5..cdc3e959fd77 100644 --- a/src/libs/API/parameters/ExportReportsToPDFParams.ts +++ b/src/libs/API/parameters/ExportReportsToPDFParams.ts @@ -1,6 +1,7 @@ type ExportReportsToPDFParams = { reportIDs: string; exportID: string; + jsonQuery?: string; }; export default ExportReportsToPDFParams; diff --git a/src/libs/API/parameters/OpenSearchTagFiltersPageParams.ts b/src/libs/API/parameters/OpenSearchTagFiltersPageParams.ts new file mode 100644 index 000000000000..19eefb7d8196 --- /dev/null +++ b/src/libs/API/parameters/OpenSearchTagFiltersPageParams.ts @@ -0,0 +1,18 @@ +type OpenSearchTagFiltersPageParams = { + /** Search query to filter tags */ + searchQuery?: string; + + /** Cursor for pagination */ + cursor?: string; + + /** Maximum number of tags to return per page */ + limit?: number; + + /** Comma-separated list of policy IDs to scope the tag search to specific workspaces */ + policyIDs?: string; + + /** Whether the request can be aborted when superseded by a newer search */ + canCancel?: boolean; +}; + +export default OpenSearchTagFiltersPageParams; diff --git a/src/libs/API/parameters/OpenSearchTagFiltersPageResponse.ts b/src/libs/API/parameters/OpenSearchTagFiltersPageResponse.ts new file mode 100644 index 000000000000..187811aebbae --- /dev/null +++ b/src/libs/API/parameters/OpenSearchTagFiltersPageResponse.ts @@ -0,0 +1,14 @@ +import type {SearchTagFilterItem} from '@src/types/onyx'; + +type OpenSearchTagFiltersPageResponse = { + /** Whether more pages of tag filter results are available */ + hasMore?: boolean; + + /** Pagination cursor to pass for fetching the next page of tag filters */ + nextCursor?: string; + + /** Slice of tag filter results for this page */ + tags?: SearchTagFilterItem[]; +}; + +export default OpenSearchTagFiltersPageResponse; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index fd90d5a6c1ff..7e7072ea393d 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -603,6 +603,8 @@ export type {default as UpdateSamlRequiredParams} from './UpdateSamlRequiredPara export type {default as AddMemberToDomainParams} from './AddMemberToDomainParams'; export type {default as GetInsightsParams} from './GetInsightsParams'; export type {default as OpenSearchPageParams} from './OpenSearchPageParams'; +export type {default as OpenSearchTagFiltersPageParams} from './OpenSearchTagFiltersPageParams'; +export type {default as OpenSearchTagFiltersPageResponse} from './OpenSearchTagFiltersPageResponse'; export type {default as OpenBulkChangeApproverPageParams} from './OpenBulkChangeApproverPageParams'; export type {default as SetPolicyRequireCompanyCardsEnabledParams} from './SetPolicyRequireCompanyCardsEnabled'; export type {default as SetTechnicalContactEmailParams} from './SetTechnicalContactEmailParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index e3ef4a8380bf..fc128967c43d 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1746,6 +1746,7 @@ const SIDE_EFFECT_REQUEST_COMMANDS = { LINK_CARD_FEED_TO_POLICY: 'LinkCardFeedToPolicy', REVEAL_CARD_PIN: 'RevealCardPIN', CHANGE_CARD_PIN: 'ChangeCardPIN', + OPEN_SEARCH_TAG_FILTERS_PAGE: 'OpenSearchTagFiltersPage', } as const; type SideEffectRequestCommand = ValueOf; @@ -1794,6 +1795,7 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.LINK_CARD_FEED_TO_POLICY]: Parameters.LinkCardToPolicyParams; [SIDE_EFFECT_REQUEST_COMMANDS.REVEAL_CARD_PIN]: Parameters.RevealCardPINParams; [SIDE_EFFECT_REQUEST_COMMANDS.CHANGE_CARD_PIN]: Parameters.ChangeCardPINParams; + [SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE]: Parameters.OpenSearchTagFiltersPageParams; }; type ApiRequestCommandParameters = WriteCommandParameters & ReadCommandParameters & SideEffectRequestCommandParameters; diff --git a/src/libs/Accessibility/fireFocusEvent/index.native.ts b/src/libs/Accessibility/fireFocusEvent/index.native.ts index 5886b6d4a4c7..bf7ab668b6a6 100644 --- a/src/libs/Accessibility/fireFocusEvent/index.native.ts +++ b/src/libs/Accessibility/fireFocusEvent/index.native.ts @@ -1,5 +1,6 @@ import Log from '@libs/Log'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports -- type-only; mirrors PressableRef's cross-platform host-instance union. import type {Text as RNText, View} from 'react-native'; @@ -9,7 +10,7 @@ import {AccessibilityInfo} from 'react-native'; * Returns `false` on Android when `sendAccessibilityEvent` throws on a stale native handle so the caller can fall * through to the retry budget. iOS silently no-ops on a stale handle and returns `true` (no way to distinguish). */ -function fireFocusEvent(view: View | RNText): boolean { +function fireFocusEvent(view: ComponentRef | ComponentRef): boolean { try { AccessibilityInfo.sendAccessibilityEvent(view, 'focus'); return true; diff --git a/src/libs/Accessibility/fireFocusEvent/index.ts b/src/libs/Accessibility/fireFocusEvent/index.ts index ceb77594ed88..54fd9e58511b 100644 --- a/src/libs/Accessibility/fireFocusEvent/index.ts +++ b/src/libs/Accessibility/fireFocusEvent/index.ts @@ -1,8 +1,9 @@ +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports -- type-only; mirrors PressableRef's cross-platform host-instance union. import type {Text as RNText, View} from 'react-native'; // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Web stub preserves the native signature so cross-platform callers compile. -function fireFocusEvent(_view: View | RNText): boolean { +function fireFocusEvent(_view: ComponentRef | ComponentRef): boolean { return true; } diff --git a/src/libs/Accessibility/scheduleRefocus/index.android.ts b/src/libs/Accessibility/scheduleRefocus/index.android.ts index 2040a91a3c90..a397e45646e8 100644 --- a/src/libs/Accessibility/scheduleRefocus/index.android.ts +++ b/src/libs/Accessibility/scheduleRefocus/index.android.ts @@ -1,6 +1,6 @@ import fireFocusEvent from '@libs/Accessibility/fireFocusEvent'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports -- type-only; mirrors PressableRef's cross-platform host-instance union. import type {Text as RNText, View} from 'react-native'; @@ -10,7 +10,7 @@ import type {Text as RNText, View} from 'react-native'; */ const REFOCUS_TIMEOUT_MS = 300; -function scheduleRefocus(ref: RefObject): {cancel: () => void} { +function scheduleRefocus(ref: RefObject | ComponentRef | null>): {cancel: () => void} { const id = requestIdleCallback( () => { const view = ref.current; diff --git a/src/libs/Accessibility/scheduleRefocus/index.ts b/src/libs/Accessibility/scheduleRefocus/index.ts index 52f7feb3c174..60e6085c96d6 100644 --- a/src/libs/Accessibility/scheduleRefocus/index.ts +++ b/src/libs/Accessibility/scheduleRefocus/index.ts @@ -1,9 +1,9 @@ -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports -- type-only; mirrors PressableRef's cross-platform host-instance union. import type {Text as RNText, View} from 'react-native'; // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Web/iOS stub preserves the Android signature so cross-platform callers compile. -function scheduleRefocus(_ref: RefObject): {cancel: () => void} { +function scheduleRefocus(_ref: RefObject | ComponentRef | null>): {cancel: () => void} { return {cancel: () => {}}; } diff --git a/src/libs/ComponentUtils/index.ts b/src/libs/ComponentUtils/index.ts index 01d0dc03b497..ba4a031a5560 100644 --- a/src/libs/ComponentUtils/index.ts +++ b/src/libs/ComponentUtils/index.ts @@ -1,6 +1,6 @@ import ObjectUtils from '@src/types/utils/ObjectUtils'; -import type {NativeMethods} from 'react-native'; +import type {HostInstance} from 'react-native'; import type {AnimatedRef} from 'react-native-reanimated'; import type AccessibilityRoleForm from './types'; @@ -10,7 +10,7 @@ import type AccessibilityRoleForm from './types'; */ const ACCESSIBILITY_ROLE_FORM: AccessibilityRoleForm = 'form'; -function forceClearInput(animatedInputRef: AnimatedRef) { +function forceClearInput(animatedInputRef: AnimatedRef) { 'worklet'; const input = animatedInputRef.current; diff --git a/src/libs/ComposerFocusManager.ts b/src/libs/ComposerFocusManager.ts index b0b4a63ac455..2433bfa6a932 100644 --- a/src/libs/ComposerFocusManager.ts +++ b/src/libs/ComposerFocusManager.ts @@ -1,5 +1,6 @@ import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; import type {ValueOf} from 'type-fest'; import {TextInput} from 'react-native'; @@ -8,7 +9,7 @@ import isWindowReadyToFocus from './isWindowReadyToFocus'; type ModalId = number | undefined; -type InputElement = (TextInput & HTMLElement) | null; +type InputElement = (ComponentRef & HTMLElement) | null; type RestoreFocusType = ValueOf | undefined; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 48485225e4f1..ffdf30e3acbe 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -1235,14 +1235,22 @@ function getFormattedQuarterForSearch(year: number, quarter: number, dateFnsLoca // This ensures the dates are created in the current/local timezone, not UTC const quarterStart = set(new Date(), {year, month: startMonth - 1, date: 1, hours: 0, minutes: 0, seconds: 0, milliseconds: 0}); const quarterEnd = set(new Date(), {year, month: endMonth, date: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 0}); - return `Q${quarter} ${year} (${format(quarterStart, 'MMM d', {locale: dateFnsLocale})} - ${format(quarterEnd, 'MMM d', {locale: dateFnsLocale})})`; + // `QQQ` is the locale's own abbreviated quarter, such as `Q1` in English, `T1` in Spanish, `K1` in Dutch, + // `I kw.` in Polish and `第一季` in Chinese. `Intl.DateTimeFormat` has no quarter option at all, so date-fns is + // the only way to localize this. The quarter still comes before the year by hand, because neither library has + // a canned pattern pairing the two. + return `${format(quarterStart, 'QQQ yyyy', {locale: dateFnsLocale})} (${format(quarterStart, 'MMM d', {locale: dateFnsLocale})} - ${format(quarterEnd, 'MMM d', {locale: dateFnsLocale})})`; } /** * Returns a compact quarter label, e.g. "Q3 ’25". */ -function getShortFormattedQuarterForSearch(year: number, quarter: number): string { - return `Q${quarter} ${format(new Date(year, 0, 1), '’yy')}`; +function getShortFormattedQuarterForSearch(year: number, quarter: number, dateFnsLocale: DateFnsLocale | undefined): string { + // Same reasoning as `getFormattedQuarterForSearch`. The quarter label has to come from `QQQ` rather than a + // hand-built `Q${quarter}`, because every locale names quarters differently and `Intl.DateTimeFormat` has no + // quarter option to fall back on. + const quarterStart = set(new Date(), {year, month: (quarter - 1) * 3, date: 1, hours: 0, minutes: 0, seconds: 0, milliseconds: 0}); + return format(quarterStart, `QQQ ’yy`, {locale: dateFnsLocale}); } function getNextNthOfMonth(nth: number) { diff --git a/src/libs/DomainUtils.ts b/src/libs/DomainUtils.ts index 292e1426f52f..31883529eba3 100644 --- a/src/libs/DomainUtils.ts +++ b/src/libs/DomainUtils.ts @@ -1,11 +1,12 @@ import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {Domain} from '@src/types/onyx'; import type DomainErrors from '@src/types/onyx/DomainErrors'; import type {DomainMemberErrors, DomainSecurityGroupErrors} from '@src/types/onyx/DomainErrors'; import type DomainPendingAction from '@src/types/onyx/DomainPendingActions'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {adminshipRequesterPendingActionSelector, isAdminSelector, pendingAdminRequesterAccountIDsSelector} from '@selectors/Domain'; @@ -108,6 +109,13 @@ function getDomainBrickRoadIndicator(hasErrors: boolean, hasPendingAdminRequests } } +/** Returns the domain error entries for domains that currently have errors. */ +function getDomainsWithErrors(allDomainErrors: OnyxCollection, allDomains: OnyxCollection): Array<[string, OnyxEntry]> { + return Object.entries(allDomainErrors ?? {}).filter(([key, domainErrors]) => + hasDomainErrors(domainErrors, allDomains?.[key.replace(ONYXKEYS.COLLECTION.DOMAIN_ERRORS, ONYXKEYS.COLLECTION.DOMAIN)]), + ); +} + /** * Checks if domain has any admin settings errors (technical contact email or billing card errors). */ @@ -181,4 +189,5 @@ export { hasDomainGroupDetailsErrors, getMemberCustomRowProps, getDomainBrickRoadIndicator, + getDomainsWithErrors, }; diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 5af7ec55c397..8c2390317fea 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -333,7 +333,6 @@ const safeOnyxKeys = new Set([ ONYXKEYS.RAM_ONLY_IS_SIDEBAR_LOADED, ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, ONYXKEYS.RAM_ONLY_MOBILE_SELECTION_MODE, - ONYXKEYS.RAM_ONLY_UPDATE_AVAILABLE, ONYXKEYS.RAM_ONLY_UPDATE_REQUIRED, ONYXKEYS.REIMBURSEMENT_ACCOUNT_OPTION_PRESSED, ONYXKEYS.REIMBURSEMENT_ACCOUNT_WORKSPACE_ID, @@ -491,6 +490,8 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER, ONYXKEYS.RAM_ONLY_HAS_FRESH_WALLET_DATA, ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_CATEGORY_DATA, + ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION, + ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS, ONYXKEYS.RAM_ONLY_WALLET_ONFIDO, ONYXKEYS.RECENTLY_USED_CURRENCIES, ONYXKEYS.RECENTLY_USED_REPORT_FIELDS, diff --git a/src/libs/HttpUtils.ts b/src/libs/HttpUtils.ts index 9877f4dd861d..d4ad52b448b9 100644 --- a/src/libs/HttpUtils.ts +++ b/src/libs/HttpUtils.ts @@ -32,6 +32,7 @@ const ABORT_COMMANDS = { All: 'All', [READ_COMMANDS.SEARCH_FOR_REPORTS]: READ_COMMANDS.SEARCH_FOR_REPORTS, [READ_COMMANDS.SEARCH_FOR_USERS]: READ_COMMANDS.SEARCH_FOR_USERS, + [SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE]: SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE, } as const; type AbortCommand = keyof typeof ABORT_COMMANDS; @@ -53,6 +54,7 @@ const abortControllerMap = new Map(); abortControllerMap.set(ABORT_COMMANDS.All, new AbortController()); abortControllerMap.set(ABORT_COMMANDS.SearchForReports, new AbortController()); abortControllerMap.set(ABORT_COMMANDS.SearchForUsers, new AbortController()); +abortControllerMap.set(ABORT_COMMANDS.OpenSearchTagFiltersPage, new AbortController()); /** * The API commands that require the skew calculation diff --git a/src/libs/InputUtils/types.ts b/src/libs/InputUtils/types.ts index c0be24244475..684d2790efef 100644 --- a/src/libs/InputUtils/types.ts +++ b/src/libs/InputUtils/types.ts @@ -1,6 +1,7 @@ +import type {ComponentRef} from 'react'; import type {TextInput} from 'react-native'; -type ScrollInput = (input: HTMLInputElement | TextInput) => void; -type MoveSelectionToEnd = (input: HTMLInputElement | TextInput) => void; +type ScrollInput = (input: HTMLInputElement | ComponentRef) => void; +type MoveSelectionToEnd = (input: HTMLInputElement | ComponentRef) => void; export type {ScrollInput, MoveSelectionToEnd}; diff --git a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx index ba74ee04a5e3..3028242dbba8 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx @@ -32,6 +32,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import type {ComponentRef} from 'react'; import type {ValueOf} from 'type-fest'; import {CardStyleInterpolators} from '@react-navigation/stack'; @@ -49,7 +50,7 @@ let signUpEventPublishedForAccountID: number | undefined; function OnboardingModalNavigator() { const styles = useThemeStyles(); const {onboardingIsMediumOrLargerScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); - const outerViewRef = React.useRef(null); + const outerViewRef = React.useRef>(null); const [account, accountMetadata] = useOnyx(ONYXKEYS.ACCOUNT); const isOnPrivateDomainAndHasAccessiblePolicies = !account?.isFromPublicDomain && account?.hasAccessibleDomainPolicies; diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx index ca4ae4fe0870..7f8b76f1e38d 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx @@ -27,8 +27,10 @@ type BaseOverlayProps = { positionRightValue?: number | Animated.Value | Animated.AnimatedAddition; }; +const defaultPositionLeftValue = -2 * variables.sideBarWidth; + // The default value of positionLeftValue is equal to -2 * variables.sideBarWidth, because we need to stretch the overlay to cover the sidebar and the translate animation distance. -function BaseOverlay({onPress, progress, positionLeftValue = -2 * variables.sideBarWidth, positionRightValue = 0}: BaseOverlayProps) { +function BaseOverlay({onPress, progress, positionLeftValue = defaultPositionLeftValue, positionRightValue = 0}: BaseOverlayProps) { const styles = useThemeStyles(); const {current} = useCardAnimation(); const {translate} = useLocalize(); diff --git a/src/libs/Navigation/AppNavigator/Navigators/ReportsSplitNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/ReportsSplitNavigator.tsx index a75aa63ab065..c3a67443d44e 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/ReportsSplitNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/ReportsSplitNavigator.tsx @@ -1,6 +1,5 @@ +import useFindLastAccessedReport from '@hooks/useFindLastAccessedReport'; import {IsInPreloadedTabContext} from '@hooks/useIsInPreloadedTab'; -import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import createSplitNavigator from '@libs/Navigation/AppNavigator/createSplitNavigator'; import FreezeWrapper from '@libs/Navigation/AppNavigator/FreezeWrapper'; @@ -10,13 +9,11 @@ import shouldOpenOnAdminRoom from '@libs/Navigation/helpers/shouldOpenOnAdminRoo import {isTabRoutePreloaded} from '@libs/Navigation/helpers/tabNavigatorUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {NavigationStateRoute, ReportsSplitNavigatorParamList, TabNavigatorParamList} from '@libs/Navigation/types'; -import * as ReportUtils from '@libs/ReportUtils'; import type {ReportScreenProps} from '@pages/inbox/ReportScreen'; import CONST from '@src/CONST'; import type NAVIGATORS from '@src/NAVIGATORS'; -import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type ReactComponentModule from '@src/types/utils/ReactComponentModule'; @@ -33,25 +30,29 @@ const Split = createSplitNavigator(); * There can be multiple report screens in the stack with different report IDs. */ function ReportsSplitNavigator({navigation, route}: PlatformStackScreenProps) { - const {isBetaEnabled} = usePermissions(); const splitNavigatorScreenOptions = useSplitNavigatorScreenOptions(SCREENS.INBOX); - const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const isOpenOnAdminRoom = shouldOpenOnAdminRoom(); const shouldClearInitialReportActionsDefer = !!route.params && 'shouldDeferInitialReportActions' in route.params && route.params.shouldDeferInitialReportActions === true; const [shouldDeferInitialReportActions] = useState(() => shouldClearInitialReportActionsDefer); const isInPreloadedTab = useNavigationState((tabState) => isTabRoutePreloaded(tabState, route.key)); + const routeReportID = route.params && 'screen' in route.params && route.params.screen === SCREENS.REPORT ? route.params.params?.reportID : undefined; + const currentURL = getCurrentUrl(); + const isTransitioning = currentURL.includes(ROUTES.TRANSITION_BETWEEN_APPS); + const reportIdFromPath = currentURL ? new URL(currentURL).pathname.match(CONST.REGEX.REPORT_ID_FROM_PATH)?.at(1) : undefined; + const shouldResolveReportID = !routeReportID && !reportIdFromPath && !isTransitioning; + + const {lastAccessedReportID} = useFindLastAccessedReport({ + enabled: shouldResolveReportID, + }); + const [initialReportID] = useState(() => { // Deep links and REPORT_WITH_ID navigation pass the reportID in nested params, - // which lets us skip the O(n) findLastAccessedReport scan over all reports. - if (route.params && 'screen' in route.params && route.params.screen === SCREENS.REPORT && route.params.params?.reportID) { - return route.params.params.reportID; + // which lets us skip the scan over all reports. + if (routeReportID) { + return routeReportID; } - const currentURL = getCurrentUrl(); - const isTransitioning = currentURL.includes(ROUTES.TRANSITION_BETWEEN_APPS); - - const reportIdFromPath = currentURL ? new URL(currentURL).pathname.match(CONST.REGEX.REPORT_ID_FROM_PATH)?.at(1) : undefined; if (reportIdFromPath) { return reportIdFromPath; } @@ -62,10 +63,8 @@ function ReportsSplitNavigator({navigation, route}: PlatformStackScreenProps { diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index c10ea1ea7d30..60d31dda0c53 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -44,6 +44,7 @@ import SCREENS from '@src/SCREENS'; import type ReactComponentModule from '@src/types/utils/ReactComponentModule'; import type {NavigatorScreenParams} from '@react-navigation/native'; +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import {useFocusEffect} from '@react-navigation/native'; @@ -126,7 +127,7 @@ type RightModalDialogFrameProps = { style: React.ComponentProps['style']; /** Callback ref for the container node so the provider can observe node identity changes. */ - onContainerRef: (node: View | null) => void; + onContainerRef: (node: ComponentRef | null) => void; /** RHP stack navigator rendered inside the dialog frame. */ children: React.ReactNode; @@ -162,8 +163,8 @@ function RightModalDialogFrame({hasDialogSemantics, style, onContainerRef, child function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); - const [containerNode, setContainerNode] = useState(null); - const [setContainerNodeFromRef] = useState(() => (node: View | null) => { + const [containerNode, setContainerNode] = useState | null>(null); + const [setContainerNodeFromRef] = useState(() => (node: ComponentRef | null) => { setContainerNode(node); }); const isExecutingRef = useRef(false); diff --git a/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx index 48b064b32452..cb57990e0c2a 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx @@ -17,7 +17,7 @@ import toggleTestToolsModal from '@userActions/TestTool'; import CONST from '@src/CONST'; import SCREENS from '@src/SCREENS'; -import type {MouseEvent} from 'react'; +import type {ComponentRef, MouseEvent} from 'react'; import React, {useCallback, useRef} from 'react'; import {View} from 'react-native'; @@ -29,7 +29,7 @@ const Stack = createPlatformStackNavigator(null); + const outerViewRef = useRef>(null); const isAuthenticated = useIsAuthenticated(); const handleOuterClick = useCallback(() => { diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 822adbbbdf0b..8cd9ac3da7b7 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -33,16 +33,7 @@ import {DeviceEventEmitter, Dimensions} from 'react-native'; import Onyx from 'react-native-onyx'; import type {LinkToOptions} from './helpers/linkTo/types'; -import type { - NavigationPartialRoute, - NavigationRef, - NavigationRoute, - NavigationStateRoute, - ReportsSplitNavigatorParamList, - RightModalNavigatorParamList, - RootNavigatorParamList, - State, -} from './types'; +import type {NavigationPartialRoute, NavigationRef, NavigationRoute, NavigationStateRoute, ReportsSplitNavigatorParamList, RightModalNavigatorParamList, State} from './types'; import {getPreInsertedOriginalTabRoute} from './AppNavigator/createRootStackNavigator/GetStateForActionHandlers'; import getInitialSplitNavigatorState from './AppNavigator/createSplitNavigator/getInitialSplitNavigatorState'; @@ -52,12 +43,13 @@ import getActiveTabName from './helpers/getActiveTabName'; import getFocusedReportParams from './helpers/getFocusedReportParams'; import getStateFromPath from './helpers/getStateFromPath'; import getTopmostReportParams from './helpers/getTopmostReportParams'; +import hasDifferentSplitScope from './helpers/hasDifferentSplitScope'; import {isFullScreenName, isOnboardingFlowName, isSplitNavigatorName} from './helpers/isNavigatorName'; import isReportOpenInRHP from './helpers/isReportOpenInRHP'; import isReportTopmostSplitNavigator from './helpers/isReportTopmostSplitNavigator'; import isSideModalNavigator from './helpers/isSideModalNavigator'; import linkTo from './helpers/linkTo'; -import getMinimalAction from './helpers/linkTo/getMinimalAction'; +import getMinimalAction, {getNestedAction, isNamedActionPayload} from './helpers/linkTo/getMinimalAction'; import {popAndRealignMfaMarker} from './helpers/mfaModalMarkerPreservation'; import { canNativeSwipeDismissRHP, @@ -373,11 +365,7 @@ function getRouteParamsToCompare(routeParams: Record * Private method used in goUp to determine whether a target route is present in the navigation state. */ function doesRouteMatchToMinimalActionPayload(route: NavigationStateRoute | NavigationPartialRoute, minimalAction: Writable, compareParams: boolean) { - if (!minimalAction.payload) { - return false; - } - - if (!('name' in minimalAction.payload)) { + if (!isNamedActionPayload(minimalAction.payload)) { return false; } @@ -387,6 +375,12 @@ function doesRouteMatchToMinimalActionPayload(route: NavigationStateRoute | Navi return false; } + // `routeParamsIgnore` drops `policyID` and `domainAccountID`, so without this every workspace's split would + // answer for every other. + if (hasDifferentSplitScope(route, minimalAction.payload)) { + return false; + } + if (!compareParams) { return true; } @@ -398,12 +392,21 @@ function doesRouteMatchToMinimalActionPayload(route: NavigationStateRoute | Navi return shallowCompare(routeParams, minimalActionParams); } +type RouteToPopTo = { + /** Index of the route addressed by the action in the target state, or -1 when it is not there */ + indexOfBackToRoute: number; + /** How many routes have to be popped to reach it from the focused one */ + distanceToPop: number; +}; + /** * @private - * Checks whether the given state is the root navigator state + * Locates the route the given action addresses within the target state. The distance is counted from the focused + * route, which is what `StackActions.pop` counts from and is not the last route in every navigator. */ -function isRootNavigatorState(state: State): state is State { - return state.key === navigationRef.current?.getRootState().key; +function findRouteToPopTo(targetState: State, minimalAction: Writable, compareParams: boolean): RouteToPopTo { + const indexOfBackToRoute = targetState.routes.findLastIndex((route) => doesRouteMatchToMinimalActionPayload(route, minimalAction, compareParams)); + return {indexOfBackToRoute, distanceToPop: (targetState.index ?? targetState.routes.length - 1) - indexOfBackToRoute}; } type GoBackOptions = { @@ -425,6 +428,67 @@ type GoBackOptions = { shouldSkipFocusRestore?: boolean; }; +/** + * @private + * Whether the located route can be popped to within `targetState`. When it cannot, the caller replaces instead, + * which keeps the visited pages that removing several root routes would throw away. + */ +function canPopToRoute(targetState: State, rootState: State, {indexOfBackToRoute, distanceToPop}: RouteToPopTo): boolean { + const isRouteInState = indexOfBackToRoute !== -1; + const isRootState = targetState.key === rootState.key; + const wouldLoseVisitedPages = isRootState && distanceToPop > 1; + return isRouteInState && !wouldLoseVisitedPages; +} + +/** + * @private + * Returns the pops that focus the navigator holding `backToRoute` at every level covering it, outermost first, or + * nothing when it is already focused all the way down. + * + * `getMinimalAction` descends through the *focused* route of each navigator, so it stops as soon as something else + * covers the one holding `backToRoute` - a modal, or another workspace's split - and `goUp` would act on that level + * instead. This walks the same path looking for the route that *matches*, popping every obstacle on the way. + */ +function getPopsToNavigatorWithBackToRoute(rootState: State, action: NavigationAction, compareParams: boolean): NavigationAction[] { + const pops: NavigationAction[] = []; + let state: State | undefined = rootState; + let currentAction: Writable = action; + + // Every level is read before any of these pops is dispatched, which stays valid because a pop only discards the + // routes above the one it focuses. + while (state) { + const routeToPopTo = findRouteToPopTo(state, currentAction, compareParams); + + // `goUp` replaces at a level it cannot pop to, and a pop here would discard the history that replace preserves. + if (!canPopToRoute(state, rootState, routeToPopTo)) { + return pops; + } + + // An unmounted navigator leaves a stale state with no key to target the dispatch at. + if (!state.key) { + return pops; + } + + if (routeToPopTo.distanceToPop > 0) { + // Only `StackRouter` handles POP. Switching tabs is the `jumpTo` case `goUp` owns. + if (state.type !== 'stack') { + return pops; + } + + pops.push({...StackActions.pop(routeToPopTo.distanceToPop), target: state.key}); + } + + // A negative distance lands here with nothing popped: the match sits after the focused route, so it is in + // another tab. Descending still trims that tab's own stacks, so the `jumpTo` that `goUp` ends on reaches the + // requested screen rather than whatever that tab was last left on. + const nestedState: State | undefined = state.routes.at(routeToPopTo.indexOfBackToRoute)?.state; + currentAction = nestedState ? getNestedAction(currentAction, nestedState) : currentAction; + state = nestedState; + } + + return pops; +} + const defaultGoBackOptions: Required> = { compareParams: true, waitForTransition: false, @@ -446,9 +510,9 @@ function goUp(backToRoute: Route, options?: GoBackOptions): boolean { return false; } + const navigationContainer = navigationRef.current; const compareParams = options?.compareParams ?? defaultGoBackOptions.compareParams; - const rootState = navigationRef.current.getRootState(); const stateFromPath = getStateFromPath(backToRoute); const action = getActionFromState(stateFromPath, linkingConfig.config); @@ -458,10 +522,9 @@ function goUp(backToRoute: Route, options?: GoBackOptions): boolean { return false; } - const {action: minimalAction, targetState} = getMinimalAction(action, rootState); - - if (minimalAction.type !== CONST.NAVIGATION.ACTION_TYPE.NAVIGATE || !targetState) { - Log.hmmm('[Navigation] Unable to go up. Minimal action type is wrong.'); + // Checked up front: resolving an action only narrows the navigator it targets, never its type. + if (action.type !== CONST.NAVIGATION.ACTION_TYPE.NAVIGATE) { + Log.hmmm('[Navigation] Unable to go up. Action type is wrong.'); return false; } @@ -473,12 +536,23 @@ function goUp(backToRoute: Route, options?: GoBackOptions): boolean { navigationRef.current?.dispatch(actionToDispatch); }; + // Once these pops are out, going back has happened, so nothing below may report a failure to go up. + const popsToNavigator = getPopsToNavigatorWithBackToRoute(navigationContainer.getRootState(), action, compareParams); + for (const popToNavigator of popsToNavigator) { + dispatch(popToNavigator); + } + const didPopToNavigator = popsToNavigator.length > 0; + + // Read again: dispatch updates the state ref synchronously, and this must resolve against what the pops left. + const rootState = navigationContainer.getRootState(); + const {action: minimalAction, targetState} = getMinimalAction(action, rootState); + // TabRouter does not handle POP or REPLACE (BaseRouter returns null). Switch tabs with jumpTo. - if (targetState.type === 'tab' && targetState?.key) { + if (targetState.type === 'tab' && targetState.key) { const payload = minimalAction.payload as NavigationRoute; if (!payload?.name) { - Log.hmmm('[Navigation] Unable to go up. Tab target missing screen name.'); - return false; + Log.hmmm('[Navigation] Unable to switch tabs. Tab target missing screen name.'); + return didPopToNavigator; } // Cross-tab PUSH stacks a new TAB_NAVIGATOR on the root. When an underlying TAB_NAVIGATOR // already has the target tab active, pop to it instead of jumping — otherwise the pushed @@ -487,11 +561,17 @@ function goUp(backToRoute: Route, options?: GoBackOptions): boolean { const underlyingTabNavIndex = rootState.routes.findLastIndex( (route, idx) => idx < topRootIndex && route.name === NAVIGATORS.TAB_NAVIGATOR && route.state?.routes?.at(route.state?.index ?? 0)?.name === payload.name, ); + const jumpParams = 'params' in payload ? payload.params : undefined; if (underlyingTabNavIndex !== -1) { dispatch(StackActions.pop(topRootIndex - underlyingTabNavIndex)); + // The uncovered tab navigator has the right tab active, but not necessarily the requested screen inside + // it, so the jump still has to be applied there rather than to the tab navigator that was popped. + const underlyingTabStateKey = rootState.routes.at(underlyingTabNavIndex)?.state?.key; + if (underlyingTabStateKey) { + dispatch({...TabActions.jumpTo(payload.name, jumpParams), target: underlyingTabStateKey}); + } return true; } - const jumpParams = 'params' in payload ? payload.params : undefined; dispatch({ ...TabActions.jumpTo(payload.name, jumpParams), target: targetState.key, @@ -499,11 +579,10 @@ function goUp(backToRoute: Route, options?: GoBackOptions): boolean { return true; } - const indexOfBackToRoute = targetState.routes.findLastIndex((route) => doesRouteMatchToMinimalActionPayload(route, minimalAction, compareParams)); - const distanceToPop = targetState.routes.length - indexOfBackToRoute - 1; + const routeToPopTo = findRouteToPopTo(targetState, minimalAction, compareParams); + const {distanceToPop} = routeToPopTo; - // If we need to pop more than one route from rootState, we replace the current route to not lose visited routes from the navigation state - if (indexOfBackToRoute === -1 || (isRootNavigatorState(targetState) && distanceToPop > 1)) { + if (!canPopToRoute(targetState, rootState, routeToPopTo)) { const replaceAction = {...minimalAction, type: CONST.NAVIGATION.ACTION_TYPE.REPLACE} as NavigationAction; dispatch(replaceAction); return true; @@ -517,16 +596,9 @@ function goUp(backToRoute: Route, options?: GoBackOptions): boolean { return true; } - // For TAB_NAVIGATOR targets, POP_TO restores nested state from the payload (#89006). Skip when - // there's nothing to pop — POP_TO would otherwise pop to an older matching route (#89209). - if (distanceToPop > 0 && (minimalAction.payload as {name?: string} | undefined)?.name === NAVIGATORS.TAB_NAVIGATOR) { - dispatch({...minimalAction, type: CONST.NAVIGATION.ACTION_TYPE.POP_TO, target: targetState.key}); - return true; - } - // Already at the target — `StackActions.pop(0)` would be a no-op that leaks the just-armed skip into the next Back/Esc. if (distanceToPop <= 0) { - return false; + return didPopToNavigator; } dispatch({...StackActions.pop(distanceToPop), target: targetState.key}); diff --git a/src/libs/Navigation/helpers/hasDifferentSplitScope.ts b/src/libs/Navigation/helpers/hasDifferentSplitScope.ts new file mode 100644 index 000000000000..4f6672e6a807 --- /dev/null +++ b/src/libs/Navigation/helpers/hasDifferentSplitScope.ts @@ -0,0 +1,65 @@ +import {getPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; +import {SPLIT_TO_SIDEBAR} from '@libs/Navigation/linkingConfig/RELATIONS'; +import {isRecord} from '@libs/ObjectUtils'; + +import type {NavigationRoute} from '@navigation/types'; + +import type {ActionPayload} from './linkTo/types'; + +import getParamsFromRoute from './getParamsFromRoute'; +import {isSplitNavigatorName} from './isNavigatorName'; + +function getSplitScopeComparisonValues(route: NavigationRoute, payload: ActionPayload & {name?: string}) { + if (!isSplitNavigatorName(route.name) || route.name !== payload.name) { + return; + } + + const sidebarScreen = SPLIT_TO_SIDEBAR[route.name]; + const scopeParams = getParamsFromRoute(sidebarScreen); + if (!scopeParams.length) { + return; + } + + // An unmounted split has no nested state on its route, but its last state is preserved under its key. + const routeKey = 'key' in route ? route.key : undefined; + const splitState = route.state ?? (routeKey ? getPreservedNavigatorState(routeKey) : undefined); + const sidebarRoute = splitState?.routes.find((nestedRoute) => nestedRoute.name === sidebarScreen); + // A narrow-layout split can be preserved without its sidebar, and its focused central screen carries the scope too. + const scopeRoute = sidebarRoute ?? splitState?.routes.at(splitState.index ?? -1); + // `getActionFromState` nests a split's screen params under `params.params`, so both sides read one level down. + const currentParams: unknown = scopeRoute?.params ?? (isRecord(route.params) ? route.params.params : undefined); + const targetParams = payload.params?.params; + if (!isRecord(currentParams) || !isRecord(targetParams)) { + return; + } + + return {scopeParams, currentParams, targetParams}; +} + +function getComparableScopeValue(value: unknown): string | undefined { + if (typeof value !== 'string' && typeof value !== 'number') { + return; + } + + return String(value); +} + +/** + * Whether the route is a split navigator with the payload's name but another scope. Workspace and domain splits all + * share one route name, so only the scope params of their sidebar (policyID, domainAccountID) tell the instances apart. + */ +function hasDifferentSplitScope(route: NavigationRoute, payload: ActionPayload & {name?: string}): boolean { + const scopeComparisonValues = getSplitScopeComparisonValues(route, payload); + if (!scopeComparisonValues) { + return false; + } + + const {scopeParams, currentParams, targetParams} = scopeComparisonValues; + return scopeParams.some((param) => { + const currentValue = getComparableScopeValue(currentParams[param]); + const targetValue = getComparableScopeValue(targetParams[param]); + return currentValue !== undefined && targetValue !== undefined && currentValue !== targetValue; + }); +} + +export default hasDifferentSplitScope; diff --git a/src/libs/Navigation/helpers/linkTo/getMinimalAction.ts b/src/libs/Navigation/helpers/linkTo/getMinimalAction.ts index 1903173f5a0c..282bc6da85f8 100644 --- a/src/libs/Navigation/helpers/linkTo/getMinimalAction.ts +++ b/src/libs/Navigation/helpers/linkTo/getMinimalAction.ts @@ -1,4 +1,7 @@ -import type {State} from '@navigation/types'; +import hasDifferentSplitScope from '@libs/Navigation/helpers/hasDifferentSplitScope'; +import {isRecord} from '@libs/ObjectUtils'; + +import type {NavigationRoute, State} from '@navigation/types'; import type {NavigationAction, NavigationState} from '@react-navigation/native'; import type {Writable} from 'type-fest'; @@ -7,9 +10,36 @@ import type {ActionPayload} from './types'; type MinimalAction = { action: Writable; - targetState: State | undefined; + /** The state of the navigator the minimal action is addressed at, which is the root state when the descent stops on it */ + targetState: State; + /** + * True when the descent stopped on a split navigator with the requested name but another scope (workspace or + * domain). Callers decide: forward navigation pushes a sibling split, backward navigation looks for the match. + */ + isFocusedRouteInDifferentScope: boolean; }; +function isNamedActionPayload(payload: unknown): payload is ActionPayload & {name: string} { + return isRecord(payload) && typeof payload.name === 'string'; +} + +/** + * One step of the descent: the same action addressed at `nestedState`. Its payload has no name when the action + * addresses nothing below that route, which is where a descent stops. + */ +function getNestedAction(action: NavigationAction, nestedState: State): Writable { + const params = isNamedActionPayload(action.payload) ? action.payload.params : undefined; + return { + type: action.type, + payload: { + name: params?.screen, + params: params?.params, + path: params?.path, + }, + target: nestedState.key, + }; +} + /** * Motivation for this function is described in NAVIGATION.md * @@ -18,32 +48,29 @@ type MinimalAction = { * @returns minimalAction minimal action is the action that we should dispatch */ function getMinimalAction(action: NavigationAction, state: NavigationState): MinimalAction { - let currentAction: NavigationAction = action; - let currentState: State | undefined = state; - let currentTargetKey: string | undefined; + let currentAction: Writable = action; + let currentState: State = state; + + while (isNamedActionPayload(currentAction.payload)) { + const currentRoute: NavigationRoute | undefined = currentState.routes.at(currentState.index ?? -1); + if (!currentRoute || currentRoute.name !== currentAction.payload.name) { + break; + } + + // Descending into a split of another scope would reuse its sidebar for a different workspace or domain. + if (hasDifferentSplitScope(currentRoute, currentAction.payload)) { + return {action: currentAction, targetState: currentState, isFocusedRouteInDifferentScope: true}; + } - while (currentAction.payload && 'name' in currentAction.payload && currentState?.routes[currentState.index ?? -1].name === currentAction.payload.name) { - if (!currentState?.routes[currentState.index ?? -1].state) { + if (!currentRoute.state) { break; } - currentState = currentState?.routes[currentState.index ?? -1].state; - currentTargetKey = currentState?.key; - - const payload = currentAction.payload as ActionPayload; - - // Creating new smaller action - currentAction = { - type: currentAction.type, - payload: { - name: payload?.params?.screen, - params: payload?.params?.params, - path: payload?.params?.path, - }, - target: currentTargetKey, - }; + currentAction = getNestedAction(currentAction, currentRoute.state); + currentState = currentRoute.state; } - return {action: currentAction, targetState: currentState}; + return {action: currentAction, targetState: currentState, isFocusedRouteInDifferentScope: false}; } export default getMinimalAction; +export {getNestedAction, isNamedActionPayload}; diff --git a/src/libs/Navigation/helpers/linkTo/index.ts b/src/libs/Navigation/helpers/linkTo/index.ts index a2bfa43eec17..7bafdef8d723 100644 --- a/src/libs/Navigation/helpers/linkTo/index.ts +++ b/src/libs/Navigation/helpers/linkTo/index.ts @@ -307,13 +307,16 @@ export default function linkTo(navigation: NavigationContainerRef; [SCREENS.SEARCH.ADVANCED_FILTERS_CONTENT_RHP]: { filterKey: string; + applyDirectly?: string; }; }; diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts index a7be3243899c..6dce299bef0b 100644 --- a/src/libs/NavigationFocusReturn/index.native.ts +++ b/src/libs/NavigationFocusReturn/index.native.ts @@ -11,27 +11,27 @@ import {diffNavigationState} from '@libs/navigationStateDiff'; import CONST from '@src/CONST'; import type {NavigationState} from '@react-navigation/native'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; import setFifoEntry from './fifoMap'; -type TriggerEntry = {ref: RefObject; identifier?: string}; +type TriggerEntry = {ref: RefObject | null>; identifier?: string}; const COLLISION_TOLERANT_IDENTIFIERS = new Set([CONST.BACK_BUTTON_NATIVE_ID]); -let lastPressedTriggerRef: RefObject | null = null; +let lastPressedTriggerRef: RefObject | null> | null = null; let lastPressedTriggerIdentifier: string | null = null; let lastPressedTriggerAt = 0; const triggerMap = new Map(); -const pressableRegistry = new Map>>>(); +const pressableRegistry = new Map | null>>>>(); let prevState: NavigationState | undefined; let pendingRestore: {cancel: () => void} | null = null; let skipNextRestore = false; let stateUnsubscribe: (() => void) | null = null; // Recorded unconditionally so cold-start presses survive the warm-up window. performance.now is monotonic — Date.now would corrupt the TTL on clock jumps. -function notifyPressedTrigger(ref: RefObject | null, identifier?: string): void { +function notifyPressedTrigger(ref: RefObject | null> | null, identifier?: string): void { lastPressedTriggerRef = ref; lastPressedTriggerIdentifier = identifier ?? null; lastPressedTriggerAt = ref ? performance.now() : 0; @@ -49,7 +49,7 @@ function skipNextFocusRestore(): void { skipNextRestore = true; } -function registerPressable(routeKey: string, identifier: string, ref: RefObject): () => void { +function registerPressable(routeKey: string, identifier: string, ref: RefObject | null>): () => void { let routeMap = pressableRegistry.get(routeKey); if (!routeMap) { routeMap = new Map(); @@ -90,7 +90,7 @@ function captureTriggerForRoute(routeKey: string): void { } // Caller passes the raw (compound-suffix-stripped) route key — pressables register under raw, but PUSH_PARAMS restores arrive under the compound key. -function resolveLiveRefFromRegistry(rawRouteKey: string, identifier: string): RefObject | null { +function resolveLiveRefFromRegistry(rawRouteKey: string, identifier: string): RefObject | null> | null { const refs = pressableRegistry.get(rawRouteKey)?.get(identifier); if (!refs || refs.size === 0) { return null; @@ -101,7 +101,7 @@ function resolveLiveRefFromRegistry(rawRouteKey: string, identifier: string): Re return sole?.current ? sole : null; } // Multi-registration: a single live ref always wins; multi-live only resolves when on the collision-tolerant allowlist. - let firstLive: RefObject | null = null; + let firstLive: RefObject | null> | null = null; let liveCount = 0; for (const ref of refs) { if (!ref.current) { @@ -125,7 +125,7 @@ function resolveLiveRefFromRegistry(rawRouteKey: string, identifier: string): Re * Registry-first: a fresh re-registration wins over the captured ref because the captured native handle * can stale-out across detach without nulling the JS ref. Falls back to the captured ref when the registry misses. */ -function restoreTriggerForRoute(routeKey: string, rawRouteKey: string): RefObject | null { +function restoreTriggerForRoute(routeKey: string, rawRouteKey: string): RefObject | null> | null { const entry = triggerMap.get(routeKey); if (!entry) { return null; diff --git a/src/libs/NavigationFocusReturn/index.ts b/src/libs/NavigationFocusReturn/index.ts index 56696c9cbb12..8f14ba35cd80 100644 --- a/src/libs/NavigationFocusReturn/index.ts +++ b/src/libs/NavigationFocusReturn/index.ts @@ -18,7 +18,7 @@ import {isCycleIdle, Priorities, resetCycle, tryClaim} from '@libs/ScreenFocusAr import CONST from '@src/CONST'; import type {NavigationState} from '@react-navigation/native'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; import setFifoEntry from './fifoMap'; @@ -171,11 +171,11 @@ function skipNextFocusRestore(): void { /** Native-only. Web captures via `focusin` so this stub exists only to keep the import cross-platform. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars -function notifyPressedTrigger(_ref: RefObject | null, _identifier?: string): void {} +function notifyPressedTrigger(_ref: RefObject | null> | null, _identifier?: string): void {} /** Native-only registry. Cross-platform stub. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars -function registerPressable(_routeKey: string, _identifier: string, _ref: RefObject): () => void { +function registerPressable(_routeKey: string, _identifier: string, _ref: RefObject | null>): () => void { return () => {}; } diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index 4950c57bfe9c..b7df9e321125 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -68,7 +68,7 @@ function buildNextStepMessage( formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], ): string { // Escape actor name to prevent HTML injection since this will be rendered as HTML - const actor = Str.safeEscape(getDisplayNameForParticipant({accountID: nextStep.actorAccountID, formatPhoneNumber, translate}) ?? ''); + const actor = Str.safeEscape(getDisplayNameForParticipant({accountID: nextStep.actorAccountID, formatPhoneNumber, hiddenTranslation: translate('common.hidden')}) ?? ''); let actorType: ValueOf; if (nextStep.actorAccountID === currentUserAccountID) { actorType = CONST.NEXT_STEP.ACTOR_TYPE.CURRENT_USER; diff --git a/src/libs/Notification/LocalNotification/BrowserNotifications.ts b/src/libs/Notification/LocalNotification/BrowserNotifications.ts index 9dc15c842298..0ecf70c7a456 100644 --- a/src/libs/Notification/LocalNotification/BrowserNotifications.ts +++ b/src/libs/Notification/LocalNotification/BrowserNotifications.ts @@ -2,7 +2,6 @@ import EXPENSIFY_ICON_URL from '@assets/images/expensify-logo-round-clearspace.p import type {CurrencyListActionsContextType} from '@components/CurrencyListContextProvider/types'; -import * as AppUpdate from '@libs/actions/AppUpdate'; import {convertToFrontendAmountAsInteger, sanitizeCurrencyCode} from '@libs/CurrencyUtils'; import {translateLocal} from '@libs/Localize'; import Log from '@libs/Log'; @@ -214,23 +213,6 @@ export default { push(title, body, icon, data, onClick); }, - /** - * Create a notification to indicate that an update is available. - */ - pushUpdateAvailableNotification() { - push( - 'Update available', - 'A new version of this app is available!', - '', - {}, - () => { - AppUpdate.triggerUpdateAvailable(); - }, - false, - 'UpdateAvailable', - ); - }, - /** * Clears all open notifications where shouldClearNotification returns true * diff --git a/src/libs/Notification/LocalNotification/index.native.ts b/src/libs/Notification/LocalNotification/index.native.ts index 49868d8515c9..6780f5e404d9 100644 --- a/src/libs/Notification/LocalNotification/index.native.ts +++ b/src/libs/Notification/LocalNotification/index.native.ts @@ -3,7 +3,6 @@ import type {LocalNotificationModule} from './types'; // Local Notifications are not currently supported on mobile so we'll just no-op here. const LocalNotification: LocalNotificationModule = { showCommentNotification: () => {}, - showUpdateAvailableNotification: () => {}, showModifiedExpenseNotification: () => {}, clearReportNotifications: () => {}, }; diff --git a/src/libs/Notification/LocalNotification/index.ts b/src/libs/Notification/LocalNotification/index.ts index e7e325d609bb..03484bb1a5be 100644 --- a/src/libs/Notification/LocalNotification/index.ts +++ b/src/libs/Notification/LocalNotification/index.ts @@ -33,10 +33,6 @@ function showCommentNotification(report: Report, reportAction: ReportAction, onC BrowserNotifications.pushReportCommentNotification(report, reportAction, onClick, derivedReportName, true); } -function showUpdateAvailableNotification() { - BrowserNotifications.pushUpdateAvailableNotification(); -} - function showModifiedExpenseNotification({ report, reportAction, @@ -76,7 +72,6 @@ function clearReportNotifications(reportID: string | undefined) { const LocalNotification: LocalNotificationModule = { showCommentNotification, - showUpdateAvailableNotification, showModifiedExpenseNotification, clearReportNotifications, }; diff --git a/src/libs/Notification/LocalNotification/types.ts b/src/libs/Notification/LocalNotification/types.ts index a2b3bb06f820..e59ca1917000 100644 --- a/src/libs/Notification/LocalNotification/types.ts +++ b/src/libs/Notification/LocalNotification/types.ts @@ -14,7 +14,6 @@ type LocalNotificationData = { type LocalNotificationModule = { showCommentNotification: (report: Report, reportAction: ReportAction, onClick: LocalNotificationClickHandler, derivedReportName: string | undefined) => void; - showUpdateAvailableNotification: () => void; showModifiedExpenseNotification: (params: LocalNotificationModifiedExpenseParams) => void; clearReportNotifications: ClearReportNotifications; }; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index f3e589e038a7..859f8d6f65ed 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -440,7 +440,7 @@ function getPersonalDetailOptionText({accountID, hasReport, personalDetails, log accountID, personalDetailsData: hasReport ? undefined : (personalDetails ?? undefined), formatPhoneNumber: formatPhoneNumberPhoneUtils, - translate, + hiddenTranslation: translate('common.hidden'), }) || formatPhoneNumberPhoneUtils(login ?? '') ); } @@ -529,6 +529,10 @@ function createOption({ let reportName; result.participantsList = personalDetailList; + // Resolve display-name translations once per option, then pass the strings to getDisplayNameForParticipant. + const hiddenText = translateFn('common.hidden'); + const youText = translateFn('common.you').toLowerCase(); + if (report) { result.private_isArchived = privateIsArchived; result.keyForList = String(report.reportID); @@ -607,7 +611,8 @@ function createOption({ shouldAddCurrentUserPostfix: true, personalDetailsData: personalDetails ?? undefined, formatPhoneNumber: formatPhoneNumberPhoneUtils, - translate: translateFn, + hiddenTranslation: hiddenText, + youTranslation: youText, }) : ''); @@ -941,11 +946,18 @@ function getSearchValueForPhoneOrEmail(searchTerm: string, countryCode: number) return parsedPhoneNumber.possible ? (parsedPhoneNumber.number?.e164 ?? '') : searchTerm.toLowerCase(); } +/** + * Verifies that a single option (category or tag) is enabled and not pending deletion + */ +function isOptionEnabled(option?: PolicyTag | PolicyCategory): boolean { + return !!option?.enabled && option.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; +} + /** * Verifies that there is at least one enabled option */ function hasEnabledOptions(options: PolicyCategories | PolicyTag[]): boolean { - return Object.values(options).some((option: PolicyTag | PolicyCategory) => option.enabled && option.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + return Object.values(options).some(isOptionEnabled); } /** @@ -1459,7 +1471,6 @@ type CreateOptionFromReportParams = { sortedActions: Record | undefined; transactionThreadIDs?: Record; lastActions?: Record; - currentUserAccountID?: number; currentUserLogin?: string; conciergeReportID: string | undefined; reportAttributesDerived?: ReportAttributesDerivedValue['reports']; @@ -1467,6 +1478,7 @@ type CreateOptionFromReportParams = { policyTags?: OnyxEntry; visibleReportActionsData?: VisibleReportActionsDerivedValue; isTrackIntentUser?: boolean; + currentUserAccountID: number; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; convertToDisplayStringWithoutCurrency?: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency']; }; @@ -1833,6 +1845,7 @@ function getUserToInviteOption({ countryCode = CONST.DEFAULT_COUNTRY_CODE, loginList = {}, currentUserEmail, + currentUserAccountID, visibleReportActionsData = {}, rules, }: GetUserToInviteConfig & {visibleReportActionsData?: VisibleReportActionsDerivedValue; dateFnsLocale: DateFnsLocale | undefined; rules: OnyxCollection}): SearchOptionData | null { @@ -1881,6 +1894,7 @@ function getUserToInviteOption({ pendingDeleteMemberAccountIDs: undefined, config: {showChatPreviewLine}, visibleReportActionsData, + currentUserAccountID, }); userToInvite.isOptimisticAccount = true; userToInvite.login = searchValue; @@ -2714,6 +2728,7 @@ function getValidOptions( excludeLogins: loginsToExclude, shouldAcceptName, searchInputValue, + currentUserAccountID, }, rules, ); @@ -3174,7 +3189,7 @@ function filterUserToInvite( config: FilterUserToInviteConfig, rules: OnyxCollection, ): SearchOptionData | null { - const {canInviteUser = true, excludeLogins = {}} = config; + const {canInviteUser = true, excludeLogins = {}, currentUserAccountID} = config; if (!canInviteUser) { return null; } @@ -3203,6 +3218,7 @@ function filterUserToInvite( currentUserEmail, rules, ...config, + currentUserAccountID, }); } @@ -3245,6 +3261,7 @@ function filterOptions( { ...config, searchInputValue: searchInputValueForInvite, + currentUserAccountID, }, rules, ); @@ -3419,6 +3436,7 @@ export { isDisablingOrDeletingLastEnabledCategory, isDisablingOrDeletingLastEnabledTag, isMakingLastRequiredTagListOptional, + isOptionEnabled, isPersonalDetailsReady, optionsOrderAndGroupBy, optionsOrderBy, diff --git a/src/libs/OptionsListUtils/types.ts b/src/libs/OptionsListUtils/types.ts index 66f5da21abae..2a44bbf9af12 100644 --- a/src/libs/OptionsListUtils/types.ts +++ b/src/libs/OptionsListUtils/types.ts @@ -313,6 +313,7 @@ type GetUserToInviteConfig = { countryCode?: number; loginList: OnyxEntry; currentUserEmail: string; + currentUserAccountID: number; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; } & Pick; @@ -359,6 +360,7 @@ type FilterUserToInviteConfig = Pick; + currentUserAccountID: number; }; type OrderOptionsConfig = diff --git a/src/libs/PersonalDetailOptionsListUtils/index.ts b/src/libs/PersonalDetailOptionsListUtils/index.ts index 71a3d20cdb97..a7d79f745d12 100644 --- a/src/libs/PersonalDetailOptionsListUtils/index.ts +++ b/src/libs/PersonalDetailOptionsListUtils/index.ts @@ -78,7 +78,7 @@ function createOption( accountID: personalDetail.accountID, formatPhoneNumber, personalDetailsData: {[personalDetail.accountID]: personalDetail}, - translate, + hiddenTranslation: translate?.('common.hidden'), }) || formatPhoneNumber(personalDetail.login ?? ''); result.icons = [ { diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 57c429482957..4eb27321cdd8 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -16,7 +16,6 @@ import {Str} from 'expensify-common'; import Onyx from 'react-native-onyx'; import {getCountryCode} from './CountryUtils'; -import {translateLocal} from './Localize'; import {areEmailsFromSamePrivateDomain} from './LoginUtils'; import {getPersonalDetailByLogin} from './PersonalDetailsStore'; import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from './PhoneNumber'; @@ -28,76 +27,15 @@ type FirstAndLastName = { lastName: string; }; -let hiddenTranslation = ''; -let youTranslation = ''; - -Onyx.connect({ - key: ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, - callback: (value) => { - if (value ?? true) { - return; - } - hiddenTranslation = translateLocal('common.hidden'); - youTranslation = translateLocal('common.you').toLowerCase(); - }, -}); - const regexMergedAccount = new RegExp(CONST.REGEX.MERGED_ACCOUNT_PREFIX); -function getDisplayNameOrDefault( - passedPersonalDetails?: Partial | null, - defaultValue = '', - shouldFallbackToHidden = true, - shouldAddCurrentUserPostfix = false, - youAfterTranslation = youTranslation, -): string { - let displayName = passedPersonalDetails?.displayName ?? ''; - - let login = passedPersonalDetails?.login ?? ''; - - // If the displayName starts with the merged account prefix, remove it. - if (regexMergedAccount.test(displayName)) { - // Remove the merged account prefix from the displayName. - displayName = displayName.replaceAll(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); - } - - // If the displayName is not set by the user, the backend sets the displayName same as the login so - // we need to remove the sms domain from the displayName if it is an sms login. - if (Str.isSMSLogin(login)) { - if (displayName === login) { - displayName = Str.removeSMSDomain(displayName); - } - login = Str.removeSMSDomain(login); - } - - if (shouldAddCurrentUserPostfix && !!displayName) { - displayName = `${displayName} (${youAfterTranslation})`; - } - - if (passedPersonalDetails?.accountID === CONST.ACCOUNT_ID.CONCIERGE) { - displayName = CONST.CONCIERGE_DISPLAY_NAME; - } - - if (displayName) { - return displayName; - } - - if (defaultValue) { - return defaultValue; - } - - if (login) { - return login; - } - return shouldFallbackToHidden ? hiddenTranslation : ''; -} - function temporaryGetDisplayNameOrDefault({ passedPersonalDetails, defaultValue = '', shouldFallbackToHidden = true, shouldAddCurrentUserPostfix = false, youAfterTranslation, + hiddenAfterTranslation, translate, formatPhoneNumber, }: { @@ -106,11 +44,12 @@ function temporaryGetDisplayNameOrDefault({ shouldFallbackToHidden?: boolean; shouldAddCurrentUserPostfix?: boolean; youAfterTranslation?: string; - translate: LocalizedTranslate; + hiddenAfterTranslation?: string; + translate?: LocalizedTranslate; formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; }): string { - const temporaryHiddenTranslation = translate('common.hidden'); - const temporaryYouTranslation = translate('common.you').toLowerCase(); + const temporaryHiddenTranslation = hiddenAfterTranslation ?? translate?.('common.hidden') ?? ''; + const temporaryYouTranslation = translate?.('common.you').toLowerCase(); let displayName = passedPersonalDetails?.displayName ?? ''; const login = passedPersonalDetails?.login ?? ''; @@ -592,7 +531,6 @@ function areTravelPersonalDetailsMissing(privatePersonalDetails: OnyxEntry): boole return !!policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL]?.config?.isConfigured; } +/** + * True when Campfire is connected AND configured. + */ +function isCampfireVendorMatchingActive(policy: OnyxEntry): boolean { + return !!policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]?.config?.isConfigured; +} + /** * True when Xero is the *active* vendor-matching source for the workspace — i.e. Xero is * connected AND neither QBO nor Intacct is in a vendor-matching export mode. Mirrors the precedence - * in `getActiveVendorMatchingIntegration` (QBO → Intacct → Xero → Rillet → DualEntry → Business Central) so the UI labels, copy, and + * in `getActiveVendorMatchingIntegration` (QBO → Intacct → Xero → Rillet → DualEntry → Business Central → Campfire) so the UI labels, copy, and * inactive-vendor guardrail stay bound to whichever integration's vendor list is actually being consulted. * Without this scoping, a workspace with active QBO matching + a lingering Xero connection would render * QBO vendors under the "Supplier" label. @@ -2771,22 +2778,23 @@ function isXeroActiveMatchingSource(policy: OnyxEntry): boolean { * the field. * * The `vendorMatching` beta only gates the integrations that haven't reached GA yet, so - * `isVendorMatchingBetaEnabled` is consulted on every branch but QBO and Sage Intacct or Dual Entry: + * `isVendorMatchingBetaEnabled` is consulted on every branch but QBO, Sage Intacct, Rillet, and DualEntry: * - QBO (R1) with non-reimbursable export = Credit Card or Debit Card. GA, so no beta required * - Sage Intacct (R2) with non-reimbursable export = Credit Card Charge. GA, so no beta required * - Xero (R3) has no export destination enum, so a configured connection is enough. Beta required - * - Rillet (R4) configured connection. Beta required + * - Rillet (R4) configured connection. GA, so no beta required * - DualEntry configured connection. GA, so no beta required * - Business Central configured connection. Beta required + * - Campfire has no export destination enum, so a configured connection is enough. Beta required */ function hasVendorFeature(policy: OnyxEntry, isVendorMatchingBetaEnabled: boolean): boolean { if (!policy) { return false; } - if (isQBOVendorMatchingActive(policy) || isIntacctVendorMatchingActive(policy) || isDualEntryVendorMatchingActive(policy)) { + if (isQBOVendorMatchingActive(policy) || isIntacctVendorMatchingActive(policy) || isRilletVendorMatchingActive(policy) || isDualEntryVendorMatchingActive(policy)) { return true; } - return isVendorMatchingBetaEnabled && (isXeroVendorMatchingActive(policy) || isRilletVendorMatchingActive(policy) || isBusinessCentralVendorMatchingActive(policy)); + return isVendorMatchingBetaEnabled && (isXeroVendorMatchingActive(policy) || isBusinessCentralVendorMatchingActive(policy) || isCampfireVendorMatchingActive(policy)); } /** @@ -2798,7 +2806,7 @@ function hasVendorFeatureOnAnyPolicy(policies: OnyxCollection, isVendorM /** * Single source of truth for which connected integration scopes the vendor field for this workspace - * (QBO, Sage Intacct, Xero, Rillet, DualEntry, or Business Central) and what its vendor list looks like. Returns `undefined` when no + * (QBO, Sage Intacct, Xero, Rillet, DualEntry, Business Central, or Campfire) and what its vendor list looks like. Returns `undefined` when no * vendor-matching integration is active OR when the active integration's list hasn't synced yet — * distinct from `[]` (loaded-empty). Lets callers tell "no vendors" from "not loaded". * @@ -2842,6 +2850,9 @@ function getActiveVendorMatchingIntegration(policy: OnyxEntry): Connecti if (isBusinessCentralVendorMatchingActive(policy)) { return CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL; } + if (isCampfireVendorMatchingActive(policy)) { + return CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE; + } return undefined; } @@ -2904,12 +2915,15 @@ function getActiveVendorMatchingVendors(policy: OnyxEntry): Vendor[] | u email: vendor.email, })); } + if (isCampfireVendorMatchingActive(policy)) { + return policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]?.data?.vendors === undefined ? undefined : getCampfireVendors(policy); + } return undefined; } /** * Returns the vendor list imported into the workspace from whichever connected integration scopes - * the vendor field for this workspace (QBO, Sage Intacct, Xero, Rillet, DualEntry, or Business Central). Empty array when no integration + * the vendor field for this workspace (QBO, Sage Intacct, Xero, Rillet, DualEntry, Business Central, or Campfire). Empty array when no integration * is connected or the sync hasn't populated vendors yet. Source of truth for the vendor selector * RHP and inactive-vendor lookups. */ @@ -3008,6 +3022,10 @@ function findVendorByID(policy: OnyxEntry, vendorID: string | undefined) email: businessCentralVendor.email ?? '', }; } + const campfireVendor = getCampfireVendors(policy).find((vendor) => vendor.id === vendorID); + if (campfireVendor) { + return campfireVendor; + } return getDualEntryVendors(policy).find((vendor) => vendor.id === vendorID); } @@ -3075,6 +3093,11 @@ function getVendorEmptyState(policy: OnyxEntry, translate: LocaleContext title: translate('workspace.businessCentral.noVendorsFound'), subtitle: translate('workspace.businessCentral.noVendorsFoundDescription'), }; + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: + return { + title: translate('workspace.campfire.noVendorsFound'), + subtitle: translate('workspace.campfire.noVendorsFoundDescription'), + }; case CONST.POLICY.CONNECTIONS.NAME.QBO: default: { const integrationName = getQuickbooksOnlineIntegrationName(policy, translate); @@ -3101,6 +3124,14 @@ function getXeroSuppliers(policy: OnyxEntry): Vendor[] { return Object.values(contacts).map((contact) => ({id: contact.id, name: contact.name, currency: '', email: contact.email})); } +/** Campfire vendor matching uses only active vendor-type records, never customers or inactive vendors */ +function getCampfireVendors(policy: OnyxEntry): Vendor[] { + const vendors = policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]?.data?.vendors; + return (vendors ?? []) + .filter((vendor) => !!vendor.id && vendor.isActive === true && vendor.vendorType === CONST.CAMPFIRE_VENDOR_TYPE.VENDOR) + .map((vendor) => ({id: vendor.id, name: vendor.name, currency: '', email: vendor.email ?? ''})); +} + /** DualEntry export settings and expense matching must use vendors available to the selected company */ function getDualEntryVendors(policy: OnyxEntry): Vendor[] { const connection = policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.DUALENTRY]; diff --git a/src/libs/ReportActionComposeFocusManager.ts b/src/libs/ReportActionComposeFocusManager.ts index 2e50106127f4..89b48a80f6b4 100644 --- a/src/libs/ReportActionComposeFocusManager.ts +++ b/src/libs/ReportActionComposeFocusManager.ts @@ -2,7 +2,7 @@ import type {ComposerRef} from '@components/Composer/types'; import SCREENS from '@src/SCREENS'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {TextInput} from 'react-native'; import {findFocusedRoute} from '@react-navigation/native'; @@ -24,7 +24,7 @@ const editComposerRef = React.createRef(); /** * There can be 2 composers present at the same time. This ref is for the side panel. */ -const sidePanelComposerRef: RefObject = React.createRef(); +const sidePanelComposerRef: RefObject | null> = React.createRef>(); // There are two types of focus callbacks: priority and general // Priority callback would take priority if it existed diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index c9808ed8af94..6b08688b58d3 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -2752,13 +2752,18 @@ function getExportIntegrationLastMessageText(translate: LocalizedTranslate, repo return fragments.reduce((acc, fragment) => `${acc} ${fragment.text}`, ''); } -function getExportIntegrationMessageHTML(translate: LocalizedTranslate, reportAction: OnyxEntry, integrationName?: string): string { - const fragments = getExportIntegrationActionFragments(translate, reportAction, integrationName); +function getExportIntegrationMessageHTML(translate: LocalizedTranslate, reportAction: OnyxEntry, integrationName?: string, shouldOmitTrailingPeriod = false): string { + const fragments = getExportIntegrationActionFragments(translate, reportAction, integrationName, shouldOmitTrailingPeriod); const htmlFragments = fragments.map((fragment) => (fragment.url ? `${fragment.text}` : fragment.text)); return htmlFragments.join(' '); } -function getExportIntegrationActionFragments(translate: LocalizedTranslate, reportAction: OnyxEntry, integrationName?: string): Array<{text: string; url: string}> { +function getExportIntegrationActionFragments( + translate: LocalizedTranslate, + reportAction: OnyxEntry, + integrationName?: string, + shouldOmitTrailingPeriod = false, +): Array<{text: string; url: string}> { if (reportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION) { throw Error(`received wrong action type. actionName: ${reportAction?.actionName}`); } @@ -2817,7 +2822,7 @@ function getExportIntegrationActionFragments(translate: LocalizedTranslate, repo const reimbursableUrl = reimbursableUrls.at(0) ?? ''; let suffix = ''; if (linkItemCount === 1) { - suffix = '.'; + suffix = shouldOmitTrailingPeriod ? '' : '.'; } else if (linkItemCount === 3) { suffix = ','; } diff --git a/src/libs/ReportAlternateTextUtils.ts b/src/libs/ReportAlternateTextUtils.ts index e3b382c1aa25..065f82483f1f 100644 --- a/src/libs/ReportAlternateTextUtils.ts +++ b/src/libs/ReportAlternateTextUtils.ts @@ -1048,7 +1048,7 @@ function getWelcomeMessage(params: WelcomeMessageParams): WelcomeMessage { welcomeMessage.messageHtml = translate( 'reportActionsView.beginningOfChatHistoryPolicyExpenseChat', getPolicyName({report, policy, unavailableTranslation: translate('workspace.common.unavailable')}), - getDisplayNameForParticipant({accountID: report?.ownerAccountID, formatPhoneNumber, translate}), + getDisplayNameForParticipant({accountID: report?.ownerAccountID, formatPhoneNumber, hiddenTranslation: translate('common.hidden')}), ); welcomeMessage.messageText = Parser.htmlToText(welcomeMessage.messageHtml); } @@ -1132,7 +1132,7 @@ function getRoomWelcomeMessage({ } else if (isInvoiceRoom(report)) { const payer = report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL - ? getDisplayNameForParticipant({accountID: report?.invoiceReceiver?.accountID, formatPhoneNumber, translate}) + ? getDisplayNameForParticipant({accountID: report?.invoiceReceiver?.accountID, formatPhoneNumber, hiddenTranslation: translate('common.hidden')}) : invoiceReceiverPolicy?.name; const receiver = getPolicyName({report, unavailableTranslation: translate('workspace.common.unavailable')}); welcomeMessage.messageHtml = translate('reportActionsView.beginningOfChatHistoryInvoiceRoom', payer ?? '', receiver); diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index a05ee55eb070..7fe888f838f7 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -253,7 +253,7 @@ const buildReportNameFromParticipantNames = ({ shouldUseShortForm: true, personalDetailsData, formatPhoneNumber, - translate, + hiddenTranslation: translate('common.hidden'), }), })) .filter((participant) => participant.name) @@ -264,7 +264,7 @@ const buildReportNameFromParticipantNames = ({ accountID, personalDetailsData, formatPhoneNumber, - translate, + hiddenTranslation: translate('common.hidden'), }); } return formattedNames ? `${formattedNames}, ${name}` : name; @@ -311,11 +311,18 @@ function getGroupChatName( const isMultipleParticipantReport = participantAccountIDs.length > 1; if (isMultipleParticipantReport) { + // Resolve the translation once, not per participant. + const hiddenText = translate('common.hidden'); return participantAccountIDs .map( (participantAccountID, index) => - getDisplayNameForParticipant({accountID: participantAccountID, shouldUseShortForm: isMultipleParticipantReport, personalDetailsData, formatPhoneNumber, translate}) || - formatPhoneNumber(participants?.[index]?.login ?? ''), + getDisplayNameForParticipant({ + accountID: participantAccountID, + shouldUseShortForm: isMultipleParticipantReport, + personalDetailsData, + formatPhoneNumber, + hiddenTranslation: hiddenText, + }) || formatPhoneNumber(participants?.[index]?.login ?? ''), ) .sort((first, second) => customCollator.compare(first ?? '', second ?? '')) .filter(Boolean) @@ -323,7 +330,10 @@ function getGroupChatName( .slice(0, CONST.REPORT_NAME_LIMIT) .concat(shouldAddEllipsis ? '...' : ''); } - return translate('groupChat.defaultReportName', getDisplayNameForParticipant({accountID: participantAccountIDs.at(0), personalDetailsData, formatPhoneNumber, translate})); + return translate( + 'groupChat.defaultReportName', + getDisplayNameForParticipant({accountID: participantAccountIDs.at(0), personalDetailsData, formatPhoneNumber, hiddenTranslation: translate('common.hidden')}), + ); } /** @@ -342,7 +352,9 @@ function getPolicyExpenseChatName({ const personalDetails = ownerAccountID ? personalDetailsList?.[ownerAccountID] : undefined; const login = personalDetails ? personalDetails.login : null; - const reportOwnerDisplayName = getDisplayNameForParticipant({accountID: ownerAccountID, shouldRemoveDomain: true, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}) || login; + const reportOwnerDisplayName = + getDisplayNameForParticipant({accountID: ownerAccountID, shouldRemoveDomain: true, formatPhoneNumber: formatPhoneNumberPhoneUtils, hiddenTranslation: translate('common.hidden')}) || + login; if (reportOwnerDisplayName) { return translate('workspace.common.policyExpenseChatName', reportOwnerDisplayName); @@ -475,7 +487,8 @@ function getMoneyRequestReportName({ const invoiceReceiverPersonalDetail = getInvoiceReceiverPersonalDetail(chatReport, personalDetailsList); payerOrApproverName = getInvoicePayerName(chatReport, translate, invoiceReceiverPersonalDetail, invoiceReceiverPolicy); } else { - payerOrApproverName = getDisplayNameForParticipant({accountID: report?.managerID, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}) ?? ''; + payerOrApproverName = + getDisplayNameForParticipant({accountID: report?.managerID, formatPhoneNumber: formatPhoneNumberPhoneUtils, hiddenTranslation: translate('common.hidden')}) ?? ''; } const payerPaidAmountMessage = translate('iou.payerPaidAmount', formattedAmount, payerOrApproverName); @@ -488,7 +501,8 @@ function getMoneyRequestReportName({ } if (!isSettled(report?.reportID) && hasNonReimbursableTransactions(linkedTransactions)) { - payerOrApproverName = getDisplayNameForParticipant({accountID: report?.ownerAccountID, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}) ?? ''; + payerOrApproverName = + getDisplayNameForParticipant({accountID: report?.ownerAccountID, formatPhoneNumber: formatPhoneNumberPhoneUtils, hiddenTranslation: translate('common.hidden')}) ?? ''; return translate('iou.payerSpentAmount', formattedAmount, payerOrApproverName); } @@ -1303,7 +1317,8 @@ function computeReportName({ shouldAddCurrentUserPostfix: true, personalDetailsData: personalDetailsList, formatPhoneNumber: formatPhoneNumberPhoneUtils, - translate, + hiddenTranslation: translate('common.hidden'), + youTranslation: translate('common.you').toLowerCase(), }); } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index bf9a91747b8b..625f11b25594 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -137,14 +137,7 @@ import {isTrackOnboardingChoice} from './OnboardingUtils'; import Parser from './Parser'; import {getParsedMessageWithShortMentions} from './ParsingUtils'; import {getAllPersonalDetails, getPersonalDetail} from './PersonalDetailsStore'; -import { - buildPersonalDetailsUpdate, - getAccountIDsByLogins, - getDisplayNameOrDefault, - getLoginByAccountID, - getPersonalDetailByEmail, - temporaryGetDisplayNameOrDefault, -} from './PersonalDetailsUtils'; +import {buildPersonalDetailsUpdate, getAccountIDsByLogins, getLoginByAccountID, getPersonalDetailByEmail, temporaryGetDisplayNameOrDefault} from './PersonalDetailsUtils'; import { canMemberWrite as canMemberWritePolicyUtils, canSendInvoiceFromWorkspace, @@ -2498,13 +2491,21 @@ function getMostRecentlyVisitedReport(reports: Array>, lastVis return lodashMaxBy(filteredReports, (a) => [(a?.reportID && lastVisitTimes?.[a.reportID]) ?? '', a?.lastReadTime ?? '']); } +/** Fields of a Report that `findLastAccessedReport` callers consume. */ +type LastAccessedReport = Pick; + +function toLastAccessedReport(report: OnyxEntry): LastAccessedReport | undefined { + if (!report?.reportID) { + return undefined; + } + return {reportID: report.reportID, policyID: report.policyID, chatType: report.chatType}; +} + /** - * This function is used to find the last accessed report and we don't need to subscribe the data in the UI. - * So please use `Onyx.connectWithoutView()` to get the necessary data when we remove the `Onyx.connect()` - * - * Callers that need to react to the report data arriving (rather than reading whatever the module-scoped - * copy happens to hold at call time) can pass their own subscribed collections through `reportNameValuePairs` - * and `reports`. Both fall back to the module-scoped copies when omitted. + * Finds the last accessed report for navigation fallbacks using the module-scoped + * Onyx subscriptions (non-UI, no view-based re-renders). `reportNameValuePairs` and + * `reports` let a caller pass its own subscribed collections and fall back to the + * module-scoped copies when omitted. Returns a minimal slice, not the full Report. */ function findLastAccessedReport( ignoreDomainRooms: boolean, @@ -2513,17 +2514,14 @@ function findLastAccessedReport( excludeReportID?: string, reportNameValuePairs?: OnyxCollection, reports?: OnyxCollection, -): OnyxEntry { +): LastAccessedReport | undefined { const reportNameValuePairsCollection = reportNameValuePairs ?? allReportNameValuePair; let reportsValues = Object.values(reports ?? deprecatedAllReports ?? {}); if (openOnAdminRoom) { - const adminReport = reportsValues.find((report) => { - const chatType = getChatType(report); - return chatType === CONST.REPORT.CHAT_TYPE.POLICY_ADMINS; - }); + const adminReport = reportsValues.find((report) => getChatType(report) === CONST.REPORT.CHAT_TYPE.POLICY_ADMINS); if (adminReport) { - return adminReport; + return toLastAccessedReport(adminReport); } } @@ -2563,11 +2561,11 @@ function findLastAccessedReport( const visibleReports = reportsValues.filter((report) => !!report?.isPinned || !isHiddenForCurrentUser(report) || (isPublicRoom(report) && isAnonymousUserSession())); const ownedReports = visibleReports.filter((report) => report?.ownerAccountID === deprecatedCurrentUserAccountID); if (ownedReports.length > 0) { - return lodashMaxBy(ownedReports, (a) => a?.lastReadTime ?? ''); + return toLastAccessedReport(lodashMaxBy(ownedReports, (a) => a?.lastReadTime ?? '')); } - return lodashMaxBy(reportsValues, (a) => a?.lastReadTime ?? ''); + return toLastAccessedReport(lodashMaxBy(reportsValues, (a) => a?.lastReadTime ?? '')); } - return getMostRecentlyVisitedReport(reportsValues, allReportLastVisitTimes); + return toLastAccessedReport(getMostRecentlyVisitedReport(reportsValues, allReportLastVisitTimes)); } /** @@ -3561,7 +3559,8 @@ function getDisplayNameForParticipant({ personalDetailsData = getAllPersonalDetails(), shouldRemoveDomain = false, formatPhoneNumber, - translate, + hiddenTranslation: hiddenTranslationOverride, + youTranslation, }: { accountID?: number; shouldUseShortForm?: boolean; @@ -3570,7 +3569,14 @@ function getDisplayNameForParticipant({ personalDetailsData?: Partial; shouldRemoveDomain?: boolean; formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; - translate?: LocalizedTranslate; + /** + * Pre-resolved "Hidden" string, passed instead of a `translate` function. Callers resolve it once (e.g. `translate('common.hidden')`, + * hoisted out of loops) and pass the value. English-only persisted messages pass the English literal. Defaults to the cached + * module value when omitted. Mirrors `getPolicyName`'s `unavailableTranslation`. + */ + hiddenTranslation?: string; + /** Pre-resolved lowercase "you" string for the `shouldAddCurrentUserPostfix` "(you)" suffix. */ + youTranslation?: string; }): string { if (!accountID) { return ''; @@ -3603,23 +3609,24 @@ function getDisplayNameForParticipant({ // For selfDM, we display the user's displayName followed by '(you)' as a postfix const shouldAddPostfix = shouldAddCurrentUserPostfix && accountID === deprecatedCurrentUserAccountID; - let longName = translate - ? temporaryGetDisplayNameOrDefault({ - passedPersonalDetails: personalDetails, - defaultValue: formattedLogin, - shouldFallbackToHidden, - shouldAddCurrentUserPostfix: shouldAddPostfix, - translate, - formatPhoneNumber, - }) - : getDisplayNameOrDefault(personalDetails, formattedLogin, shouldFallbackToHidden, shouldAddPostfix); + // Resolve the "Hidden" fallback once from the passed string (or the cached module value), never via a per-call `translate`. + const resolvedHiddenTranslation = hiddenTranslationOverride ?? hiddenTranslation; + let longName = temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: personalDetails, + defaultValue: formattedLogin, + shouldFallbackToHidden, + shouldAddCurrentUserPostfix: shouldAddPostfix, + youAfterTranslation: youTranslation, + hiddenAfterTranslation: resolvedHiddenTranslation, + formatPhoneNumber, + }); if (shouldRemoveDomain && longName === formattedLogin) { longName = longName.split('@').at(0) ?? ''; } // If the user's personal details (first name) should be hidden, make sure we return "hidden" instead of the short name - if (shouldFallbackToHidden && longName === (translate ? translate('common.hidden') : hiddenTranslation)) { + if (shouldFallbackToHidden && longName === resolvedHiddenTranslation) { return longName; } @@ -4180,12 +4187,24 @@ function getDisplayNamesWithTooltips( ): DisplayNameWithTooltips { const personalDetailsListArray = Array.isArray(personalDetailsList) ? personalDetailsList : Object.values(personalDetailsList); + // Resolve translations once, not per participant, then pass the strings down. + const hiddenText = translate('common.hidden'); + const youText = translate('common.you').toLowerCase(); + return personalDetailsListArray .map((user) => { const accountID = Number(user?.accountID); const displayName = - getDisplayNameForParticipant({accountID, shouldUseShortForm, shouldFallbackToHidden, shouldAddCurrentUserPostfix, formatPhoneNumber, translate}) || + getDisplayNameForParticipant({ + accountID, + shouldUseShortForm, + shouldFallbackToHidden, + shouldAddCurrentUserPostfix, + formatPhoneNumber, + hiddenTranslation: hiddenText, + youTranslation: youText, + }) || // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing user?.login || ''; @@ -4221,7 +4240,7 @@ function getDisplayNamesWithTooltips( * Returns the the display names of the given user accountIDs */ function getUserDetailTooltipText(accountID: number, formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], translate: LocalizedTranslate, fallbackUserDisplayName = ''): string { - const displayNameForParticipant = getDisplayNameForParticipant({accountID, formatPhoneNumber, translate}); + const displayNameForParticipant = getDisplayNameForParticipant({accountID, formatPhoneNumber, hiddenTranslation: translate('common.hidden')}); return displayNameForParticipant || fallbackUserDisplayName; } @@ -4265,7 +4284,7 @@ function getReimbursementQueuedActionMessage({ shouldUseShortForm: shouldUseShortDisplayName, personalDetailsData: personalDetails, formatPhoneNumber, - translate, + hiddenTranslation: translate('common.hidden'), }) ?? ''; const originalMessage = getOriginalMessage(reportAction); let messageKey: TranslationPaths; @@ -4293,7 +4312,13 @@ function getReimbursementDeQueuedOrCanceledActionMessage( if (originalMessage?.cancellationReason === CONST.REPORT.CANCEL_PAYMENT_REASONS.ADMIN || originalMessage?.cancellationReason === CONST.REPORT.CANCEL_PAYMENT_REASONS.USER) { return translate('iou.adminCanceledRequest'); } - const submitterDisplayName = getDisplayNameForParticipant({accountID: reportOwnerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}) ?? ''; + const submitterDisplayName = + getDisplayNameForParticipant({ + accountID: reportOwnerAccountID, + shouldUseShortForm: true, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: translate('common.hidden'), + }) ?? ''; return translate('iou.canceledRequest', formattedAmount, submitterDisplayName); } @@ -5291,12 +5316,11 @@ function canEditMultipleTransactions( const report = reports?.[reportKey] ?? (searchSnapshotData?.[reportKey] as OnyxEntry); const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; - const isApproved = isReportApproved({report}); - - if (isApproved || isSettled(report)) { - return false; - } - + // Expenses on approved and paid reports are intentionally allowed here. Per-field permission is what decides + // what can actually change: canEditFieldOfMoneyRequest blocks the restricted fields (amount, merchant, date, + // billable, reimbursable, ...) on a finalized report and keeps the coding fields (category, tag, description, + // tax, attendees) editable, and canEditMoneyRequest only grants those to policy admins and the report manager. + // That mirrors the single-expense edit flow, which admins can already use on an approved or paid expense. const fieldsToCheck = [ CONST.EDIT_REQUEST_FIELD.AMOUNT, CONST.EDIT_REQUEST_FIELD.MERCHANT, @@ -6021,7 +6045,12 @@ function getReportPreviewMessage( const policyName = getPolicyName({report: parentReport ?? report, policy, unavailableTranslation: translate('workspace.common.unavailable')}); const payerName = isExpenseReport(report) ? policyName - : getDisplayNameForParticipant({accountID: report.managerID, shouldUseShortForm: !isPreviewMessageForParentChatReport, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}); + : getDisplayNameForParticipant({ + accountID: report.managerID, + shouldUseShortForm: !isPreviewMessageForParentChatReport, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: translate('common.hidden'), + }); const formattedAmount = convertToDisplayString(totalAmount, report.currency); @@ -6076,7 +6105,12 @@ function getReportPreviewMessage( let actualPayerName = report.managerID === deprecatedCurrentUserAccountID && !isForListPreview ? '' - : getDisplayNameForParticipant({accountID: payerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}); + : getDisplayNameForParticipant({ + accountID: payerAccountID, + shouldUseShortForm: true, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: translate('common.hidden'), + }); actualPayerName = actualPayerName && isForListPreview && !isPreviewMessageForParentChatReport ? `${actualPayerName}:` : actualPayerName; const payerDisplayName = isPreviewMessageForParentChatReport ? payerName : actualPayerName; @@ -6101,7 +6135,12 @@ function getReportPreviewMessage( if (report.isWaitingOnBankAccount) { const submitterDisplayName = - getDisplayNameForParticipant({accountID: report.ownerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}) ?? ''; + getDisplayNameForParticipant({ + accountID: report.ownerAccountID, + shouldUseShortForm: true, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: translate('common.hidden'), + }) ?? ''; return translate('iou.waitingOnBankAccount', submitterDisplayName); } @@ -6130,7 +6169,12 @@ function getReportPreviewMessage( // We only want to show the actor name in the preview if it's not the current user who took the action const requestorName = lastActorID && lastActorID !== deprecatedCurrentUserAccountID - ? getDisplayNameForParticipant({accountID: lastActorID, shouldUseShortForm: !isPreviewMessageForParentChatReport, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}) + ? getDisplayNameForParticipant({ + accountID: lastActorID, + shouldUseShortForm: !isPreviewMessageForParentChatReport, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: translate('common.hidden'), + }) : ''; return `${requestorName ? `${requestorName}: ` : ''}${translate('iou.expenseAmount', amountToDisplay, comment)}`; } @@ -6139,7 +6183,7 @@ function getReportPreviewMessage( return translate( 'iou.payerSpentAmount', formattedAmount, - getDisplayNameForParticipant({accountID: report.ownerAccountID, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}) ?? '', + getDisplayNameForParticipant({accountID: report.ownerAccountID, formatPhoneNumber: formatPhoneNumberPhoneUtils, hiddenTranslation: translate('common.hidden')}) ?? '', ); } return translate('iou.payerOwesAmount', formattedAmount, payerName ?? '', comment); @@ -6503,7 +6547,12 @@ function getPayeeName(report: OnyxEntry, translate: LocalizedTranslate, if (participantsWithoutCurrentUser.length === 0) { return undefined; } - return getDisplayNameForParticipant({accountID: participantsWithoutCurrentUser.at(0), shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}); + return getDisplayNameForParticipant({ + accountID: participantsWithoutCurrentUser.at(0), + shouldUseShortForm: true, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: translate('common.hidden'), + }); } // TODO: currentUserEmail will be required eventually so this becomes a pure function. Subscribe the data via useOnyx and pass it from the component. Refactor issue: https://github.com/Expensify/App/issues/66412 @@ -6624,7 +6673,12 @@ function getParentNavigationSubtitle( const login = personalDetails ? personalDetails.login : null; const reportOwnerDisplayName = - getDisplayNameForParticipant({accountID: ownerAccountID, shouldRemoveDomain: true, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}) || login; + getDisplayNameForParticipant({ + accountID: ownerAccountID, + shouldRemoveDomain: true, + formatPhoneNumber: formatPhoneNumberPhoneUtils, + hiddenTranslation: translate('common.hidden'), + }) || login; if (isExpenseReport(report)) { return { @@ -10335,17 +10389,11 @@ function reasonForReportToBeInOptionList({ !report?.reportID || !report?.type || report?.reportName === undefined || - (!report?.participants && - // We omit sending back participants for chat rooms when searching for reports since they aren't needed to display the results and can get very large. - // So we allow showing rooms with no participants–in any other circumstances we should never have these reports with no participants in Onyx. - !isChatRoom(report) && - !isChatThreadReport && - !isReportArchived && - !isMoneyRequestReport(report) && - !isTaskReport(report) && - !isSelfDMReport && - !isSystemChatReport && - !isGroupChat(report)) + // A DM's name and avatar come entirely from `participants`, so a DM without them has nothing to render. + // Every other report type gets its name and icon from elsewhere (the policy, the chat type, the parent action), + // and search-shaped endpoints routinely omit `participants` to keep their payloads small, so those reports + // stay in the list. Archived DMs are kept too, since they can no longer be repopulated. + (!report?.participants && isDM(report) && !isReportArchived) ) { return null; } @@ -10964,8 +11012,10 @@ function getWhisperDisplayNames(translate: LocalizedTranslate, formatPhoneNumber return translate('common.youAfterPreposition'); } + // Resolve the translation once, not per participant. + const hiddenText = translate('common.hidden'); return participantAccountIDs - ?.map((accountID) => getDisplayNameForParticipant({accountID, shouldUseShortForm: !isWhisperOnlyVisibleToCurrentUser, formatPhoneNumber, translate})) + ?.map((accountID) => getDisplayNameForParticipant({accountID, shouldUseShortForm: !isWhisperOnlyVisibleToCurrentUser, formatPhoneNumber, hiddenTranslation: hiddenText})) .join(', '); } @@ -14716,4 +14766,5 @@ export type { SelfDMParameters, OptimisticReportAction, ActionErrorsByTransaction, + LastAccessedReport, }; diff --git a/src/libs/SearchQueryRefinement.ts b/src/libs/SearchQueryRefinement.ts new file mode 100644 index 000000000000..2f7d496f5ba7 --- /dev/null +++ b/src/libs/SearchQueryRefinement.ts @@ -0,0 +1,24 @@ +import type {SearchQueryString} from '@components/Search/types'; + +/** + * Records which search query was produced by refining the filters of the list already on screen. + * + * The results area holds the previous query's results through its swap animation so a filter tweak doesn't flash a + * skeleton. That only reads correctly for a refinement: navigating to a sidebar item or a saved search asks for a + * different search, where holding the old results would show rows unrelated to the destination. A query alone can't + * tell the two apart — both arrive as a new `q` on the same screen — so the filter path records its query here. + * + * Only the most recent query is kept: an unknown query is treated as a different search, so a missed mark costs a + * skeleton rather than showing stale results. + */ +let refinedQuery: SearchQueryString | undefined; + +function markQueryAsRefinement(query: SearchQueryString) { + refinedQuery = query; +} + +function isQueryARefinement(query: SearchQueryString | undefined) { + return !!query && refinedQuery === query; +} + +export {markQueryAsRefinement, isQueryARefinement}; diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index b6cb7381cf2c..021310182ab2 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -1603,11 +1603,9 @@ function getDateRangeForPreset(preset: SearchDatePreset): {start: string; end: s * Reverse operation of buildQueryStringFromFilterFormValues() */ // Adds bankAccountList and currentUserAccountID for the new bank account and from:me filters. Refactoring this to a params object would touch every call site and is out of scope here. -// eslint-disable-next-line @typescript-eslint/max-params function buildFilterFormValuesFromQuery( queryJSON: SearchQueryJSON, policyCategories: OnyxCollection, - policyTags: OnyxCollection, currencyList: OnyxTypes.CurrencyList, personalDetails: OnyxTypes.PersonalDetailsList | undefined, cardList: OnyxTypes.CardList | undefined, @@ -1748,17 +1746,8 @@ function buildFilterFormValuesFromQuery( filtersForm[filterKey] = filterValues.find((currency) => validCurrencies.has(currency)); } if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG) { - const uniqueTags = new Set(); - const tagLists = getAllPolicyValues(policyID, ONYXKEYS.COLLECTION.POLICY_TAGS, policyTags); - for (const tagList of tagLists) { - for (const policyTagList of Object.values(tagList ?? {})) { - for (const tag of Object.values(policyTagList.tags ?? {})) { - uniqueTags.add(tag.name); - } - } - } - uniqueTags.add(CONST.SEARCH.TAG_EMPTY_VALUE); - filtersForm[addNegation(filterKey, isNegated)] = filterValues.filter((name) => uniqueTags.has(name)); + // Tag values are kept as-is: with server-side tag pagination the local tag data is never complete, so validating against it would silently drop valid tags + filtersForm[addNegation(filterKey, isNegated)] = filterValues; } if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY) { const uniqueCategories = new Set(); diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 4e9407a23aa7..1f65940357a2 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -681,6 +681,11 @@ function isPolicyEligibleForSpendOverTime(policy: OnyxTypes.Policy, currentUserE ); } +/** Ranking members by spend needs at least two members to compare, and a role allowed to see what other people spend. */ +function isPolicyEligibleForTopSpenders(policy: OnyxTypes.Policy, currentUserEmail: string | undefined): boolean { + return isPolicyEligibleForSpendOverTime(policy, currentUserEmail) && Object.keys(policy.employeeList ?? {}).length >= 2; +} + /** * `hasReportAwaitingApproval` seeds the approve suggestion so a user who is the manager of a report awaiting their * approval sees it even when they are not part of the policy's approval workflow (e.g. an approver chosen manually on @@ -754,7 +759,7 @@ function getSuggestedSearchesVisibility( const isEligibleForExpensifyCardSuggestion = isPaidPolicy && (isAdmin || isAuditor) && isECardEnabled; const isEligibleForReimbursementsSuggestion = isPaidPolicy && (isAdmin || isAuditor) && isPaymentEnabled && hasVBBA && hasReimburser; const memberCount = Object.keys(policy.employeeList ?? {}).length; - const isEligibleForTopSpendersSuggestion = isGroupPolicyEligible && (isAdmin || isAuditor || isUserApprover) && memberCount >= 2; + const isEligibleForTopSpendersSuggestion = isPolicyEligibleForTopSpenders(policy, currentUserEmail); const isEligibleForTopCategoriesSuggestion = isGroupPolicyEligible && policy.areCategoriesEnabled === true; const isEligibleForTopMerchantsSuggestion = isGroupPolicyEligible; const isEligibleForViolationsBySubmitterSuggestion = @@ -3176,6 +3181,7 @@ function getMemberSections( queryJSON: SearchQueryJSON | undefined, formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], translate: LocalizedTranslate, + onyxPersonalDetailsList?: OnyxTypes.PersonalDetailsList, ): [TransactionMemberGroupListItemType[], number, boolean] { const memberSections: Record = {}; @@ -3183,7 +3189,7 @@ function getMemberSections( if (isGroupEntry(key)) { const memberGroup = data[key] as SearchMemberGroup; - const personalDetails = data.personalDetailsList?.[memberGroup.accountID] ?? emptyPersonalDetails; + const personalDetails = data.personalDetailsList?.[memberGroup.accountID] ?? onyxPersonalDetailsList?.[memberGroup.accountID] ?? emptyPersonalDetails; const transactionsQueryJSON = queryJSON && memberGroup.accountID ? buildSpecificGroupQuery(queryJSON, CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, memberGroup.accountID) : undefined; memberSections[key] = { @@ -3618,7 +3624,7 @@ function getQuarterSections( ? buildDateRangeGroupQuery(queryJSON, DateUtils.getQuarterDateRange(quarterGroup.year, quarterGroup.quarter))?.transactionsQueryJSON : undefined; const formattedQuarter = DateUtils.getFormattedQuarterForSearch(quarterGroup.year, quarterGroup.quarter, dateFnsLocale); - const shortFormattedQuarter = DateUtils.getShortFormattedQuarterForSearch(quarterGroup.year, quarterGroup.quarter); + const shortFormattedQuarter = DateUtils.getShortFormattedQuarterForSearch(quarterGroup.year, quarterGroup.quarter, dateFnsLocale); quarterSections[key] = { groupedBy: CONST.SEARCH.GROUP_BY.QUARTER, @@ -3701,7 +3707,7 @@ function getSections({ // eslint-disable-next-line default-case switch (groupBy) { case CONST.SEARCH.GROUP_BY.FROM: - return getMemberSections(data, queryJSON, formatPhoneNumber, translate); + return getMemberSections(data, queryJSON, formatPhoneNumber, translate, onyxPersonalDetailsList); case CONST.SEARCH.GROUP_BY.CARD: return getCardSections(data, queryJSON, translate, cardFeeds, customCardNames, cardList, nonPersonalAndWorkspaceCardList); case CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID: @@ -4653,6 +4659,13 @@ function isTodoSearch(recentSearchHash: number, suggestedSearches: Record([ CONST.SEARCH.TABLE_COLUMNS.SUBMITTER_USER_ID, @@ -6541,7 +6550,8 @@ function getColumnsToShow({ // If the user has set custom columns for the search, we need to respect their preference and order const allowedColumns: string[] = isExpenseReportView ? Object.values(CONST.SEARCH.REPORT_DETAILS_CUSTOM_COLUMNS) : Object.values(CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE); - const filteredVisibleColumns = visibleColumns.filter((column) => allowedColumns.includes(column)); + // The saved list outlives the vendor feature, so Vendor is dropped once no workspace has the feature anymore. + const filteredVisibleColumns = visibleColumns.filter((column) => allowedColumns.includes(column) && (isVendorColumnAvailable || column !== CONST.SEARCH.TABLE_COLUMNS.VENDOR)); const isDefaultExpenseColumnSelection = arraysEqual(Object.values(CONST.SEARCH.TYPE_DEFAULT_COLUMNS.EXPENSE), filteredVisibleColumns); const shouldUseCustomResult = !isDefaultExpenseColumnSelection && filteredVisibleColumns.length > 0; @@ -6657,7 +6667,7 @@ function getColumnsToShow({ columns[CONST.SEARCH.TABLE_COLUMNS.CARD] = true; } - if (transaction.comment?.vendor?.externalID) { + if (isVendorColumnAvailable && transaction.comment?.vendor?.externalID) { columns[CONST.SEARCH.TABLE_COLUMNS.VENDOR] = true; } @@ -7254,6 +7264,7 @@ export { getActions, getPrimaryAction, createTypeMenuSections, + SPEND_INSIGHT_KEYS, formatBadgeText, getSectionBadgeText, getItemBadgeText, @@ -7326,6 +7337,7 @@ export { isCreatedDateType, doesSearchItemMatchSort, isPolicyEligibleForSpendOverTime, + isPolicyEligibleForTopSpenders, hasFlexColumn, isTransactionSearchType, splitGroupsIntoPairs, diff --git a/src/libs/SuggestionUtils.ts b/src/libs/SuggestionUtils.ts index 28636f7be71b..b5b6f75f55c1 100644 --- a/src/libs/SuggestionUtils.ts +++ b/src/libs/SuggestionUtils.ts @@ -11,8 +11,8 @@ function trimLeadingSpace(str: string): string { return str.startsWith(' ') ? str.slice(1) : str; } -function getDisplayName(details: PersonalDetails, formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], translate: LocaleContextProps['translate']) { - const displayNameFromAccountID = getDisplayNameForParticipant({accountID: details.accountID, formatPhoneNumber, translate}); +function getDisplayName(details: PersonalDetails, formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], hiddenTranslation: string) { + const displayNameFromAccountID = getDisplayNameForParticipant({accountID: details.accountID, formatPhoneNumber, hiddenTranslation}); if (!displayNameFromAccountID) { return details.login?.length ? details.login : ''; } @@ -28,12 +28,14 @@ function getSortedPersonalDetails( formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], translate: LocaleContextProps['translate'], ) { + // Resolve the translation once, not per comparison. + const hiddenText = translate('common.hidden'); return personalDetails.sort((first, second) => { if (first.weight !== second.weight) { return first.weight - second.weight; } - const displayNameLoginOrder = localeCompare(getDisplayName(first, formatPhoneNumber, translate), getDisplayName(second, formatPhoneNumber, translate)); + const displayNameLoginOrder = localeCompare(getDisplayName(first, formatPhoneNumber, hiddenText), getDisplayName(second, formatPhoneNumber, hiddenText)); if (displayNameLoginOrder !== 0) { return displayNameLoginOrder; } diff --git a/src/libs/TagsOptionsListUtils.ts b/src/libs/TagsOptionsListUtils.ts index b8038d62b005..0953002f63fc 100644 --- a/src/libs/TagsOptionsListUtils.ts +++ b/src/libs/TagsOptionsListUtils.ts @@ -9,7 +9,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {Option} from './OptionsListUtils'; import {insertTagIntoTransactionTagsString} from './IOUUtils'; -import {hasEnabledOptions} from './OptionsListUtils'; +import {hasEnabledOptions, isOptionEnabled} from './OptionsListUtils'; import { getCleanedTagName, getGLCodeFromPolicyTag, @@ -208,12 +208,27 @@ function getTagListSections({ * Verifies that there is at least one enabled tag */ function hasEnabledTags(policyTagList: Array) { - const policyTagValueList = policyTagList - .filter((tag) => tag?.tags) - .map(({tags}) => Object.values(tags)) - .flat(); + for (const tagList of policyTagList) { + const tags = tagList?.tags; - return hasEnabledOptions(policyTagValueList); + if (!tags) { + continue; + } + + for (const tagName in tags) { + if (!Object.hasOwn(tags, tagName)) { + continue; + } + + const tag = tags[tagName]; + + if (isOptionEnabled(tag)) { + return true; + } + } + } + + return false; } /** diff --git a/src/libs/actions/AppUpdate/index.ts b/src/libs/actions/AppUpdate/index.ts deleted file mode 100644 index c45b0bff9384..000000000000 --- a/src/libs/actions/AppUpdate/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import ONYXKEYS from '@src/ONYXKEYS'; - -import Onyx from 'react-native-onyx'; - -import updateApp from './updateApp'; - -function triggerUpdateAvailable() { - Onyx.set(ONYXKEYS.RAM_ONLY_UPDATE_AVAILABLE, true); -} - -export {triggerUpdateAvailable, updateApp}; diff --git a/src/libs/actions/EmojiPickerAction.ts b/src/libs/actions/EmojiPickerAction.ts index 94acc788b6aa..670742ef8580 100644 --- a/src/libs/actions/EmojiPickerAction.ts +++ b/src/libs/actions/EmojiPickerAction.ts @@ -6,7 +6,7 @@ import type {ComposerType} from '@libs/ReportActionComposeFocusManager'; import type CONST from '@src/CONST'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {TextInput, View} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -18,7 +18,7 @@ type AnchorOrigin = { shiftVertical?: number; }; -type EmojiPopoverAnchor = RefObject; +type EmojiPopoverAnchor = RefObject | HTMLDivElement | ComponentRef | null>; type EmojiPickerOnWillShow = (callback?: CloseContextMenuCallback) => void; diff --git a/src/libs/actions/Export.ts b/src/libs/actions/Export.ts index 3d887ce767f0..157da051d2aa 100644 --- a/src/libs/actions/Export.ts +++ b/src/libs/actions/Export.ts @@ -102,7 +102,7 @@ function clearStaleExportDownloads() { }); } -function exportReportsToPDF(reportIDs: string[]): string { +function exportReportsToPDF(reportIDs: string[], jsonQuery?: string): string { const exportID = rand64(); const onyxKey = `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}` as const; @@ -124,7 +124,7 @@ function exportReportsToPDF(reportIDs: string[]): string { markExportInitiatedLocally(exportID); - write(WRITE_COMMANDS.EXPORT_REPORTS_TO_PDF, {reportIDs: JSON.stringify(reportIDs), exportID}, {optimisticData, failureData}); + write(WRITE_COMMANDS.EXPORT_REPORTS_TO_PDF, {reportIDs: JSON.stringify(reportIDs), exportID, ...(jsonQuery ? {jsonQuery} : {})}, {optimisticData, failureData}); return exportID; } diff --git a/src/libs/actions/Insights.ts b/src/libs/actions/Insights.ts index 2fd98bdc1198..2e1a6369ca5a 100644 --- a/src/libs/actions/Insights.ts +++ b/src/libs/actions/Insights.ts @@ -9,10 +9,10 @@ import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -function getInsights(dashboard: InsightsDashboardID, hash: number, jsonQuery: string) { +function getInsights(dashboard: InsightsDashboardID, hash: number, jsonQuery: string, snapshotHashes: number[]) { const key = `${ONYXKEYS.COLLECTION.INSIGHTS}${dashboard}_${hash}` as const; - const optimisticData: Array> = [ + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key, @@ -20,6 +20,14 @@ function getInsights(dashboard: InsightsDashboardID, hash: number, jsonQuery: st errors: null, }, }, + ...snapshotHashes.map>((snapshotHash) => ({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${snapshotHash}`, + value: { + errors: null, + search: {responseJsonCode: null}, + }, + })), ]; const failureData: Array> = [ { diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index 2fbbc4333a2c..78747261c2b6 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -32,7 +32,7 @@ import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {hasCompletedGuidedSetupFlowSelector} from '@src/selectors/Onboarding'; -import type {Beta, IntroSelected, Report} from '@src/types/onyx'; +import type {Beta, IntroSelected, Report, ReportNameValuePairs} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -433,6 +433,7 @@ function openReportFromDeepLink( isSelfTourViewed: boolean | undefined, betas: OnyxEntry, callerAccountID: number, + reportNameValuePairs: OnyxCollection, ) { const reportID = getReportIDFromLink(url); @@ -601,7 +602,7 @@ function openReportFromDeepLink( // If the report does not exist, navigate to the last accessed report or Concierge chat if (reportID && (!report?.reportID || report.errorFields?.notFound)) { // TODO: Pass guideAccountIDs once callers are fully migrated — PR 33 (https://github.com/Expensify/App/issues/66413); findLastAccessedReport falls back to hasExpensifyGuidesEmails → allPersonalDetails - const lastAccessedReportID = findLastAccessedReport(false, undefined, shouldOpenOnAdminRoom(), reportID)?.reportID; + const lastAccessedReportID = findLastAccessedReport(false, undefined, shouldOpenOnAdminRoom(), reportID, reportNameValuePairs)?.reportID; if (lastAccessedReportID) { const lastAccessedReportRoute = ROUTES.REPORT_WITH_ID.getRoute(lastAccessedReportID); Navigation.navigate(lastAccessedReportRoute, {forceReplace: Navigation.getTopmostReportId() === reportID, waitForTransition: true}); diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index 2e566c882397..93b3f043f0ca 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -22,6 +22,7 @@ import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; import * as LoginUtils from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; +import {isSupportAuthToken} from '@libs/Network/NetworkStore'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as UserAvatarUtils from '@libs/UserAvatarUtils'; @@ -252,6 +253,10 @@ function updateAutomaticTimezone(timezone: Timezone, currentUserAccountID: numbe return; } + if (isSupportAuthToken()) { + return; + } + const formattedTimezone = DateUtils.formatToSupportedTimezone(timezone); const parameters: UpdateAutomaticTimezoneParams = { timezone: JSON.stringify(formattedTimezone), diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 59ffb84762ba..d9340c47cccc 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -131,7 +131,6 @@ import { buildTransactionThread, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, computeOptimisticReportName, - findLastAccessedReport, findSelfDMReportID, formatReportLastMessageText, generateReportID, @@ -5313,10 +5312,8 @@ function navigateToMostRecentReport( introSelected: OnyxEntry, isSelfTourViewed: boolean | undefined, betas: OnyxEntry, + lastAccessedReportID?: string, ) { - // TODO: Pass guideAccountIDs once callers are fully migrated — PR 30 (https://github.com/Expensify/App/issues/66413); findLastAccessedReport falls back to hasExpensifyGuidesEmails → allPersonalDetails - const lastAccessedReportID = findLastAccessedReport(false, undefined, false, currentReport?.reportID)?.reportID; - if (lastAccessedReportID) { // Check if route exists for super wide RHP vs regular full screen report const topmostSuperWideRHP = Navigation.getTopmostSuperWideRHPReportID(); @@ -5354,9 +5351,7 @@ function getSearchThreadLeaveRoute(report: Report, activeRoute: string): Route | }); } -function getMostRecentReportID(currentReport: OnyxEntry, conciergeReportID: string | undefined) { - // TODO: Pass guideAccountIDs once callers are fully migrated — PR 30 (https://github.com/Expensify/App/issues/66413); findLastAccessedReport falls back to hasExpensifyGuidesEmails → allPersonalDetails - const lastAccessedReportID = findLastAccessedReport(false, undefined, false, currentReport?.reportID)?.reportID; +function getMostRecentReportID(conciergeReportID: string | undefined, lastAccessedReportID?: string) { return lastAccessedReportID ?? conciergeReportID; } @@ -5382,6 +5377,7 @@ function leaveGroupChat( introSelected: OnyxEntry, isSelfTourViewed: boolean | undefined, betas: OnyxEntry, + lastAccessedReportID?: string, ) { const reportID = report.reportID; // Use merge instead of set to avoid deleting the report too quickly, which could cause a brief "not found" page to appear. @@ -5432,7 +5428,7 @@ function leaveGroupChat( if (isSearchTopmostFullScreenRoute()) { Navigation.revealRouteBeforeDismissingModal(getReportRouteForCurrentContext({reportID})); } else { - navigateToMostRecentReport(report, conciergeReportID, currentUserAccountID, introSelected, isSelfTourViewed, betas); + navigateToMostRecentReport(report, conciergeReportID, currentUserAccountID, introSelected, isSelfTourViewed, betas, lastAccessedReportID); } API.write(WRITE_COMMANDS.LEAVE_GROUP_CHAT, {reportID}, {optimisticData, successData, failureData}); } @@ -5446,6 +5442,7 @@ function leaveRoom( isSelfTourViewed: boolean | undefined, betas: OnyxEntry, isWorkspaceMemberLeavingWorkspaceRoom = false, + lastAccessedReportID?: string, ) { const reportID = report.reportID; const isChatThread = isChatThreadReportUtils(report); @@ -5557,7 +5554,7 @@ function leaveRoom( return; } // In other cases, the report is deleted and we should move the user to another report. - navigateToMostRecentReport(report, conciergeReportID, currentUserAccountID, introSelected, isSelfTourViewed, betas); + navigateToMostRecentReport(report, conciergeReportID, currentUserAccountID, introSelected, isSelfTourViewed, betas, lastAccessedReportID); } function buildInviteToRoomOnyxData( diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index d48d5a9e9196..38d0600a4de0 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -16,17 +16,20 @@ import type { ExportSearchWithTemplateParams, OpenBulkChangeApproverPageParams, OpenSearchPageParams, + OpenSearchTagFiltersPageParams, + OpenSearchTagFiltersPageResponse, QueueExportSearchItemsToCSVParams, QueueExportSearchWithTemplateParams, ReportExportParams, SubmitReportParams, } from '@libs/API/parameters'; -import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import {getCommandURL} from '@libs/ApiUtils'; import deferModalPresentationAfterPopoverDismiss from '@libs/deferModalPresentationAfterPopoverDismiss'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; import {getExportFileName} from '@libs/fileDownload/FileUtils'; +import HttpUtils from '@libs/HttpUtils'; import Log from '@libs/Log'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; @@ -88,6 +91,7 @@ import type { ReportActions, Rule, SaveSearch, + SearchTagFilterItem, Transaction, TransactionViolations, } from '@src/types/onyx'; @@ -1082,6 +1086,64 @@ function openSearchCategoryFiltersPage() { read(READ_COMMANDS.OPEN_SEARCH_CATEGORY_FILTERS_PAGE, null, {optimisticData, successData, finallyData}); } +/** + * Fetches a page of tag filter search results from the server. + * Returns pagination metadata (hasMore, nextCursor) for infinite scroll. + * A new search passes `shouldCancelPendingRequests` so a superseded in-flight request cannot overwrite the fresh results. + * `currentResults` is the already-loaded tag list, used to append the next page when a cursor is passed. + */ +function openSearchTagFiltersPage( + params: OpenSearchTagFiltersPageParams, + shouldCancelPendingRequests = false, + currentResults: SearchTagFilterItem[] = [], +): Promise<{hasMore: boolean; nextCursor: string}> { + if (shouldCancelPendingRequests) { + HttpUtils.cancelPendingRequests(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE); + } + + const optimisticData: AnyOnyxUpdate[] = shouldCancelPendingRequests + ? [ + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS, + value: [], + }, + ] + : []; + + // The response body contains pagination metadata (hasMore, nextCursor) and tag slices needed to drive tag filter infinite scroll. + return makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE, {...params, canCancel: true}, {optimisticData}).then((response) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- OpenSearchTagFiltersPage response fields are command-specific and not declared on the shared Response type + const tagFiltersResponse = response as OpenSearchTagFiltersPageResponse | undefined; + const newTags = tagFiltersResponse?.tags ?? []; + if (params.cursor && newTags.length > 0) { + Onyx.set(ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS, [...currentResults, ...newTags]); + } + return { + hasMore: !!tagFiltersResponse?.hasMore, + nextCursor: tagFiltersResponse?.nextCursor ?? '', + }; + }); +} + +/** + * Updates the pagination state for tag filter search. + * Stored in RAM-only Onyx key so it survives component remounts but resets on app restart. + */ +function setSearchTagFiltersPagination(hasMore: boolean, nextCursor: string, searchQuery: string) { + Onyx.set(ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION, { + hasMore, + nextCursor, + searchQuery, + }); +} + +/** Resets tag filter pagination and cached results when the filter closes. */ +function clearSearchTagFiltersState() { + setSearchTagFiltersPagination(false, '', ''); + Onyx.set(ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS, []); +} + function openBulkChangeApproverPage(reportIDList: OpenBulkChangeApproverPageParams['reportIDList']) { const optimisticData: Array> = [ { @@ -2509,6 +2571,9 @@ export { handlePreventSearchAPI, openSearchCardFiltersPage, openSearchCategoryFiltersPage, + openSearchTagFiltersPage, + setSearchTagFiltersPagination, + clearSearchTagFiltersState, getPolicyFromSearchSnapshot, getReportFromSearchSnapshot, getReportActionsFromSearchSnapshot, diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 02f1c891566a..1cdf9b92c187 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -1599,7 +1599,11 @@ function waitForUserSignIn(): Promise { function handleExitToNavigation(exitTo: Route) { waitForUserSignIn().then(() => { Navigation.waitForProtectedRoutes().then(() => { - Navigation.goBack(ROUTES.HOME, {waitForTransition: true}); + if (navigationRef.canGoBack()) { + Navigation.pop(navigationRef.getRootState().key); + } else { + Navigation.goBack(ROUTES.HOME, {waitForTransition: true}); + } Navigation.navigate(exitTo, {waitForTransition: true}); }); }); diff --git a/src/libs/actions/TaskDeletion.ts b/src/libs/actions/TaskDeletion.ts index f6557fe06015..574448203e9f 100644 --- a/src/libs/actions/TaskDeletion.ts +++ b/src/libs/actions/TaskDeletion.ts @@ -27,6 +27,8 @@ import {notifyNewAction} from './Report/reportActionSubscribers'; type DeleteTaskOptions = { ancestors?: Ancestor[]; shouldNavigateBack?: boolean; + /** Fallback report ID when the deleted task has no parent report. */ + lastAccessedReportID?: string; }; /** @@ -34,7 +36,12 @@ type DeleteTaskOptions = { * @param report - The task report being deleted * @returns The URL to navigate to */ -function getNavigationUrlOnTaskDelete(report: OnyxEntry, conciergeReportID: string | undefined, reportActions: OnyxEntry): string | undefined { +function getNavigationUrlOnTaskDelete( + report: OnyxEntry, + conciergeReportID: string | undefined, + reportActions: OnyxEntry, + lastAccessedReportID?: string, +): string | undefined { if (!report) { return undefined; } @@ -49,7 +56,7 @@ function getNavigationUrlOnTaskDelete(report: OnyxEntry, conci } // If no parent report, try to navigate to most recent report - const mostRecentReportID = getMostRecentReportID(report, conciergeReportID); + const mostRecentReportID = getMostRecentReportID(conciergeReportID, lastAccessedReportID); if (mostRecentReportID) { return ROUTES.REPORT_WITH_ID.getRoute(mostRecentReportID); } @@ -70,7 +77,7 @@ function deleteTask( conciergeReportID: string | undefined, delegateEmail: string | undefined, reportActions: OnyxEntry, - {ancestors = [], shouldNavigateBack = true}: DeleteTaskOptions = {}, + {ancestors = [], shouldNavigateBack = true, lastAccessedReportID}: DeleteTaskOptions = {}, ) { if (!report) { return; @@ -195,7 +202,7 @@ function deleteTask( write(WRITE_COMMANDS.CANCEL_TASK, parameters, {optimisticData, successData, failureData}); notifyNewAction(report.reportID, undefined, true); - const urlToNavigateBack = shouldNavigateBack ? getNavigationUrlOnTaskDelete(report, conciergeReportID, reportActions) : undefined; + const urlToNavigateBack = shouldNavigateBack ? getNavigationUrlOnTaskDelete(report, conciergeReportID, reportActions, lastAccessedReportID) : undefined; if (urlToNavigateBack) { Navigation.goBack(); return urlToNavigateBack; diff --git a/src/libs/focusAfterModalClose.ts b/src/libs/focusAfterModalClose.ts index 0edad24834eb..f841a147795f 100644 --- a/src/libs/focusAfterModalClose.ts +++ b/src/libs/focusAfterModalClose.ts @@ -1,9 +1,10 @@ +import type {ComponentRef} from 'react'; import type {TextInput} from 'react-native'; import ComposerFocusManager from './ComposerFocusManager'; import isWindowReadyToFocus from './isWindowReadyToFocus'; -function focusAfterModalClose(textInput: TextInput | null) { +function focusAfterModalClose(textInput: ComponentRef | null) { if (!textInput) { return; } diff --git a/src/libs/focusComposerWithDelay/setTextInputSelection.ts b/src/libs/focusComposerWithDelay/setTextInputSelection.ts index f2ac4edd8805..857ee7d3d89b 100644 --- a/src/libs/focusComposerWithDelay/setTextInputSelection.ts +++ b/src/libs/focusComposerWithDelay/setTextInputSelection.ts @@ -1,5 +1,6 @@ import shouldSetSelectionRange from '@libs/shouldSetSelectionRange'; +import type {ComponentRef} from 'react'; import type {TextInput} from 'react-native'; import type {InputType, Selection} from './types'; @@ -10,7 +11,7 @@ const setTextInputSelection = (textInput: InputType, forcedSelectionRange: Selec if (setSelectionRange) { (textInput as HTMLTextAreaElement).setSelectionRange?.(forcedSelectionRange.start, forcedSelectionRange.end); } else { - (textInput as TextInput).setSelection?.(forcedSelectionRange.start, forcedSelectionRange.end); + (textInput as ComponentRef).setSelection?.(forcedSelectionRange.start, forcedSelectionRange.end); } }; diff --git a/src/libs/focusComposerWithDelay/types.ts b/src/libs/focusComposerWithDelay/types.ts index 1447161e88f2..df263b3df7a6 100644 --- a/src/libs/focusComposerWithDelay/types.ts +++ b/src/libs/focusComposerWithDelay/types.ts @@ -1,3 +1,4 @@ +import type {ComponentRef} from 'react'; import type {TextInput} from 'react-native'; type Selection = { @@ -9,6 +10,6 @@ type Selection = { type FocusComposerWithDelay = (shouldDelay?: boolean, forcedSelectionRange?: Selection, forceKeyboardIfAlreadyFocused?: boolean) => Promise; -type InputType = TextInput | HTMLTextAreaElement; +type InputType = ComponentRef | HTMLTextAreaElement; export type {Selection, FocusComposerWithDelay, InputType}; diff --git a/src/libs/getSearchColumnContentToMeasure.ts b/src/libs/getSearchColumnContentToMeasure.ts index c7f8e46f792e..4a45cdd86a4e 100644 --- a/src/libs/getSearchColumnContentToMeasure.ts +++ b/src/libs/getSearchColumnContentToMeasure.ts @@ -19,7 +19,7 @@ import {getCategoryGLCode, getDecodedLeafCategoryName, isCategoryMissing} from ' import getBase62ReportID from './getBase62ReportID'; import {getTagGLCode, getVendorDisplayName} from './PolicyUtils'; import {getReportName} from './ReportNameUtils'; -import {getReportStatusTranslation} from './ReportUtils'; +import {getPolicyName, getReportStatusTranslation} from './ReportUtils'; import { isTransactionCategoryGroupListItemType, isTransactionDayGroupListItemType, @@ -186,10 +186,12 @@ function getSearchColumnExtraWidth(column: SearchColumnType): number { const editableCellWidth = EDITABLE_SEARCH_COLUMNS.has(column) ? variables.editableCellChromeWidth : 0; switch (column) { + // The workspace cell draws the same avatar ahead of its text as the user cells do, so it reserves the same room. case CONST.SEARCH.TABLE_COLUMNS.FROM: case CONST.SEARCH.TABLE_COLUMNS.TO: case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER: case CONST.SEARCH.TABLE_COLUMNS.PAID_BY: + case CONST.SEARCH.TABLE_COLUMNS.POLICY_NAME: return editableCellWidth + USER_INFO_CELL_AVATAR_WIDTH; case CONST.SEARCH.TABLE_COLUMNS.STATUS: return editableCellWidth + variables.statusBadgeChromeWidth; @@ -248,7 +250,9 @@ function getTransactionColumnContentToMeasure( case CONST.SEARCH.TABLE_COLUMNS.ORDER_DEAL_NUMBERS: return [{text: item.report?.orderDealNumbers}]; case CONST.SEARCH.TABLE_COLUMNS.POLICY_NAME: - return [{text: item.policy?.name}]; + // Resolved the way the cell resolves it, so a report whose workspace is unavailable is measured at the + // width of the fallback it actually shows rather than at nothing. + return [{text: getPolicyName({report: item.report, unavailableTranslation: translate('workspace.common.unavailable')})}]; case CONST.SEARCH.TABLE_COLUMNS.TAX_RATE: return [{text: isTimeRequest(item) || isPerDiemRequest(item) ? '' : (getTaxName(item.policy, item) ?? item.taxValue ?? '')}]; case CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE: diff --git a/src/libs/isInputAutoFilled.ts b/src/libs/isInputAutoFilled.ts index 8535976c94c5..2b244685787f 100644 --- a/src/libs/isInputAutoFilled.ts +++ b/src/libs/isInputAutoFilled.ts @@ -1,3 +1,4 @@ +import type {ComponentRef} from 'react'; import type {TextInput} from 'react-native'; import isSelectorSupported from './isSelectorSupported'; @@ -5,7 +6,7 @@ import isSelectorSupported from './isSelectorSupported'; /** * Check the input is auto filled or not */ -export default function isInputAutoFilled(input: (TextInput | HTMLElement) | null): boolean { +export default function isInputAutoFilled(input: (ComponentRef | HTMLElement) | null): boolean { if ((!!input && !('matches' in input)) || !input?.matches) { return false; } diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 4454302ff163..dc05430a908f 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -16,7 +16,7 @@ import isReportTopmostSplitNavigator from './Navigation/helpers/isReportTopmostS import {dismissOnboardingModalBeforeExit} from './Navigation/helpers/OnboardingNavigationUtils'; import shouldOpenOnAdminRoom from './Navigation/helpers/shouldOpenOnAdminRoom'; import Navigation from './Navigation/Navigation'; -import {findLastAccessedReport, isConciergeChatReport, isSelfDM} from './ReportUtils'; +import {findLastAccessedReport} from './ReportUtils'; import {buildCannedSearchQuery} from './SearchQueryUtils'; let onboardingRHPVariant: OnyxEntry; @@ -62,7 +62,12 @@ function getReportIDAfterOnboarding( // When the user goes through the onboarding flow, a workspace can be created if the user selects specific options. The user should be taken to the #admins room for that workspace because it is the most natural place for them to start their experience in the app. // The user should never go to the self DM or the Concierge chat if a workspace was created during the onboarding flow. - if (lastAccessedReportID && lastAccessedReport.policyID !== onboardingPolicyID && !isConciergeChatReport(lastAccessedReport, conciergeReportID) && !isSelfDM(lastAccessedReport)) { + if ( + lastAccessedReportID && + lastAccessedReport.policyID !== onboardingPolicyID && + lastAccessedReport.reportID !== conciergeReportID && + lastAccessedReport.chatType !== CONST.REPORT.CHAT_TYPE.SELF_DM + ) { return lastAccessedReportID; } diff --git a/src/libs/preventTextInputFocusOnFirstResponderOnce/types.ts b/src/libs/preventTextInputFocusOnFirstResponderOnce/types.ts index 0c2e1233c557..6a7c3a15b2e4 100644 --- a/src/libs/preventTextInputFocusOnFirstResponderOnce/types.ts +++ b/src/libs/preventTextInputFocusOnFirstResponderOnce/types.ts @@ -1,7 +1,7 @@ -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {TextInput} from 'react-native'; -type PreventTextInputFocusOnFirstResponderOnce = (composerRef: RefObject) => void; +type PreventTextInputFocusOnFirstResponderOnce = (composerRef: RefObject | null>) => void; // eslint-disable-next-line import/prefer-default-export export type {PreventTextInputFocusOnFirstResponderOnce}; diff --git a/src/libs/updateMultilineInputRange/types.ts b/src/libs/updateMultilineInputRange/types.ts index 091f0a692ebb..348332586466 100644 --- a/src/libs/updateMultilineInputRange/types.ts +++ b/src/libs/updateMultilineInputRange/types.ts @@ -1,7 +1,8 @@ import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; +import type {ComponentRef} from 'react'; import type {TextInput} from 'react-native'; -type UpdateMultilineInputRange = (input: HTMLInputElement | BaseTextInputRef | TextInput | null, shouldAutoFocus?: boolean) => void; +type UpdateMultilineInputRange = (input: HTMLInputElement | BaseTextInputRef | ComponentRef | null, shouldAutoFocus?: boolean) => void; export default UpdateMultilineInputRange; diff --git a/src/pages/AddExistingExpense.tsx b/src/pages/AddExistingExpense.tsx index 31a7ef0add91..f24c4a1ceba6 100644 --- a/src/pages/AddExistingExpense.tsx +++ b/src/pages/AddExistingExpense.tsx @@ -1,35 +1,31 @@ import AddExistingExpenseFooter from '@components/AddExistingExpenseFooter'; import EmptyStateComponent from '@components/EmptyStateComponent'; +import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import {PressableWithFeedback} from '@components/Pressable'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; -import DropdownButton from '@components/Search/FilterDropdowns/DropdownButton'; -import type {MultiSelectItem} from '@components/Search/FilterDropdowns/MultiSelectPopup'; -import MultiSelectPopup from '@components/Search/FilterDropdowns/MultiSelectPopup'; -import SelectionList from '@components/SelectionList'; -import type {ListItem, SelectionListHandle} from '@components/SelectionList/types'; import UnreportedExpensesSkeleton from '@components/Skeletons/UnreportedExpensesSkeleton'; -import Text from '@components/Text'; +import type {UnreportedExpenseTableRowData} from '@components/Tables/AddExistingExpenseTable'; +import AddExistingExpenseTable from '@components/Tables/AddExistingExpenseTable'; -import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDebouncedState from '@hooks/useDebouncedState'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {fetchUnreportedExpenses} from '@libs/actions/UnreportedExpenses'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import type {AddExistingExpensesParamList} from '@libs/Navigation/types'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import tokenizedSearch from '@libs/tokenizedSearch'; -import {createUnreportedExpenses, getAmount, getCurrency, getDescription, getEligibleTransactionsToAdd, getMerchant, isUnreportedTransaction} from '@libs/TransactionUtils'; +import {createUnreportedExpenses, getEligibleTransactionsToAdd} from '@libs/TransactionUtils'; import Navigation from '@navigation/Navigation'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; @@ -47,24 +43,18 @@ import getEmptyArray from '@src/types/utils/getEmptyArray'; import type {OnyxCollection} from 'react-native-onyx'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useCallback, useEffect, useState} from 'react'; import {View} from 'react-native'; -import UnreportedExpenseListItem from './UnreportedExpenseListItem'; - type AddExistingExpensePageType = PlatformStackScreenProps; -type ExpenseStatus = typeof CONST.SEARCH.STATUS.EXPENSE.UNREPORTED | typeof CONST.SEARCH.STATUS.EXPENSE.DRAFTS; function AddExistingExpense({route}: AddExistingExpensePageType) { - const {convertToDisplayString} = useCurrencyListActions(); const {translate} = useLocalize(); const illustrations = useMemoizedLazyIllustrations(['FolderWithPapersAndWatch']); const [errorMessage, setErrorMessage] = useState(''); const [offset, setOffset] = useState(0); const {isOffline} = useNetwork(); - const [selectedIds, setSelectedIds] = useState(new Set()); - const [selectedStatuses, setSelectedStatuses] = useState>>([]); - const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + const [selectedIds, setSelectedIds] = useState([]); const {reportID, backToReport} = route.params; const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [reportToConfirm] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.reportID ?? CONST.REPORT.UNREPORTED_REPORT_ID}`); @@ -82,10 +72,16 @@ function AddExistingExpense({route}: AddExistingExpensePageType) { const [allOpenReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: openExpenseReportIDsSelector}); const [openReportDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT, {selector: openExpenseReportIDsSelector}); const isInLandscapeMode = useIsInLandscapeMode(); + const styles = useThemeStyles(); + // The table enables selection in this narrow pane modal off the real screen size, so the header has to match it. + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + const isMobileSelectionModeEnabled = useMobileSelectionMode(); + const shouldShowSelectionModeHeader = isMobileSelectionModeEnabled && isSmallScreenWidth; const transactionsSelector = useCallback( - (transactions: OnyxCollection) => - getEligibleTransactionsToAdd({transactions, report, policy, cardList, currentUserAccountID, reportID, allOpenReports, openReportDrafts}), + (allTransactions: OnyxCollection) => + getEligibleTransactionsToAdd({transactions: allTransactions, report, policy, cardList, currentUserAccountID, reportID, allOpenReports, openReportDrafts}), [report, policy, cardList, currentUserAccountID, reportID, allOpenReports, openReportDrafts], ); const [transactions = getEmptyArray()] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {selector: transactionsSelector}); @@ -102,75 +98,16 @@ function AddExistingExpense({route}: AddExistingExpensePageType) { fetchUnreportedExpenses(0); }, []); - const styles = useThemeStyles(); - const selectionListRef = useRef>(null); - - const shouldShowTextInput = useMemo(() => { - return transactions.length >= CONST.STANDARD_LIST_ITEM_LIMIT; - }, [transactions.length]); - - const filteredTransactions = useMemo(() => { - if (!debouncedSearchValue.trim() || !shouldShowTextInput) { - return transactions; - } - - return tokenizedSearch(transactions, debouncedSearchValue, (transaction) => { - const searchableFields: string[] = []; - - const merchant = getMerchant(transaction); - if (merchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && merchant !== CONST.TRANSACTION.DEFAULT_MERCHANT) { - searchableFields.push(merchant); - } - - const description = getDescription(transaction); - if (description.trim()) { - searchableFields.push(description); - } - - const amount = getAmount(transaction); - const currency = getCurrency(transaction); - const formattedAmount = convertToDisplayString(amount, currency); - searchableFields.push(formattedAmount); - - // This allows users to search "2000" and find "$2,000.00" for example - const normalizedAmount = (amount / 100).toString(); - searchableFields.push(normalizedAmount); + const unreportedExpenses: UnreportedExpenseTableRowData[] = createUnreportedExpenses(transactions).map((item) => ({ + ...item, + disabled: item.isDisabled, + })); - return searchableFields; - }); - }, [convertToDisplayString, debouncedSearchValue, shouldShowTextInput, transactions]); - - const selectedStatusValues = useMemo(() => selectedStatuses.map((s) => s.value), [selectedStatuses]); - - const statusFilteredTransactions = useMemo(() => { - if (selectedStatusValues.length === 0) { - return filteredTransactions; - } - - const includesUnreported = selectedStatusValues.includes(CONST.SEARCH.STATUS.EXPENSE.UNREPORTED); - const includesDrafts = selectedStatusValues.includes(CONST.SEARCH.STATUS.EXPENSE.DRAFTS); - - return filteredTransactions.filter((item) => { - const isUnreported = isUnreportedTransaction(item); - if (includesUnreported && isUnreported) { - return true; - } - if (includesDrafts && !isUnreported) { - return true; - } - return false; - }); - }, [filteredTransactions, selectedStatusValues]); - - const unreportedExpenses = useMemo(() => { - return createUnreportedExpenses(statusFilteredTransactions).map((item) => ({ - ...item, - isSelected: selectedIds.has(item.transactionID), - })); - }, [statusFilteredTransactions, selectedIds]); - - const footerContent = useMemo( - () => ( + const footerContent = ( + - ), - [selectedIds, report, reportToConfirm, policy, policyCategories, errorMessage, setErrorMessage], + ); - const headerMessage = useMemo(() => { - if ((debouncedSearchValue.trim() || selectedStatusValues.length > 0) && unreportedExpenses?.length === 0) { - return translate('common.noResultsFound'); - } - return ''; - }, [debouncedSearchValue, unreportedExpenses?.length, translate, selectedStatusValues.length]); - - const textInputOptions = useMemo( - () => ({ - value: searchValue, - label: shouldShowTextInput ? translate('iou.findExpense') : undefined, - onChangeText: setSearchValue, - }), - [searchValue, shouldShowTextInput, translate, setSearchValue], - ); - - const onSelectRow = useCallback( - (item: {transactionID: string}) => { - setSelectedIds((prevIds) => { - const newIds = new Set(prevIds); - if (newIds.has(item.transactionID)) { - newIds.delete(item.transactionID); - } else { - newIds.add(item.transactionID); - if (errorMessage) { - setErrorMessage(''); - } - } - return newIds; - }); - }, - [errorMessage], - ); - - const onSelectAll = () => { - setSelectedIds((prevSelectedIDs) => { - const availableUnreportedExpenses = unreportedExpenses.filter(({isDisabled}) => !isDisabled); - if (availableUnreportedExpenses.some(({transactionID}) => prevSelectedIDs.has(transactionID))) { - return new Set(); - } - if (errorMessage) { - setErrorMessage(''); - } - return new Set(availableUnreportedExpenses.map(({transactionID}) => transactionID)); - }); + // Must not read errorMessage. The Table re-runs its clear-selection effects when this callback's identity changes, + // so setting the error would immediately clear it again. + const onRowSelectionChange = (selectedRowKeys: string[]) => { + setSelectedIds(selectedRowKeys); + setErrorMessage(''); }; - const statusItems: Array> = useMemo( - () => [ - {text: translate('common.unreported'), value: CONST.SEARCH.STATUS.EXPENSE.UNREPORTED}, - {text: translate('common.draft'), value: CONST.SEARCH.STATUS.EXPENSE.DRAFTS}, - ], - [translate], - ); - - const statusPopoverComponent = useCallback( - (props: {closeOverlay: () => void}) => ( - - ), - [translate, statusItems, selectedStatuses], - ); - - const customListHeader = useMemo( - () => ( - - - {translate('workspace.people.selectAll')} - - s.text)} - PopoverComponent={statusPopoverComponent} - /> - - ), - [ - styles.flex1, - styles.flexRow, - styles.alignItemsCenter, - styles.justifyContentBetween, - styles.userSelectNone, - styles.textStrong, - styles.ph3, - onSelectAll, - translate, - selectedStatuses, - statusPopoverComponent, - ], - ); - - const listFooterContent = useMemo(() => { - if (shouldShowUnreportedTransactionsSkeletons) { - return ; - } - if (headerMessage) { - return ( - - {headerMessage} - - ); - } - return undefined; - }, [shouldShowUnreportedTransactionsSkeletons, headerMessage, styles.ph5, styles.pt3, styles.textLabel, styles.colorMuted]); + const paginationFooterContent = shouldShowUnreportedTransactionsSkeletons ? : undefined; - const hasSearchTerm = debouncedSearchValue.trim().length > 0; - const isShowingEmptyState = !hasSearchTerm && transactions.length === 0; + const isShowingEmptyState = transactions.length === 0; if (isShowingEmptyState && isLoadingUnreportedTransactions) { return ( @@ -381,28 +208,27 @@ function AddExistingExpense({route}: AddExistingExpensePageType) { focusTrapSettings={{active: false}} > - - data={unreportedExpenses} - ref={selectionListRef} - onSelectRow={onSelectRow} - onSelectAll={onSelectAll} - customListHeader={customListHeader} - style={{listHeaderWrapperStyle: styles.ph8}} - textInputOptions={textInputOptions} - shouldShowTextInput={shouldShowTextInput} - shouldShowListEmptyContent={false} - canSelectMultiple - ListItem={UnreportedExpenseListItem} - onEndReached={fetchMoreUnreportedTransactions} - onEndReachedThreshold={0.75} - addBottomSafeAreaPadding - listFooterContent={listFooterContent} - footerContent={footerContent} - disableMaintainingScrollPosition + title={shouldShowSelectionModeHeader ? translate('common.selectMultiple') : translate('iou.addExistingExpense')} + onBackButtonPress={() => { + if (shouldShowSelectionModeHeader) { + setSelectedIds([]); + turnOffMobileSelectionMode(); + return; + } + Navigation.goBack(); + }} /> + + + + {footerContent} ); } diff --git a/src/pages/Debug/ConstantSelector.tsx b/src/pages/Debug/ConstantSelector.tsx index 0a532792cc0a..931f0927c12e 100644 --- a/src/pages/Debug/ConstantSelector.tsx +++ b/src/pages/Debug/ConstantSelector.tsx @@ -1,12 +1,15 @@ -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; import type {View} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -35,7 +38,7 @@ type ConstantSelectorProps = { policyID?: string; /** The ref is required by InputWrapper, even though it's not used in this component yet */ - ref: ForwardedRef; + ref: ForwardedRef>; }; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -59,16 +62,25 @@ function ConstantSelector({formType, policyID, errorText = '', name, value, onIn }, [fieldValue, name, onInputChange]); return ( - { + { Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.DETAILS_CONSTANT_PICKER.getRoute(formType, name, value, policyID))); - }} - shouldShowRightIcon - /> + })} + > + + {!!errorText && } + + + {!!errorText && ( + + )} + ); } diff --git a/src/pages/Debug/DateTimeSelector.tsx b/src/pages/Debug/DateTimeSelector.tsx index 0851979cfd12..01246897fe12 100644 --- a/src/pages/Debug/DateTimeSelector.tsx +++ b/src/pages/Debug/DateTimeSelector.tsx @@ -1,12 +1,15 @@ -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; import type {View} from 'react-native'; import {useRoute} from '@react-navigation/native'; @@ -29,7 +32,7 @@ type DateTimeSelectorProps = { inputID: string; /** The ref is required by InputWrapper, even though it's not used in this component yet */ - ref?: ForwardedRef; + ref?: ForwardedRef>; }; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -53,16 +56,25 @@ function DateTimeSelector({errorText = '', name, value, onInputChange, ref}: Dat }, [fieldValue, name, onInputChange]); return ( - { + { Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.DETAILS_DATE_TIME_PICKER.getRoute(name, value))); - }} - shouldShowRightIcon - /> + })} + > + + {!!errorText && } + + + {!!errorText && ( + + )} + ); } diff --git a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx index 088354e9047a..9dadbf096997 100644 --- a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx +++ b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx @@ -32,7 +32,7 @@ import type {PersonalDetails, ReportAction, Session} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; type DebugReportActionCreatePageProps = PlatformStackScreenProps; @@ -74,13 +74,13 @@ function DebugReportActionCreatePage({ const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.chatReportID)}`); - const reportAction = useMemo(() => parseReportActionJSON(draftReportAction), [draftReportAction]); + const reportAction = parseReportActionJSON(draftReportAction); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportAction?.childReportID}`); const [error, setError] = useState(); - const createReportAction = useCallback(() => { + const createReportAction = () => { if (!reportAction) { return; } @@ -88,22 +88,18 @@ function DebugReportActionCreatePage({ [reportAction.reportActionID]: reportAction, }); Navigation.navigate(ROUTES.DEBUG_REPORT_TAB_ACTIONS.getRoute(reportID)); - }, [reportAction, reportID]); - - const editJSON = useCallback( - (updatedJSON: string) => { - try { - DebugUtils.validateReportActionJSON(updatedJSON); - setError(''); - } catch (e) { - const {cause, message} = e as SyntaxError; - setError(cause ? translate(message as TranslationPaths, cause as never) : message); - } finally { - setDraftReportAction(updatedJSON); - } - }, - [translate], - ); + }; + + const editJSON = (updatedJSON: string) => { + try { + DebugUtils.validateReportActionJSON(updatedJSON); + setError(''); + } catch (e) { + const {cause, message} = e as SyntaxError; + setError(cause ? translate(message as TranslationPaths, cause as never) : message); + } + setDraftReportAction(updatedJSON); + }; return ( ; @@ -75,26 +75,22 @@ function DebugTransactionViolationCreatePage({ const [draftTransactionViolation, setDraftTransactionViolation] = useState(() => getInitialTransactionViolation()); const [error, setError] = useState(); - const editJSON = useCallback( - (updatedJSON: string) => { - try { - DebugUtils.validateTransactionViolationJSON(updatedJSON); - setError(''); - } catch (e) { - const {cause, message} = e as SyntaxError; - setError(cause ? translate(message as TranslationPaths, cause as never) : message); - } finally { - setDraftTransactionViolation(updatedJSON); - } - }, - [translate], - ); + const editJSON = (updatedJSON: string) => { + try { + DebugUtils.validateTransactionViolationJSON(updatedJSON); + setError(''); + } catch (e) { + const {cause, message} = e as SyntaxError; + setError(cause ? translate(message as TranslationPaths, cause as never) : message); + } + setDraftTransactionViolation(updatedJSON); + }; - const createTransactionViolation = useCallback(() => { + const createTransactionViolation = () => { const parsedTransactionViolation = DebugUtils.stringToOnyxData(draftTransactionViolation, 'object') as TransactionViolation; Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [...(transactionViolations ?? []), parsedTransactionViolation]); Navigation.navigate(ROUTES.DEBUG_TRANSACTION_TAB_VIOLATIONS.getRoute(transactionID)); - }, [draftTransactionViolation, transactionID, transactionViolations]); + }; if (!transactionID) { return ; diff --git a/src/pages/DynamicReportChangeWorkspacePage.tsx b/src/pages/DynamicReportChangeWorkspacePage.tsx index 6786d7f56d6a..e01fddac6636 100644 --- a/src/pages/DynamicReportChangeWorkspacePage.tsx +++ b/src/pages/DynamicReportChangeWorkspacePage.tsx @@ -14,8 +14,6 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useDebouncedState from '@hooks/useDebouncedState'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useIsAppLoadPending} from '@hooks/useInFlightRequests'; -import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; -import useKeyboardState from '@hooks/useKeyboardState'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -24,6 +22,7 @@ import usePermissions from '@hooks/usePermissions'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactions from '@hooks/useReportTransactions'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useShouldSuppressPromotionalUI from '@hooks/useShouldSuppressPromotionalUI'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceList from '@hooks/useWorkspaceList'; @@ -126,9 +125,7 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace const [draftPolicyID, setDraftPolicyID] = useState(); const currentSelection = draftPolicyID ?? report.policyID; - const isInLandscapeMode = useIsInLandscapeMode(); - const {isKeyboardActive} = useKeyboardState(); - const shouldFooterBeInsideList = isInLandscapeMode && isKeyboardActive; + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); // The snapshot keeps the report row after a workspace change, and only the server can tell whether it still matches the query. const refreshSearch = () => { diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index 23cfb87d58cd..ae512bb5a222 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -64,6 +64,7 @@ import { canJoinChat, canLeaveChat, canWriteInReport, + findLastAccessedReport, getAvailableReportFields, getChatRoomSubtitle, getIcons, @@ -150,7 +151,7 @@ import {delegateEmailSelector} from '@selectors/Account'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import {billingRestrictionPolicySelector, createFilteredPoliciesInfoSelector, createHasWorkspaceToSubmitToSelector} from '@selectors/Policy'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import type {WithReportOrNotFoundProps} from './inbox/report/withReportOrNotFound'; @@ -216,6 +217,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const hasOutstandingChildTask = useHasOutstandingChildTask(report); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`); + const [guideAccountIDs] = useOnyx(ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [pendingDeleteMemberAccountIDs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID}`, {selector: pendingDeleteMemberAccountIDsSelector}); @@ -228,7 +230,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const {removeTransaction} = useSearchSelectionActions(); - const transactionThreadReportID = useMemo(() => getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline), [reportActions, isOffline, report, chatReport]); + const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -242,65 +244,61 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const currentUserAccountID = currentUserPersonalDetails?.accountID; + const currentUserEmail = currentUserPersonalDetails?.email; + const currentUserLogin = currentUserPersonalDetails?.login; + const currentUserDisplayName = currentUserPersonalDetails?.displayName; + const currentUserLocalCurrencyCode = currentUserPersonalDetails?.localCurrencyCode; const {getCurrencyDecimals} = useCurrencyListActions(); - const filteredPoliciesInfoSelector = useMemo(() => createFilteredPoliciesInfoSelector(currentUserPersonalDetails?.email), [currentUserPersonalDetails?.email]); + const filteredPoliciesInfoSelector = createFilteredPoliciesInfoSelector(currentUserEmail); const [filteredPoliciesInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: filteredPoliciesInfoSelector}); const [preferredPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(preferredPolicyID)}`, {selector: billingRestrictionPolicySelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {showConfirmModal} = useConfirmModal(); - const reportForHeader = useMemo(() => getReportForHeader(report, parentReport), [report, parentReport]); + const reportForHeader = getReportForHeader(report, parentReport); const derivedReportNames = useDerivedReportNamesByReportIDs([report?.parentReportID, reportForHeader?.reportID]); const derivedParentReportName = getReportNameFromNames(derivedReportNames, report?.parentReportID); const derivedHeaderReportName = getReportNameFromNames(derivedReportNames, reportForHeader?.reportID); - const isPolicyAdmin = useMemo(() => isPolicyAdminUtil(policy), [policy]); - const isPolicyEmployee = useMemo(() => isPolicyEmployeeUtil(report?.policyID, policy), [report?.policyID, policy]); - const isPolicyExpenseChat = useMemo(() => isPolicyExpenseChatUtil(report), [report]); - const isChatRoom = useMemo(() => isChatRoomUtil(report), [report]); - const isUserCreatedPolicyRoom = useMemo(() => isUserCreatedPolicyRoomUtil(report), [report]); - const isDefaultRoom = useMemo(() => isDefaultRoomUtil(report), [report]); - const isChatThread = useMemo(() => isChatThreadUtil(report), [report]); - const isMoneyRequestReport = useMemo(() => isMoneyRequestReportUtil(report), [report]); - const isMoneyRequest = useMemo(() => isMoneyRequestUtil(report), [report]); - const isInvoiceReport = useMemo(() => isInvoiceReportUtil(report), [report]); - const isFinancialReportsForBusinesses = useMemo(() => isFinancialReportsForBusinessesUtil(report), [report]); - const isInvoiceRoom = useMemo(() => isInvoiceRoomUtil(report), [report]); - const isTaskReport = useMemo(() => isTaskReportUtil(report), [report]); - const isSelfDM = useMemo(() => isSelfDMUtil(report), [report]); - const isTrackExpenseReport = useMemo(() => isTrackExpenseReportUtil(report, parentReport, parentReportAction), [report, parentReport, parentReportAction]); + const isPolicyAdmin = isPolicyAdminUtil(policy); + const isPolicyEmployee = isPolicyEmployeeUtil(report?.policyID, policy); + const isPolicyExpenseChat = isPolicyExpenseChatUtil(report); + const isChatRoom = isChatRoomUtil(report); + const isUserCreatedPolicyRoom = isUserCreatedPolicyRoomUtil(report); + const isDefaultRoom = isDefaultRoomUtil(report); + const isChatThread = isChatThreadUtil(report); + const isMoneyRequestReport = isMoneyRequestReportUtil(report); + const isMoneyRequest = isMoneyRequestUtil(report); + const isInvoiceReport = isInvoiceReportUtil(report); + const isFinancialReportsForBusinesses = isFinancialReportsForBusinessesUtil(report); + const isInvoiceRoom = isInvoiceRoomUtil(report); + const isTaskReport = isTaskReportUtil(report); + const isSelfDM = isSelfDMUtil(report); + const isTrackExpenseReport = isTrackExpenseReportUtil(report, parentReport, parentReportAction); const isCanceledTaskReport = isCanceledTaskReportUtil(report, parentReportAction); const isParentReportArchived = useReportIsArchived(parentReport?.reportID); - const isTaskModifiable = canModifyTask(report, currentUserPersonalDetails?.accountID, isParentReportArchived); - const isTaskActionable = canActionTask(report, parentReportAction, currentUserPersonalDetails?.accountID, parentReport, isParentReportArchived); - const canEditReportDescription = useMemo(() => canEditReportDescriptionUtil(report, policy), [report, policy]); + const isTaskModifiable = canModifyTask(report, currentUserAccountID, isParentReportArchived); + const isTaskActionable = canActionTask(report, parentReportAction, currentUserAccountID, parentReport, isParentReportArchived); + const canEditReportDescription = canEditReportDescriptionUtil(report, policy); const shouldShowReportDescription = isChatRoom && (canEditReportDescription || report.description !== '') && (isTaskReport ? isTaskModifiable : true); const isExpenseReport = isMoneyRequestReport || isInvoiceReport || isMoneyRequest; const isSingleTransactionView = isMoneyRequest || isTrackExpenseReport; const isSelfDMTrackExpenseReport = isTrackExpenseReport && isSelfDMUtil(parentReport); const isReportArchived = useReportIsArchived(report?.reportID); - const isArchivedRoom = useMemo(() => isArchivedNonExpenseReport(report, isReportArchived), [report, isReportArchived]); - const shouldDisableRename = useMemo(() => shouldDisableRenameUtil(report, isReportArchived), [report, isReportArchived]); + const isArchivedRoom = isArchivedNonExpenseReport(report, isReportArchived); + const shouldDisableRename = shouldDisableRenameUtil(report, isReportArchived); const parentNavigationSubtitleData = getParentNavigationSubtitle(report, policy, conciergeReportID, translate, derivedParentReportName, isParentReportArchived); const base62ReportID = getBase62ReportID(Number(report.reportID)); const ancestors = useAncestors(report); - const chatRoomSubtitle = useMemo(() => { - const subtitle = getChatRoomSubtitle(report, policy, conciergeReportID, translate, rules, false, isReportArchived); + const subtitle = getChatRoomSubtitle(report, policy, conciergeReportID, translate, rules, false, isReportArchived); + const chatRoomSubtitle = subtitle ?? ''; - if (subtitle) { - return subtitle; - } - - return ''; - }, [isReportArchived, report, policy, conciergeReportID, translate, rules]); - - const isSystemChat = useMemo(() => isSystemChatUtil(report), [report]); - const isGroupChat = useMemo(() => isGroupChatUtil(report), [report]); - const isRootGroupChat = useMemo(() => isRootGroupChatUtil(report, isReportArchived), [report, isReportArchived]); - const isThread = useMemo(() => isThreadUtil(report), [report]); + const isSystemChat = isSystemChatUtil(report); + const isGroupChat = isGroupChatUtil(report); + const isRootGroupChat = isRootGroupChatUtil(report, isReportArchived); + const isThread = isThreadUtil(report); const shouldOpenRoomMembersPage = isUserCreatedPolicyRoom || isChatThread || (isPolicyExpenseChat && isPolicyAdmin); - const participants = useMemo(() => { - return getParticipantsList(report, personalDetails, shouldOpenRoomMembersPage); - }, [report, personalDetails, shouldOpenRoomMembersPage]); + const participants = getParticipantsList(report, personalDetails, shouldOpenRoomMembersPage); let caseID: CaseID; if (isMoneyRequestReport || isInvoiceReport) { @@ -325,33 +323,22 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report }); const isPrivateNotesFetchTriggered = reportLoadingState?.isLoadingPrivateNotes !== undefined; - const requestParentReportAction = useMemo(() => { - // 2. MoneyReport case - if (caseID === CASES.MONEY_REPORT) { - if (!reportActions || !transactionThreadReport?.parentReportActionID) { - return undefined; - } - return reportActions.find((action) => action.reportActionID === transactionThreadReport.parentReportActionID); - } - return parentReportAction; - }, [caseID, parentReportAction, reportActions, transactionThreadReport?.parentReportActionID]); + const transactionThreadParentReportActionID = transactionThreadReport?.parentReportActionID; + // Without an ID to match, the scan can only ever miss, so skip it rather than walking every action. + const transactionThreadParentReportAction = transactionThreadParentReportActionID + ? reportActions?.find((action) => action.reportActionID === transactionThreadParentReportActionID) + : undefined; + const requestParentReportAction = caseID === CASES.MONEY_REPORT ? transactionThreadParentReportAction : parentReportAction; const {iouReport, chatReport: chatIOUReport, isChatIOUReportArchived} = useGetIOUReportFromReportAction(requestParentReportAction); const [iouPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${iouReport?.policyID}`); const [requestParentReportActionChildReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(requestParentReportAction?.childReportID)}`); const [transactionThreadReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(requestParentReportAction?.childReportID)}`); const isActionOwner = - typeof requestParentReportAction?.actorAccountID === 'number' && - typeof currentUserPersonalDetails?.accountID === 'number' && - requestParentReportAction.actorAccountID === currentUserPersonalDetails?.accountID; + typeof requestParentReportAction?.actorAccountID === 'number' && typeof currentUserAccountID === 'number' && requestParentReportAction.actorAccountID === currentUserAccountID; const isDeletedParentAction = isDeletedAction(requestParentReportAction); - const moneyRequestReport: OnyxEntry = useMemo(() => { - if (caseID === CASES.MONEY_REQUEST) { - return parentReport; - } - return report; - }, [caseID, parentReport, report]); + const moneyRequestReport: OnyxEntry = caseID === CASES.MONEY_REQUEST ? parentReport : report; const isMoneyRequestReportArchived = useReportIsArchived(moneyRequestReport?.reportID); const [moneyRequestReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(moneyRequestReport?.reportID)}`); @@ -367,7 +354,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const iouTransactionID = isMoneyRequestAction(requestParentReportAction) ? getOriginalMessage(requestParentReportAction)?.IOUTransactionID : undefined; const [iouTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransactionID)}`); const [iouOriginalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransaction?.comment?.originalTransactionID)}`); - const hasWorkspaceToSubmitToSelector = useMemo(() => createHasWorkspaceToSubmitToSelector(currentUserPersonalDetails.login), [currentUserPersonalDetails.login]); + const hasWorkspaceToSubmitToSelector = createHasWorkspaceToSubmitToSelector(currentUserLogin); const [hasWorkspaceToSubmitTo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasWorkspaceToSubmitToSelector}); const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(iouTransactionID ? [iouTransactionID] : []); const {deleteTransactions, shouldOpenSplitExpenseEditFlowOnDelete} = useDeleteTransactions({ @@ -384,7 +371,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report } else if (caseID === CASES.DEFAULT) { deleteMenuItemTitle = translate('common.delete'); } - const isWorkspaceChat = useMemo(() => isWorkspaceChatUtil(report?.chatType ?? ''), [report?.chatType]); + const isWorkspaceChat = isWorkspaceChatUtil(report?.chatType ?? ''); useEffect(() => { // Do not fetch private notes if the feature is disabled, isLoadingPrivateNotes is already defined, the network is offline, or if the report is a self DM. @@ -395,36 +382,28 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report getReportPrivateNote(report?.reportID); }, [report?.reportID, isOffline, isPrivateNotesFetchTriggered, isSelfDM]); - const leaveChat = useCallback(() => { + const leaveChat = () => { + // Resolve on tap from the module-scoped copies so this large page does not subscribe to whole collections. + const lastAccessedReportID = findLastAccessedReport(false, guideAccountIDs, false, report.reportID)?.reportID; if (isRootGroupChat) { leaveGroupChat( report, quickAction?.chatReportID?.toString() === report.reportID, - currentUserPersonalDetails.accountID, + currentUserAccountID, conciergeReportID, introSelected, isSelfTourViewed, betas, + lastAccessedReportID, ); return; } const isWorkspaceMemberLeavingWorkspaceRoom = isWorkspaceMemberLeavingWorkspaceRoomUtil(report, isPolicyEmployee, isPolicyAdmin); - leaveRoom(report, currentUserPersonalDetails.accountID, conciergeReportID, introSelected, isSelfTourViewed, betas, isWorkspaceMemberLeavingWorkspaceRoom); - }, [ - isRootGroupChat, - isPolicyEmployee, - isPolicyAdmin, - quickAction?.chatReportID, - report, - currentUserPersonalDetails.accountID, - conciergeReportID, - introSelected, - isSelfTourViewed, - betas, - ]); + leaveRoom(report, currentUserAccountID, conciergeReportID, introSelected, isSelfTourViewed, betas, isWorkspaceMemberLeavingWorkspaceRoom, lastAccessedReportID); + }; - const showLastMemberLeavingModal = useCallback(async () => { + const showLastMemberLeavingModal = async () => { const {action} = await showConfirmModal({ title: translate('groupChat.lastMemberTitle'), prompt: translate('groupChat.lastMemberWarning'), @@ -437,9 +416,9 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report return; } leaveChat(); - }, [showConfirmModal, translate, leaveChat]); + }; - const shouldShowLeaveButton = canLeaveChat(report, policy, currentUserPersonalDetails?.accountID, !!reportNameValuePairs?.private_isArchived); + const shouldShowLeaveButton = canLeaveChat(report, policy, currentUserAccountID, !!reportNameValuePairs?.private_isArchived); // Snapshot on focus whether the room is the screen behind the Details page, so the row doesn't flip while the page // is closing after it's tapped, yet still reflects the correct screen on later visits. @@ -448,7 +427,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report setIsRoomCurrentlyOpen(isReportTopmostSplitNavigator() && Navigation.getTopmostReportId() === report?.reportID); }); const shouldShowGoToRoom = (isChatRoom || isPolicyExpenseChat) && !isRoomCurrentlyOpen; - const shouldShowGoToWorkspace = shouldShowPolicy(policy, false, currentUserPersonalDetails?.email) && !policy?.isJoinRequestPending && !shouldShowGoToRoom; + const shouldShowGoToWorkspace = shouldShowPolicy(policy, false, currentUserEmail) && !policy?.isJoinRequestPending && !shouldShowGoToRoom; const shouldParseFullTitle = parentReportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT && !isGroupChat; const rawReportName = getReportName(reportForHeader, derivedHeaderReportName); @@ -468,7 +447,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const shouldShowWriteCapability = !isMoneyRequestReport; const shouldShowMenuItem = shouldShowNotificationPref || shouldShowWriteCapability || (!!report?.visibility && report.chatType !== CONST.REPORT.CHAT_TYPE.INVOICE); - const menuItems: DynamicReportDetailsPageMenuItem[] = useMemo(() => { + const menuItems: DynamicReportDetailsPageMenuItem[] = (() => { const items: DynamicReportDetailsPageMenuItem[] = []; if (isSelfDM) { @@ -550,7 +529,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report if (isTrackExpenseReport && !isDeletedParentAction) { const whisperAction = getTrackExpenseActionableWhisper(iouTransactionID, moneyRequestReport?.reportID, moneyRequestReportActions); const actionableWhisperReportActionID = whisperAction?.reportActionID; - const currentUserLocalCurrency = currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD; + const currentUserLocalCurrency = currentUserLocalCurrencyCode ?? CONST.CURRENCY.USD; const {isExpenseSplit: isSelfDMExpenseSplit} = getOriginalTransactionWithSplitInfo(iouTransaction, iouOriginalTransaction); // Hide the "Submit it to someone" option for self-DM split expenses when the user isn't a member of any workspace. @@ -567,15 +546,15 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report ownerBillingGracePeriodEnd, restrictedPreferredPolicy: isRestrictedToPreferredPolicy ? preferredPolicy : undefined, transaction: iouTransaction, - currentUserAccountID: currentUserPersonalDetails.accountID, - currentUserEmail: currentUserPersonalDetails.email ?? '', + currentUserAccountID, + currentUserEmail: currentUserEmail ?? '', currentUserLocalCurrency, filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, firstPolicy: filteredPoliciesInfo?.firstPolicy, }; // "Submit to someone" splits into two destinations here too, matching the track-expense whisper: // submit to an individual ("a friend") or a submit-enabled workspace ("my employer"). - const defaultWorkspaceName = generateDefaultWorkspaceName(currentUserPersonalDetails.email ?? '', currentUserPersonalDetails.displayName, lastWorkspaceNumber, translate); + const defaultWorkspaceName = generateDefaultWorkspaceName(currentUserEmail ?? '', currentUserDisplayName, lastWorkspaceNumber, translate); // Self-DM split expenses can only be submitted to a workspace, so the "a friend" destination is omitted here // just like it is on the track-expense whisper. @@ -632,8 +611,8 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report amountOwed, ownerBillingGracePeriodEnd, transaction: iouTransaction, - currentUserAccountID: currentUserPersonalDetails.accountID, - currentUserEmail: currentUserPersonalDetails.email ?? '', + currentUserAccountID, + currentUserEmail: currentUserEmail ?? '', currentUserLocalCurrency, filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, firstPolicy: filteredPoliciesInfo?.firstPolicy, @@ -659,8 +638,8 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report amountOwed, ownerBillingGracePeriodEnd, transaction: iouTransaction, - currentUserAccountID: currentUserPersonalDetails.accountID, - currentUserEmail: currentUserPersonalDetails.email ?? '', + currentUserAccountID, + currentUserEmail: currentUserEmail ?? '', currentUserLocalCurrency, filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, firstPolicy: filteredPoliciesInfo?.firstPolicy, @@ -678,7 +657,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report icon: expensifyIcons.Pencil, isAnonymousAction: false, shouldShowRightIcon: true, - action: () => navigateToPrivateNotes(report, currentUserPersonalDetails.accountID), + action: () => navigateToPrivateNotes(report, currentUserAccountID), brickRoadIndicator: hasErrorInPrivateNotes(report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, }); } @@ -693,7 +672,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report isAnonymousAction: false, action: callFunctionIfActionIsAllowed(() => { Navigation.goBack(navigateBackFromReportDetailsPath); - reopenTask(report, parentReport, currentUserPersonalDetails?.accountID, delegateEmail); + reopenTask(report, parentReport, currentUserAccountID, delegateEmail); }), }); } @@ -748,84 +727,11 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report } return items; - }, [ - isSelfDM, - isArchivedRoom, - shouldShowGoToRoom, - isGroupChat, - isDefaultRoom, - isChatThread, - isPolicyEmployee, - isUserCreatedPolicyRoom, - participants.length, - report, - isSystemChat, - activeChatMembers.length, - isPolicyExpenseChat, - shouldShowMenuItem, - isTrackExpenseReport, - isDeletedParentAction, - isMoneyRequestReport, - isInvoiceReport, - isTaskReport, - isCanceledTaskReport, - shouldShowGoToWorkspace, - shouldShowLeaveButton, - isDebugModeEnabled, - expensifyIcons.Users, - expensifyIcons.Gear, - expensifyIcons.Send, - expensifyIcons.Folder, - expensifyIcons.UserPlus, - expensifyIcons.Pencil, - expensifyIcons.Checkmark, - expensifyIcons.Building, - expensifyIcons.Exit, - expensifyIcons.Bug, - expensifyIcons.Hashtag, - styles.ph2, - shouldOpenRoomMembersPage, - navigateBackFromReportDetailsPath, - actionReportID, - actionReportActions, - iouTransactionID, - moneyRequestReport?.reportID, - moneyRequestReportActions, - currentUserPersonalDetails.accountID, - currentUserPersonalDetails.email, - currentUserPersonalDetails.localCurrencyCode, - isTaskActionable, - isRootGroupChat, - leaveChat, - showLastMemberLeavingModal, - isSmallScreenWidth, - isRestrictedToPreferredPolicy, - preferredPolicy, - introSelected, - draftTransactionIDs, - activePolicy, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - iouTransaction, - iouOriginalTransaction, - hasWorkspaceToSubmitTo, - filteredPoliciesInfo?.filteredPoliciesCount, - filteredPoliciesInfo?.firstPolicy, - parentReport, - delegateEmail, - conciergeReportID, - lastWorkspaceNumber, - translate, - currentUserPersonalDetails.displayName, - ]); + })(); - const icons = useMemo( - () => getIcons(report, formatPhoneNumber, translate, personalDetails, null, '', -1, policy, undefined, isReportArchived, pendingDeleteMemberAccountIDs, conciergeReportID), - [report, formatPhoneNumber, translate, personalDetails, policy, isReportArchived, pendingDeleteMemberAccountIDs, conciergeReportID], - ); + const icons = getIcons(report, formatPhoneNumber, translate, personalDetails, null, '', -1, policy, undefined, isReportArchived, pendingDeleteMemberAccountIDs, conciergeReportID); - const renderedAvatar = useMemo(() => { + const renderedAvatar = (() => { if (isChatRoom && !isThread) { return ( @@ -834,7 +740,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report report={report} policy={policy} participants={participants} - currentUserAccountID={currentUserPersonalDetails.accountID} + currentUserAccountID={currentUserAccountID} /> ); @@ -878,30 +784,15 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report style={[styles.w100, styles.mb3]} /> ); - }, [ - isChatRoom, - isThread, - isGroupChat, - icons, - report, - styles.smallEditIconAccount, - styles.mt6, - styles.w100, - styles.mb3, - policy, - participants, - moneyRequestReport?.reportID, - expensifyIcons.Camera, - currentUserPersonalDetails?.accountID, - ]); + })(); const canJoin = canJoinChat(report, parentReportAction, policy, parentReport, !!reportNameValuePairs?.private_isArchived); - const promotedActions = useMemo(() => { + const promotedActions = (() => { const result: PromotedAction[] = []; if (canJoin) { - result.push(PromotedActions.join(report, currentUserPersonalDetails.accountID)); + result.push(PromotedActions.join(report, currentUserAccountID)); } if (report) { @@ -911,7 +802,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report result.push(PromotedActions.share()); return result; - }, [canJoin, report, currentUserPersonalDetails.accountID]); + })(); const shouldDisplayGroupWorkspaceAsPushRow = !isThread && (isGroupChat || isUserCreatedPolicyRoom || isDefaultRoom); const nameSectionGroupWorkspace = ( @@ -953,14 +844,14 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report ); - const titleField = useMemo((): OnyxTypes.PolicyReportField | undefined => { + const titleField: OnyxTypes.PolicyReportField | undefined = (() => { const fields = getAvailableReportFields(report, Object.values(policy?.fieldList ?? {})); return fields.find((reportField) => isReportFieldOfTypeTitle(reportField)); - }, [report, policy?.fieldList]); + })(); const fieldKey = getReportFieldKey(titleField?.fieldID); const isFieldDisabled = isReportFieldDisabled(report, titleField, policy, rules); - const shouldShowEditableTitleField = caseID !== CASES.MONEY_REQUEST && canEditReportTitle(report, policy, currentUserPersonalDetails?.accountID, rules); + const shouldShowEditableTitleField = caseID !== CASES.MONEY_REQUEST && canEditReportTitle(report, policy, currentUserAccountID, rules); const nameSectionFurtherDetailsContent = ( ); - const deleteTransaction = useCallback(() => { + const deleteTransaction = () => { if (caseID === CASES.DEFAULT) { deleteTask( report, parentReport, isReportArchived, - currentUserPersonalDetails.accountID, + currentUserAccountID, hasOutstandingChildTask, parentReportAction, conciergeReportID, @@ -1032,6 +923,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report { ancestors, shouldNavigateBack: !taskDeleteBackTo, + lastAccessedReportID: findLastAccessedReport(false, guideAccountIDs, false, report.reportID)?.reportID, }, ); return; @@ -1060,8 +952,8 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report isChatReportArchived: isMoneyRequestReportArchived, isChatIOUReportArchived, allTransactionViolationsParam: allTransactionViolations, - currentUserAccountID: currentUserPersonalDetails.accountID, - currentUserEmail: currentUserPersonalDetails.email ?? '', + currentUserAccountID, + currentUserEmail: currentUserEmail ?? '', policy: iouPolicy, getCurrencyDecimals, }); @@ -1072,43 +964,10 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report } removeTransaction(iouTransactionID); } - }, [ - caseID, - taskDeleteBackTo, - requestParentReportAction, - iouTransaction, - iouOriginalTransaction, - iouTransactionID, - report, - parentReport, - isReportArchived, - currentUserPersonalDetails.accountID, - currentUserPersonalDetails.email, - hasOutstandingChildTask, - parentReportAction, - conciergeReportID, - delegateEmail, - ancestors, - reportActionsForOriginalReportID, - moneyRequestReport, - moneyRequestReportActions, - transactionThreadReportActions, - iouReport, - chatIOUReport, - duplicateTransactions, - duplicateTransactionViolations, - isSingleTransactionView, - isMoneyRequestReportArchived, - isChatIOUReportArchived, - allTransactionViolations, - deleteTransactions, - removeTransaction, - iouPolicy, - getCurrencyDecimals, - ]); + }; // Where to navigate back to after deleting the transaction and its report. - const navigateToTargetUrl = useCallback(() => { + const navigateToTargetUrl = () => { if (caseID === CASES.DEFAULT && taskDeleteBackTo) { Navigation.goBack(taskDeleteBackTo); return; @@ -1181,22 +1040,9 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report setDeleteTransactionNavigateBackUrl(urlToNavigateBack); navigateBackOnDeleteTransaction(urlToNavigateBack as Route); } - }, [ - caseID, - taskDeleteBackTo, - requestParentReportAction, - route.params.reportID, - moneyRequestReport, - iouTransactionID, - iouReport, - chatIOUReport, - isChatIOUReportArchived, - isSingleTransactionView, - requestParentReportActionChildReport, - getCurrencyDecimals, - ]); + }; - const showDeleteModal = useCallback(async () => { + const showDeleteModal = async () => { const deletePrompt = caseID === CASES.DEFAULT ? translate('task.deleteConfirmation') : getDeleteConfirmationPrompt(translate, iouTransaction); const {action} = await showConfirmModal({ title: caseID === CASES.DEFAULT ? translate('task.deleteTask') : getDeleteExpenseTitle(translate, iouTransaction), @@ -1221,9 +1067,9 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report // "Not Found" flash inside the animating-out panel on slower devices. TransitionTracker.runAfterTransitions({callback: deleteTransaction, waitForUpcomingTransition: true}); }); - }, [showConfirmModal, translate, caseID, iouTransaction, iouTransactionID, shouldOpenSplitExpenseEditFlowOnDelete, navigateToTargetUrl, deleteTransaction]); + }; - const mentionReportContextValue = useMemo(() => ({currentReportID: report.reportID, exactlyMatch: true}), [report.reportID]); + const mentionReportContextValue = {currentReportID: report.reportID, exactlyMatch: true}; const shouldShowFurtherDetailsContent = !isEmptyObject(parentNavigationSubtitleData) && (shouldShowEditableTitleField || isMoneyRequestReport || isInvoiceReport || isMoneyRequest || isTaskReport); diff --git a/src/pages/EnablePayments/Pay/Onfido/OnfidoPrivacy.tsx b/src/pages/EnablePayments/Pay/Onfido/OnfidoPrivacy.tsx index 5dfd0720f35f..21367043e01e 100644 --- a/src/pages/EnablePayments/Pay/Onfido/OnfidoPrivacy.tsx +++ b/src/pages/EnablePayments/Pay/Onfido/OnfidoPrivacy.tsx @@ -16,6 +16,7 @@ import CONST from '@src/CONST'; import type {WalletOnfido} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -39,7 +40,7 @@ type OnfidoPrivacyProps = { function OnfidoPrivacy({walletOnfidoData = DEFAULT_WALLET_ONFIDO_DATA}: OnfidoPrivacyProps) { const {translate} = useLocalize(); - const formRef = useRef(null); + const formRef = useRef>(null); const styles = useThemeStyles(); if (!walletOnfidoData) { return; diff --git a/src/pages/ErrorPage/UpdateRequiredView/index.native.tsx b/src/pages/ErrorPage/UpdateRequiredView/index.native.tsx index 5a348e468d85..99fa71e4b543 100644 --- a/src/pages/ErrorPage/UpdateRequiredView/index.native.tsx +++ b/src/pages/ErrorPage/UpdateRequiredView/index.native.tsx @@ -17,7 +17,7 @@ import Accessibility from '@libs/Accessibility'; import variables from '@styles/variables'; -import {updateApp} from '@userActions/AppUpdate'; +import updateApp from '@userActions/AppUpdate/updateApp'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; diff --git a/src/pages/ErrorPage/UpdateRequiredView/index.tsx b/src/pages/ErrorPage/UpdateRequiredView/index.tsx index f77cbef955e4..e28d47e9204b 100644 --- a/src/pages/ErrorPage/UpdateRequiredView/index.tsx +++ b/src/pages/ErrorPage/UpdateRequiredView/index.tsx @@ -17,7 +17,7 @@ import Accessibility from '@libs/Accessibility'; import variables from '@styles/variables'; -import {updateApp} from '@userActions/AppUpdate'; +import updateApp from '@userActions/AppUpdate/updateApp'; import CONST from '@src/CONST'; diff --git a/src/pages/Insights/InsightsDashboard.tsx b/src/pages/Insights/InsightsDashboard.tsx new file mode 100644 index 000000000000..9ecdeebeb314 --- /dev/null +++ b/src/pages/Insights/InsightsDashboard.tsx @@ -0,0 +1,187 @@ +import BlockingView from '@components/BlockingViews/BlockingView'; +import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; +import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; +import TopBar from '@components/Navigation/TopBar'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; + +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getInsights} from '@libs/actions/Insights'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {InsightsDashboardID} from '@src/types/onyx'; + +import {useIsFocused} from '@react-navigation/native'; +import React, {useEffect, useEffectEvent} from 'react'; +import {View} from 'react-native'; + +import type {InsightsFilters} from './insightsFilters'; +import type {InsightsDashboardState} from './resolveDashboardState'; + +import InsightsChartWidget from './charts/InsightsChartWidget'; +import INSIGHTS_DASHBOARD_SPECS from './dashboardSpecs'; +import buildInsightsJsonQuery from './insightsQueries'; +import {getDashboardState, INSIGHTS_DASHBOARD_STATE} from './resolveDashboardState'; +import InsightsEmptyState from './states/InsightsEmptyState'; +import InsightsNoExpensesState from './states/InsightsNoExpensesState'; +import useInsightsFilters from './useInsightsFilters'; + +type InsightsDashboardContentProps = { + dashboardID: InsightsDashboardID; + + /** Hash of the dashboard-wide query, which the record every chart reads is stored under */ + hash: number | undefined; + + state: InsightsDashboardState; + + /** Page-level filters every chart on the dashboard is narrowed by */ + filters: InsightsFilters; + + /** Called by the retry button to request the dashboard again */ + onRetry: () => void; +}; + +function InsightsDashboardContent({dashboardID, hash, state, filters, onRetry}: InsightsDashboardContentProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {login} = useCurrentUserPersonalDetails(); + const icons = useMemoizedLazyExpensifyIcons(['OfflineCloud']); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + + if (state === INSIGHTS_DASHBOARD_STATE.ERROR) { + return ( + + ); + } + + if (state === INSIGHTS_DASHBOARD_STATE.OFFLINE) { + return ( + + ); + } + + if (state === INSIGHTS_DASHBOARD_STATE.NO_EXPENSES) { + return ; + } + + if (state === INSIGHTS_DASHBOARD_STATE.EMPTY) { + return ; + } + + const {headlineChart, supportingCharts} = INSIGHTS_DASHBOARD_SPECS[dashboardID]; + const policiesInScope = Object.values(policies ?? {}).filter((policy) => !!policy && (filters.policyIDs.length === 0 || filters.policyIDs.includes(policy.id))); + const visibleCharts = supportingCharts.filter(({isPolicyEligible}) => !isPolicyEligible || policiesInScope.some((policy) => !!policy && isPolicyEligible(policy, login))); + + return ( + + + + + {visibleCharts.map((chart) => ( + + + + ))} + + + + ); +} + +function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const isFocused = useIsFocused(); + const {filters, isResolved} = useInsightsFilters(); + + const query = isResolved ? buildInsightsJsonQuery(dashboardID, filters) : undefined; + const jsonQuery = query?.jsonQuery; + const hash = query?.hash; + + const requestDashboard = () => { + if (!query || !jsonQuery || hash === undefined || isOffline) { + return; + } + getInsights(dashboardID, hash, jsonQuery, query.snapshotHashes); + }; + + const onRequestConditionsChanged = useEffectEvent(() => { + requestDashboard(); + }); + + useEffect(() => { + if (!isFocused) { + return; + } + onRequestConditionsChanged(); + }, [dashboardID, jsonQuery, hash, isFocused, isOffline]); + + const [dashboard] = useOnyx(`${ONYXKEYS.COLLECTION.INSIGHTS}${dashboardID}_${hash}`); + const [headlineSnapshot] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${dashboard?.graphs?.[INSIGHTS_DASHBOARD_SPECS[dashboardID].headlineChart.graphKey]?.snapshotHash}`); + + return ( + } + testID="InsightsPage" + > + + + + ); +} + +export default InsightsDashboard; diff --git a/src/pages/Insights/InsightsPage.tsx b/src/pages/Insights/InsightsPage.tsx index 968f1759bd00..648699bd7dfa 100644 --- a/src/pages/Insights/InsightsPage.tsx +++ b/src/pages/Insights/InsightsPage.tsx @@ -1,66 +1,22 @@ -import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; -import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; -import TopBar from '@components/Navigation/TopBar'; -import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; - import useDocumentTitle from '@hooks/useDocumentTitle'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import usePermissions from '@hooks/usePermissions'; -import {getInsights} from '@libs/actions/Insights'; import type {TabNavigatorParamList} from '@libs/Navigation/types'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import CONST from '@src/CONST'; import type SCREENS from '@src/SCREENS'; -import type {InsightsDashboardID} from '@src/types/onyx'; import type {BottomTabScreenProps} from '@react-navigation/bottom-tabs'; -import {useIsFocused} from '@react-navigation/native'; -import React, {useEffect} from 'react'; +import React from 'react'; -import buildInsightsJsonQuery from './insightsQueries'; -import useInsightsFilters from './useInsightsFilters'; +import InsightsDashboard from './InsightsDashboard'; type InsightsPageProps = BottomTabScreenProps; -function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { - const {translate} = useLocalize(); - const {isOffline} = useNetwork(); - const isFocused = useIsFocused(); - const {filters, isResolved} = useInsightsFilters(); - - const query = isResolved ? buildInsightsJsonQuery(dashboardID, filters) : undefined; - const jsonQuery = query?.jsonQuery; - const hash = query?.hash; - - useEffect(() => { - if (!jsonQuery || hash === undefined || !isFocused || isOffline) { - return; - } - getInsights(dashboardID, hash, jsonQuery); - }, [dashboardID, hash, jsonQuery, isFocused, isOffline]); - - return ( - } - testID="InsightsPage" - > - - - - ); -} - function InsightsPage({route}: InsightsPageProps) { const {translate} = useLocalize(); const {isBetaEnabled} = usePermissions(); diff --git a/src/pages/Insights/charts/InsightsChartWidget.tsx b/src/pages/Insights/charts/InsightsChartWidget.tsx new file mode 100644 index 000000000000..dc2ddfea712c --- /dev/null +++ b/src/pages/Insights/charts/InsightsChartWidget.tsx @@ -0,0 +1,98 @@ +import {buildViewOnSpendQuery} from '@components/Search/chartDrillDown'; +import ChartEmptyState from '@components/Search/ChartEmptyState'; +import ChartErrorState from '@components/Search/ChartErrorState'; +import SearchChartView from '@components/Search/SearchChartView'; +import WidgetContainer from '@components/WidgetContainer'; +import WidgetHeaderMenu from '@components/WidgetHeaderMenu'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import Navigation from '@libs/Navigation/Navigation'; + +import type {InsightsChartSpec} from '@pages/Insights/dashboardSpecs'; +import type {InsightsFilters} from '@pages/Insights/insightsFilters'; +import {INSIGHTS_CHART_STATE} from '@pages/Insights/resolveChartData'; +import useInsightsChartData from '@pages/Insights/useInsightsChartData'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {InsightsDashboardID} from '@src/types/onyx'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +import React from 'react'; +import {View} from 'react-native'; + +type InsightsChartWidgetProps = { + dashboardID: InsightsDashboardID; + + chart: InsightsChartSpec; + + /** Page-level filters every chart on the dashboard is narrowed by */ + filters: InsightsFilters; + + /** Hash of the dashboard-wide query, which the record naming this chart's snapshot is stored under */ + hash: number | undefined; + + /** Called by the retry button to request the dashboard again */ + onRetry: () => void; + + containerStyles?: StyleProp; +}; + +function InsightsChartWidget({dashboardID, hash, chart, filters, onRetry, containerStyles}: InsightsChartWidgetProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const icons = useMemoizedLazyExpensifyIcons(['Expand']); + + const {queryJSON, data, state} = useInsightsChartData(dashboardID, hash, chart, filters); + const groupBy = queryJSON?.groupBy; + + if (!queryJSON || !groupBy) { + return null; + } + + return ( + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: buildViewOnSpendQuery(queryJSON)})), + shouldCallAfterModalHide: true, + }, + ]} + /> + ) : null + } + > + {state === INSIGHTS_CHART_STATE.ERROR && } + {state === INSIGHTS_CHART_STATE.EMPTY && } + {(state === INSIGHTS_CHART_STATE.LOADING || state === INSIGHTS_CHART_STATE.READY) && ( + + + + )} + + ); +} + +export default InsightsChartWidget; diff --git a/src/pages/Insights/dashboardSpecs.ts b/src/pages/Insights/dashboardSpecs.ts index 8cf7982e9acb..6992fe6f46a0 100644 --- a/src/pages/Insights/dashboardSpecs.ts +++ b/src/pages/Insights/dashboardSpecs.ts @@ -1,22 +1,34 @@ /** Declares the charts each Insights dashboard renders and how they map to backend graph slots and search views. */ -import type {SearchGroupBy, SearchView} from '@components/Search/types'; +import type {ChartView, SearchGroupBy} from '@components/Search/types'; + +import {isPolicyEligibleForTopSpenders} from '@libs/SearchUIUtils'; + +import colors from '@styles/theme/colors'; import CONST from '@src/CONST'; -import type {InsightsDashboardID, InsightsGraphKey} from '@src/types/onyx'; +import type {TranslationPaths} from '@src/languages/types'; +import type {InsightsDashboardID, InsightsGraphKey, Policy} from '@src/types/onyx'; import type {ValueOf} from 'type-fest'; type InsightsChartSpec = { /** Slot the chart finds its snapshot hash under in the stored dashboard's `graphs` */ graphKey: InsightsGraphKey; - view: SearchView; + titleKey: TranslationPaths; + view: ChartView; /** What the chart aggregates by, left out by charts that follow the page's group-by filter */ groupBy?: SearchGroupBy; sortBy?: string; sortOrder?: string; limit?: number; + + /** Color every bar is drawn in. Only a bar chart reads it. */ + color?: string; + + /** The chart is shown when any workspace in scope passes this. A chart that declares none is always shown. */ + isPolicyEligible?: (policy: Policy, login: string | undefined) => boolean; }; type InsightsDashboardSpec = { @@ -35,20 +47,26 @@ const INSIGHTS_DASHBOARD_SPECS: Record buildSnapshotHashEntries(chart, filters)), - ]); + const graphEntries = [...buildSnapshotHashEntries(headlineChart, filters), ...supportingCharts.flatMap((chart) => buildSnapshotHashEntries(chart, filters))]; + const insightsHashes: InsightsDashboard['graphs'] = Object.fromEntries(graphEntries); return { jsonQuery: JSON.stringify({ @@ -65,6 +66,7 @@ function buildInsightsJsonQuery(dashboard: InsightsDashboardID, filters: Insight insightsHashes, }), hash: queryJSON.hash, + snapshotHashes: graphEntries.map(([, {snapshotHash}]) => snapshotHash), }; } diff --git a/src/pages/Insights/resolveChartData.ts b/src/pages/Insights/resolveChartData.ts new file mode 100644 index 000000000000..398a1f1fe3b7 --- /dev/null +++ b/src/pages/Insights/resolveChartData.ts @@ -0,0 +1,63 @@ +import type {GroupedItem} from '@components/Search/types'; + +import type {InsightsDashboard} from '@src/types/onyx'; +import type SearchResults from '@src/types/onyx/SearchResults'; + +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import type {InsightsChartSpec} from './dashboardSpecs'; + +const INSIGHTS_CHART_STATE = { + LOADING: 'loading', + READY: 'ready', + EMPTY: 'empty', + ERROR: 'error', +} as const; + +type InsightsChartState = ValueOf; + +type ResolveInsightsChartDataParams = { + /** The chart to resolve, as its dashboard declares it */ + chart: InsightsChartSpec; + + /** The dashboard's stored record, which says what was asked for and what came back */ + dashboard: OnyxEntry; + + /** The snapshot the record names for this chart */ + snapshot: OnyxEntry; + + /** The snapshot's rows, grouped and sorted the way the chart plots them */ + sortedData: GroupedItem[] | undefined; +}; + +type InsightsChartData = { + /** Rows for the period on screen, empty in every state but `ready` */ + data: GroupedItem[]; + + state: InsightsChartState; +}; + +/** Resolves one chart's rows and state from the snapshot the dashboard record named for it. */ +function resolveInsightsChartData({chart, dashboard, snapshot, sortedData}: ResolveInsightsChartDataParams): InsightsChartData { + if (Object.keys(snapshot?.errors ?? {}).length > 0) { + return {data: [], state: INSIGHTS_CHART_STATE.ERROR}; + } + + if (!dashboard?.graphs?.[chart.graphKey]?.snapshotHash) { + return {data: [], state: dashboard?.inputQuery ? INSIGHTS_CHART_STATE.EMPTY : INSIGHTS_CHART_STATE.LOADING}; + } + + if (!snapshot?.data) { + return {data: [], state: INSIGHTS_CHART_STATE.LOADING}; + } + + if (!sortedData?.length) { + return {data: [], state: INSIGHTS_CHART_STATE.EMPTY}; + } + + return {data: sortedData, state: INSIGHTS_CHART_STATE.READY}; +} + +export {INSIGHTS_CHART_STATE, resolveInsightsChartData}; +export type {InsightsChartData}; diff --git a/src/pages/Insights/resolveDashboardState.ts b/src/pages/Insights/resolveDashboardState.ts new file mode 100644 index 000000000000..dd94be91b91d --- /dev/null +++ b/src/pages/Insights/resolveDashboardState.ts @@ -0,0 +1,44 @@ +import {isGroupEntry} from '@libs/SearchUIUtils'; + +import type {InsightsDashboard} from '@src/types/onyx'; +import type SearchResults from '@src/types/onyx/SearchResults'; + +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +const INSIGHTS_DASHBOARD_STATE = { + READY: 'ready', + LOADING: 'loading', + ERROR: 'error', + OFFLINE: 'offline', + EMPTY: 'empty', + NO_EXPENSES: 'noExpenses', +} as const; + +type InsightsDashboardState = ValueOf; + +/** Resolves the page's state from the record stored for the query on screen, which the key it is read under already scopes. */ +function getDashboardState(dashboard: OnyxEntry, isOffline: boolean, headlineSnapshot: OnyxEntry): InsightsDashboardState { + // Only a response sets `inputQuery`, so until one lands the record holds nothing to draw. + const isDataLoaded = !!dashboard?.inputQuery; + + if (isOffline && !isDataLoaded) { + return INSIGHTS_DASHBOARD_STATE.OFFLINE; + } + if (!isOffline && Object.keys(dashboard?.errors ?? {}).length > 0) { + return INSIGHTS_DASHBOARD_STATE.ERROR; + } + if (!isDataLoaded) { + return INSIGHTS_DASHBOARD_STATE.LOADING; + } + if (dashboard?.hasResults === false) { + return INSIGHTS_DASHBOARD_STATE.NO_EXPENSES; + } + if (headlineSnapshot?.data && !Object.keys(headlineSnapshot.data).some(isGroupEntry)) { + return INSIGHTS_DASHBOARD_STATE.EMPTY; + } + return INSIGHTS_DASHBOARD_STATE.READY; +} + +export {INSIGHTS_DASHBOARD_STATE, getDashboardState}; +export type {InsightsDashboardState}; diff --git a/src/pages/Insights/states/InsightsEmptyState.tsx b/src/pages/Insights/states/InsightsEmptyState.tsx new file mode 100644 index 000000000000..295ec411ea57 --- /dev/null +++ b/src/pages/Insights/states/InsightsEmptyState.tsx @@ -0,0 +1,26 @@ +import GenericEmptyStateComponent from '@components/EmptyStateComponent/GenericEmptyStateComponent'; + +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +/** Stands in for the whole dashboard when the account has expenses but none of them fall inside the filters on screen. */ +function InsightsEmptyState() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const illustrations = useMemoizedLazyIllustrations(['MagnifyingGlassChart']); + + return ( + + ); +} + +export default InsightsEmptyState; diff --git a/src/pages/Insights/states/InsightsNoExpensesState.tsx b/src/pages/Insights/states/InsightsNoExpensesState.tsx new file mode 100644 index 000000000000..669e1c13ed8d --- /dev/null +++ b/src/pages/Insights/states/InsightsNoExpensesState.tsx @@ -0,0 +1,38 @@ +import GenericEmptyStateComponent from '@components/EmptyStateComponent/GenericEmptyStateComponent'; + +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {startMoneyRequest} from '@libs/actions/IOU/MoneyRequest'; +import {generateReportID} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +/** Stands in for the whole dashboard when the account has no expenses at all. */ +function InsightsNoExpensesState() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const illustrations = useMemoizedLazyIllustrations(['Chart']); + + return ( + startMoneyRequest(CONST.IOU.TYPE.CREATE, generateReportID(), undefined), + buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, + }, + ]} + /> + ); +} + +export default InsightsNoExpensesState; diff --git a/src/pages/Insights/useInsightsChartData.ts b/src/pages/Insights/useInsightsChartData.ts new file mode 100644 index 000000000000..bdfd2016a323 --- /dev/null +++ b/src/pages/Insights/useInsightsChartData.ts @@ -0,0 +1,33 @@ +import useGroupedItems from '@components/Search/hooks/useGroupedItems'; +import type {SearchQueryJSON} from '@components/Search/types'; + +import useOnyx from '@hooks/useOnyx'; + +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {InsightsDashboardID} from '@src/types/onyx'; + +import type {InsightsChartSpec} from './dashboardSpecs'; +import type {InsightsFilters} from './insightsFilters'; +import type {InsightsChartData} from './resolveChartData'; + +import {applyInsightsFilters} from './insightsQueries'; +import {resolveInsightsChartData} from './resolveChartData'; + +/** Resolves one chart's data from the snapshot the dashboard record names for it. */ +function useInsightsChartData( + dashboardID: InsightsDashboardID, + hash: number | undefined, + chart: InsightsChartSpec, + filters: InsightsFilters, +): InsightsChartData & {queryJSON: Readonly | undefined} { + const queryJSON = buildSearchQueryJSON(applyInsightsFilters(chart, filters)); + const [dashboard] = useOnyx(`${ONYXKEYS.COLLECTION.INSIGHTS}${dashboardID}_${hash}`); + const [snapshot] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${dashboard?.graphs?.[chart.graphKey]?.snapshotHash}`); + const sortedData = useGroupedItems(snapshot, queryJSON); + + return {queryJSON, ...resolveInsightsChartData({chart, dashboard, snapshot, sortedData})}; +} + +export default useInsightsChartData; diff --git a/src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts b/src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts index afc319be116c..3f02a7e5263b 100644 --- a/src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts +++ b/src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts @@ -73,6 +73,7 @@ function useGroupChatDraftParticipantSync( personalDetails: allPersonalDetails, loginList, currentUserEmail, + currentUserAccountID, rules, }); if (option) { diff --git a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx index 7e592572ff88..f0ec9e86884d 100644 --- a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx +++ b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx @@ -38,6 +38,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type IconAsset from '@src/types/utils/IconAsset'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView} from 'react-native'; @@ -141,7 +142,7 @@ function BaseOnboardingAccounting({shouldUseNativeStyles}: BaseOnboardingAccount const [userReportedIntegrationName, setUserReportedIntegrationName] = useState(''); const [shouldScrollToOtherInput, setShouldScrollToOtherInput] = useState(initialSelectedIntegration === 'other'); const [error, setError] = useState(''); - const scrollViewRef = useRef(null); + const scrollViewRef = useRef>(null); const otherAccountingSoftwareInputRef = useRef(null); const isOtherSelected = selectedIntegration === 'other'; diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 3dae8a6c1aa1..654298e701f6 100755 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -329,10 +329,10 @@ function ProfilePage({route}: ProfilePageProps) { onPress={() => switchToDelegator(login)} /> )} - {!!accountID && !isAnonymousUserSession() && !!login && ( + {!!accountID && !isAnonymousUserSession() && ( { const query = buildQueryStringFromFilterFormValues({ diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx index c1f8258aeeb0..6838ad00ee4a 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx +++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx @@ -64,6 +64,7 @@ import type {ACHDataReimbursementAccount, ReimbursementAccountStep} from '@src/t import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import type {ComponentRef} from 'react'; import type {TupleToUnion} from 'type-fest'; import {useIsFocused} from '@react-navigation/native'; @@ -114,7 +115,7 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen const styles = useThemeStyles(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const requestorStepRef = useRef(null); + const requestorStepRef = useRef>(null); const hasRequestedNewBankAccountRef = useRef(false); const hasClearedStalePlaidErrorsRef = useRef(false); const isChangingBankAccountRef = useRef(isChangingBankAccount); diff --git a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/BankAccountValidationForm.tsx b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/BankAccountValidationForm.tsx index e7a73eff472a..c7b4912ec667 100644 --- a/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/BankAccountValidationForm.tsx +++ b/src/pages/ReimbursementAccount/USD/ConnectBankAccount/components/BankAccountValidationForm.tsx @@ -21,7 +21,7 @@ import type {Policy, ReimbursementAccount} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {Str} from 'expensify-common'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; import Enable2FACard from './Enable2FACard'; @@ -75,7 +75,7 @@ function BankAccountValidationForm({requiresTwoFactorAuth, reimbursementAccount, const errors: FormInputErrors = {}; const amountValues = getAmountValues(values); const outputCurrency = policy?.outputCurrency ?? CONST.CURRENCY.USD; - const amountRegex = RegExp(String.raw`^-?\d{0,${CONST.IOU.AMOUNT_MAX_LENGTH}}([${permittedDecimalSeparator}]\d{0,${getCurrencyDecimals(outputCurrency)}})?$`, 'i'); + const amountRegex = RegExp(`^-?\\d{0,${CONST.IOU.AMOUNT_MAX_LENGTH}}([${permittedDecimalSeparator}]\\d{0,${getCurrencyDecimals(outputCurrency)}})?$`, 'i'); for (const key of Object.keys(amountValues)) { const value = amountValues[key as keyof AmountValues]; @@ -89,21 +89,18 @@ function BankAccountValidationForm({requiresTwoFactorAuth, reimbursementAccount, return errors; }; - const submit = useCallback( - (values: FormOnyxValues) => { - const amount1 = filterInput(values.amount1 ?? '', undefined, permittedDecimalSeparator); - const amount2 = filterInput(values.amount2 ?? '', undefined, permittedDecimalSeparator); - const amount3 = filterInput(values.amount3 ?? '', undefined, permittedDecimalSeparator); + const submit = (values: FormOnyxValues) => { + const amount1 = filterInput(values.amount1 ?? '', undefined, permittedDecimalSeparator); + const amount2 = filterInput(values.amount2 ?? '', undefined, permittedDecimalSeparator); + const amount3 = filterInput(values.amount3 ?? '', undefined, permittedDecimalSeparator); - const validateCode = [amount1, amount2, amount3].join(','); + const validateCode = [amount1, amount2, amount3].join(','); - // Send valid amounts to BankAccountAPI::validateBankAccount in Web-Expensify - if (bankAccountID) { - validateBankAccount(bankAccountID, validateCode, policyID); - } - }, - [bankAccountID, policyID, permittedDecimalSeparator], - ); + // Send valid amounts to BankAccountAPI::validateBankAccount in Web-Expensify + if (bankAccountID) { + validateBankAccount(bankAccountID, validateCode, policyID); + } + }; // On android autoCapitalize="words" is necessary when keyboardType="decimal-pad" or inputMode="decimal" to prevent input lag. // See https://github.com/Expensify/App/issues/51868 for more information return ( diff --git a/src/pages/ReimbursementAccount/USD/Requestor/PersonalInfo/PersonalInfo.tsx b/src/pages/ReimbursementAccount/USD/Requestor/PersonalInfo/PersonalInfo.tsx index ecb647dc88e2..c7e9ef35b34a 100644 --- a/src/pages/ReimbursementAccount/USD/Requestor/PersonalInfo/PersonalInfo.tsx +++ b/src/pages/ReimbursementAccount/USD/Requestor/PersonalInfo/PersonalInfo.tsx @@ -21,7 +21,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; @@ -39,7 +39,7 @@ type PersonalInfoProps = { /** Handles submit button press (URL-based navigation) */ onSubmit?: () => void; - ref?: ForwardedRef; + ref?: ForwardedRef>; /** Back to URL for preserving navigation context */ backTo?: string; diff --git a/src/pages/ReimbursementAccount/USD/Requestor/RequestorStep.tsx b/src/pages/ReimbursementAccount/USD/Requestor/RequestorStep.tsx index 7c01afee52e5..388f2a9afaa7 100644 --- a/src/pages/ReimbursementAccount/USD/Requestor/RequestorStep.tsx +++ b/src/pages/ReimbursementAccount/USD/Requestor/RequestorStep.tsx @@ -1,4 +1,4 @@ -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; import type {View} from 'react-native'; import React from 'react'; @@ -12,7 +12,7 @@ type RequestorStepProps = { /** Handles submit button press (URL-based navigation) */ onSubmit?: () => void; - ref?: ForwardedRef; + ref?: ForwardedRef>; /** Back to URL for preserving navigation context */ backTo?: string; diff --git a/src/pages/ReimbursementAccount/USD/USDVerifiedBankAccountFlowPage.tsx b/src/pages/ReimbursementAccount/USD/USDVerifiedBankAccountFlowPage.tsx index 9c87c39bbff1..f0d54e742751 100644 --- a/src/pages/ReimbursementAccount/USD/USDVerifiedBankAccountFlowPage.tsx +++ b/src/pages/ReimbursementAccount/USD/USDVerifiedBankAccountFlowPage.tsx @@ -11,6 +11,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import type {ComponentRef} from 'react'; + import React, {useCallback, useMemo, useRef} from 'react'; import {View} from 'react-native'; @@ -104,7 +106,7 @@ function USDVerifiedBankAccountFlowPage({route}: USDVerifiedBankAccountFlowPageP const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); - const requestorStepRef = useRef(null); + const requestorStepRef = useRef>(null); const isOnfidoSetupComplete = reimbursementAccount?.achData?.isOnfidoSetupComplete; const isKYBDocumentsRequired = getRequiredKYBDocuments(reimbursementAccount?.achData?.verifications?.externalApiResponses).length > 0; diff --git a/src/pages/ReimbursementAccount/USD/types.ts b/src/pages/ReimbursementAccount/USD/types.ts index 8a6a35e50895..392da968ba23 100644 --- a/src/pages/ReimbursementAccount/USD/types.ts +++ b/src/pages/ReimbursementAccount/USD/types.ts @@ -1,6 +1,6 @@ import type {Route} from '@src/ROUTES'; -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; import type {View} from 'react-native'; type USDPageProps = { @@ -15,7 +15,7 @@ type USDPageProps = { stepNames?: readonly string[]; /** Reference to the outer element (used by RequestorStep) */ - ref?: ForwardedRef; + ref?: ForwardedRef>; /** Back to URL for preserving navigation context */ backTo?: Route; diff --git a/src/pages/ReportAddApproverPage.tsx b/src/pages/ReportAddApproverPage.tsx index 5ee044d47039..215dd82a9075 100644 --- a/src/pages/ReportAddApproverPage.tsx +++ b/src/pages/ReportAddApproverPage.tsx @@ -64,6 +64,8 @@ function ReportAddApproverPage({report, isLoadingReportData, policy}: ReportAddA } const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(employeeList, undefined, true, false); + // Resolve the translation once, not per member. + const hiddenText = translate('common.hidden'); return Object.values(employeeList) .map((employee): SelectionListApprover | null => { const isAdmin = employee?.role === CONST.REPORT.ROLE.ADMIN; @@ -81,7 +83,7 @@ function ReportAddApproverPage({report, isLoadingReportData, policy}: ReportAddA } const {avatar} = personalDetails?.[accountID] ?? {}; - const displayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails, formatPhoneNumber, translate}); + const displayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails, formatPhoneNumber, hiddenTranslation: hiddenText}); return { text: displayName, alternateText: email, diff --git a/src/pages/ReportSubmitToContent.tsx b/src/pages/ReportSubmitToContent.tsx index aed8e0532af4..10a862929646 100644 --- a/src/pages/ReportSubmitToContent.tsx +++ b/src/pages/ReportSubmitToContent.tsx @@ -216,6 +216,7 @@ function ReportSubmitToContent({ personalDetails, loginList, currentUserEmail: currentUserDetails.email ?? '', + currentUserAccountID: currentUserDetails.accountID, countryCode, selectedOptions: [], loginsToExclude: CONST.EXPENSIFY_EMAILS_OBJECT, @@ -233,7 +234,19 @@ function ReportSubmitToContent({ keyForList: `nonWorkspace:${login}`, isSelected: managerEmail.trim().toLowerCase() === login.trim().toLowerCase(), }; - }, [countryCode, currentUserDetails.email, searchTerm, filteredWorkspaceMembers.length, loginList, managerEmail, personalDetails, dateFnsLocale, convertToDisplayString, rules]); + }, [ + countryCode, + currentUserDetails.email, + searchTerm, + filteredWorkspaceMembers.length, + loginList, + managerEmail, + personalDetails, + dateFnsLocale, + convertToDisplayString, + rules, + currentUserDetails.accountID, + ]); const submitToSelectionData = useMemo(() => { if (!nonWorkspaceInviteRow) { diff --git a/src/pages/ScheduleCall/ScheduleCallPage.tsx b/src/pages/ScheduleCall/ScheduleCallPage.tsx index 726ce74ac463..565ff9d20792 100644 --- a/src/pages/ScheduleCall/ScheduleCallPage.tsx +++ b/src/pages/ScheduleCall/ScheduleCallPage.tsx @@ -33,7 +33,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useFocusEffect, useRoute} from '@react-navigation/native'; import {compareAsc, parse} from 'date-fns'; -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import AvailableBookingDay from './AvailableBookingDay'; @@ -81,11 +81,9 @@ function ScheduleCallPage() { }, [adminsRoomReportID]); // Clear selected time when user comes back to the selection screen - useFocusEffect( - useCallback(() => { - saveBookingDraft({timeSlot: null}); - }, []), - ); + useFocusEffect(() => { + saveBookingDraft({timeSlot: null}); + }); useEffect(() => { return () => { @@ -94,11 +92,11 @@ function ScheduleCallPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const loadTimeSlotsAndSaveDate = useCallback((date: string) => { + const loadTimeSlotsAndSaveDate = (date: string) => { saveBookingDraft({date}); - }, []); + }; - const timeSlotDateMap: Record = useMemo(() => { + const timeSlotDateMap: Record = (() => { if (!calendlySchedule?.data) { return {}; } @@ -135,7 +133,7 @@ function ScheduleCallPage() { } return timeSlotMap; - }, [calendlySchedule?.data, userTimezone, dateFnsLocale]); + })(); const selectableDates = Object.keys(timeSlotDateMap).sort(compareAsc); const firstDate = selectableDates.at(0); @@ -152,12 +150,8 @@ function ScheduleCallPage() { }, [firstDate, calendlySchedule?.isLoading, scheduleCallDraft?.date]); // When there is only one time slot on the row, it will take full width of the row, use a hidden filler item to keep 2 columns - const timeFillerItem = useMemo(() => { - if (timeSlotsForSelectedData.length % 2 === 0) { - return null; - } - - return ( + const timeFillerItem = + timeSlotsForSelectedData.length % 2 === 0 ? null : ( ); - }, [styles.twoColumnLayoutCol, styles.visibilityHidden, timeSlotsForSelectedData.length]); return ( { const isAdmin = employee?.role === CONST.REPORT.ROLE.ADMIN; @@ -107,7 +109,7 @@ function SearchAddApproverPage() { } const {avatar} = personalDetails?.[accountID] ?? {}; - const displayName = getDisplayNameForParticipant({accountID, formatPhoneNumber, personalDetailsData: personalDetails, translate}); + const displayName = getDisplayNameForParticipant({accountID, formatPhoneNumber, personalDetailsData: personalDetails, hiddenTranslation: hiddenText}); return { text: displayName, alternateText: email, diff --git a/src/pages/Search/SearchAdvancedFiltersContentPage/AmountFilterContentPageWrapper.tsx b/src/pages/Search/SearchAdvancedFiltersContentPage/AmountFilterContentPageWrapper.tsx index 2e8d789cc3cc..fcf631f55418 100644 --- a/src/pages/Search/SearchAdvancedFiltersContentPage/AmountFilterContentPageWrapper.tsx +++ b/src/pages/Search/SearchAdvancedFiltersContentPage/AmountFilterContentPageWrapper.tsx @@ -5,13 +5,14 @@ import CONST from '@src/CONST'; import React from 'react'; -function AmountFilterContentPageWrapper({baseFilterKey, value, onChange}: AmountFilterContentWrapperProps) { +function AmountFilterContentPageWrapper({baseFilterKey, buttonText, value, onChange}: AmountFilterContentWrapperProps) { return ( ); diff --git a/src/pages/Search/SearchAdvancedFiltersContentPage/DateFilterContentPageWrapper.tsx b/src/pages/Search/SearchAdvancedFiltersContentPage/DateFilterContentPageWrapper.tsx index a9bbaf977a6e..7934aabe2046 100644 --- a/src/pages/Search/SearchAdvancedFiltersContentPage/DateFilterContentPageWrapper.tsx +++ b/src/pages/Search/SearchAdvancedFiltersContentPage/DateFilterContentPageWrapper.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import React, {useState} from 'react'; import {View} from 'react-native'; -function DateFilterContentPageWrapper({baseFilterKey, value: initialValue, hasFeed, onChange}: DateFilterContentWrapperProps) { +function DateFilterContentPageWrapper({baseFilterKey, value: initialValue, hasFeed, buttonText, onChange}: DateFilterContentWrapperProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [value, setValue] = useState(initialValue); @@ -38,7 +38,7 @@ function DateFilterContentPageWrapper({baseFilterKey, value: initialValue, hasFe onPress={() => onChange(value)} > - {translate('common.confirm')} + {buttonText ?? translate('common.confirm')} )} diff --git a/src/pages/Search/SearchAdvancedFiltersContentPage/ListFilterContentPageWrapper.tsx b/src/pages/Search/SearchAdvancedFiltersContentPage/ListFilterContentPageWrapper.tsx index 81fd05739b16..7d8dcf2e7f2b 100644 --- a/src/pages/Search/SearchAdvancedFiltersContentPage/ListFilterContentPageWrapper.tsx +++ b/src/pages/Search/SearchAdvancedFiltersContentPage/ListFilterContentPageWrapper.tsx @@ -9,7 +9,7 @@ import CONST from '@src/CONST'; import React, {useState} from 'react'; -function ListFilterContentPageWrapper({baseFilterKey, value: initialValue, isNegated: initialIsNegated, type, policyID, ready, onChange}: ListFilterContentWrapperProps) { +function ListFilterContentPageWrapper({baseFilterKey, value: initialValue, isNegated: initialIsNegated, type, policyID, ready, onChange, buttonText}: ListFilterContentWrapperProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [value, setValue] = useState(initialValue); @@ -33,7 +33,7 @@ function ListFilterContentPageWrapper({baseFilterKey, value: initialValue, isNeg size={CONST.BUTTON_SIZE.LARGE} onPress={() => onChange(value, isNegated)} > - {translate('common.confirm')} + {buttonText ?? translate('common.confirm')} } /> diff --git a/src/pages/Search/SearchAdvancedFiltersContentPage/ReportFieldFilterContentPageWrapper.tsx b/src/pages/Search/SearchAdvancedFiltersContentPage/ReportFieldFilterContentPageWrapper.tsx index b22e7327bfa5..7de7a11ae2e7 100644 --- a/src/pages/Search/SearchAdvancedFiltersContentPage/ReportFieldFilterContentPageWrapper.tsx +++ b/src/pages/Search/SearchAdvancedFiltersContentPage/ReportFieldFilterContentPageWrapper.tsx @@ -11,7 +11,7 @@ import type {PolicyReportField} from '@src/types/onyx'; import React, {useState} from 'react'; import {View} from 'react-native'; -function ReportFieldFilterContentPageWrapper({values: initialValues, onChange}: ReportFieldFilterContentWrapperProps) { +function ReportFieldFilterContentPageWrapper({values: initialValues, onChange, buttonText}: ReportFieldFilterContentWrapperProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [selectedField, setSelectedField] = useState(null); @@ -40,7 +40,7 @@ function ReportFieldFilterContentPageWrapper({values: initialValues, onChange}: }} > - {translate('common.confirm')} + {buttonText ?? translate('common.confirm')} )} diff --git a/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx b/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx index 258b8b42af3c..31e9652459fa 100644 --- a/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx +++ b/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx @@ -1,21 +1,30 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SearchAdvancedFiltersContent from '@components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent'; +import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery'; +import {useSearchQueryContext} from '@components/Search/SearchContext'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchAdvancedFiltersParamList} from '@libs/Navigation/types'; -import {FILTER_VIEW_MAP, getFilterViewLabelKey} from '@libs/SearchUIUtils'; +import {FILTER_VIEW_MAP, getFilterViewLabelKey, isAmountFilterKey, isDateFilterKey, isReportFieldKey, isTextFilterKey} from '@libs/SearchUIUtils'; import type {SearchFilter} from '@libs/SearchUIUtils'; import {SearchAdvancedFiltersActionContext, SearchAdvancedFiltersContext} from '@pages/Search/SearchAdvancedFiltersProvider'; +import variables from '@styles/variables'; + +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; import {useRoute} from '@react-navigation/core'; import React, {useContext} from 'react'; @@ -31,19 +40,50 @@ function isFilterKeyValid(filterKey: string): filterKey is SearchFilter['key'] { return filterKey in FILTER_VIEW_MAP; } +const CONFIRM_BUTTON_HEIGHT = variables.componentSizeLarge; + function SearchAdvancedFiltersContentBase() { const route = useRoute>(); const styles = useThemeStyles(); const {translate} = useLocalize(); const filterKey = route.params.filterKey; + const shouldApplyFilterChangeDirectly = !!route.params.applyDirectly; const {currentDraftFilters} = useContext(SearchAdvancedFiltersContext); const {setDraftFilters} = useContext(SearchAdvancedFiltersActionContext); + const {currentSearchQueryJSON} = useSearchQueryContext(); + const {updateFilterQueryParams} = useUpdateFilterQuery(currentSearchQueryJSON); + const [searchAdvancedFiltersForm = getEmptyObject>()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const validFilterKey = isFilterKeyValid(filterKey) ? filterKey : undefined; + // In direct-apply mode there is no draft to read from, the form holds the currently applied filters + const currentValues = shouldApplyFilterChangeDirectly ? searchAdvancedFiltersForm : currentDraftFilters; const goBack = () => { - Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS); + if (shouldApplyFilterChangeDirectly) { + Navigation.goBack(); + } else { + Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS); + } + }; + + // Every key that isn't amount/date/text/report-field falls through to the selection list branch of SearchAdvancedFiltersContent + const isFilterWithSelectionList = + !!validFilterKey && !isAmountFilterKey(validFilterKey) && !isDateFilterKey(validFilterKey) && !isTextFilterKey(validFilterKey) && !isReportFieldKey(validFilterKey); + + const buttonText = shouldApplyFilterChangeDirectly ? translate('common.apply') : undefined; + + const getCollapsibleHeaderOffset = () => { + if (!validFilterKey) { + return undefined; + } + + // We want to make space for the confirm button for filters with text inputs without list + if (isAmountFilterKey(validFilterKey) || isTextFilterKey(validFilterKey) || isReportFieldKey(validFilterKey)) { + return CONFIRM_BUTTON_HEIGHT; + } + + return undefined; }; return ( @@ -57,13 +97,20 @@ function SearchAdvancedFiltersContentBase() { {({didScreenTransitionEnd}) => validFilterKey ? ( <> - + + + + { const updatedValues = {...newValues}; const selectedReceiptTypes = newValues.receiptType; // A positive receipt-type selection drops those values from the negated filter so the query can't emit both receiptType and -receiptType for the same value if (selectedReceiptTypes !== undefined) { - const remainingNegatedReceiptTypes = currentDraftFilters?.receiptTypeNot?.filter((receiptType) => !selectedReceiptTypes.includes(receiptType)); + const remainingNegatedReceiptTypes = currentValues?.receiptTypeNot?.filter((receiptType) => !selectedReceiptTypes.includes(receiptType)); updatedValues.receiptTypeNot = remainingNegatedReceiptTypes?.length ? remainingNegatedReceiptTypes : undefined; } + + if (shouldApplyFilterChangeDirectly) { + Navigation.dismissModal({ + afterTransition: () => { + updateFilterQueryParams(updatedValues); + }, + }); + return; + } + setDraftFilters(updatedValues); goBack(); }} diff --git a/src/pages/Search/SearchAdvancedFiltersContentPage/TextInputFilterContentPageWrapper.tsx b/src/pages/Search/SearchAdvancedFiltersContentPage/TextInputFilterContentPageWrapper.tsx index d2b9a605ab27..03bcbcb9f528 100644 --- a/src/pages/Search/SearchAdvancedFiltersContentPage/TextInputFilterContentPageWrapper.tsx +++ b/src/pages/Search/SearchAdvancedFiltersContentPage/TextInputFilterContentPageWrapper.tsx @@ -5,13 +5,14 @@ import CONST from '@src/CONST'; import React from 'react'; -function TextInputFilterContentPageWrapper({baseFilterKey, value, isNegated, onChange}: TextInputFilterContentWrapperProps) { +function TextInputFilterContentPageWrapper({baseFilterKey, value, isNegated, buttonText, onChange}: TextInputFilterContentWrapperProps) { return ( diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx index 059d4ca30a14..5a2ea21b9225 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx @@ -3,11 +3,11 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import FilterList from '@components/Search/FilterComponents/AdvancedFilters/FilterList'; +import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import {getFilterNegatableValue} from '@libs/SearchUIUtils'; import {SearchAdvancedFiltersActionContext, SearchAdvancedFiltersContext} from '@pages/Search/SearchAdvancedFiltersProvider'; @@ -15,12 +15,14 @@ import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import React, {useContext} from 'react'; +import {View} from 'react-native'; function SearchAdvancedFiltersBase() { const styles = useThemeStyles(); const {translate} = useLocalize(); const {currentDraftFilters, shouldShowResetFilters} = useContext(SearchAdvancedFiltersContext); const {applyFilters, resetFilters} = useContext(SearchAdvancedFiltersActionContext); + const isInLandscapeMode = useIsInLandscapeMode(); return ( Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS_CONTENT.getRoute(filterKey))} /> - {shouldShowResetFilters && ( + + {shouldShowResetFilters && ( + + )} - )} - + ); } diff --git a/src/pages/Search/SearchColumnsPage.tsx b/src/pages/Search/SearchColumnsPage.tsx index 4e9a3e61df6c..585c44834169 100644 --- a/src/pages/Search/SearchColumnsPage.tsx +++ b/src/pages/Search/SearchColumnsPage.tsx @@ -2,11 +2,10 @@ import ColumnsSettingsList from '@components/ColumnsSettingsList'; import {useSearchQueryContext} from '@components/Search/SearchContext'; import type {SearchCustomColumnIds} from '@components/Search/types'; +import useIsVendorColumnAvailable from '@hooks/useIsVendorColumnAvailable'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import Navigation from '@libs/Navigation/Navigation'; -import {hasVendorFeatureOnAnyPolicy} from '@libs/PolicyUtils'; import {buildQueryStringFromFilterFormValues, getCurrentSearchQueryJSON, hasValuesIncludeViolationFilter} from '@libs/SearchQueryUtils'; import {getCustomColumnDefault, getCustomColumns, insertColumnBeforeTotalAmount} from '@libs/SearchUIUtils'; @@ -14,22 +13,13 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; -import type {Policy} from '@src/types/onyx'; -import type {OnyxCollection} from 'react-native-onyx'; - -import React, {useCallback} from 'react'; +import React from 'react'; function SearchColumnsPage() { const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const {currentSearchKey} = useSearchQueryContext(); - const {isBetaEnabled} = usePermissions(); - const isVendorMatchingBetaEnabled = isBetaEnabled(CONST.BETAS.VENDOR_MATCHING); - const isVendorColumnAvailableSelector = useCallback( - (allPolicies: OnyxCollection) => hasVendorFeatureOnAnyPolicy(allPolicies, isVendorMatchingBetaEnabled), - [isVendorMatchingBetaEnabled], - ); - const [isVendorColumnAvailable = false] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: isVendorColumnAvailableSelector}); + const isVendorColumnAvailable = useIsVendorColumnAvailable(); const groupBy = searchAdvancedFiltersForm?.groupBy; const queryType = searchAdvancedFiltersForm?.type ?? CONST.SEARCH.DATA_TYPES.EXPENSE; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx index 4c592449134f..f8d672a45b8b 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx @@ -1,11 +1,13 @@ import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions} from '@components/Search/SearchContext'; import Text from '@components/Text'; +import useConfirmModal from '@hooks/useConfirmModal'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; @@ -19,7 +21,7 @@ import {clearBulkEditDraftTransaction, updateMultipleMoneyRequests} from '@libs/ import Navigation from '@libs/Navigation/Navigation'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import {getCleanedTagName, getTagLists, hasDependentTags as hasDependentTagsPolicyUtils} from '@libs/PolicyUtils'; -import {canEditFieldOfMoneyRequest, isInvoiceReport, isIOUReport} from '@libs/ReportUtils'; +import {canEditFieldOfMoneyRequest, isInvoiceReport, isIOUReport, isReportApproved, isSettled} from '@libs/ReportUtils'; import {getSearchBulkEditPolicyID} from '@libs/SearchUIUtils'; import {hasEnabledTags, shouldShowDependentTagList} from '@libs/TagsOptionsListUtils'; import { @@ -65,6 +67,7 @@ function SearchEditMultiplePage() { const {currentSearchHash} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); const {clearSelectedTransactions} = useSearchSelectionActions(); + const {showConfirmModal} = useConfirmModal(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const personalPolicy = usePersonalPolicy(); const delegateAccountID = useDelegateAccountID(); @@ -106,6 +109,10 @@ function SearchEditMultiplePage() { const hasSplitTransaction = hasSplitExpenseInSelection(selectedTransactionContexts.map(({transaction}) => transaction)); + // Expenses on approved or paid reports are finalized records. Editing them in bulk is allowed for admins, but it + // needs an explicit confirmation first so a large selection can't quietly rewrite them. + const finalizedTransactionCount = selectedTransactionContexts.filter(({report}) => isReportApproved({report}) || isSettled(report)).length; + const isFieldDisabledForAnyTransaction = (field: ValueOf) => selectedTransactionContexts.some(({transaction, report, reportAction, reportActions, transactionPolicy}) => { // Unreported expenses have no report actions yet but are always editable @@ -173,6 +180,44 @@ function SearchEditMultiplePage() { const [isSaving, setIsSaving] = useState(false); + const commit = (changes: TransactionChanges) => { + setIsSaving(true); + + // Defer the bulk edit loop so the loading spinner has a chance to paint + // before the synchronous Onyx writes block the JS thread. + requestAnimationFrame(() => { + updateMultipleMoneyRequests({ + isVendorMatchingBetaEnabled, + transactionIDs: selectedTransactionIDs, + changes, + bulkEditTagChanges: draftTransaction?.bulkEditTagChanges, + policy, + reports: mergedReports, + transactions: mergedTransactions, + reportActions: mergedReportActions, + policyCategories: allPolicyCategories, + policyTags: allPolicyTags, + violations: allTransactionViolations, + reportNameValuePairs, + hash: currentSearchHash, + allPolicies: policies, + currentUserAccountID, + delegateAccountID, + personalPolicyOutputCurrency: personalPolicy?.outputCurrency, + personalDetailsList, + getCurrencyDecimals, + getCurrencySymbol, + rules, + }); + // Bulk edit can start from report (ID-based selection) or search (map-based selection), + // so clear both stores to keep deselection behavior consistent. + clearSelectedTransactions(true); + clearSelectedTransactions(); + + Navigation.dismissToPreviousRHP(); + }); + }; + const save = () => { if (!draftTransaction || isSaving) { return; @@ -215,41 +260,22 @@ function SearchEditMultiplePage() { return; } - setIsSaving(true); - - // Defer the bulk edit loop so the loading spinner has a chance to paint - // before the synchronous Onyx writes block the JS thread. - requestAnimationFrame(() => { - updateMultipleMoneyRequests({ - isVendorMatchingBetaEnabled, - transactionIDs: selectedTransactionIDs, - changes, - bulkEditTagChanges: draftTransaction.bulkEditTagChanges, - policy, - reports: mergedReports, - transactions: mergedTransactions, - reportActions: mergedReportActions, - policyCategories: allPolicyCategories, - policyTags: allPolicyTags, - violations: allTransactionViolations, - reportNameValuePairs, - hash: currentSearchHash, - allPolicies: policies, - currentUserAccountID, - delegateAccountID, - personalPolicyOutputCurrency: personalPolicy?.outputCurrency, - personalDetailsList, - getCurrencyDecimals, - getCurrencySymbol, - rules, + if (finalizedTransactionCount > 0) { + showConfirmModal({ + title: translate('search.bulkActions.editFinalizedExpensesTitle'), + prompt: translate('search.bulkActions.editFinalizedExpensesConfirmation', {count: finalizedTransactionCount, total: selectedTransactionContexts.length}), + confirmText: translate('common.yesContinue'), + cancelText: translate('common.cancel'), + }).then(({action}) => { + if (action !== ModalActions.CONFIRM) { + return; + } + commit(changes); }); - // Bulk edit can start from report (ID-based selection) or search (map-based selection), - // so clear both stores to keep deselection behavior consistent. - clearSelectedTransactions(true); - clearSelectedTransactions(); + return; + } - Navigation.dismissToPreviousRHP(); - }); + commit(changes); }; const currency = policy?.outputCurrency ?? CONST.CURRENCY.USD; @@ -341,6 +367,7 @@ function SearchEditMultiplePage() { description: translate('common.billable'), title: getBooleanTitle(draftTransaction?.billable), route: ROUTES.SEARCH_EDIT_MULTIPLE_BILLABLE_RHP, + disabled: isFieldDisabledForAnyTransaction(CONST.EDIT_REQUEST_FIELD.BILLABLE), }, ] : []), diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 2e27ea6b4e23..00d3c3efd8ce 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -1,6 +1,6 @@ import {ReportSubmitToPopoverHost, SEARCH_REPORT_SUBMIT_TO_POPOVER_ANCHOR_ALIGNMENT} from '@components/ReportSubmitToPopoverAnchor'; import {useSearchQueryContext, useSearchResultsActions, useSearchResultsContext, useSearchSelectionActions} from '@components/Search/SearchContext'; -import type {SearchParams} from '@components/Search/types'; +import type {SearchParams, SearchQueryJSON} from '@components/Search/types'; import {usePlaybackActionsContext} from '@components/VideoPlayerContexts/PlaybackContext'; import useDocumentTitle from '@hooks/useDocumentTitle'; @@ -21,8 +21,10 @@ import {searchInServer} from '@libs/actions/Report'; import {clearFooterConversion, search} from '@libs/actions/Search'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; +import {isQueryARefinement} from '@libs/SearchQueryRefinement'; import {isSearchDataLoaded} from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import {hasFilterBarsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; @@ -113,6 +115,38 @@ function SearchPage({route}: SearchPageProps) { setIsSorting(false); }, [currentSearchResults?.isLoading, isSorting, prevIsLoading]); + const [lastResolvedSearch, setLastResolvedSearch] = useState<{queryJSON: SearchQueryJSON; searchResults: SearchResults} | undefined>(undefined); + + // Changing a filter builds a query that has never been cached, so keying the results area on the requested + // query would mount it with no data and flash a skeleton in the middle of the fade. Key it on the last query + // that actually resolved instead: the current results stay on screen until the new ones arrive, then the area + // swaps once. + const isSearchResolvedForCurrentQuery = isCurrentSearchResolved && !!searchResults && !!currentSearchQueryJSON; + if (isSearchResolvedForCurrentQuery && currentSearchQueryJSON && searchResults && lastResolvedSearch?.searchResults !== searchResults) { + setLastResolvedSearch({queryJSON: currentSearchQueryJSON, searchResults}); + } + + // Allow showing skeleton when the query exceeds max hold time + const [staleHoldTimedOutHash, setStaleHoldTimedOutHash] = useState(undefined); + const currentQueryHash = currentSearchQueryJSON?.hash; + + useEffect(() => { + if (isSearchResolvedForCurrentQuery || currentQueryHash === undefined) { + return; + } + + const timeoutID = setTimeout(() => setStaleHoldTimedOutHash(currentQueryHash), CONST.SEARCH.ANIMATION.MAX_STALE_HOLD_DURATION); + return () => clearTimeout(timeoutID); + }, [isSearchResolvedForCurrentQuery, currentQueryHash]); + + const hasStaleHoldTimedOut = staleHoldTimedOutHash !== undefined && staleHoldTimedOutHash === currentQueryHash; + + // Only a filter refinement is worth holding for. A sidebar item or saved search is a different search, so its + // results area starts from the skeleton rather than showing rows that belong to the query the user just left. + const shouldHoldLastResolvedSearch = !isSearchResolvedForCurrentQuery && !!lastResolvedSearch && !hasStaleHoldTimedOut && isQueryARefinement(currentSearchQueryJSON?.inputQuery); + const contentQueryJSON = shouldHoldLastResolvedSearch ? lastResolvedSearch.queryJSON : currentSearchQueryJSON; + const contentSearchResults = shouldHoldLastResolvedSearch ? lastResolvedSearch.searchResults : searchResults; + const handleSearchAction = useCallback((value: SearchParams | string) => { if (typeof value === 'string') { searchInServer(value); @@ -145,6 +179,8 @@ function SearchPage({route}: SearchPageProps) { void; /** Overlay rendered above Search content during expense-creation flows (SearchStaticList or null). */ @@ -71,6 +81,8 @@ const tabBarContent = (null); + const receiptDropTargetRef = useRef>(null); const scrollOffset = useSharedValue(0); const topBarOffset = useSharedValue(StyleUtils.searchHeaderDefaultOffset); @@ -207,7 +224,8 @@ function SearchPageNarrow({ }, [isHeaderInteractive, isInteractive, startTransition]), ); - if (!queryJSON) { + // contentQueryJSON falls back to queryJSON upstream, so the two are always absent together. + if (!queryJSON || !contentQueryJSON) { return ( {isInteractive && ( - {shouldShowLoadingSkeleton ? ( - - ) : ( - - )} + + {shouldShowLoadingSkeleton ? ( + + ) : ( + + )} + {shouldRenderLayoutProbe && } {!!searchOverlayContent && ( ; + + /** The last query whose results resolved. Drives the results area so it holds the current results while a new query loads. */ + contentQueryJSON?: SearchQueryJSON; + + /** Results for `contentQueryJSON`. */ + contentSearchResults: OnyxEntry; + isMobileSelectionModeEnabled: boolean; handleSearchAction: (value: SearchParams | string) => void; onSortPressedCallback: () => void; @@ -51,6 +60,8 @@ type SearchPageWideProps = { function SearchPageWide({ queryJSON, searchResults, + contentQueryJSON, + contentSearchResults, isMobileSelectionModeEnabled, handleSearchAction, onSortPressedCallback, @@ -58,7 +69,12 @@ function SearchPageWide({ searchOverlayContent, onSearchContentReady, }: SearchPageWideProps) { - const shouldShowLoadingSkeleton = useSearchLoadingState(queryJSON, searchResults); + const shouldShowLoadingSkeleton = useSearchLoadingState(contentQueryJSON, contentSearchResults); + + // A hash change swaps the content layer while the previous results are still displayed, so the incoming layer + // must not flash a skeleton over them. The first render has no previous hash so it keeps its skeleton. + const previousContentHash = usePrevious(contentQueryJSON?.hash); + const isReplacingPreviousContent = previousContentHash !== undefined && previousContentHash !== contentQueryJSON?.hash; const styles = useThemeStyles(); const {currentSearchKey} = useSearchQueryContext(); const {hasSelectedTransactions} = useSearchSelectionContext(); @@ -95,7 +111,7 @@ function SearchPageWide({ const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES})); const splitContainerAnimatedStyle = useSearchSidebarContentOffsetStyle(); - const receiptDropTargetRef = useRef(null); + const receiptDropTargetRef = useRef>(null); return ( - {!!queryJSON && ( + {!!queryJSON && !!contentQueryJSON && ( <> - {shouldShowLoadingSkeleton ? ( - - ) : ( - - )} + + {shouldShowLoadingSkeleton ? ( + + ) : ( + + )} + {!!searchOverlayContent && {searchOverlayContent}} {/* Floats over the bottom of the list, which already ends above SearchSelectionFooter. */} diff --git a/src/pages/Search/SearchTypeMenuNarrow.tsx b/src/pages/Search/SearchTypeMenuNarrow.tsx index 3c8df203e3e5..011a3169e948 100644 --- a/src/pages/Search/SearchTypeMenuNarrow.tsx +++ b/src/pages/Search/SearchTypeMenuNarrow.tsx @@ -15,7 +15,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useReportAttributes from '@hooks/useReportAttributes'; -import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; +import {useSearchTypeMenuSectionsForNavigation} from '@hooks/useSearchTypeMenuSections'; import useShareSavedSearch, {MENU_CLOSE_DELAY_MS} from '@hooks/useShareSavedSearch'; import useThemeStyles from '@hooks/useThemeStyles'; import useTodoCounts from '@hooks/useTodoCounts'; @@ -32,6 +32,8 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {accountIDSelector} from '@src/selectors/Session'; +import type {ComponentRef} from 'react'; + // NOTE: This component has a static twin in SearchPageNarrow/StaticSearchTypeMenu.tsx // used for fast perceived performance. If you change the UI here, verify the // static version still looks visually identical. @@ -53,7 +55,7 @@ type SearchTypeMenuNarrowContentProps = { onActiveTabPress?: (key: SearchKey) => void; onTabPress?: (key: SearchKey) => void; onLongTabPress?: (key: SearchKey) => void; - containerRef?: React.RefObject; + containerRef?: React.RefObject | null>; children?: React.ReactNode; }; @@ -85,7 +87,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const styles = useThemeStyles(); const isFocused = useIsFocused(); - const typeMenuSections = useSearchTypeMenuSections(isFocused); + const typeMenuSections = useSearchTypeMenuSectionsForNavigation(isFocused); const personalDetails = usePersonalDetails(); const feedKeysWithCards = useFeedKeysWithAssignedCards(); const [restoreFocusType, setRestoreFocusType] = useState(); @@ -123,7 +125,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps }); const [savedSearchToModifyKey, setSavedSearchToModifyKey] = useState(null); - const menuAnchorRef = useRef(null); + const menuAnchorRef = useRef>(null); const {showDeleteModal} = useDeleteSavedSearch(); const {copiedID, handleShare} = useShareSavedSearch(); diff --git a/src/pages/Search/SearchTypeMenuWide.tsx b/src/pages/Search/SearchTypeMenuWide.tsx index 4f22f2189129..a7700e0e7bcd 100644 --- a/src/pages/Search/SearchTypeMenuWide.tsx +++ b/src/pages/Search/SearchTypeMenuWide.tsx @@ -5,7 +5,7 @@ import {useSearchQueryContext, useSearchSelectionActions} from '@components/Sear import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; +import {useSearchTypeMenuSectionsForNavigation} from '@hooks/useSearchTypeMenuSections'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; import useTodoCounts from '@hooks/useTodoCounts'; @@ -18,6 +18,7 @@ import {getItemBadgeText, getLastSearchQuery, getSectionBadgeText, SEARCH_TYPE_M import ONYXKEYS from '@src/ONYXKEYS'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {NativeScrollEvent, NativeSyntheticEvent, ScrollView as RNScrollView} from 'react-native'; @@ -79,14 +80,14 @@ function SearchTypeMenuWide() { const styles = useThemeStyles(); const {singleExecution} = useSingleExecution(); const {clearSelectedTransactions} = useSearchSelectionActions(); - const typeMenuSections = useSearchTypeMenuSections(); + const typeMenuSections = useSearchTypeMenuSectionsForNavigation(); const [searchFilters] = useOnyx(ONYXKEYS.SEARCH_FILTERS); // Intentionally left enabled (no focus freeze): the wide menu renders in the search navigator's ExtraContent // slot, where useIsFocused() does not track visibility, so freezing on it would be unreliable. const {counts: reportCounts} = useTodoCounts(); const route = useRoute(); - const scrollViewRef = useRef(null); + const scrollViewRef = useRef>(null); const {saveScrollOffset, getScrollOffset} = useContext(ScrollOffsetContext); const onScroll = (e: NativeSyntheticEvent) => { // If the layout measurement is 0, it means the flash list is not displayed but the onScroll may be triggered with offset value 0. diff --git a/src/pages/SetDefaultWorkspacePage.tsx b/src/pages/SetDefaultWorkspacePage.tsx index 9ac951f8a262..ec722df36abb 100644 --- a/src/pages/SetDefaultWorkspacePage.tsx +++ b/src/pages/SetDefaultWorkspacePage.tsx @@ -9,11 +9,10 @@ import UserListItem from '@components/SelectionList/ListItem/UserListItem'; import useDebouncedState from '@hooks/useDebouncedState'; import {useIsAppLoadPending} from '@hooks/useInFlightRequests'; -import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; -import useKeyboardState from '@hooks/useKeyboardState'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceList from '@hooks/useWorkspaceList'; @@ -51,9 +50,7 @@ function SetDefaultWorkspacePage({route}: SetDefaultWorkspacePageProps) { const [draftPolicyID, setDraftPolicyID] = useState(); - const isInLandscapeMode = useIsInLandscapeMode(); - const {isKeyboardActive} = useKeyboardState(); - const shouldFooterBeInsideList = isInLandscapeMode && isKeyboardActive; + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const selectPolicy = (selectedPolicyID?: string) => { if (!selectedPolicyID) { diff --git a/src/pages/ShareCodePage.tsx b/src/pages/ShareCodePage.tsx index 314150680a41..da1ab57c8f81 100644 --- a/src/pages/ShareCodePage.tsx +++ b/src/pages/ShareCodePage.tsx @@ -124,9 +124,10 @@ function ShareCodePage({report, policy, backTo}: ShareCodePageProps) { return getPolicyName({report, unavailableTranslation: translate('workspace.common.unavailable')}); } if (isMoneyRequestReport(report)) { - // generate subtitle from participants + // generate subtitle from participants; resolve the translation once, not per participant + const hiddenText = translate('common.hidden'); return getParticipantsAccountIDsForDisplay(report, true) - .map((accountID) => getDisplayNameForParticipant({accountID, formatPhoneNumber, translate})) + .map((accountID) => getDisplayNameForParticipant({accountID, formatPhoneNumber, hiddenTranslation: hiddenText})) .join(' & '); } diff --git a/src/pages/TransactionDuplicate/ReviewFields.tsx b/src/pages/TransactionDuplicate/ReviewFields.tsx index 5e10f42e3896..f7184bcb2860 100644 --- a/src/pages/TransactionDuplicate/ReviewFields.tsx +++ b/src/pages/TransactionDuplicate/ReviewFields.tsx @@ -11,7 +11,7 @@ import {fontScale} from '@styles/typography'; import CONST from '@src/CONST'; import type {ReviewDuplicates} from '@src/types/onyx'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type FieldItemType = { @@ -36,25 +36,25 @@ function ReviewFields({stepNames, label, optio const styles = useThemeStyles(); const {translate} = useLocalize(); - let falsyCount = 0; + const noneText = translate('violations.none'); + let seenNone = false; const filteredOptions = options?.filter((name) => { - if (name.text !== translate('violations.none')) { + if (name.text !== noneText) { return true; } - falsyCount++; - return falsyCount <= 1; + if (seenNone) { + return false; + } + seenNone = true; + return true; }); - const optionRows = useMemo( - () => - filteredOptions?.map((option, idx) => ({ - text: option.text, - keyForList: `${option.text}-${idx}`, - value: option.value, - isSelected: option.value === selectedValue, - })), - [filteredOptions, selectedValue], - ); + const optionRows = filteredOptions?.map((option, idx) => ({ + text: option.text, + keyForList: `${option.text}-${idx}`, + value: option.value, + isSelected: option.value === selectedValue, + })); return ( ({item, isFocused, showTool backgroundColor: theme.highlightBG, }); const StyleUtils = useStyleUtils(); - const pressableRef = useRef(null); + const pressableRef = useRef>(null); useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus); diff --git a/src/pages/Travel/ManageTrips.tsx b/src/pages/Travel/ManageTrips.tsx index 27788b6cf0f0..5872a62badfd 100644 --- a/src/pages/Travel/ManageTrips.tsx +++ b/src/pages/Travel/ManageTrips.tsx @@ -19,6 +19,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView} from 'react-native'; @@ -53,7 +54,7 @@ function ManageTrips({policyID}: ManageTripsProps) { Linking.openURL(CONST.BOOK_TRAVEL_DEMO_URL); }; - const scrollViewRef = useRef(null); + const scrollViewRef = useRef>(null); const handleOnContentSizeChange = useCallback(() => { if (!shouldScrollToBottom) { diff --git a/src/pages/UnreportedExpenseListItem.tsx b/src/pages/UnreportedExpenseListItem.tsx index ed3cf4b9bace..960e5de15418 100644 --- a/src/pages/UnreportedExpenseListItem.tsx +++ b/src/pages/UnreportedExpenseListItem.tsx @@ -21,6 +21,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {TransactionViolation} from '@src/types/onyx'; +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import React, {useRef} from 'react'; @@ -55,7 +56,7 @@ function UnreportedExpenseListItem({ backgroundColor: theme.highlightBG, }); const StyleUtils = useStyleUtils(); - const pressableRef = useRef(null); + const pressableRef = useRef>(null); const transactionReportID = getNonEmptyStringOnyxID(transactionItem.reportID); const [transactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionReportID}`); diff --git a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx index 228b96b32afd..146fe6a05e98 100644 --- a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx +++ b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx @@ -16,6 +16,7 @@ import type {SettingsNavigatorParamList} from '@navigation/types'; import BaseDomainMemberDetailsComponent from '@pages/domain/BaseDomainMemberDetailsComponent'; import {revokeDomainAdminAccess} from '@userActions/Domain'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -86,13 +87,20 @@ function DomainAdminDetailsPage({route}: DomainAdminDetailsPageProps) { /> )} {!domainHasOnlyOneAdmin && ( - + + + + + + + {translate('domain.admins.revokeAdminAccess')} + + + {isCurrentUserPrimaryContact && } + )} ); diff --git a/src/pages/domain/Groups/BaseDomainGroupPreferredWorkspacePage.tsx b/src/pages/domain/Groups/BaseDomainGroupPreferredWorkspacePage.tsx index c1a34aaf90c0..61f6aff35cf1 100644 --- a/src/pages/domain/Groups/BaseDomainGroupPreferredWorkspacePage.tsx +++ b/src/pages/domain/Groups/BaseDomainGroupPreferredWorkspacePage.tsx @@ -12,11 +12,10 @@ import UserListItem from '@components/SelectionList/ListItem/UserListItem'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; -import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; -import useKeyboardState from '@hooks/useKeyboardState'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useSearchResults from '@hooks/useSearchResults'; +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useThemeStyles from '@hooks/useThemeStyles'; import tokenizedSearch from '@libs/tokenizedSearch'; @@ -78,9 +77,7 @@ function BaseDomainGroupPreferredWorkspacePage({ const [draftPolicyID, setDraftPolicyID] = useState(); const checkedPolicyID = draftPolicyID ?? selectedPolicyID; - const isInLandscapeMode = useIsInLandscapeMode(); - const {isKeyboardActive} = useKeyboardState(); - const shouldFooterBeInsideList = isInLandscapeMode && isKeyboardActive; + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createAdminPoliciesSelector(selectedPolicyID)}); diff --git a/src/pages/domain/Members/DomainMemberDetailsPage.tsx b/src/pages/domain/Members/DomainMemberDetailsPage.tsx index 96eace018ff2..33de39909c57 100644 --- a/src/pages/domain/Members/DomainMemberDetailsPage.tsx +++ b/src/pages/domain/Members/DomainMemberDetailsPage.tsx @@ -32,6 +32,7 @@ import BaseDomainMemberDetailsComponent from '@pages/domain/BaseDomainMemberDeta import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; import {clearVacationDelegateError} from '@userActions/Domain'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -215,14 +216,30 @@ function DomainMemberDetailsPage({route}: DomainMemberDetailsPageProps) { onPress={showUnlockAccountModal} /> ) : ( - Navigation.navigate(ROUTES.DOMAIN_LOCK_ACCOUNT.getRoute(domainAccountID, accountID))} - brickRoadIndicator={lockDomainErrorMessage ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={lockDomainErrorMessage} - /> + onPress={callFunctionIfActionIsAllowed(() => Navigation.navigate(ROUTES.DOMAIN_LOCK_ACCOUNT.getRoute(domainAccountID, accountID)))} + > + + + + + + {translate('lockAccountPage.reportSuspiciousActivity')} + + {!!lockDomainErrorMessage && ( + + + + )} + + {!!lockDomainErrorMessage && ( + + )} + )} (null); const [popoverAnchorPosition, setPopoverAnchorPosition] = useState(null); - const actionButtonRef = useRef(null); - const animatedRef = useAnimatedRef(); + const actionButtonRef = useRef | HTMLDivElement | null>(null); + const animatedRef = useAnimatedRef(); - const containerRef = useRef(null); + const containerRef = useRef>(null); // The native Composer only forwards its underlying input to a callback ref, so an object ref would never be populated. const composerRef = useRef(null); diff --git a/src/pages/home/ForYouSection/index.tsx b/src/pages/home/ForYouSection/index.tsx index aed415639a1d..c3eb04658fc6 100644 --- a/src/pages/home/ForYouSection/index.tsx +++ b/src/pages/home/ForYouSection/index.tsx @@ -27,6 +27,7 @@ import React, {useCallback, useEffect, useMemo} from 'react'; import ConciergePromptBox from './ConciergePromptBox'; import ForYouBody from './ForYouBody'; import shouldHideForYouSection from './shouldHideForYouSection'; +import useReviewDomainAdminRequests from './useReviewDomainAdminRequests'; import useReviewFlaggedExpenses from './useReviewFlaggedExpenses'; type ForYouSectionProps = { @@ -50,17 +51,16 @@ function ForYouSection({isInitialLoad, isConciergeMenuVisible, setIsConciergeMen const isOnboardingCompleted = hasCompletedGuidedSetupFlowSelector(onboarding); const [hasSeenForYouTodo = false] = useOnyx(ONYXKEYS.NVP_HAS_SEEN_FOR_YOU_TODO); const {count: flaggedExpensesCount, reviewExpenses} = useReviewFlaggedExpenses(); + const {count: domainAdminRequestsCount, reviewDomainAdminRequests} = useReviewDomainAdminRequests(); const timeSensitiveItems = useTimeSensitiveItems(); - const icons = useMemoizedLazyExpensifyIcons(['ReceiptSearch', 'MoneyBag', 'Send', 'ThumbsUp', 'Export']); + const icons = useMemoizedLazyExpensifyIcons(['ReceiptSearch', 'MoneyBag', 'Send', 'ThumbsUp', 'Export', 'UserShield']); const submitCount = reportCounts[CONST.SEARCH.SEARCH_KEYS.SUBMIT]; const approveCount = reportCounts[CONST.SEARCH.SEARCH_KEYS.APPROVE]; const payCount = reportCounts[CONST.SEARCH.SEARCH_KEYS.PAY]; const exportCount = reportCounts[CONST.SEARCH.SEARCH_KEYS.EXPORT]; - const hasAnyTodos = flaggedExpensesCount > 0 || submitCount > 0 || approveCount > 0 || payCount > 0 || exportCount > 0; - const navigateToReport = useCallback( (reportID: string) => { if (shouldUseNarrowLayout) { @@ -152,11 +152,20 @@ function ForYouSection({isInitialLoad, isConciergeMenuVisible, setIsConciergeMen singleReportIDs[CONST.SEARCH.SEARCH_KEYS.EXPORT], ), }, + { + key: 'reviewDomainAdminRequests', + count: domainAdminRequestsCount, + icon: icons.UserShield, + translationKey: 'homePage.forYouSection.reviewDomainAdminRequests' as const, + handler: reviewDomainAdminRequests, + }, ].filter((item) => item.count > 0), [ accountID, approveCount, createNavigationHandler, + domainAdminRequestsCount, + reviewDomainAdminRequests, reviewExpenses, exportCount, flaggedExpensesCount, @@ -165,12 +174,15 @@ function ForYouSection({isInitialLoad, isConciergeMenuVisible, setIsConciergeMen icons.ReceiptSearch, icons.Send, icons.ThumbsUp, + icons.UserShield, payCount, singleReportIDs, submitCount, ], ); + const hasAnyTodos = todoItems.length > 0; + const forYouRows: React.ReactNode[] = todoItems.map(({key, count, icon, translationKey, handler, buttonVariant}) => ( Number(key.replace(ONYXKEYS.COLLECTION.DOMAIN_ERRORS, ''))); + + const markedDomainAccountIDs = new Set([...pendingDomainAdminRequestAccountIDs, ...errorDomainAccountIDs]); + const count = markedDomainAccountIDs.size; + + return { + badgeText: count > 0 ? count.toString() : undefined, + hasDomainErrors: errorDomainAccountIDs.length > 0, + }; +} + +export default useDomainsTabBadge; diff --git a/src/pages/home/ForYouSection/useReviewDomainAdminRequests.ts b/src/pages/home/ForYouSection/useReviewDomainAdminRequests.ts new file mode 100644 index 000000000000..8d2fa05290d1 --- /dev/null +++ b/src/pages/home/ForYouSection/useReviewDomainAdminRequests.ts @@ -0,0 +1,40 @@ +import useOnyx from '@hooks/useOnyx'; + +import Navigation from '@libs/Navigation/Navigation'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import {getPendingDomainAdminRequests} from '@src/selectors/Domain'; +import {accountIDSelector} from '@src/selectors/Session'; + +type ReviewDomainAdminRequests = { + /** Number of pending domain adminship requests awaiting review, used to decide whether to render the review row */ + count: number; + + /** accountIDs of the domains that have at least one pending request, used to count distinct marked domain rows */ + domainAccountIDs: number[]; + + /** Opens the domain admins page for the one domain with pending requests, or the domains list when there are several. */ + reviewDomainAdminRequests: () => void; +}; + +/** Handles the "Review X domain admin request(s)" row on the "For you" section of the home page. */ +function useReviewDomainAdminRequests(): ReviewDomainAdminRequests { + const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); + const [allDomains] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN); + const [allDomainPendingActions] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS); + + const {count, domainAccountIDs} = getPendingDomainAdminRequests(allDomains, allDomainPendingActions, accountID); + + const reviewDomainAdminRequests = () => { + if (domainAccountIDs.length === 1) { + Navigation.navigate(ROUTES.DOMAIN_ADMINS.getRoute(domainAccountIDs.at(0) ?? 0)); + return; + } + Navigation.navigate(ROUTES.DOMAINS_LIST.getRoute()); + }; + + return {count, domainAccountIDs, reviewDomainAdminRequests}; +} + +export default useReviewDomainAdminRequests; diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index 847ede357a27..21bb6b18bb92 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -18,6 +18,8 @@ import variables from '@styles/variables'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {ComponentRef} from 'react'; + import {PortalHost} from '@gorhom/portal'; import {useRef, useState} from 'react'; import {View} from 'react-native'; @@ -46,7 +48,7 @@ function HomePage() { // Offline the underlying commands never send, so the loading flags can stay true forever. Match useLoadingBarVisibility and hide the bar when offline. const isForYouLoading = !isOffline && !!(isLoadingApp || isLoadingReportData); const shouldShowHomeSkeleton = useAppLoadSkeletonVisibility(); - const receiptDropTargetRef = useRef(null); + const receiptDropTargetRef = useRef>(null); // Owned here (above the narrow/wide layout branch) so the Concierge "+" menu survives the ForYouSection remount that // happens on breakpoint change, converting between anchored popover and bottom-docked modal instead of vanishing. diff --git a/src/pages/home/InsightsSection/InsightTitleDropdown.tsx b/src/pages/home/InsightsSection/InsightTitleDropdown.tsx index d124bb692e05..764f1cf525a5 100644 --- a/src/pages/home/InsightsSection/InsightTitleDropdown.tsx +++ b/src/pages/home/InsightsSection/InsightTitleDropdown.tsx @@ -20,6 +20,8 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; +import type {ComponentRef} from 'react'; + import React, {useRef, useState} from 'react'; import {View} from 'react-native'; @@ -45,7 +47,7 @@ function InsightTitleDropdown({configs, selectedKey, onSelect}: InsightTitleDrop const icons = useMemoizedLazyExpensifyIcons(['DownArrow', 'UpArrow', 'CalendarSolid', 'User', 'Folder', 'Basket']) as Partial>; const {calculatePopoverPosition} = usePopoverPosition(); - const triggerRef = useRef(null); + const triggerRef = useRef>(null); const [isMenuOpen, setIsMenuOpen] = useState(false); const [menuPosition, setMenuPosition] = useState({horizontal: 0, vertical: 0}); diff --git a/src/pages/home/InsightsSection/InsightsSectionContent.tsx b/src/pages/home/InsightsSection/InsightsSectionContent.tsx index 95c5d47ac0f3..f7a1698a65c5 100644 --- a/src/pages/home/InsightsSection/InsightsSectionContent.tsx +++ b/src/pages/home/InsightsSection/InsightsSectionContent.tsx @@ -1,11 +1,15 @@ import BlockingView from '@components/BlockingViews/BlockingView'; import {CHART_CONTENT_MIN_HEIGHT} from '@components/Charts/VictoryTheme'; +import ChartEmptyState from '@components/Search/ChartEmptyState'; +import ChartErrorState from '@components/Search/ChartErrorState'; import SearchChartView from '@components/Search/SearchChartView'; import WidgetContainer from '@components/WidgetContainer'; +import WidgetHeaderMenu from '@components/WidgetHeaderMenu'; -import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -14,9 +18,6 @@ import {setNameValuePair} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; import type {SearchKey} from '@libs/SearchKeyUtils'; -import WidgetHeaderMenu from '@pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu'; -import HomeSectionEmptyState from '@pages/home/HomeSectionEmptyState'; - import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -35,8 +36,9 @@ function InsightsSectionContent() { const {translate} = useLocalize(); const theme = useTheme(); const icons = useMemoizedLazyExpensifyIcons(['Expand', 'OfflineCloud']); - const illustrations = useMemoizedLazyIllustrations(['BrokenMagnifyingGlass', 'Chart']); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isBetaEnabled} = usePermissions(); + const isInsightsPageEnabled = isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE); const insightConfigs = useHomeInsightConfigs(); const [selectedKey] = useOnyx(ONYXKEYS.NVP_HOME_SELECTED_INSIGHT); @@ -74,7 +76,12 @@ function InsightsSectionContent() { { text: translate('common.view'), icon: icons.Expand, - onSelected: () => Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: config.searchQuery, searchKey: config.key})), + onSelected: () => + Navigation.navigate( + isInsightsPageEnabled + ? ROUTES.INSIGHTS.getRoute(CONST.INSIGHTS.DASHBOARD.SPEND) + : ROUTES.SEARCH_ROOT.getRoute({query: config.searchQuery, searchKey: config.key}), + ), shouldCallAfterModalHide: true, }, ]} @@ -94,30 +101,8 @@ function InsightsSectionContent() { containerStyle={[{minHeight: CHART_CONTENT_MIN_HEIGHT}, styles.gap5]} /> )} - {state === INSIGHT_STATE.EMPTY && ( - - )} - {state === INSIGHT_STATE.ERROR && ( - - )} + {state === INSIGHT_STATE.EMPTY && } + {state === INSIGHT_STATE.ERROR && } {(state === INSIGHT_STATE.LOADING || state === INSIGHT_STATE.READY) && ( visibility[key]).map((key) => suggestedSearches[key]); + return SPEND_INSIGHT_KEYS.filter((key) => visibility[key]).map((key) => suggestedSearches[key]); } export default useHomeInsightConfigs; diff --git a/src/pages/home/InsightsSection/useInsightData.ts b/src/pages/home/InsightsSection/useInsightData.ts index 45bc77376840..93e6f99b5078 100644 --- a/src/pages/home/InsightsSection/useInsightData.ts +++ b/src/pages/home/InsightsSection/useInsightData.ts @@ -1,14 +1,12 @@ +import useGroupedItems from '@components/Search/hooks/useGroupedItems'; import type {ChartView, GroupedItem, SearchQueryJSON, SearchView} from '@components/Search/types'; -import {useCurrencyListActions} from '@hooks/useCurrencyList'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import {search} from '@libs/actions/Search'; import type {SearchTypeMenuItem} from '@libs/SearchUIUtils'; -import {getSections, getSortedSections, isGroupedItemArray, isSearchDataLoaded} from '@libs/SearchUIUtils'; +import {isSearchDataLoaded} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -61,12 +59,7 @@ function useInsightData(config: SearchTypeMenuItem | undefined) { const {groupBy} = queryJSON ?? {}; const view = queryJSON?.view && isChartView(queryJSON.view) ? queryJSON.view : CONST.SEARCH.VIEW.BAR; - const {translate, localeCompare, formatPhoneNumber, dateFnsLocale} = useLocalize(); - const {convertToDisplayString} = useCurrencyListActions(); - const {accountID, login} = useCurrentUserPersonalDetails(); - const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [searchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON?.hash}`); - const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {isOffline} = useNetwork(); const isFocused = useIsFocused(); @@ -99,34 +92,7 @@ function useInsightData(config: SearchTypeMenuItem | undefined) { onConfigChanged(); }, [queryJSON?.hash, isOffline, isFocused]); - const sortedSections = - searchResults?.data && queryJSON && groupBy && login - ? getSortedSections( - queryJSON.type, - getSections({ - dateFnsLocale, - type: queryJSON.type, - data: searchResults.data, - groupBy, - queryJSON, - currentAccountID: accountID, - currentUserEmail: login, - translate, - formatPhoneNumber, - bankAccountList: undefined, - rules, - conciergeReportID, - convertToDisplayString, - reportAttributesDerivedValue: undefined, - })[0], - localeCompare, - translate, - queryJSON.sortBy, - queryJSON.sortOrder, - groupBy, - ) - : undefined; - const sortedData = sortedSections && isGroupedItemArray(sortedSections) ? sortedSections : undefined; + const sortedData = useGroupedItems(searchResults, queryJSON); const state = getInsightState(isOffline, searchResults, queryJSON, sortedData); diff --git a/src/pages/home/RecentlyAddedSection/index.tsx b/src/pages/home/RecentlyAddedSection/index.tsx index 6f7bc620aea4..847dc5fcdd03 100644 --- a/src/pages/home/RecentlyAddedSection/index.tsx +++ b/src/pages/home/RecentlyAddedSection/index.tsx @@ -1,6 +1,7 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; import WidgetContainer from '@components/WidgetContainer'; +import WidgetHeaderMenu from '@components/WidgetHeaderMenu'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; @@ -16,8 +17,6 @@ import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils'; import type {TransactionThreadNavigationDescriptor} from '@libs/TransactionThreadNavigationUtils'; import {getReportIDToOpenForExpense} from '@libs/TransactionThreadNavigationUtils'; -import WidgetHeaderMenu from '@pages/home/common/WidgetHeaderMenu/WidgetHeaderMenu'; - import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; diff --git a/src/pages/inbox/ReactionListContext.ts b/src/pages/inbox/ReactionListContext.ts index 636b8e52ee9c..e2ae98b85581 100644 --- a/src/pages/inbox/ReactionListContext.ts +++ b/src/pages/inbox/ReactionListContext.ts @@ -1,10 +1,10 @@ -import type {SyntheticEvent} from 'react'; +import type {ComponentRef, SyntheticEvent} from 'react'; // eslint-disable-next-line no-restricted-imports import type {GestureResponderEvent, Text, View} from 'react-native'; import {createContext} from 'react'; -type ReactionListAnchor = View | Text | HTMLDivElement | null; +type ReactionListAnchor = ComponentRef | ComponentRef | HTMLDivElement | null; type ReactionListEvent = GestureResponderEvent | MouseEvent | SyntheticEvent; diff --git a/src/pages/inbox/ReportRouteParamHandler.tsx b/src/pages/inbox/ReportRouteParamHandler.tsx index fd5e4eb6d4f5..44480acdb948 100644 --- a/src/pages/inbox/ReportRouteParamHandler.tsx +++ b/src/pages/inbox/ReportRouteParamHandler.tsx @@ -1,16 +1,12 @@ -import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; +import useFindLastAccessedReport from '@hooks/useFindLastAccessedReport'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import {findLastAccessedReport} from '@libs/ReportUtils'; import {isNumeric} from '@libs/ValidationUtils'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@navigation/types'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -27,20 +23,16 @@ type ReportScreenRoute = function ReportRouteParamHandler() { const route = useRoute(); const navigation = useNavigation(); - const {isBetaEnabled} = usePermissions(); - const [reportNameValuePairs, reportNameValuePairsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const shouldResolveReportID = !route.params.reportID; - const ignoreDomainRooms = !isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS); const shouldOpenOnAdminRoom = 'openOnAdminRoom' in route.params && !!route.params.openOnAdminRoom; - const [guideAccountIDs] = useOnyx(ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS); // Subscribing to the reports collection (instead of relying on the module-scoped copy inside ReportUtils) // makes this handler re-run once the reports finish loading, so a route that was created without a reportID // recovers instead of staying stuck on the loading skeleton. Resolving inside the selector keeps that cheap: // the route only re-renders when the resolved ID changes, and once one is set nothing is computed at all. - const [lastAccessedReportID, reportsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { - selector: (reports) => - shouldResolveReportID ? findLastAccessedReport(ignoreDomainRooms, guideAccountIDs, shouldOpenOnAdminRoom, undefined, reportNameValuePairs, reports)?.reportID : undefined, + const {lastAccessedReportID, reportsMetadata, reportNameValuePairsMetadata} = useFindLastAccessedReport({ + enabled: shouldResolveReportID, + openOnAdminRoom: shouldOpenOnAdminRoom, }); useFocusEffect(() => { diff --git a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx index ab9072d90c2e..243c0e95a5f1 100755 --- a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -60,7 +60,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {OriginalMessageIOU, ReportAction} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports import type {GestureResponderEvent, Text as RNText, View as ViewType} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -110,7 +110,7 @@ type BaseReportActionContextMenuProps = { */ isThreadReportParentAction?: boolean; - contentRef?: RefObject; + contentRef?: RefObject | null>; checkIfContextMenuActive?: () => void; disabledActions?: ContextMenuAction[]; setIsEmojiPickerActive?: (state: boolean) => void; @@ -168,7 +168,7 @@ function BaseReportActionContextMenu({ const {isOffline} = useNetwork(); const {isProduction, isDevelopment, environment} = useEnvironment(); const isStaging = environment === CONST.ENVIRONMENT.STAGING; - const threeDotRef = useRef(null); + const threeDotRef = useRef>(null); const [betas] = useOnyx(ONYXKEYS.BETAS); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { selector: withDEWRoutedActionsObject, @@ -347,12 +347,12 @@ function BaseReportActionContextMenu({ } }; - const openOverflowMenu = (event: GestureResponderEvent | MouseEvent, anchorRef: RefObject) => { + const openOverflowMenu = (event: GestureResponderEvent | MouseEvent, anchorRef: RefObject | null>) => { showContextMenu({ type: CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, event, selection, - contextMenuAnchor: anchorRef?.current as ViewType | RNText | null, + contextMenuAnchor: anchorRef?.current as ComponentRef | ComponentRef | null, report: { reportID, originalReportID, diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index 48a4423fc2b2..1b58bf7375f3 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -248,8 +248,10 @@ import type { import type WithSentryLabel from '@src/types/utils/SentryLabel'; import KeyboardUtils from '@src/utils/keyboard'; -import type {RefObject} from 'react'; -import type {GestureResponderEvent, View} from 'react-native'; +import type {ComponentRef, RefObject} from 'react'; +// RN Text is restricted in favor of @components/Text, but this type is the host instance, which the wrapper does not export. +// eslint-disable-next-line no-restricted-imports +import type {GestureResponderEvent, Text as RNText, View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {Str} from 'expensify-common'; @@ -334,12 +336,12 @@ type ContextMenuActionPayload = { transitionActionSheetState: (params: {type: string; payload?: Record}) => void; openContextMenu: () => void; interceptAnonymousUser: (callback: () => void, isAnonymousAction?: boolean) => void; - anchor?: RefObject; + anchor?: RefObject | ComponentRef | null>; checkIfContextMenuActive?: () => void; - openOverflowMenu: (event: GestureResponderEvent | MouseEvent, anchorRef: RefObject) => void; + openOverflowMenu: (event: GestureResponderEvent | MouseEvent, anchorRef: RefObject | null>) => void; event?: GestureResponderEvent | MouseEvent | KeyboardEvent; setIsEmojiPickerActive?: (state: boolean) => void; - anchorRef?: RefObject; + anchorRef?: RefObject | null>; moneyRequestAction: ReportAction | undefined; card?: Card; originalReport: OnyxEntry; diff --git a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx index 79b4e53dd904..83ae79ecda54 100644 --- a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -34,7 +34,7 @@ import type {AnchorDimensions} from '@src/styles'; import type {ReportAction} from '@src/types/onyx'; import type {Location} from '@src/types/utils/Layout'; -import type {ForwardedRef} from 'react'; +import type {ComponentRef, ForwardedRef} from 'react'; import type {EmitterSubscription, GestureResponderEvent, NativeTouchEvent, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -102,8 +102,8 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - const contentRef = useRef(null); - const anchorRef = useRef(null); + const contentRef = useRef>(null); + const anchorRef = useRef | HTMLDivElement | null>(null); const dimensionsEventListener = useRef(null); const contextMenuAnchorRef = useRef(null); const contextMenuTargetNode = useRef(null); diff --git a/src/pages/inbox/report/ContextMenu/ReportActionContextMenu.ts b/src/pages/inbox/report/ContextMenu/ReportActionContextMenu.ts index 50b78060c667..9698b2a87fca 100644 --- a/src/pages/inbox/report/ContextMenu/ReportActionContextMenu.ts +++ b/src/pages/inbox/report/ContextMenu/ReportActionContextMenu.ts @@ -3,7 +3,7 @@ import type {ComposerType} from '@libs/ReportActionComposeFocusManager'; import type CONST from '@src/CONST'; import type {ReportAction} from '@src/types/onyx'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports import type {GestureResponderEvent, Text as RNText, TextInput, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -19,7 +19,7 @@ type OnCancel = () => void; type ContextMenuType = ValueOf; -type ContextMenuAnchor = View | RNText | TextInput | HTMLDivElement | null | undefined; +type ContextMenuAnchor = ComponentRef | ComponentRef | ComponentRef | HTMLDivElement | null | undefined; type ShowContextMenuParams = { type: ContextMenuType; @@ -66,7 +66,7 @@ type ReportActionContextMenu = { instanceIDRef: RefObject; runAndResetOnPopoverHide: () => void; clearActiveReportAction: () => void; - contentRef: RefObject; + contentRef: RefObject | null>; isContextMenuOpening: boolean; composerToRefocusOnCloseEmojiPicker?: ComposerType; }; diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index dde7931f36bf..758a080d0e0d 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -54,6 +54,7 @@ import type {AnchorPosition} from '@src/styles'; import type * as OnyxTypes from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; +import type {ComponentRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {useIsFocused} from '@react-navigation/native'; @@ -91,7 +92,7 @@ type AttachmentPickerWithMenuItemsProps = { /** Called when the menu item is selected */ onItemSelected: () => void; - actionButtonRef: React.RefObject; + actionButtonRef: React.RefObject | null>; /** A function that toggles isScrollLikelyLayoutTriggered flag for a certain period of time */ raiseIsScrollLikelyLayoutTriggered: () => void; @@ -401,7 +402,7 @@ function AttachmentPickerWithMenuItems({ return; } - calculatePopoverPosition(actionButtonRef as React.RefObject, { + calculatePopoverPosition(actionButtonRef as React.RefObject>, { horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, }).then((position) => { diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts index ac8f4a07ac8e..5c9625313f73 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts +++ b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts @@ -9,7 +9,7 @@ import CONST from '@src/CONST'; import type {ReportAction} from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {BlurEvent, TextInputSelectionChangeEvent, View} from 'react-native'; import {createContext, useContext} from 'react'; @@ -83,10 +83,10 @@ type ComposerEditActions = { // Frozen — stable refs, set once type ComposerMeta = { - containerRef: RefObject; + containerRef: RefObject | null>; composerRef: RefObject; suggestionsRef: RefObject; - actionButtonRef: RefObject; + actionButtonRef: RefObject | HTMLDivElement | null>; isNextModalWillOpenRef: RefObject; attachmentFileRef: RefObject; textRef: RefObject; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index 567298adb0f3..9fe85deb7a6e 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -14,6 +14,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {FileObject} from '@src/types/utils/Attachment'; +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import React, {useRef, useState} from 'react'; @@ -71,10 +72,10 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { textRef.current = v; }; - const containerRef = useRef(null); + const containerRef = useRef>(null); const suggestionsRef = useRef(null); const composerRef = useRef(null); - const actionButtonRef = useRef(null); + const actionButtonRef = useRef | HTMLDivElement | null>(null); const attachmentFileRef = useRef(null); const {editingState, editingReportID, editingReportActionID, editingReportAction, editingMessage, currentEditMessageSelection} = useReportActionActiveEdit(); diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 83c386d8a718..2b9e386320e5 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -53,7 +53,7 @@ import type {FileObject} from '@src/types/utils/Attachment'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {Ref, RefObject} from 'react'; -import type {BlurEvent, LayoutChangeEvent, MeasureInWindowOnSuccessCallback, NativeMethods, TextInputContentSizeChangeEvent, TextInputKeyPressEvent} from 'react-native'; +import type {BlurEvent, HostInstance, LayoutChangeEvent, MeasureInWindowOnSuccessCallback, TextInputContentSizeChangeEvent, TextInputKeyPressEvent} from 'react-native'; import {useIsFocused, useNavigation, useRoute} from '@react-navigation/native'; import lodashDebounce from 'lodash/debounce'; @@ -379,7 +379,7 @@ function ComposerWithSuggestions({ isTransitioningToPreExistingReport.current = false; }, []); - const animatedRef = useAnimatedRef(); + const animatedRef = useAnimatedRef(); /** * Set the TextInput Ref */ diff --git a/src/pages/inbox/report/ReportActionCompose/getScrollPosition/types.ts b/src/pages/inbox/report/ReportActionCompose/getScrollPosition/types.ts index 926585b7438e..41fa35e2f13a 100644 --- a/src/pages/inbox/report/ReportActionCompose/getScrollPosition/types.ts +++ b/src/pages/inbox/report/ReportActionCompose/getScrollPosition/types.ts @@ -1,8 +1,9 @@ +import type {ComponentRef} from 'react'; import type {TextInput} from 'react-native'; type TextInputScrollProps = { mobileInputScrollPosition: React.RefObject; - textInputRef: React.RefObject; + textInputRef: React.RefObject | null>; }; type GetScrollPositionType = {scrollValue: number}; diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts b/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts index 3857224262fe..609a1eee82ea 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts @@ -1,6 +1,6 @@ import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {BlurEvent, View} from 'react-native'; import {useRef, useState} from 'react'; @@ -13,7 +13,7 @@ const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); type UseComposerFocusParams = { composerRef: RefObject; suggestionsRef: RefObject; - actionButtonRef: RefObject; + actionButtonRef: RefObject | HTMLDivElement | null>; initialFocused: boolean; }; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 1bb5e3877cbd..e6cd200eeb43 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -36,6 +36,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; +import type {ComponentRef} from 'react'; import type {TextInputKeyPressEvent} from 'react-native'; import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; @@ -90,7 +91,7 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy const shouldDisableEmojiPicker = (chatIncludesConcierge(report) && isBlockedFromConcierge(blockedFromConcierge)) || isArchivedNonExpenseReport(report, isArchivedRoom); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const containerRef = useRef(null); + const containerRef = useRef>(null); const reportScrollManager = useReportScrollManager(); const {translate, preferredLocale} = useLocalize(); const {isKeyboardShown} = useKeyboardState(); diff --git a/src/pages/inbox/report/ReportTypingIndicator.tsx b/src/pages/inbox/report/ReportTypingIndicator.tsx index 9d9ad4d59c8e..2e9b440d4c45 100755 --- a/src/pages/inbox/report/ReportTypingIndicator.tsx +++ b/src/pages/inbox/report/ReportTypingIndicator.tsx @@ -35,7 +35,7 @@ function ReportTypingIndicator({reportID}: ReportTypingIndicatorProps) { // If the user is typing on OldDot, firstUserTyping will be a string (the user's displayName) const firstUserTypingDisplayName = isUserTypingADisplayName ? firstUserTyping - : getDisplayNameForParticipant({accountID: Number(firstUserTyping), shouldFallbackToHidden: false, formatPhoneNumber, translate}); + : getDisplayNameForParticipant({accountID: Number(firstUserTyping), shouldFallbackToHidden: false, formatPhoneNumber, hiddenTranslation: translate('common.hidden')}); if (usersTyping.length === 1) { return ( diff --git a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts index bd79d13445c3..c02909053c2f 100644 --- a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts @@ -1,7 +1,8 @@ import CONST from '@src/CONST'; +import type {ViewToken} from '@src/types/utils/ReactNativeCompat'; import type {RefObject} from 'react'; -import type {NativeScrollEvent, NativeSyntheticEvent, ViewToken} from 'react-native'; +import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {useIsFocused} from '@react-navigation/native'; import {useCallback, useEffect, useRef, useState} from 'react'; diff --git a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx index 34bfaaa710d5..34be8e768b44 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx @@ -13,7 +13,7 @@ import {isSafari} from '@libs/Browser'; import CONST from '@src/CONST'; -import type {ActivityProps, RefObject} from 'react'; +import type {ActivityProps, ComponentRef, RefObject} from 'react'; import React, {Activity, useState} from 'react'; import {View} from 'react-native'; @@ -35,7 +35,7 @@ type FABPopoverMenuProps = { isVisible: boolean; onClose: () => void; onItemSelected: () => void; - anchorRef: RefObject; + anchorRef: RefObject | HTMLDivElement | null>; animationInTiming?: number; animationOutTiming?: number; children: React.ReactNode; diff --git a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/QuickActionMenuItem.tsx b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/QuickActionMenuItem.tsx index c3f2bc49ea91..6402b44c9659 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/QuickActionMenuItem.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/QuickActionMenuItem.tsx @@ -134,7 +134,7 @@ function QuickActionMenuItem({reportID}: QuickActionMenuItemProps) { if (!isEmptyObject(quickActionReport) && quickAction?.action) { if (quickAction?.action === CONST.QUICK_ACTIONS.SEND_MONEY && quickActionAvatars.length > 0) { const accountID = quickActionAvatars.at(0)?.id ?? CONST.DEFAULT_NUMBER_ID; - const name = getDisplayNameForParticipant({accountID: Number(accountID), shouldUseShortForm: true, formatPhoneNumber, translate}) ?? ''; + const name = getDisplayNameForParticipant({accountID: Number(accountID), shouldUseShortForm: true, formatPhoneNumber, hiddenTranslation: translate('common.hidden')}) ?? ''; quickActionTitle = translate('quickAction.paySomeone', name); } else { const titleKey = getQuickActionTitle(quickAction.action); diff --git a/src/pages/inbox/sidebar/InboxTabSelector.tsx b/src/pages/inbox/sidebar/InboxTabSelector.tsx index 9b7556660deb..0e027bab7161 100644 --- a/src/pages/inbox/sidebar/InboxTabSelector.tsx +++ b/src/pages/inbox/sidebar/InboxTabSelector.tsx @@ -26,6 +26,7 @@ import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {ComponentRef} from 'react'; import type {ValueOf} from 'type-fest'; import {reportNameValuePairsArchivedSelector} from '@selectors/ReportNameValuePairs'; @@ -57,9 +58,9 @@ function InboxTabSelector() { ); // Anchor the popover to the tab it was opened from (not the whole tab row) so it opens at that tab's left edge. - const allTabRef = useRef(null); - const unreadTabRef = useRef(null); - const todoTabRef = useRef(null); + const allTabRef = useRef | HTMLDivElement>(null); + const unreadTabRef = useRef | HTMLDivElement>(null); + const todoTabRef = useRef | HTMLDivElement>(null); const tabRefs = { [CONST.INBOX_TAB.ALL]: allTabRef, [CONST.INBOX_TAB.UNREAD]: unreadTabRef, diff --git a/src/pages/inbox/sidebar/SidebarLinks.tsx b/src/pages/inbox/sidebar/SidebarLinks.tsx index d4859cf99422..6af8512410a8 100644 --- a/src/pages/inbox/sidebar/SidebarLinks.tsx +++ b/src/pages/inbox/sidebar/SidebarLinks.tsx @@ -24,7 +24,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {EdgeInsets} from 'react-native-safe-area-context'; import type {ValueOf} from 'type-fest'; -import React, {memo, useCallback, useEffect, useMemo} from 'react'; +import React, {memo, useEffect} from 'react'; import {StyleSheet, View} from 'react-native'; type SidebarLinksProps = { @@ -54,36 +54,32 @@ function SidebarLinks({insets, optionListItems, hasReportData, priorityMode = CO /** * Show Report page with selected report id */ - const showReportPage = useCallback( - (option: Report & Pick) => { - // Prevent opening Report page when clicking LHN row quickly after clicking FAB icon - // or when clicking the active LHN row on large screens - // or when continuously clicking different LHNs, only apply to small screen - // since getTopmostReportId always returns on other devices - const reportActionID = Navigation.getTopmostReportActionId(); - const actionTargetReportActionID = option.actionTargetReportActionID; - - // When the sidebar is focused on narrow screens, force navigation to bypass stale report IDs. - // Otherwise, only block navigation if the report is already open or initial data is still loading. - const shouldBlockReportNavigation = shouldUseNarrowLayout - ? Navigation.getActiveRoute() !== `/${ROUTES.INBOX}` - : option.reportID === Navigation.getTopmostReportId() && !reportActionID && !actionTargetReportActionID; - - if (shouldBlockReportNavigation) { - cancelSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${option.reportID}`); - return; - } - // Keep this report visible in the active To-do/Unread tab even after opening it marks it read. - setStickyReportID(option.reportID); - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(option.reportID, actionTargetReportActionID)); - }, - [shouldUseNarrowLayout, setStickyReportID], - ); + const showReportPage = (option: Report & Pick) => { + // Prevent opening Report page when clicking LHN row quickly after clicking FAB icon + // or when clicking the active LHN row on large screens + // or when continuously clicking different LHNs, only apply to small screen + // since getTopmostReportId always returns on other devices + const reportActionID = Navigation.getTopmostReportActionId(); + const actionTargetReportActionID = option.actionTargetReportActionID; + + // When the sidebar is focused on narrow screens, force navigation to bypass stale report IDs. + // Otherwise, only block navigation if the report is already open or initial data is still loading. + const shouldBlockReportNavigation = shouldUseNarrowLayout + ? Navigation.getActiveRoute() !== `/${ROUTES.INBOX}` + : option.reportID === Navigation.getTopmostReportId() && !reportActionID && !actionTargetReportActionID; + + if (shouldBlockReportNavigation) { + cancelSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${option.reportID}`); + return; + } + // Keep this report visible in the active To-do/Unread tab even after opening it marks it read. + setStickyReportID(option.reportID); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(option.reportID, actionTargetReportActionID)); + }; const viewMode = priorityMode === CONST.PRIORITY_MODE.GSD ? CONST.OPTION_MODE.COMPACT : CONST.OPTION_MODE.DEFAULT; - // eslint-disable-next-line react-hooks/exhaustive-deps - const contentContainerStyles = useMemo(() => StyleSheet.flatten([styles.pt2, {paddingBottom: StyleUtils.getSafeAreaMargins(insets).marginBottom}]), [insets]); + const contentContainerStyles = StyleSheet.flatten([styles.pt2, {paddingBottom: StyleUtils.getSafeAreaMargins(insets).marginBottom}]); const shouldShowEmptyLHN = optionListItems.length === 0; diff --git a/src/pages/iou/request/step/DistanceManualTabContent.tsx b/src/pages/iou/request/step/DistanceManualTabContent.tsx index bf40718e1236..b3b31cc10d8b 100644 --- a/src/pages/iou/request/step/DistanceManualTabContent.tsx +++ b/src/pages/iou/request/step/DistanceManualTabContent.tsx @@ -15,6 +15,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {Unit} from '@src/types/onyx/Policy'; +import type {ComponentRef} from 'react'; import type {TextInput} from 'react-native'; import React from 'react'; @@ -38,7 +39,7 @@ function DistanceManualTabContent({currentDistance, distanceUnit, onSubmit, manu const setInputRef = (ref: BaseTextInputRef | null) => { // eslint-disable-next-line no-param-reassign -- Assign to ref's .current which is a safe mutation, not a true parameter reassignment manualTextInputRef.current = ref; - inputCallbackRef(ref as unknown as TextInput | null); + inputCallbackRef(ref as unknown as ComponentRef | null); }; return ( diff --git a/src/pages/iou/request/step/DistanceMapTabContent.tsx b/src/pages/iou/request/step/DistanceMapTabContent.tsx index 78a392d35ce8..155d99fbb917 100644 --- a/src/pages/iou/request/step/DistanceMapTabContent.tsx +++ b/src/pages/iou/request/step/DistanceMapTabContent.tsx @@ -13,6 +13,7 @@ import type Transaction from '@src/types/onyx/Transaction'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; import type TransactionStateType from '@src/types/utils/TransactionStateType'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView} from 'react-native'; import type {RenderItemParams} from 'react-native-draggable-flatlist/lib/typescript/types'; @@ -41,7 +42,7 @@ type DistanceMapTabContentProps = { waypoints: WaypointCollection; extractKey: (key: string) => string; updateWaypoints: (data: {data: string[]}) => void; - scrollViewRef: React.RefObject; + scrollViewRef: React.RefObject | null>; renderItem: (params: RenderItemParams) => React.JSX.Element; navigateToWaypointEditPage: (index: number) => void; transaction: OnyxEntry; diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx index 30b69f72e5d9..884efbb98ce2 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx @@ -60,6 +60,7 @@ import type Transaction from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type TransactionStateType from '@src/types/utils/TransactionStateType'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView} from 'react-native'; import type {RenderItemParams} from 'react-native-draggable-flatlist/lib/typescript/types'; @@ -168,7 +169,7 @@ function DynamicIOURequestStepDistance({ const previousWaypoints = usePrevious(waypoints); const numberOfWaypoints = Object.keys(waypoints).length; const numberOfPreviousWaypoints = Object.keys(previousWaypoints).length; - const scrollViewRef = useRef(null); + const scrollViewRef = useRef>(null); const isLoadingRoute = currentTransaction?.comment?.isLoading ?? false; const isLoading = currentTransaction?.isLoading ?? false; const isSplitRequest = iouType === CONST.IOU.TYPE.SPLIT; diff --git a/src/pages/iou/request/step/DynamicIOURequestStepWaypoint.tsx b/src/pages/iou/request/step/DynamicIOURequestStepWaypoint.tsx index b58888be0213..76ea739a5d0b 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepWaypoint.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepWaypoint.tsx @@ -33,6 +33,7 @@ import type {RecentWaypoint, Transaction} from '@src/types/onyx'; import type {Waypoint} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {ComponentRef} from 'react'; import type {TextInput} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -83,7 +84,7 @@ function DynamicIOURequestStepWaypoint({ const isFocused = navigation.isFocused(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const textInput = useRef(null); + const textInput = useRef | null>(null); const parsedWaypointIndex = parseInt(pageIndex, 10); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); @@ -259,7 +260,7 @@ function DynamicIOURequestStepWaypoint({ canUseCurrentLocation inputID={`waypoint${pageIndex}`} ref={(e: HTMLElement | null) => { - textInput.current = e as unknown as TextInput; + textInput.current = e as unknown as ComponentRef; }} hint={!isOffline ? translate('distance.error.selectSuggestedAddress') : ''} containerStyles={[styles.mt4]} diff --git a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx index 5f42dc8417af..1f5fb8908eff 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx @@ -41,6 +41,7 @@ import type Transaction from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type TransactionStateType from '@src/types/utils/TransactionStateType'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView} from 'react-native'; import type {RenderItemParams} from 'react-native-draggable-flatlist/lib/typescript/types'; @@ -123,7 +124,7 @@ function IOURequestStepDistanceMap({ const previousWaypoints = usePrevious(waypoints); const numberOfWaypoints = Object.keys(waypoints).length; const numberOfPreviousWaypoints = Object.keys(previousWaypoints).length; - const scrollViewRef = useRef(null); + const scrollViewRef = useRef>(null); const isLoadingRoute = transaction?.comment?.isLoading ?? false; const isLoading = transaction?.isLoading ?? false; const isSplitRequest = iouType === CONST.IOU.TYPE.SPLIT; diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts index 39f8ad63a6bf..b12de4234035 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts @@ -7,7 +7,7 @@ import type CONST from '@src/CONST'; import type * as OnyxTypes from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {RotationDegrees} from 'react-fast-pdf'; import type {StyleProp, View, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -95,7 +95,7 @@ type AttachmentModalBaseContentProps = { attachmentLink?: string; /** Ref to the submit button */ - submitRef?: RefObject; + submitRef?: RefObject | HTMLElement | null>; AttachmentContent?: AttachmentContent; diff --git a/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx b/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx index cbe046be65bc..ad5d354857f4 100644 --- a/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/report/ReportAddAttachmentModalContent/index.tsx @@ -22,6 +22,7 @@ import type SCREENS from '@src/SCREENS'; import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; @@ -63,7 +64,7 @@ function ReportAddAttachmentModalContent({route, navigation}: AttachmentModalScr const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const {isOffline} = useNetwork(); - const submitRef = useRef(null); + const submitRef = useRef | HTMLElement>(null); // Extract the reportActionID from the attachmentID (format: reportActionID_index) const reportActionID = useMemo(() => attachmentID?.split('_')?.[0], [attachmentID]); diff --git a/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx index 866c7d1433ca..6d5de42c034e 100644 --- a/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx @@ -23,6 +23,7 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; @@ -66,7 +67,7 @@ function ReportAttachmentModalContent({route, navigation}: AttachmentModalScreen const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const {isOffline} = useNetwork(); - const submitRef = useRef(null); + const submitRef = useRef | HTMLElement>(null); const shouldFetchReport = isEmptyObject(reportActions?.[reportActionID ?? CONST.DEFAULT_NUMBER_ID]); diff --git a/src/pages/settings/AboutPage/AboutPage.tsx b/src/pages/settings/AboutPage/AboutPage.tsx index 21fcb00593b1..334698673bc6 100644 --- a/src/pages/settings/AboutPage/AboutPage.tsx +++ b/src/pages/settings/AboutPage/AboutPage.tsx @@ -33,6 +33,7 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type IconAsset from '@src/types/utils/IconAsset'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; +import type {ComponentRef} from 'react'; import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; import {hasSeenTourSelector} from '@selectors/Onboarding'; @@ -67,7 +68,7 @@ function AboutPage() { const icons = useMemoizedLazyExpensifyIcons(['NewWindow', 'Link', 'Keyboard', 'Eye', 'MoneyBag', 'Bug']); const {translate} = useLocalize(); const styles = useThemeStyles(); - const popoverAnchor = useRef(null); + const popoverAnchor = useRef>(null); const waitForNavigate = useWaitForNavigation(); const {shouldUseNarrowLayout} = useResponsiveLayout(); useDocumentTitle(translate('initialSettingsPage.about')); diff --git a/src/pages/settings/Copilot/CopilotPage.tsx b/src/pages/settings/Copilot/CopilotPage.tsx index 0c8fee2666df..7546ca2b761f 100644 --- a/src/pages/settings/Copilot/CopilotPage.tsx +++ b/src/pages/settings/Copilot/CopilotPage.tsx @@ -48,7 +48,7 @@ import type Account from '@src/types/onyx/Account'; import type {Delegate, DelegateRole} from '@src/types/onyx/Account'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {GestureResponderEvent} from 'react-native'; import debounce from 'lodash/debounce'; @@ -440,7 +440,7 @@ function CopilotPage() { openSecuritySettingsPage(); }, []); - const delegateAnchorRef = delegateButtonRef as RefObject; + const delegateAnchorRef = delegateButtonRef as RefObject | null>; return ( (null); + const scrollViewRef = useRef>(null); const triggerScrollEvent = useScrollEventEmitter(); const onScroll: NonNullable = (e) => { diff --git a/src/pages/settings/Profile/AgentAIPromptSection.tsx b/src/pages/settings/Profile/AgentAIPromptSection.tsx index c82661e8d2c6..32336375e7d2 100644 --- a/src/pages/settings/Profile/AgentAIPromptSection.tsx +++ b/src/pages/settings/Profile/AgentAIPromptSection.tsx @@ -21,7 +21,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView, TextInputKeyPressEvent} from 'react-native'; @@ -34,10 +34,10 @@ const SAVED_CONFIRMATION_DURATION_MS = 2000; type AgentAIPromptSectionProps = { accountID: number; - parentScrollViewRef?: RefObject; + parentScrollViewRef?: RefObject | null>; }; -function scrollInputIntoView(parentScrollViewRef: RefObject) { +function scrollInputIntoView(parentScrollViewRef: RefObject | null>) { if (getPlatform() !== CONST.PLATFORM.IOS) { return; } diff --git a/src/pages/settings/Profile/ProfilePage.tsx b/src/pages/settings/Profile/ProfilePage.tsx index eb96b5710653..3e400f5eb294 100755 --- a/src/pages/settings/Profile/ProfilePage.tsx +++ b/src/pages/settings/Profile/ProfilePage.tsx @@ -48,6 +48,7 @@ import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -67,7 +68,7 @@ function ProfilePage() { const {shouldUseNarrowLayout} = useResponsiveLayout(); const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings(); const scrollEnabled = useScrollEnabled(); - const scrollViewRef = useRef(null); + const scrollViewRef = useRef>(null); const [loginList] = useOnyx(ONYXKEYS.LOGINS, {selector: expensifyLoginsSelector}); const [session] = useOnyx(ONYXKEYS.SESSION); const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); diff --git a/src/pages/settings/Security/TwoFactorAuth/DynamicVerifyPage.tsx b/src/pages/settings/Security/TwoFactorAuth/DynamicVerifyPage.tsx index dd6651ec2c2a..16961d209499 100644 --- a/src/pages/settings/Security/TwoFactorAuth/DynamicVerifyPage.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/DynamicVerifyPage.tsx @@ -27,6 +27,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView} from 'react-native'; @@ -81,7 +82,7 @@ function DynamicVerifyPage() { return `otpauth://totp/Expensify:${contactMethod}?secret=${account?.twoFactorAuthSecretKey}&issuer=Expensify`; } - const scrollViewRef = useRef(null); + const scrollViewRef = useRef>(null); const handleInputFocus = useCallback(() => { requestAnimationFrame(() => { scrollViewRef.current?.scrollToEnd({animated: true}); diff --git a/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx b/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx index 50810e70cb91..e5b8657db934 100644 --- a/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx @@ -19,6 +19,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView} from 'react-native'; @@ -36,7 +37,7 @@ function ReplaceDeviceVerifyNewPage() { const contactMethod = getContactMethod(account?.primaryLogin, session?.email); const formRef = useRef(null); - const scrollViewRef = useRef(null); + const scrollViewRef = useRef>(null); const errorMessage = getLatestErrorMessage(account); diff --git a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx index f501676b85b7..01c855f5e53e 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx +++ b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx @@ -10,7 +10,9 @@ import DotIndicatorMessage from '@components/DotIndicatorMessage'; import FrozenCardHeader from '@components/FrozenCardHeader'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; +import MenuItem from '@components/MenuItem'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemNavigation from '@components/MenuItem/presets/MenuItemNavigation'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; @@ -436,25 +438,28 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { accessibilityLabel={spendRulesSummary.join('. ')} /> )} - - + + + {!!remainingLimitHint && } + + + + + {shouldShowReportLostCardButton && ( <> )} {!!limitType && ( - + + + {!!cardHintText && } + )} {expiration?.length > 0 && ( - {(triggerKYCFlow, buttonRef: RefObject) => { + {(triggerKYCFlow, buttonRef: RefObject | null>) => { if (shouldShowLoadingSpinner) { return null; } @@ -824,7 +824,7 @@ function WalletPage() { if (hasActivatedWallet) { return ( } + ref={buttonRef as ForwardedRef>} title={translate('common.transferBalance')} icon={icons.Transfer} onPress={(event) => { @@ -871,7 +871,7 @@ function WalletPage() { } + ref={buttonRef as ForwardedRef>} onPress={() => { if (isAccountLocked) { showLockedAccountModal(); diff --git a/src/pages/signin/LoginForm/BaseLoginForm.tsx b/src/pages/signin/LoginForm/BaseLoginForm.tsx index cc363ffed0d6..6999da82a066 100644 --- a/src/pages/signin/LoginForm/BaseLoginForm.tsx +++ b/src/pages/signin/LoginForm/BaseLoginForm.tsx @@ -39,6 +39,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import htmlDivElementRef from '@src/types/utils/htmlDivElementRef'; import viewRef from '@src/types/utils/viewRef'; +import type {ComponentRef} from 'react'; + import {useIsFocused} from '@react-navigation/native'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -228,7 +230,7 @@ function BaseLoginForm({submitBehavior = 'submit', isVisible, ref}: BaseLoginFor const isSigningWithAppleOrGoogle = useRef(false); const setIsSigningWithAppleOrGoogle = useCallback((isPressed: boolean) => (isSigningWithAppleOrGoogle.current = isPressed), []); - const submitContainerRef = useRef(null); + const submitContainerRef = useRef | HTMLDivElement>(null); const handleFocus = useCallback(() => { if (!isMobileWebKit()) { return; diff --git a/src/pages/signin/SignInPageLayout/index.tsx b/src/pages/signin/SignInPageLayout/index.tsx index f359c8c239f4..582c0a3a811f 100644 --- a/src/pages/signin/SignInPageLayout/index.tsx +++ b/src/pages/signin/SignInPageLayout/index.tsx @@ -20,6 +20,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView} from 'react-native'; @@ -49,7 +50,7 @@ function SignInPageLayout({ const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {top: topInsets, bottom: bottomInsets} = useSafeAreaInsets(); - const scrollViewRef = useRef(null); + const scrollViewRef = useRef>(null); const {windowHeight} = useWindowDimensions(); const {shouldUseNarrowLayout, isMediumScreenWidth, isLargeScreenWidth} = useResponsiveLayout(); diff --git a/src/pages/tasks/DynamicNewTaskPage.tsx b/src/pages/tasks/DynamicNewTaskPage.tsx index 5f072ea170c4..74d792a1a7b4 100644 --- a/src/pages/tasks/DynamicNewTaskPage.tsx +++ b/src/pages/tasks/DynamicNewTaskPage.tsx @@ -40,6 +40,8 @@ import {personalDetailsListSelector} from '@src/selectors/PersonalDetails'; import {pendingDeleteMemberAccountIDsSelector} from '@src/selectors/ReportMetaData'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {ComponentRef} from 'react'; + import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -113,7 +115,7 @@ function DynamicNewTaskPage() { const {paddingBottom} = useSafeAreaPaddings(); const detailsBackPath = useDynamicBackPath(DYNAMIC_ROUTES.NEW_TASK.path); - const confirmButtonRef = useRef(null); + const confirmButtonRef = useRef>(null); const navigateToAssignee = () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NEW_TASK_ASSIGNEE.path)); const navigateToShareDestination = task?.parentReportID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NEW_TASK_SHARE_DESTINATION.path)); diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index c429f66ef140..f1f05138fc85 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -176,6 +176,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const canSelectMultiple = canWriteMembers && (shouldUseNarrowLayout ? isMobileSelectionModeEnabled : true); const confirmModalPrompt = useMemo(() => { + const hiddenText = translate('common.hidden'); const approverEmail = selectedEmployees.find( (selectedEmployee) => isPolicyApprover(policy, selectedEmployee) || @@ -186,8 +187,8 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const approverAccountID = policyMemberEmailsToAccountIDs[approverEmail]; return translate( 'workspace.people.removeMembersWarningPrompt', - getDisplayNameForParticipant({accountID: approverAccountID, formatPhoneNumber, translate}), - getDisplayNameForParticipant({accountID: policy?.ownerAccountID, formatPhoneNumber, translate}), + getDisplayNameForParticipant({accountID: approverAccountID, formatPhoneNumber, hiddenTranslation: hiddenText}), + getDisplayNameForParticipant({accountID: policy?.ownerAccountID, formatPhoneNumber, hiddenTranslation: hiddenText}), ); } @@ -197,8 +198,8 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers if (userExporter) { const exporterAccountID = policyMemberEmailsToAccountIDs[userExporter]; return translate('workspace.people.removeMemberPromptExporter', { - memberName: getDisplayNameForParticipant({accountID: exporterAccountID, formatPhoneNumber, translate}), - workspaceOwner: getDisplayNameForParticipant({accountID: policy?.ownerAccountID, formatPhoneNumber, translate}), + memberName: getDisplayNameForParticipant({accountID: exporterAccountID, formatPhoneNumber, hiddenTranslation: hiddenText}), + workspaceOwner: getDisplayNameForParticipant({accountID: policy?.ownerAccountID, formatPhoneNumber, hiddenTranslation: hiddenText}), }); } diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx index d2261f9e7704..a438326ed3f5 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx @@ -174,7 +174,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro // // Use the active vendor source so a stale GA connection cannot bypass the beta for another // integration. When no source is active, keep the connected integration's discovery row. - // QBO (R1) and Sage Intacct (R2), and DualEntry are GA. Xero, Rillet, and Business Central require the vendorMatching beta. + // QBO (R1), Sage Intacct (R2), Rillet, and DualEntry are GA. Xero and Business Central require the vendorMatching beta. const vendorMatchingConnection = getActiveVendorMatchingIntegration(policy) ?? getConnectedIntegration(policy, [ @@ -188,6 +188,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro const isGenerallyAvailableVendorConnection = vendorMatchingConnection === CONST.POLICY.CONNECTIONS.NAME.QBO || vendorMatchingConnection === CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT || + vendorMatchingConnection === CONST.POLICY.CONNECTIONS.NAME.RILLET || vendorMatchingConnection === CONST.POLICY.CONNECTIONS.NAME.DUALENTRY; const shouldShowVendorsFeature = isGenerallyAvailableVendorConnection || (!!isVendorMatchingBetaEnabled && !!vendorMatchingConnection); diff --git a/src/pages/workspace/accounting/AccountingContext/default.ts b/src/pages/workspace/accounting/AccountingContext/default.ts index 561aed3f0428..fa7205c9342e 100644 --- a/src/pages/workspace/accounting/AccountingContext/default.ts +++ b/src/pages/workspace/accounting/AccountingContext/default.ts @@ -1,7 +1,7 @@ import CONST from '@src/CONST'; import type {ConnectionName} from '@src/types/onyx/Policy'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; import type {AccountingActionsContextType, AccountingStateContextType} from './types'; @@ -11,7 +11,7 @@ const popoverAnchorRefsInitialValue = [...CONST.POLICY.CONNECTIONS.ACCOUNTING_CO acc[key] = {current: null}; return acc; }, - {} as Record>, + {} as Record | null>>, ); const defaultAccountingStateContextValue: AccountingStateContextType = { diff --git a/src/pages/workspace/accounting/AccountingContext/index.tsx b/src/pages/workspace/accounting/AccountingContext/index.tsx index 844a9b05b1e7..f20e0bd10a08 100644 --- a/src/pages/workspace/accounting/AccountingContext/index.tsx +++ b/src/pages/workspace/accounting/AccountingContext/index.tsx @@ -1,7 +1,8 @@ -import AccountingConnectionConfirmationModal from '@components/AccountingConnectionConfirmationModal'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import useCardFeeds from '@hooks/useCardFeeds'; import useCardsLists from '@hooks/useCardsLists'; +import useConfirmModal from '@hooks/useConfirmModal'; import useHasReusablePoliciesConnectedTo from '@hooks/useHasReusablePoliciesConnectedTo'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -17,11 +18,11 @@ import ROUTES from '@src/ROUTES'; import type Policy from '@src/types/onyx/Policy'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useCallback, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {ActiveIntegration, ActiveIntegrationState} from './types'; @@ -32,11 +33,62 @@ type AccountingContextProviderProps = ChildrenProps & { policy: OnyxEntry; }; +// Names the connect-confirmation prompt on the global modal stack, so the two places that reach for it below cannot +// drift apart. +const ACCOUNTING_CONNECTION_CONFIRMATION_MODAL_ID = 'accountingConnectionConfirmation'; + function AccountingContextProvider({children, policy}: AccountingContextProviderProps) { - const popoverAnchorRefs = useRef>>(popoverAnchorRefsInitialValue); + const popoverAnchorRefs = useRef | null>>>(popoverAnchorRefsInitialValue); const [activeIntegration, setActiveIntegration] = useState(); const {translate} = useLocalize(); + const {showConfirmModal, closeModalByID} = useConfirmModal(); const policyID = policy?.id; + + // `removePolicyConnection` only runs once the user confirms, which can be a while after the flow started, so the + // policy is read at that point instead of the one captured when the prompt was shown. + const policyRef = useRef(policy); + useEffect(() => { + policyRef.current = policy; + }, [policy]); + + const isDisconnectConfirmationPendingRef = useRef(false); + + // Read through a ref so the unmount cleanup below can be registered once. `closeModalByID` takes a new identity + // whenever the modal stack changes, and an effect that listed it as a dependency would tear the prompt down as + // soon as any other modal opened. + const closeModalByIDRef = useRef(closeModalByID); + useEffect(() => { + closeModalByIDRef.current = closeModalByID; + }, [closeModalByID]); + + useEffect( + () => () => { + // The prompt lives on the global modal stack, so it outlives this provider. An unanswered entry left + // behind would hand the next accounting provider the same promise under the same id, and the one answer + // the user eventually gives would then also run this provider's handler, against the workspace it + // captured rather than the one on screen. + if (!isDisconnectConfirmationPendingRef.current) { + return; + } + isDisconnectConfirmationPendingRef.current = false; + closeModalByIDRef.current(ACCOUNTING_CONNECTION_CONFIRMATION_MODAL_ID); + }, + [], + ); + + const closeConfirmationModal = useCallback(() => { + setActiveIntegration((prev) => { + if (prev) { + return { + ...prev, + shouldDisconnectIntegrationBeforeConnecting: false, + integrationToDisconnect: undefined, + }; + } + return undefined; + }); + }, []); + const accountingIcons = useMemoizedLazyExpensifyIcons([ 'IntacctSquare', 'IntuitSquare', @@ -103,10 +155,61 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider ...newActiveIntegration, key: Math.random(), }); + + const integrationToDisconnect = newActiveIntegration.integrationToDisconnect; + if (!newActiveIntegration.shouldDisconnectIntegrationBeforeConnecting || !integrationToDisconnect) { + return; + } + + // Mirrors `shouldShowConfirmationModal` below, which keeps `renderActiveIntegration()` from mounting the + // setup flow until the user has decided what to do with the connection that has to be disconnected first. + const connectionName = newActiveIntegration.isIntuitEnterpriseSuite + ? translate('workspace.accounting.intuitEnterpriseSuite') + : (CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[newActiveIntegration.name] ?? newActiveIntegration.name); + + isDisconnectConfirmationPendingRef.current = true; + + showConfirmModal({ + // `startIntegrationFlow` can run more than once for the same flow (the `useFocusEffect` in + // `PolicyAccountingPage` re-fires whenever `startIntegrationFlow` is re-created). A stable id keeps the + // repeat call updating this prompt in place instead of stacking a second copy behind it. + id: ACCOUNTING_CONNECTION_CONFIRMATION_MODAL_ID, + title: translate('workspace.accounting.connectTitle', connectionName), + prompt: translate('workspace.accounting.connectPrompt', connectionName), + confirmText: translate('workspace.accounting.setup'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, + }).then((result) => { + // A repeat call for the same id is handed back the promise the first call got, so every call's handler + // runs on a single user answer. Only the first one may act, or the disconnect would be requested twice. + if (!isDisconnectConfirmationPendingRef.current) { + return; + } + isDisconnectConfirmationPendingRef.current = false; + + if (result.action !== ModalActions.CONFIRM) { + setActiveIntegration(undefined); + return; + } + + const currentPolicy = policyRef.current; + + // If the policy went away while the prompt was open there is nothing to disconnect, so leave the + // confirmation state intact and keep `renderActiveIntegration()` held back. + if (!currentPolicy) { + return; + } + + removePolicyConnection(currentPolicy, integrationToDisconnect); + + closeConfirmationModal(); + }); }, [ + closeConfirmationModal, policy, policyID, + showConfirmModal, translate, hasReusablePoliciesConnectedToSageIntacct, hasReusablePoliciesConnectedToQBD, @@ -120,19 +223,6 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider ], ); - const closeConfirmationModal = () => { - setActiveIntegration((prev) => { - if (prev) { - return { - ...prev, - shouldDisconnectIntegrationBeforeConnecting: false, - integrationToDisconnect: undefined, - }; - } - return undefined; - }); - }; - const stateValue = useMemo( () => ({ activeIntegration, @@ -177,6 +267,8 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider )?.setupConnectionFlow; }; + // The confirmation prompt itself lives on the global modal stack and is shown from `startIntegrationFlow`, but this + // flag still has to gate the setup flow so it does not mount behind the prompt. const shouldShowConfirmationModal = !!activeIntegration?.shouldDisconnectIntegrationBeforeConnecting && !!activeIntegration?.integrationToDisconnect; return ( @@ -184,22 +276,6 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider {children} {!shouldShowConfirmationModal && renderActiveIntegration()} - {shouldShowConfirmationModal && ( - { - if (!policyID || !activeIntegration?.integrationToDisconnect) { - return; - } - removePolicyConnection(policy, activeIntegration?.integrationToDisconnect); - closeConfirmationModal(); - }} - integrationToConnect={activeIntegration.name} - integrationDisplayName={activeIntegration.isIntuitEnterpriseSuite ? translate('workspace.accounting.intuitEnterpriseSuite') : undefined} - onCancel={() => { - setActiveIntegration(undefined); - }} - /> - )} ); diff --git a/src/pages/workspace/accounting/AccountingContext/types.ts b/src/pages/workspace/accounting/AccountingContext/types.ts index 1ee7d3a8ad5f..ae790a35d1b9 100644 --- a/src/pages/workspace/accounting/AccountingContext/types.ts +++ b/src/pages/workspace/accounting/AccountingContext/types.ts @@ -1,6 +1,6 @@ import type {ConnectionName} from '@src/types/onyx/Policy'; -import type {RefObject} from 'react'; +import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; type ActiveIntegration = { @@ -14,7 +14,7 @@ type ActiveIntegrationState = ActiveIntegration & {key: number}; type AccountingStateContextType = { activeIntegration?: ActiveIntegration; - popoverAnchorRefs: RefObject>>; + popoverAnchorRefs: RefObject | null>>>; }; type AccountingActionsContextType = { diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index 5407e89768b8..57dcfe3870d4 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -71,7 +71,7 @@ import type {ConnectionName} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useFocusEffect, useRoute} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {MenuItemData, PolicyAccountingPageProps} from './types'; @@ -283,11 +283,26 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { ], ); + // `startIntegrationFlow` changes identity whenever `policy` does, which re-runs this effect. The + // Navigation.setParams below clears newConnectionName through a navigation state update that lands in a later + // render, so that re-run can still see the param set. Key the guard on the value to start the flow only once. + const startedIntegrationFlowForRef = useRef(undefined); + useFocusEffect( useCallback(() => { if (!newConnectionName || !isControlPolicy(policy) || !canWriteAccounting) { + // Re-arm the guard once the param is gone, so a later round-trip that asks for the same integration + // again is not mistaken for the re-run this guard exists to swallow. + if (!newConnectionName) { + startedIntegrationFlowForRef.current = undefined; + } + return; + } + + if (startedIntegrationFlowForRef.current === newConnectionName) { return; } + startedIntegrationFlowForRef.current = newConnectionName; startIntegrationFlow({ name: newConnectionName, diff --git a/src/pages/workspace/accounting/businessCentral/BusinessCentralPrerequisitesPage.tsx b/src/pages/workspace/accounting/businessCentral/BusinessCentralPrerequisitesPage.tsx index 1b2fd3ceac1e..2dafc0cbfb15 100644 --- a/src/pages/workspace/accounting/businessCentral/BusinessCentralPrerequisitesPage.tsx +++ b/src/pages/workspace/accounting/businessCentral/BusinessCentralPrerequisitesPage.tsx @@ -22,6 +22,7 @@ import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import type {ComponentRef} from 'react'; import type {GestureResponderEvent} from 'react-native'; import React, {useRef} from 'react'; @@ -34,7 +35,7 @@ function BusinessCentralPrerequisitesPage({route}: BusinessCentralPrerequisitesP const styles = useThemeStyles(); const icons = useMemoizedLazyExpensifyIcons(['NewWindow', 'Task']); const illustrations = useMemoizedLazyIllustrations(['Computer']); - const popoverAnchor = useRef(null); + const popoverAnchor = useRef>(null); const policyID: string = route.params.policyID; const menuItems = [ diff --git a/src/pages/workspace/accounting/certinia/export/CertiniaDefaultVendorPage.tsx b/src/pages/workspace/accounting/certinia/export/CertiniaDefaultVendorPage.tsx index ce97572045f2..ff06db94f24e 100644 --- a/src/pages/workspace/accounting/certinia/export/CertiniaDefaultVendorPage.tsx +++ b/src/pages/workspace/accounting/certinia/export/CertiniaDefaultVendorPage.tsx @@ -36,12 +36,20 @@ function CertiniaDefaultVendorPage({policy}: WithPolicyConnectionsProps) { const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_DEFAULT_VENDOR.path); const illustrations = useMemoizedLazyIllustrations(['Telescope']); - const dataOptions: VendorListItem[] = vendors.map((vendor) => ({ + const vendorOptions: VendorListItem[] = vendors.map((vendor) => ({ value: vendor.id, text: vendor.name, keyForList: vendor.id, isSelected: exportConfig?.vendorAccount === vendor.id, })); + const clearOption: VendorListItem = { + value: '', + text: translate('common.none'), + keyForList: '', + isSelected: !exportConfig?.vendorAccount, + }; + const shouldShowClearOption = !!exportConfig?.vendorAccount || vendorOptions.length > 0; + const dataOptions: VendorListItem[] = shouldShowClearOption ? [clearOption, ...vendorOptions] : vendorOptions; const listEmptyContent = ( { - if (row.value !== exportConfig?.vendorAccount && policyID) { + const isAlreadySelected = row.value === exportConfig?.vendorAccount || (!row.value && !exportConfig?.vendorAccount); + if (isAlreadySelected) { + return; + } + if (policyID) { updateFinancialForceDefaultVendor(policyID, row.value, exportConfig?.vendorAccount ?? null); } Navigation.goBack(backPath); diff --git a/src/pages/workspace/accounting/dualentry/advanced/DualEntryAdvancedPage.tsx b/src/pages/workspace/accounting/dualentry/advanced/DualEntryAdvancedPage.tsx index d07d4e0f3d70..175e651fe3fa 100644 --- a/src/pages/workspace/accounting/dualentry/advanced/DualEntryAdvancedPage.tsx +++ b/src/pages/workspace/accounting/dualentry/advanced/DualEntryAdvancedPage.tsx @@ -2,7 +2,6 @@ import Accordion from '@components/Accordion'; import ConnectionLayout from '@components/ConnectionLayout'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useAccordionAnimation from '@hooks/useAccordionAnimation'; @@ -29,6 +28,8 @@ import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -95,16 +96,20 @@ function DualEntryAdvancedPage({policy}: WithPolicyConnectionsProps) { isToggleTriggered={shouldAnimateAutoSyncAccordionSection} > - (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_DUALENTRY_EXPORT_METHOD.getRoute(policyID)) : undefined)} - shouldShowRightIcon - brickRoadIndicator={ - areSettingsInErrorFields([CONST.DUALENTRY_CONFIG.ACCOUNTING_METHOD], dualentryConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined - } - /> + (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_DUALENTRY_EXPORT_METHOD.getRoute(policyID)) : undefined))} + > + + {areSettingsInErrorFields([CONST.DUALENTRY_CONFIG.ACCOUNTING_METHOD], dualentryConfig?.errorFields) && ( + + )} + + + + diff --git a/src/pages/workspace/accounting/dualentry/export/DualEntryCardAccountPage.tsx b/src/pages/workspace/accounting/dualentry/export/DualEntryCardAccountPage.tsx index ad0f83bd783a..ff5e13cd9f28 100644 --- a/src/pages/workspace/accounting/dualentry/export/DualEntryCardAccountPage.tsx +++ b/src/pages/workspace/accounting/dualentry/export/DualEntryCardAccountPage.tsx @@ -1,5 +1,6 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import Text from '@components/Text'; @@ -15,6 +16,8 @@ import Navigation from '@libs/Navigation/Navigation'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -68,22 +71,24 @@ function DualEntryCardAccountPage({policy}: WithPolicyConnectionsProps) { key={feedKey} pendingAction={getCardsCustomExportPendingAction(cardFeeds ?? {}, cardLists ?? {}, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT, feedKey)} > - (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_DUALENTRY_CARD_ACCOUNT_CARD_LIST.getRoute(policyID, feedWithDomainID)) : undefined)} - shouldShowRightIcon - brickRoadIndicator={ - areCardsCustomExportInErrorFields(cardFeeds ?? {}, cardLists ?? {}, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT, feedKey) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined - } - /> + + policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_DUALENTRY_CARD_ACCOUNT_CARD_LIST.getRoute(policyID, feedWithDomainID)) : undefined, + )} + > + + {areCardsCustomExportInErrorFields(cardFeeds ?? {}, cardLists ?? {}, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_DUALENTRY_EXPORT_ACCOUNT, feedKey) && ( + + )} + + + {!!cardsUsingCustomAccountsCount.perFeedCount[feedKey] && ( + + )} + ); })} diff --git a/src/pages/workspace/accounting/dualentry/export/DualEntryCardProgramAccountPage.tsx b/src/pages/workspace/accounting/dualentry/export/DualEntryCardProgramAccountPage.tsx index a5ea6dae67d0..a0f9947f831e 100644 --- a/src/pages/workspace/accounting/dualentry/export/DualEntryCardProgramAccountPage.tsx +++ b/src/pages/workspace/accounting/dualentry/export/DualEntryCardProgramAccountPage.tsx @@ -1,5 +1,6 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import Text from '@components/Text'; @@ -16,6 +17,8 @@ import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -69,24 +72,24 @@ function DualEntryCardProgramAccountPage({policy}: WithPolicyConnectionsProps) { key={feedKey} pendingAction={settingsPendingAction([`${CONST.DUALENTRY_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${feedKey}`], dualentryConfig?.pendingFields)} > - - policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_DUALENTRY_CARD_PROGRAM_ACCOUNT_SELECTOR.getRoute(policyID, feedWithDomainID)) : undefined - } - shouldShowRightIcon - brickRoadIndicator={ - areSettingsInErrorFields([`${CONST.DUALENTRY_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${feedKey}`], dualentryConfig?.errorFields) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined - } - /> + + policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_DUALENTRY_CARD_PROGRAM_ACCOUNT_SELECTOR.getRoute(policyID, feedWithDomainID)) : undefined, + )} + > + + {areSettingsInErrorFields([`${CONST.DUALENTRY_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${feedKey}`], dualentryConfig?.errorFields) && ( + + )} + + + {!!cardsUsingCustomAccountsCount.perFeedCount[feedKey] && ( + + )} + ); })} diff --git a/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx b/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx index 0aaa44520273..d756b29002fd 100644 --- a/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx +++ b/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx @@ -39,13 +39,21 @@ function DualEntryDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsPr const backPath = policyID ? ROUTES.POLICY_ACCOUNTING_DUALENTRY_EXPORT.getRoute(policyID) : undefined; const sortedVendors = sortVendors(getDualEntryVendors(policy), localeCompare); - const data: VendorListItem[] = sortedVendors.map((vendorItem) => ({ + const vendorOptions: VendorListItem[] = sortedVendors.map((vendorItem) => ({ value: vendorItem.id, text: vendorItem.name, keyForList: vendorItem.id, isSelected: defaultCompanyCardVendorID === vendorItem.id, })); - const {filteredData, textInputOptions} = useSelectionListSearch(data); + const clearOption: VendorListItem = { + value: '', + text: translate('common.none'), + keyForList: '', + isSelected: !defaultCompanyCardVendorID, + }; + const shouldShowClearOption = !!defaultCompanyCardVendorID || vendorOptions.length > 0; + const {filteredData: filteredVendorOptions, textInputOptions} = useSelectionListSearch(vendorOptions); + const data: VendorListItem[] = shouldShowClearOption ? [clearOption, ...filteredVendorOptions] : filteredVendorOptions; const headerContent = ( @@ -65,7 +73,11 @@ function DualEntryDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsPr ); const selectDefaultVendor = (item: VendorListItem) => { - if (item.value !== defaultCompanyCardVendorID && policyID) { + const isAlreadySelected = item.value === defaultCompanyCardVendorID || (!item.value && !defaultCompanyCardVendorID); + if (isAlreadySelected) { + return; + } + if (policyID) { updateDualEntryDefaultVendor(policyID, item.value, defaultCompanyCardVendorID); } Navigation.goBack(backPath); @@ -78,7 +90,7 @@ function DualEntryDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsPr featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} displayName="DualEntryDefaultCompanyCardVendorPage" title="workspace.dualEntry.defaultCompanyCardVendor.label" - data={filteredData} + data={data} textInputOptions={textInputOptions} headerContent={headerContent} listEmptyContent={listEmptyContent} diff --git a/src/pages/workspace/accounting/intacct/DynamicSageIntacctPrerequisitesPage.tsx b/src/pages/workspace/accounting/intacct/DynamicSageIntacctPrerequisitesPage.tsx index 6898aa1eb280..018c754f7562 100644 --- a/src/pages/workspace/accounting/intacct/DynamicSageIntacctPrerequisitesPage.tsx +++ b/src/pages/workspace/accounting/intacct/DynamicSageIntacctPrerequisitesPage.tsx @@ -24,6 +24,7 @@ import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {GestureResponderEvent, Text as RNText} from 'react-native'; @@ -37,7 +38,7 @@ function DynamicSageIntacctPrerequisitesPage({route}: DynamicSageIntacctPrerequi const styles = useThemeStyles(); const icons = useMemoizedLazyExpensifyIcons(['Download', 'NewWindow', 'Task']); const illustrations = useMemoizedLazyIllustrations(['Computer']); - const popoverAnchor = useRef(null); + const popoverAnchor = useRef | ComponentRef | null>(null); const policyID: string = route.params.policyID; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.SAGE_INTACCT_PREREQUISITES.path); diff --git a/src/pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage.tsx b/src/pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage.tsx index f30cc4f8d2a9..85c9bea93c6d 100644 --- a/src/pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage.tsx +++ b/src/pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage.tsx @@ -2,7 +2,6 @@ import Accordion from '@components/Accordion'; import ConnectionLayout from '@components/ConnectionLayout'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useAccordionAnimation from '@hooks/useAccordionAnimation'; @@ -27,6 +26,7 @@ import { updateSageIntacctSyncReimbursementAccountID, } from '@userActions/connections/SageIntacct'; import {clearSageIntacctErrorField} from '@userActions/Policy/Policy'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -111,23 +111,20 @@ function SageIntacctAdvancedPage({policy}: WithPolicyProps) { onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING.getRoute(policyID))} > - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_AUTO_SYNC.path))} - brickRoadIndicator={ - areSettingsInErrorFields([CONST.SAGE_INTACCT_CONFIG.AUTO_SYNC, CONST.SAGE_INTACCT_CONFIG.ACCOUNTING_METHOD], config?.errorFields) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined - } - hintText={(() => { - if (!config?.autoSync?.enabled) { - return undefined; - } - return translate(`workspace.sageIntacct.accountingMethods.alternateText.${accountingMethod}` as TranslationPaths); - })()} - /> + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_AUTO_SYNC.path)))}> + + {areSettingsInErrorFields([CONST.SAGE_INTACCT_CONFIG.AUTO_SYNC, CONST.SAGE_INTACCT_CONFIG.ACCOUNTING_METHOD], config?.errorFields) && ( + + )} + + + {!!config?.autoSync?.enabled && ( + + )} + {toggleSections.map((section) => ( - account.id === config?.fxExpenseAccount)?.name} - description={translate('workspace.sageIntacct.fxExpenseAccount')} - shouldShowRightIcon + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_FX_EXPENSE_ACCOUNT.getRoute(policyID))} - brickRoadIndicator={areSettingsInErrorFields([CONST.SAGE_INTACCT_CONFIG.FX_EXPENSE_ACCOUNT], errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - /> + value={data?.expenseAccounts?.find((account) => account.id === config?.fxExpenseAccount)?.name} + > + {areSettingsInErrorFields([CONST.SAGE_INTACCT_CONFIG.FX_EXPENSE_ACCOUNT], errorFields) && ( + + )} + )} diff --git a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx index 6fee54abfb8b..a9a0a87a538c 100644 --- a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx +++ b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx @@ -67,7 +67,24 @@ function DynamicSageIntacctDefaultVendorPage() { settingName = CONST.SAGE_INTACCT_CONFIG.REIMBURSABLE_VENDOR; } - const vendorSelectorOptions = useMemo(() => getSageIntacctVendors(policy, defaultVendor, localeCompare), [defaultVendor, localeCompare, policy]); + // Only the non-reimbursable credit-card-charge path treats a blank vendor as a valid state (falls back to "Credit Card Misc"), so we only offer a "None" row on that setting. + const canClear = settingName === CONST.SAGE_INTACCT_CONFIG.NON_REIMBURSABLE_CREDIT_CARD_VENDOR; + + const vendorOptions = useMemo(() => getSageIntacctVendors(policy, defaultVendor, localeCompare), [defaultVendor, localeCompare, policy]); + const clearOption: SelectorType = useMemo( + () => ({ + value: CLEAR_DEFAULT_VENDOR, + text: translate('common.none'), + keyForList: CLEAR_DEFAULT_VENDOR, + isSelected: !defaultVendor, + }), + [translate, defaultVendor], + ); + const shouldShowClearOption = canClear && (!!defaultVendor || vendorOptions.length > 0); + const vendorSelectorOptions = useMemo( + () => (shouldShowClearOption ? [clearOption, ...vendorOptions] : vendorOptions), + [shouldShowClearOption, clearOption, vendorOptions], + ); const listHeaderComponent = useMemo( () => ( @@ -80,21 +97,16 @@ function DynamicSageIntacctDefaultVendorPage() { [translate, styles.pb2, styles.ph5, styles.pb5, styles.textNormal, isReimbursable], ); - // Only the non-reimbursable credit-card-charge path treats a blank vendor as a valid state (falls back to "Credit Card Misc"), so we only allow clearing when that setting is active. - const canClearByReSelecting = settingName === CONST.SAGE_INTACCT_CONFIG.NON_REIMBURSABLE_CREDIT_CARD_VENDOR; - const updateDefaultVendor = useCallback( ({value}: SelectorType) => { - if (value === defaultVendor) { - if (canClearByReSelecting) { - updateSageIntacctDefaultVendor(policyID, settingName, CLEAR_DEFAULT_VENDOR, defaultVendor); - } - } else { - updateSageIntacctDefaultVendor(policyID, settingName, value, defaultVendor); + const isAlreadySelected = value === defaultVendor || (!value && !defaultVendor); + if (isAlreadySelected) { + return; } + updateSageIntacctDefaultVendor(policyID, settingName, value, defaultVendor); goBack(); }, - [defaultVendor, policyID, settingName, goBack, canClearByReSelecting], + [defaultVendor, policyID, settingName, goBack], ); const listEmptyContent = useMemo( diff --git a/src/pages/workspace/accounting/intacct/import/SageIntacctToggleMappingsPage.tsx b/src/pages/workspace/accounting/intacct/import/SageIntacctToggleMappingsPage.tsx index 91372141408a..d974cd62fc36 100644 --- a/src/pages/workspace/accounting/intacct/import/SageIntacctToggleMappingsPage.tsx +++ b/src/pages/workspace/accounting/intacct/import/SageIntacctToggleMappingsPage.tsx @@ -1,6 +1,7 @@ import Accordion from '@components/Accordion'; import ConnectionLayout from '@components/ConnectionLayout'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import RenderHTML from '@components/RenderHTML'; @@ -17,6 +18,8 @@ import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; @@ -112,14 +115,16 @@ function SageIntacctToggleMappingsPage({route}: SageIntacctToggleMappingsPagePro isToggleTriggered={shouldAnimateAccordionSection} > - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_MAPPINGS_TYPE.getRoute(policyID, mappingName))} - brickRoadIndicator={areSettingsInErrorFields([mappingName], config?.errorFields) ? 'error' : undefined} - hintText={translationKeys?.descriptionKey ? translate(translationKeys?.descriptionKey) : undefined} - /> + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_MAPPINGS_TYPE.getRoute(policyID, mappingName)))}> + + {areSettingsInErrorFields([mappingName], config?.errorFields) && } + + + {!!translationKeys?.descriptionKey && } + diff --git a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteAdvancedPage.tsx b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteAdvancedPage.tsx index 397cad0b9b12..a26c558c960e 100644 --- a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteAdvancedPage.tsx +++ b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteAdvancedPage.tsx @@ -1,6 +1,7 @@ import Accordion from '@components/Accordion'; import ConnectionLayout from '@components/ConnectionLayout'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useCanConfigureCurrencyConversionFees from '@hooks/useCanConfigureCurrencyConversionFees'; @@ -38,6 +39,8 @@ import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnec import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -89,14 +92,16 @@ function NetSuiteAdvancedPage({policy}: WithPolicyConnectionsProps) { key={item.description} pendingAction={settingsPendingAction(item.subscribedSettings, config?.pendingFields) ?? settingsPendingAction(item.subscribedSettings, autoSyncConfig?.pendingFields)} > - + + + {areSettingsInErrorFields(item.subscribedSettings, config?.errorFields) && } + + + {!!item.hintText && } + ); }; diff --git a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx index ae9a97b5ba98..75298c35f884 100644 --- a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx @@ -41,7 +41,21 @@ function DynamicNetSuiteExportExpensesVendorSelectPage({policy}: WithPolicyConne const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_VENDOR_SELECT.path); const config = policy?.connections?.netsuite?.options.config; - const netsuiteVendorOptions = useMemo(() => getNetSuiteVendorOptions(policy ?? undefined, config?.defaultVendor), [config?.defaultVendor, policy]); + const vendorOptions = useMemo(() => getNetSuiteVendorOptions(policy ?? undefined, config?.defaultVendor), [config?.defaultVendor, policy]); + const clearOption: SelectorType = useMemo( + () => ({ + value: '', + text: translate('common.none'), + keyForList: '', + isSelected: !config?.defaultVendor, + }), + [translate, config?.defaultVendor], + ); + const shouldShowClearOption = !!config?.defaultVendor || vendorOptions.length > 0; + const netsuiteVendorOptions = useMemo( + () => (shouldShowClearOption ? [clearOption, ...vendorOptions] : vendorOptions), + [shouldShowClearOption, clearOption, vendorOptions], + ); const initiallyFocusedOptionKey = useMemo(() => netsuiteVendorOptions?.find((mode) => mode.isSelected)?.keyForList, [netsuiteVendorOptions]); @@ -51,7 +65,11 @@ function DynamicNetSuiteExportExpensesVendorSelectPage({policy}: WithPolicyConne const updateDefaultVendor = useCallback( ({value}: SelectorType) => { - if (config?.defaultVendor !== value && policyID) { + const isAlreadySelected = value === config?.defaultVendor || (!value && !config?.defaultVendor); + if (isAlreadySelected) { + return; + } + if (policyID) { updateNetSuiteDefaultVendor(policyID, value, config?.defaultVendor); } goBack(); diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListPicker.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListPicker.tsx index 820a43ed1cd2..448c20b63f78 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListPicker.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListPicker.tsx @@ -1,9 +1,12 @@ -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -26,19 +29,28 @@ function NetSuiteCustomListPicker({value, policyID, errorText, isEditing}: NetSu const {translate} = useLocalize(); return ( - { + { if (!policyID) { return; } Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOM_LIST_SELECTOR.getRoute(policyID, isEditing ? 'edit' : undefined)); - }} - brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={errorText} - /> + })} + > + + {!!errorText && } + + + {!!errorText && ( + + )} + ); } diff --git a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopCompanyCardExpenseAccountPage.tsx b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopCompanyCardExpenseAccountPage.tsx index b558cb1cae22..7b2fcb280549 100644 --- a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopCompanyCardExpenseAccountPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopCompanyCardExpenseAccountPage.tsx @@ -2,7 +2,6 @@ import Accordion from '@components/Accordion'; import ConnectionLayout from '@components/ConnectionLayout'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useAccordionAnimation from '@hooks/useAccordionAnimation'; @@ -24,6 +23,7 @@ import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; import {clearQBDErrorField} from '@userActions/Policy/Policy'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -83,14 +83,16 @@ function DynamicQuickbooksDesktopCompanyCardExpenseAccountPage({policy}: WithPol key={section.keyForList} pendingAction={settingsPendingAction(section.subscribedSettings, qbdConfig?.pendingFields)} > - + + + {areSettingsInErrorFields(section.subscribedSettings, qbdConfig?.errorFields) && } + + + {!!section.hintText && } + ))} {nonReimbursable === CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL && ( diff --git a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseConfigurationPage.tsx b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseConfigurationPage.tsx index 818e1d908cd7..7572551e0eb9 100644 --- a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseConfigurationPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseConfigurationPage.tsx @@ -1,5 +1,6 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; @@ -19,6 +20,7 @@ import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; import {clearQBDErrorField} from '@userActions/Policy/Policy'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -113,14 +115,16 @@ function DynamicQuickbooksDesktopOutOfPocketExpenseConfigurationPage({policy}: W // eslint-disable-next-line react/no-array-index-key key={index} > - + + + {!!section.brickRoadIndicator && } + + + {!!section.hintText && } + ))} {reimbursable === CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK && ( diff --git a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx index 63ed6500c4f4..bf952cc7e288 100644 --- a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx @@ -36,7 +36,7 @@ function QuickbooksDesktopNonReimbursableDefaultVendorSelectPage({policy}: WithP const nonReimbursableBillDefaultVendor = qbdConfig?.export?.nonReimbursableBillDefaultVendor; const policyID = policy?.id ?? CONST.DEFAULT_NUMBER_ID.toString(); - const data: CardListItem[] = useMemo( + const vendorOptions: CardListItem[] = useMemo( () => vendors?.map((vendor) => ({ value: vendor.id, @@ -47,14 +47,29 @@ function QuickbooksDesktopNonReimbursableDefaultVendorSelectPage({policy}: WithP [nonReimbursableBillDefaultVendor, vendors], ); + const isNonReimbursableBillDefaultVendorNone = !nonReimbursableBillDefaultVendor || nonReimbursableBillDefaultVendor === CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE; + const clearOption: CardListItem = useMemo( + () => ({ + value: CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, + text: translate('common.none'), + keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, + isSelected: isNonReimbursableBillDefaultVendorNone, + }), + [translate, isNonReimbursableBillDefaultVendorNone], + ); + const shouldShowClearOption = !!nonReimbursableBillDefaultVendor || vendorOptions.length > 0; + const data: CardListItem[] = useMemo(() => (shouldShowClearOption ? [clearOption, ...vendorOptions] : vendorOptions), [shouldShowClearOption, clearOption, vendorOptions]); + const selectVendor = useCallback( (row: CardListItem) => { - if (row.value !== nonReimbursableBillDefaultVendor) { - updateQuickbooksDesktopNonReimbursableBillDefaultVendor(policyID, row.value, nonReimbursableBillDefaultVendor); + const isAlreadySelected = row.value === nonReimbursableBillDefaultVendor || (row.value === CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE && isNonReimbursableBillDefaultVendorNone); + if (isAlreadySelected) { + return; } + updateQuickbooksDesktopNonReimbursableBillDefaultVendor(policyID, row.value, nonReimbursableBillDefaultVendor); Navigation.goBack(); }, - [nonReimbursableBillDefaultVendor, policyID], + [nonReimbursableBillDefaultVendor, isNonReimbursableBillDefaultVendorNone, policyID], ); const listEmptyContent = useMemo( diff --git a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksCompanyCardExpenseAccountPage.tsx b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksCompanyCardExpenseAccountPage.tsx index 15521395ce85..6268e0173163 100644 --- a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksCompanyCardExpenseAccountPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksCompanyCardExpenseAccountPage.tsx @@ -18,6 +18,8 @@ import {getQuickbooksOnlineIntegrationName} from '@pages/workspace/accounting/ut import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -92,14 +94,16 @@ function DynamicQuickbooksCompanyCardExpenseAccountPage({policy}: WithPolicyConn key={section.title} pendingAction={settingsPendingAction(section.subscribedSettings, qboConfig?.pendingFields)} > - + + + {areSettingsInErrorFields(section.subscribedSettings, qboConfig?.errorFields) && } + + + {!!section.hintText && } + ))} {isVendorFeatureAvailable && ( diff --git a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseConfigurationPage.tsx b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseConfigurationPage.tsx index 65f02c08b5dd..72a976f03840 100644 --- a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseConfigurationPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseConfigurationPage.tsx @@ -1,5 +1,6 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; @@ -16,6 +17,8 @@ import {getQuickbooksOnlineIntegrationName} from '@pages/workspace/accounting/ut import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; @@ -133,14 +136,16 @@ function DynamicQuickbooksOutOfPocketExpenseConfigurationPage({policy}: WithPoli errors={section.errors} errorRowStyles={[styles.ph5]} > - + + + {!!section.brickRoadIndicator && } + + + {!!section.hintText && } + ))} diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx index 5fa0672b3ca1..24909db88a40 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksNonReimbursableVendorSelectPage.tsx @@ -55,24 +55,31 @@ function QuickbooksNonReimbursableVendorSelectPage({policy, configKey, updateVen const policyID = policy?.id ?? CONST.DEFAULT_NUMBER_ID.toString(); const sortedVendors = sortVendors(vendors ?? [], localeCompare); - const data: CardListItem[] = sortedVendors.map((vendor) => ({ + const vendorOptions: CardListItem[] = sortedVendors.map((vendor) => ({ value: vendor.id, text: vendor.name, keyForList: vendor.id, isSelected: vendor.id === currentVendor, })); - // Only the CC/DC export path treats a blank vendor as a valid state (falls back to "Credit Card Misc"), so we only allow clearing on that configKey. - const canClearByReSelecting = configKey === CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_CREDIT_CARD_DEFAULT_VENDOR; + // Only the CC/DC export path treats a blank vendor as a valid state (falls back to "Credit Card Misc"), so we only offer a "None" row on that configKey. + const canClear = configKey === CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_CREDIT_CARD_DEFAULT_VENDOR; + const clearOption: CardListItem = { + value: CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, + text: translate('common.none'), + keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, + isSelected: !currentVendor || currentVendor === CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, + }; + const shouldShowClearOption = canClear && (!!currentVendor || vendorOptions.length > 0); + const data: CardListItem[] = shouldShowClearOption ? [clearOption, ...vendorOptions] : vendorOptions; const selectVendor = (row: CardListItem) => { - if (row.value === currentVendor) { - if (canClearByReSelecting) { - updateVendor(policyID, CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, currentVendor); - } - } else { - updateVendor(policyID, row.value, currentVendor); + const isCurrentVendorNone = !currentVendor || currentVendor === CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE; + const isAlreadySelected = row.value === currentVendor || (row.value === CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE && isCurrentVendorNone); + if (isAlreadySelected) { + return; } + updateVendor(policyID, row.value, currentVendor); Navigation.goBack(); }; diff --git a/src/pages/workspace/accounting/rillet/advanced/RilletAdvancedPage.tsx b/src/pages/workspace/accounting/rillet/advanced/RilletAdvancedPage.tsx index 862f44d13d72..dd029a177c68 100644 --- a/src/pages/workspace/accounting/rillet/advanced/RilletAdvancedPage.tsx +++ b/src/pages/workspace/accounting/rillet/advanced/RilletAdvancedPage.tsx @@ -2,7 +2,6 @@ import Accordion from '@components/Accordion'; import ConnectionLayout from '@components/ConnectionLayout'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useAccordionAnimation from '@hooks/useAccordionAnimation'; @@ -29,6 +28,8 @@ import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -94,16 +95,20 @@ function RilletAdvancedPage({policy}: WithPolicyConnectionsProps) { isToggleTriggered={shouldAnimateAutoSyncAccordionSection} > - (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_EXPORT_METHOD.getRoute(policyID)) : undefined)} - shouldShowRightIcon - brickRoadIndicator={ - areSettingsInErrorFields([CONST.RILLET_CONFIG.ACCOUNTING_METHOD], rilletConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined - } - /> + (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_EXPORT_METHOD.getRoute(policyID)) : undefined))} + > + + {areSettingsInErrorFields([CONST.RILLET_CONFIG.ACCOUNTING_METHOD], rilletConfig?.errorFields) && ( + + )} + + + + diff --git a/src/pages/workspace/accounting/rillet/export/RilletCardAccount.tsx b/src/pages/workspace/accounting/rillet/export/RilletCardAccount.tsx index 7b1423ceca6b..95d51943fdc3 100644 --- a/src/pages/workspace/accounting/rillet/export/RilletCardAccount.tsx +++ b/src/pages/workspace/accounting/rillet/export/RilletCardAccount.tsx @@ -1,5 +1,6 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import Text from '@components/Text'; @@ -15,6 +16,8 @@ import Navigation from '@libs/Navigation/Navigation'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -68,22 +71,24 @@ function RilletCardAccount({policy}: WithPolicyConnectionsProps) { key={feedKey} pendingAction={getCardsCustomExportPendingAction(cardFeeds ?? {}, cardLists ?? {}, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT, feedKey)} > - (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_CARD_ACCOUNT_CARD_LIST.getRoute(policyID, feedWithDomainID)) : undefined)} - shouldShowRightIcon - brickRoadIndicator={ - areCardsCustomExportInErrorFields(cardFeeds ?? {}, cardLists ?? {}, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT, feedKey) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined - } - /> + + policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_CARD_ACCOUNT_CARD_LIST.getRoute(policyID, feedWithDomainID)) : undefined, + )} + > + + {areCardsCustomExportInErrorFields(cardFeeds ?? {}, cardLists ?? {}, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT, feedKey) && ( + + )} + + + {!!cardsUsingCustomAccountsCount.perFeedCount[feedKey] && ( + + )} + ); })} diff --git a/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccount.tsx b/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccount.tsx index 03b3a1804563..f72cbb157e59 100644 --- a/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccount.tsx +++ b/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccount.tsx @@ -1,5 +1,6 @@ import ConnectionLayout from '@components/ConnectionLayout'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import Text from '@components/Text'; @@ -16,6 +17,8 @@ import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -69,24 +72,24 @@ function RilletCardProgramAccount({policy}: WithPolicyConnectionsProps) { key={feedKey} pendingAction={settingsPendingAction([`${CONST.RILLET_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${feedKey}`], rilletConfig?.pendingFields)} > - - policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_CARD_PROGRAM_ACCOUNT_SELECTOR.getRoute(policyID, feedWithDomainID)) : undefined - } - shouldShowRightIcon - brickRoadIndicator={ - areSettingsInErrorFields([`${CONST.RILLET_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${feedKey}`], rilletConfig?.errorFields) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined - } - /> + + policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_CARD_PROGRAM_ACCOUNT_SELECTOR.getRoute(policyID, feedWithDomainID)) : undefined, + )} + > + + {areSettingsInErrorFields([`${CONST.RILLET_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${feedKey}`], rilletConfig?.errorFields) && ( + + )} + + + {!!cardsUsingCustomAccountsCount.perFeedCount[feedKey] && ( + + )} + ); })} diff --git a/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx b/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx index 2a1274578034..560b686872ee 100644 --- a/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx +++ b/src/pages/workspace/accounting/rillet/export/RilletDefaultCompanyCardVendorPage.tsx @@ -41,13 +41,21 @@ function RilletDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsProps const rawVendors = rilletData?.vendors ?? []; const sortedVendors = sortVendors(rawVendors, localeCompare); - const data: VendorListItem[] = sortedVendors.map((vendorItem) => ({ + const vendorOptions: VendorListItem[] = sortedVendors.map((vendorItem) => ({ value: vendorItem.id, text: vendorItem.name, keyForList: vendorItem.id, isSelected: defaultCompanyCardVendorID === vendorItem.id, })); - const {filteredData, textInputOptions} = useSelectionListSearch(data); + const clearOption: VendorListItem = { + value: '', + text: translate('common.none'), + keyForList: '', + isSelected: !defaultCompanyCardVendorID, + }; + const shouldShowClearOption = !!defaultCompanyCardVendorID || vendorOptions.length > 0; + const {filteredData: filteredVendorOptions, textInputOptions} = useSelectionListSearch(vendorOptions); + const data: VendorListItem[] = shouldShowClearOption ? [clearOption, ...filteredVendorOptions] : filteredVendorOptions; const headerContent = ( @@ -67,7 +75,11 @@ function RilletDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsProps ); const selectDefaultVendor = (item: VendorListItem) => { - if (item.value !== defaultCompanyCardVendorID && policyID) { + const isAlreadySelected = item.value === defaultCompanyCardVendorID || (!item.value && !defaultCompanyCardVendorID); + if (isAlreadySelected) { + return; + } + if (policyID) { updateRilletDefaultVendor(policyID, item.value, defaultCompanyCardVendorID); } Navigation.goBack(backPath); @@ -80,7 +92,7 @@ function RilletDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsProps featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} displayName="RilletDefaultCompanyCardVendorPage" title="workspace.rillet.defaultCompanyCardVendor.label" - data={filteredData} + data={data} textInputOptions={textInputOptions} headerContent={headerContent} listEmptyContent={listEmptyContent} diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx index 79f51b5f4e73..2213aa2eb5c8 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx @@ -114,7 +114,10 @@ function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicy // Treat the clear row and an already-empty default as the same state so picking // "None" on a workspace that never had a default doesn't fire a no-op write. const isAlreadySelected = value === currentContactID || (!value && !currentContactID); - if (!isAlreadySelected && policyID) { + if (isAlreadySelected) { + return; + } + if (policyID) { updateManyPolicyConnectionConfigs( policyID, CONST.POLICY.CONNECTIONS.NAME.XERO, diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx index 4e5a5d001988..6874738715d6 100644 --- a/src/pages/workspace/categories/CategorySettingsPage.tsx +++ b/src/pages/workspace/categories/CategorySettingsPage.tsx @@ -48,7 +48,7 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; type CategorySettingsPageProps = @@ -77,20 +77,16 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti const decodedCategoryName = getDecodedCategoryName(policyCategory?.name ?? ''); const categoryRulesEnabled = arePolicyRulesEnabled(policy, policyCategories); - const contextualRules = useMemo(() => { - if (!policyCategory) { - return []; - } - - return getCategoryContextualRules({ - policy, - category: policyCategory, - categoryName: policyCategory.name, - translate, - convertToDisplayString, - isOffline, - }); - }, [convertToDisplayString, isOffline, policy, policyCategory, translate]); + const contextualRules = !policyCategory + ? [] + : getCategoryContextualRules({ + policy, + category: policyCategory, + categoryName: policyCategory.name, + translate, + convertToDisplayString, + isOffline, + }); const shouldPreventDisableOrDelete = isDisablingOrDeletingLastEnabledCategory(policy, policyData.categories, [policyCategory]); const isQuickSettingsFlow = name === SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_SETTINGS; @@ -133,49 +129,28 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti const approverText = usePersonalDetailByLogin(categoryApprover, (personalDetails) => formatPhoneNumber(personalDetails?.displayName ?? categoryApprover)); // eslint-disable-next-line rulesdir/no-negated-variables - const showCannotDeleteOrDisableLastCategoryModal = useCallback(() => { + const showCannotDeleteOrDisableLastCategoryModal = () => { showConfirmModal({ title: translate('workspace.categories.cannotDeleteOrDisableAllCategories.title'), prompt: translate('workspace.categories.cannotDeleteOrDisableAllCategories.description'), confirmText: translate('common.buttonConfirm'), shouldShowCancelButton: false, }); - }, [showConfirmModal, translate]); + }; - const updateWorkspaceCategoryEnabled = useCallback( - (value: boolean) => { - if (shouldPreventDisableOrDelete) { - showCannotDeleteOrDisableLastCategoryModal(); - return; - } - setWorkspaceCategoryEnabled({ - isVendorMatchingBetaEnabled, - policyData, - categoriesToUpdate: {[policyCategory.name]: {name: policyCategory.name, enabled: value}}, - isSetupCategoriesTaskParentReportArchived: isSetupCategoryTaskParentReportArchived, - setupCategoryTaskReport, - setupCategoryTaskParentReport, - currentUserAccountID: currentUserPersonalDetails.accountID, - hasOutstandingChildTask, - parentReportAction, - setupCategoriesAndTagsTaskReport, - setupCategoriesAndTagsTaskParentReport, - isSetupCategoriesAndTagsTaskParentReportArchived, - setupCategoriesAndTagsHasOutstandingChildTask, - setupCategoriesAndTagsParentReportAction, - policyHasTags, - }); - }, - [ + const updateWorkspaceCategoryEnabled = (value: boolean) => { + if (shouldPreventDisableOrDelete) { + showCannotDeleteOrDisableLastCategoryModal(); + return; + } + setWorkspaceCategoryEnabled({ isVendorMatchingBetaEnabled, - showCannotDeleteOrDisableLastCategoryModal, - shouldPreventDisableOrDelete, policyData, - policyCategory?.name, - isSetupCategoryTaskParentReportArchived, + categoriesToUpdate: {[policyCategory.name]: {name: policyCategory.name, enabled: value}}, + isSetupCategoriesTaskParentReportArchived: isSetupCategoryTaskParentReportArchived, setupCategoryTaskReport, setupCategoryTaskParentReport, - currentUserPersonalDetails.accountID, + currentUserAccountID: currentUserPersonalDetails.accountID, hasOutstandingChildTask, parentReportAction, setupCategoriesAndTagsTaskReport, @@ -184,8 +159,8 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti setupCategoriesAndTagsHasOutstandingChildTask, setupCategoriesAndTagsParentReportAction, policyHasTags, - ], - ); + }); + }; const navigateToEditCategory = () => { Navigation.navigate(isQuickSettingsFlow ? buildDynamicRoute(DYNAMIC_ROUTES.SETTINGS_CATEGORY_EDIT.path) : buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_EDIT.path)); diff --git a/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx b/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx index f555906cb960..f751c71cd9aa 100644 --- a/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx +++ b/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx @@ -1,7 +1,8 @@ import Button from '@components/Button'; import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import {PressableWithoutFeedback} from '@components/Pressable'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -21,6 +22,7 @@ import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; import {setAddNewCompanyCardStepAndData} from '@userActions/CompanyCards'; import {openLink} from '@userActions/Link'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -124,15 +126,21 @@ function ImportFromFileStep() { - Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_LAYOUT_NAME.getRoute(policyID))} - brickRoadIndicator={shouldShowLayoutNameError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={shouldShowLayoutNameError ? translate('workspace.companyCards.addNewCard.cardLayoutNameRequired') : undefined} - /> + Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_LAYOUT_NAME.getRoute(policyID)))}> + + {!!shouldShowLayoutNameError && } + + + {!!shouldShowLayoutNameError && ( + + )} +