diff --git a/.claude/skills/app-coding-standards/SKILL.md b/.claude/skills/app-coding-standards/SKILL.md index bd904c38e077..3daa07beadb4 100644 --- a/.claude/skills/app-coding-standards/SKILL.md +++ b/.claude/skills/app-coding-standards/SKILL.md @@ -65,7 +65,6 @@ Coding standards for the Expensify App. Each standard is a standalone file in `r - [CONSISTENCY-20](rules/consistency-20-function-names.md) — Name functions with a verb that says what they do ### Clean React Patterns -- [CLEAN-REACT-PATTERNS-0](rules/clean-react-0-compiler.md) — React Compiler compliance - [CLEAN-REACT-PATTERNS-1](rules/clean-react-1-composition-over-config.md) — Composition over configuration - [CLEAN-REACT-PATTERNS-2](rules/clean-react-2-own-behavior.md) — Components own their behavior - [CLEAN-REACT-PATTERNS-3](rules/clean-react-3-context-free-contracts.md) — Context-free component contracts diff --git a/.claude/skills/app-coding-standards/rules/clean-react-0-compiler.md b/.claude/skills/app-coding-standards/rules/clean-react-0-compiler.md deleted file mode 100644 index e43d5c7fb94a..000000000000 --- a/.claude/skills/app-coding-standards/rules/clean-react-0-compiler.md +++ /dev/null @@ -1,129 +0,0 @@ ---- -ruleId: CLEAN-REACT-PATTERNS-0 -title: React Compiler compliance ---- - -## [CLEAN-REACT-PATTERNS-0] React Compiler compliance - -### Reasoning - -React Compiler is enabled in this codebase (`babel-plugin-react-compiler` runs first in both Rspack and metro configs). It automatically memoizes components and hooks at the AST level — analyzing data flow, tracking dependencies, and inserting fine-grained caching that is more precise than any hand-written `useMemo`, `useCallback`, or `React.memo`. - -Manual memoization is therefore: - -1. **Redundant** — the compiler already handles it, so the manual wrapper adds zero value -2. **Harmful** — it interferes with the compiler's optimization model, potentially preventing it from applying its own caching strategy or causing double-wrapping -3. **Noisy** — it clutters the codebase with dependency arrays that must be maintained, reviewed, and debugged - -The codebase enforces this via: -- **Babel plugin**: `babel-plugin-react-compiler` in `babel.config.js` -- **Lint post-processor**: `scripts/lint/processors/ReactCompilerFilter.ts` suppresses redundant lint rules when both React Compilers memoize the file -- **CI compliance check**: `scripts/react-compiler-compliance-check.ts` enforces that new components/hooks compile and that existing compiled files don't regress - -Reference: [React Compiler documentation](https://react.dev/learn/react-compiler) - -### Incorrect - -#### Incorrect (useCallback) - -```tsx -function ReportScreen({reportID}: {reportID: string}) { - const handlePress = useCallback(() => { - Navigation.navigate(ROUTES.REPORT_DETAILS.getRoute(reportID)); - }, [reportID]); - - return + ) : ( + ); - } - return ( - - ); - }, [hasCta, shouldUseNarrowLayout, isInLandscapeMode, ctaText, onCtaPress, ctaSentryLabel, styles]); + } return ( diff --git a/src/components/AmountWithoutCurrencyInput.tsx b/src/components/AmountWithoutCurrencyInput.tsx index dceadfd0f354..003949e300d9 100644 --- a/src/components/AmountWithoutCurrencyInput.tsx +++ b/src/components/AmountWithoutCurrencyInput.tsx @@ -5,7 +5,7 @@ import {handleNegativeAmountFlipping, replaceAllDigits, replaceCommasWithPeriod, import CONST from '@src/CONST'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import type {BaseTextInputProps} from './TextInput/BaseTextInput/types'; @@ -41,29 +41,22 @@ function AmountWithoutCurrencyInput({ ...rest }: AmountFormProps) { const {toLocaleDigit} = useLocalize(); - const separator = useMemo( - () => - replaceAllDigits('1.1', toLocaleDigit) - .split('') - .filter((char) => char !== '1') - .join(''), - [toLocaleDigit], - ); + const separator = replaceAllDigits('1.1', toLocaleDigit) + .split('') + .filter((char) => char !== '1') + .join(''); /** * Sets the selection and the amount accordingly to the value passed to the input * @param newAmount - Changed amount from user input */ - const setNewAmount = useCallback( - (newAmount: string) => { - // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value - // More info: https://github.com/Expensify/App/issues/16974 - const newAmountWithoutSpaces = stripSpacesFromAmount(newAmount); - const processedAmount = handleNegativeAmountFlipping(newAmountWithoutSpaces, allowFlippingAmount ?? false, toggleNegative); - const replacedCommasAmount = replaceCommasWithPeriod(processedAmount); - onInputChange?.(replacedCommasAmount); - }, - [onInputChange, allowFlippingAmount, toggleNegative], - ); + const setNewAmount = (newAmount: string) => { + // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value + // More info: https://github.com/Expensify/App/issues/16974 + const newAmountWithoutSpaces = stripSpacesFromAmount(newAmount); + const processedAmount = handleNegativeAmountFlipping(newAmountWithoutSpaces, allowFlippingAmount ?? false, toggleNegative); + const replacedCommasAmount = replaceCommasWithPeriod(processedAmount); + onInputChange?.(replacedCommasAmount); + }; // Add custom notation for using '-' character in the mask. // If we only use '-' for characterSet instead of '0123456789.-' diff --git a/src/components/ApproverSelectionList.tsx b/src/components/ApproverSelectionList.tsx index 784eab25857d..2688a6b6bb6d 100644 --- a/src/components/ApproverSelectionList.tsx +++ b/src/components/ApproverSelectionList.tsx @@ -20,7 +20,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import type {ListItem} from './SelectionList/types'; @@ -89,18 +89,15 @@ function ApproverSelectionList({ const lazyIllustrations = useMemoizedLazyIllustrations(['TurtleInShell']); const shouldFooterBeInsideList = useShouldFooterBeInsideList(); - const handleSearchChange = useCallback( - (term: string) => { - setSearchTerm(term); - if (onSearchChange) { - onSearchChange(term); - } - }, - [onSearchChange, setSearchTerm], - ); + const handleSearchChange = (term: string) => { + setSearchTerm(term); + if (onSearchChange) { + onSearchChange(term); + } + }; - const selectedMembers = useMemo(() => allApprovers.filter((approver) => approver.isSelected), [allApprovers]); - const selectedApproverKeys = useMemo(() => selectedMembers.map((approver) => approver.value?.toString() ?? '').filter(Boolean), [selectedMembers]); + const selectedMembers = allApprovers.filter((approver) => approver.isSelected); + const selectedApproverKeys = selectedMembers.map((approver) => approver.value?.toString() ?? '').filter(Boolean); const initialSelectedApproverKeys = useInitialSelection(selectedApproverKeys, {isVisible: !shouldShowLoadingPlaceholder && allApprovers.length > 0, resetOnFocus: true}); const initiallyFocusedApproverKey = allApprovers.find((approver) => initialSelectedApproverKeys.includes(String(approver.value)))?.keyForList; const selectionListKey = initialSelectedApproverKeys.join(','); @@ -108,12 +105,9 @@ function ApproverSelectionList({ const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || (shouldRequirePolicyAdmin && !isPolicyAdmin(policy)) || isPendingDeletePolicy(policy) || shouldShowNotFoundViewProp; - const data = useMemo(() => { - const sortedApprovers = sortAlphabetically(allApprovers, 'text', localeCompare); - const orderedApprovers = moveInitialSelectionToTop(sortedApprovers, initialSelectedApproverKeys); - - return tokenizedSearch(orderedApprovers, getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode), (option) => [option.text ?? '', option.login ?? '']); - }, [allApprovers, debouncedSearchTerm, countryCode, localeCompare, initialSelectedApproverKeys]); + const sortedApprovers = sortAlphabetically(allApprovers, 'text', localeCompare); + const orderedApprovers = moveInitialSelectionToTop(sortedApprovers, initialSelectedApproverKeys); + const data = tokenizedSearch(orderedApprovers, getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode), (option) => [option.text ?? '', option.login ?? '']); const shouldShowListEmptyContent = !debouncedSearchTerm && !data.length && shouldShowListEmptyContentProp; @@ -132,31 +126,25 @@ function ApproverSelectionList({ } }; - const listEmptyContent = useMemo( - () => ( - - ), - [translate, listEmptyContentSubtitle, styles.textSupporting, styles.pb10, lazyIllustrations.TurtleInShell], + const listEmptyContent = ( + ); - const textInputOptions = useMemo( - () => ({ - label: shouldShowListEmptyContent ? undefined : translate('selectionList.findMember'), - value: searchTerm, - onChangeText: handleSearchChange, - headerMessage: searchTerm && !data?.length ? translate('common.noResultsFound') : '', - }), - [shouldShowListEmptyContent, translate, searchTerm, handleSearchChange, data?.length], - ); + const textInputOptions = { + label: shouldShowListEmptyContent ? undefined : translate('selectionList.findMember'), + value: searchTerm, + onChangeText: handleSearchChange, + headerMessage: searchTerm && !data?.length ? translate('common.noResultsFound') : '', + }; return ( { - const rawFileName = getFileName(source); - return cleanFileName(rawFileName); - }, [source]); + const rawFileName = getFileName(source); + const fileName = cleanFileName(rawFileName); const isVideo = typeof source === 'string' && Str.isVideo(source); const isFileImage = checkIsFileImage(source, fileName); diff --git a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx index e461ea57ddef..4bb33a0b08da 100644 --- a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx +++ b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.native.tsx @@ -12,7 +12,7 @@ import {canUseTouchScreen as canUseTouchScreenUtil} from '@libs/DeviceCapabiliti import variables from '@styles/variables'; -import React, {useCallback, useRef, useState} from 'react'; +import React, {useRef, useState} from 'react'; import {Keyboard, View} from 'react-native'; import type AttachmentCarouselViewProps from './types'; @@ -41,41 +41,35 @@ function AttachmentCarouselView({ const pagerRef = useRef(null); /** Updates the page state when the user navigates between attachments */ - const updatePage = useCallback( - (newPageIndex: number) => { - Keyboard.dismiss(); - setShouldShowArrows(true); + const updatePage = (newPageIndex: number) => { + Keyboard.dismiss(); + setShouldShowArrows(true); - const item = attachments.at(newPageIndex); + const item = attachments.at(newPageIndex); - setPage(newPageIndex); - if (newPageIndex >= 0 && item) { - setActiveAttachmentID(item.attachmentID ?? item.source); - if (onNavigate) { - onNavigate(item); - } + setPage(newPageIndex); + if (newPageIndex >= 0 && item) { + setActiveAttachmentID(item.attachmentID ?? item.source); + if (onNavigate) { + onNavigate(item); } - }, - [setShouldShowArrows, attachments, setPage, onNavigate], - ); + } + }; /** * Increments or decrements the index to get another selected item - * @param {Number} deltaSlide + * @param deltaSlide */ - const cycleThroughAttachments = useCallback( - (deltaSlide: number) => { - if (page === undefined) { - return; - } - const nextPageIndex = page + deltaSlide; - updatePage(nextPageIndex); - pagerRef.current?.setPage(nextPageIndex); + const cycleThroughAttachments = (deltaSlide: number) => { + if (page === undefined) { + return; + } + const nextPageIndex = page + deltaSlide; + updatePage(nextPageIndex); + pagerRef.current?.setPage(nextPageIndex); - autoHideArrows(); - }, - [autoHideArrows, page, updatePage], - ); + autoHideArrows(); + }; return ( PixelRatio.roundToNearestPixel(windowWidth - (modalStyles.marginHorizontal + modalStyles.borderWidth) * 2), - [modalStyles.borderWidth, modalStyles.marginHorizontal, windowWidth], - ); + const cellWidth = PixelRatio.roundToNearestPixel(windowWidth - (modalStyles.marginHorizontal + modalStyles.borderWidth) * 2); /** Updates the page state when the user navigates between attachments */ - const updatePage = useCallback( - ({viewableItems}: UpdatePageProps) => { - if (isFullScreenRef.current) { - return; - } + const updatePage = ({viewableItems}: UpdatePageProps) => { + if (isFullScreenRef.current) { + return; + } - Keyboard.dismiss(); + Keyboard.dismiss(); - // Since we can have only one item in view at a time, we can use the first item in the array - // to get the index of the current page - const entry = viewableItems.at(0); - if (!entry) { - setActiveAttachmentID(null); - return; - } + // Since we can have only one item in view at a time, we can use the first item in the array + // to get the index of the current page + const entry = viewableItems.at(0); + if (!entry) { + setActiveAttachmentID(null); + return; + } - const item = entry.item; - if (entry.index != null) { - setPage(entry.index); - setActiveAttachmentID(item.attachmentID ?? item.source); - } + const item = entry.item; + if (entry.index != null) { + setPage(entry.index); + setActiveAttachmentID(item.attachmentID ?? item.source); + } - if (onNavigate) { - onNavigate(item); - } - }, - [isFullScreenRef, onNavigate, setPage, setActiveAttachmentID], - ); + if (onNavigate) { + onNavigate(item); + } + }; /** Increments or decrements the index to get another selected item, returning whether the carousel moved */ - const cycleThroughAttachments = useCallback( - (deltaSlide: number) => { - if (isFullScreen) { - return false; - } + const cycleThroughAttachments = (deltaSlide: number) => { + if (isFullScreen) { + return false; + } - const nextIndex = page + deltaSlide; - const nextItem = attachments.at(nextIndex); + const nextIndex = page + deltaSlide; + const nextItem = attachments.at(nextIndex); - if (!nextItem || nextIndex < 0 || !scrollRef.current) { - return false; - } + if (!nextItem || nextIndex < 0 || !scrollRef.current) { + return false; + } - scrollRef.current.scrollToIndex({index: nextIndex, animated: canUseTouchScreen}); - return true; - }, - [attachments, canUseTouchScreen, isFullScreen, page, scrollRef], - ); + scrollRef.current.scrollToIndex({index: nextIndex, animated: canUseTouchScreen}); + return true; + }; - const extractItemKey = useCallback( - (item: Attachment) => - !!item.attachmentID || (typeof item.source !== 'string' && typeof item.source !== 'number') - ? `attachmentID-${item.attachmentID}` - : `source-${item.source}|${item.attachmentLink}`, - [], - ); + const extractItemKey = (item: Attachment) => + !!item.attachmentID || (typeof item.source !== 'string' && typeof item.source !== 'number') ? `attachmentID-${item.attachmentID}` : `source-${item.source}|${item.attachmentLink}`; /** Calculate items layout information to optimize scrolling performance */ - const getItemLayout = useCallback( - (data: ArrayLike | null | undefined, index: number) => ({ - length: cellWidth, - offset: cellWidth * index, - index, - }), - [cellWidth], - ); + const getItemLayout = (data: ArrayLike | null | undefined, index: number) => ({ + length: cellWidth, + offset: cellWidth * index, + index, + }); /** Pan gesture handling swiping through attachments on touch screen devices */ const pan = usePanGesture({ @@ -193,42 +176,33 @@ function AttachmentCarouselView({ }, }); - const stateValue = useMemo( - () => ({ - pagerItems: [{source, index: 0, isActive: true}], - activePage: 0, - pagerRef: scrollRef, - pagerGesture: pan, - isPagerScrolling, - isScrollEnabled, - }), - [source, scrollRef, pan, isPagerScrolling, isScrollEnabled], - ); - - const actionsValue = useMemo( - () => ({ - onTap: handleTap, - onScaleChanged: handleScaleChange, - onSwipeDown, - onAttachmentError, - }), - [handleTap, handleScaleChange, onSwipeDown, onAttachmentError], - ); + const stateValue: AttachmentCarouselPagerStateContextType = { + pagerItems: [{source, index: 0, isActive: true}], + activePage: 0, + pagerRef: scrollRef, + pagerGesture: pan, + isPagerScrolling, + isScrollEnabled, + }; + + const actionsValue: AttachmentCarouselPagerActionsContextType = { + onTap: handleTap, + onScaleChanged: handleScaleChange, + onSwipeDown, + onAttachmentError, + }; /** Defines how a single attachment should be rendered */ - const renderItem = useCallback( - ({item}: ListRenderItemInfo) => ( - - - - ), - [activeAttachmentID, canUseTouchScreen, cellWidth, handleTap, report?.reportID, shouldShowArrows, styles.h100], + const renderItem = ({item}: ListRenderItemInfo) => ( + + + ); // Scroll position is affected when window width is resized, so we readjust it on width changes diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx index f7dbe5043d2f..ff2eea92284b 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx @@ -7,7 +7,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import type {ForwardedRef, SetStateAction} from 'react'; import type {NativeSyntheticEvent} from 'react-native'; -import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; import {View} from 'react-native'; import {GestureDetector, useNativeGesture} from 'react-native-gesture-handler'; import PagerView from 'react-native-pager-view'; @@ -78,36 +78,27 @@ function AttachmentCarouselPager({items, activeAttachmentID, initialPage, setSho }, [activePage, initialPage]); /** The `pagerItems` object that passed down to the context. Later used to detect current page, whether it's a single image gallery etc. */ - const pagerItems = useMemo( - () => items.map((item, index) => ({source: item.source, previewSource: item.previewSource, index, isActive: index === activePageIndex, attachmentID: item.attachmentID})), - [activePageIndex, items], - ); + const pagerItems = items.map((item, index) => ({source: item.source, previewSource: item.previewSource, index, isActive: index === activePageIndex, attachmentID: item.attachmentID})); - const extractItemKey = useCallback((item: Attachment, index: number) => `attachmentID-${item.attachmentID}-${index}`, []); + const extractItemKey = (item: Attachment, index: number) => `attachmentID-${item.attachmentID}-${index}`; const nativeGestureHandler = useNativeGesture(); - const stateValue = useMemo( - () => ({ - pagerItems, - activePage: activePageIndex, - isPagerScrolling, - isScrollEnabled, - pagerRef, - externalGestureHandler: nativeGestureHandler, - }), - [pagerItems, activePageIndex, isPagerScrolling, isScrollEnabled, nativeGestureHandler], - ); - - const actionsValue = useMemo( - () => ({ - onTap: handleTap, - onSwipeDown, - onScaleChanged: handleScaleChange, - onAttachmentError, - }), - [handleTap, onSwipeDown, handleScaleChange, onAttachmentError], - ); + const stateValue: AttachmentCarouselPagerStateContextType = { + pagerItems, + activePage: activePageIndex, + isPagerScrolling, + isScrollEnabled, + pagerRef, + externalGestureHandler: nativeGestureHandler, + }; + + const actionsValue: AttachmentCarouselPagerActionsContextType = { + onTap: handleTap, + onSwipeDown, + onScaleChanged: handleScaleChange, + onAttachmentError, + }; const animatedProps = useAnimatedProps(() => ({ scrollEnabled: isScrollEnabled.get(), diff --git a/src/components/Attachments/AttachmentCarousel/index.tsx b/src/components/Attachments/AttachmentCarousel/index.tsx index 427f2328a319..abb1c2611721 100644 --- a/src/components/Attachments/AttachmentCarousel/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {deepEqual} from 'fast-equals'; -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import type {AttachmentCarouselProps} from './types'; @@ -50,11 +50,8 @@ function AttachmentCarousel({ setShouldShowArrows(true); }, [canUseTouchScreen, page, setShouldShowArrows]); - const compareImage = useCallback( - (attachment: Attachment) => - (attachmentID ? attachment.attachmentID === attachmentID : attachment.source === source) && (!attachmentLink || attachment.attachmentLink === attachmentLink), - [attachmentLink, attachmentID, source], - ); + const compareImage = (attachment: Attachment) => + (attachmentID ? attachment.attachmentID === attachmentID : attachment.source === source) && (!attachmentLink || attachment.attachmentLink === attachmentLink); useEffect(() => { const parentReportAction = report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : undefined; diff --git a/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts b/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts index 7645e9e15a5e..44feaa711245 100644 --- a/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts +++ b/src/components/Attachments/AttachmentCarousel/useCarouselArrows.ts @@ -4,7 +4,7 @@ import CONST from '@src/CONST'; import type {SetStateAction} from 'react'; -import {useCallback, useEffect, useRef, useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; function useCarouselArrows() { const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); @@ -14,17 +14,17 @@ function useCarouselArrows() { /** * Cancels the automatic hiding of the arrows. */ - const cancelAutoHideArrows = useCallback(() => { + const cancelAutoHideArrows = () => { if (!autoHideArrowTimeout.current) { return; } clearTimeout(autoHideArrowTimeout.current); - }, []); + }; /** * Automatically hide the arrows if there is no interaction for 3 seconds. */ - const autoHideArrows = useCallback(() => { + const autoHideArrows = () => { if (!canUseTouchScreen) { return; } @@ -33,18 +33,15 @@ function useCarouselArrows() { autoHideArrowTimeout.current = setTimeout(() => { setShouldShowArrowsInternal(false); }, CONST.ARROW_HIDE_DELAY); - }, [canUseTouchScreen, cancelAutoHideArrows]); + }; /** * Sets the visibility of the arrows. */ - const setShouldShowArrows = useCallback( - (show: SetStateAction = true) => { - setShouldShowArrowsInternal(show); - autoHideArrows(); - }, - [autoHideArrows], - ); + const setShouldShowArrows = (show: SetStateAction = true) => { + setShouldShowArrowsInternal(show); + autoHideArrows(); + }; useEffect(() => { autoHideArrows(); diff --git a/src/components/Attachments/AttachmentCarousel/useCarouselContextEvents.ts b/src/components/Attachments/AttachmentCarousel/useCarouselContextEvents.ts index fd3c37bcb3a4..ccb07c3ab280 100644 --- a/src/components/Attachments/AttachmentCarousel/useCarouselContextEvents.ts +++ b/src/components/Attachments/AttachmentCarousel/useCarouselContextEvents.ts @@ -1,6 +1,6 @@ import type {SetStateAction} from 'react'; -import {useCallback, useRef} from 'react'; +import {useRef} from 'react'; import {useSharedValue} from 'react-native-reanimated'; function useCarouselContextEvents(setShouldShowArrows?: (show?: SetStateAction) => void) { @@ -10,56 +10,47 @@ function useCarouselContextEvents(setShouldShowArrows?: (show?: SetStateAction { - if (showArrows === undefined) { - setShouldShowArrows?.((prevShouldShowArrows) => !prevShouldShowArrows); - return; - } + const onRequestToggleArrows = (showArrows?: boolean) => { + if (showArrows === undefined) { + setShouldShowArrows?.((prevShouldShowArrows) => !prevShouldShowArrows); + return; + } - setShouldShowArrows?.(showArrows); - }, - [setShouldShowArrows], - ); + setShouldShowArrows?.(showArrows); + }; /** * This callback is passed to the MultiGestureCanvas/Lightbox through the AttachmentCarouselPagerContext. * It is used to react to zooming/pinching and (mostly) enabling/disabling scrolling on the pager, * as well as enabling/disabling the carousel buttons. */ - const handleScaleChange = useCallback( - (newScale: number) => { - if (newScale === scale.current) { - return; - } + const handleScaleChange = (newScale: number) => { + if (newScale === scale.current) { + return; + } - scale.current = newScale; + scale.current = newScale; - const newIsScrollEnabled = newScale === 1; - if (isScrollEnabled.get() === newIsScrollEnabled) { - return; - } + const newIsScrollEnabled = newScale === 1; + if (isScrollEnabled.get() === newIsScrollEnabled) { + return; + } - isScrollEnabled.set(newIsScrollEnabled); - onRequestToggleArrows(newIsScrollEnabled); - }, - [isScrollEnabled, onRequestToggleArrows], - ); + isScrollEnabled.set(newIsScrollEnabled); + onRequestToggleArrows(newIsScrollEnabled); + }; /** * This callback is passed to the MultiGestureCanvas/Lightbox through the AttachmentCarouselPagerContext. * It is used to trigger touch events on the pager when the user taps on the MultiGestureCanvas/Lightbox. */ - const handleTap = useCallback( - (shouldShowArrows?: boolean) => { - if (!isScrollEnabled.get()) { - return; - } + const handleTap = (shouldShowArrows?: boolean) => { + if (!isScrollEnabled.get()) { + return; + } - onRequestToggleArrows(shouldShowArrows); - }, - [isScrollEnabled, onRequestToggleArrows], - ); + onRequestToggleArrows(shouldShowArrows); + }; return {handleTap, handleScaleChange, scale, isScrollEnabled}; } diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.tsx b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.tsx index 96b2b908945a..337f75f2d713 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.tsx +++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.tsx @@ -6,7 +6,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import React, {memo} from 'react'; +import React from 'react'; import type {AttachmentViewProps} from '..'; @@ -48,4 +48,4 @@ function AttachmentViewImage({attachmentID, url, file, isAuthTokenRequired, load ); } -export default memo(AttachmentViewImage); +export default AttachmentViewImage; diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.tsx b/src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.tsx index 6a927f31acdd..9e8b24645688 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.tsx +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.tsx @@ -3,7 +3,7 @@ import PDFView from '@components/PDFView'; import type {GestureResponderEvent} from 'react-native'; -import React, {memo, useCallback, useEffect} from 'react'; +import React, {useEffect} from 'react'; import type AttachmentViewPdfProps from './types'; @@ -37,19 +37,16 @@ function BaseAttachmentViewPdf({ * because we want to disable the pager scroll when the pdf is zoomed in, * as well as call the onScaleChanged prop of the AttachmentViewPdf component if defined. */ - const onScaleChanged = useCallback( - (newScale: number) => { - if (onScaleChangedProp !== undefined) { - onScaleChangedProp(newScale); - } + const onScaleChanged = (newScale: number) => { + if (onScaleChangedProp !== undefined) { + onScaleChangedProp(newScale); + } - // When a pdf is shown in a carousel, we want to disable the pager scroll when the pdf is zoomed in - if (state?.pagerRef && actions) { - actions.onScaleChanged?.(newScale); - } - }, - [state?.pagerRef, actions, onScaleChangedProp], - ); + // When a pdf is shown in a carousel, we want to disable the pager scroll when the pdf is zoomed in + if (state?.pagerRef && actions) { + actions.onScaleChanged?.(newScale); + } + }; /** * This callback is used to pass-through the onPress event from the AttachmentViewPdf's props @@ -57,18 +54,15 @@ function BaseAttachmentViewPdf({ * The onTap event should only be triggered, if the pager is currently scrollable. * Otherwise it means that the PDF is currently zoomed in, therefore the onTap callback should be ignored */ - const onPress = useCallback( - (event?: GestureResponderEvent | KeyboardEvent) => { - if (onPressProp !== undefined) { - onPressProp(event); - } + const onPress = (event?: GestureResponderEvent | KeyboardEvent) => { + if (onPressProp !== undefined) { + onPressProp(event); + } - if (state !== null && actions && isScrollEnabled?.get()) { - actions.onTap?.(); - } - }, - [state, actions, isScrollEnabled, onPressProp], - ); + if (state !== null && actions && isScrollEnabled?.get()) { + actions.onTap?.(); + } + }; return ( ( - { - // The react-native-pdf's onScaleChanged event will sometimes give us scale values of e.g. 0.99... instead of 1, - // even though we're not pinching/zooming - // Rounding the scale value to 2 decimal place fixes this issue, since pinching will still be possible but very small pinches are ignored. - scale.set(Math.round(newScale * 1e2) / 1e2); - }} - /> - ), - [props, scale], + const Content = ( + { + // The react-native-pdf's onScaleChanged event will sometimes give us scale values of e.g. 0.99... instead of 1, + // even though we're not pinching/zooming + // Rounding the scale value to 2 decimal place fixes this issue, since pinching will still be possible but very small pinches are ignored. + scale.set(Math.round(newScale * 1e2) / 1e2); + }} + /> ); return ( @@ -100,4 +97,4 @@ function AttachmentViewPdf(props: AttachmentViewPdfProps) { ); } -export default memo(AttachmentViewPdf); +export default AttachmentViewPdf; diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.tsx b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.tsx index 392ddc79f55c..51b4eb04a136 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.tsx +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.tsx @@ -1,4 +1,4 @@ -import React, {memo} from 'react'; +import React from 'react'; import type AttachmentViewPdfProps from './types'; @@ -8,4 +8,4 @@ function AttachmentViewPdf(props: AttachmentViewPdfProps) { return ; } -export default memo(AttachmentViewPdf); +export default AttachmentViewPdf; diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.tsx b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.tsx index f6797a571c9f..f51536c94d13 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.tsx +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.tsx @@ -1,6 +1,6 @@ import PDFView from '@components/PDFView'; -import React, {memo} from 'react'; +import React from 'react'; import type AttachmentViewPdfProps from './types'; @@ -36,4 +36,4 @@ function AttachmentViewPdf({ ); } -export default memo(AttachmentViewPdf); +export default AttachmentViewPdf; diff --git a/src/components/Attachments/AttachmentView/AttachmentViewVideo/index.tsx b/src/components/Attachments/AttachmentView/AttachmentViewVideo/index.tsx index d920dccc1c4e..10d2d6ef3a69 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewVideo/index.tsx +++ b/src/components/Attachments/AttachmentView/AttachmentViewVideo/index.tsx @@ -43,4 +43,4 @@ function AttachmentViewVideo({source, fileName, isHovered = false, shouldUseShar AttachmentViewVideo.displayName = 'AttachmentViewVideo'; -export default React.memo(AttachmentViewVideo); +export default AttachmentViewVideo; diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx index 45f937f24f9f..7c685f449d7a 100644 --- a/src/components/Attachments/AttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/index.tsx @@ -42,7 +42,7 @@ import type {GestureResponderEvent, ImageURISource, StyleProp, ViewStyle} from ' import type {OnyxEntry} from 'react-native-onyx'; import {SafeString, Str} from 'expensify-common'; -import React, {memo, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import AttachmentViewImage from './AttachmentViewImage'; @@ -433,7 +433,7 @@ function AttachmentView({ ); } -export default memo(AttachmentView); +export default AttachmentView; export {checkIsFileImage}; export type {AttachmentViewProps}; diff --git a/src/components/Attachments/AttachmentView/useAttachmentErrors.ts b/src/components/Attachments/AttachmentView/useAttachmentErrors.ts index 44a634754663..99ca2eb26015 100644 --- a/src/components/Attachments/AttachmentView/useAttachmentErrors.ts +++ b/src/components/Attachments/AttachmentView/useAttachmentErrors.ts @@ -1,6 +1,6 @@ import type {AttachmentSource} from '@components/Attachments/types'; -import {useCallback, useState} from 'react'; +import {useState} from 'react'; function convertSourceToString(source: AttachmentSource) { if (typeof source === 'string' || typeof source === 'number') { @@ -26,7 +26,7 @@ function convertSourceToString(source: AttachmentSource) { function useAttachmentErrors() { const [attachmentErrors, setAttachmentErrors] = useState>({}); - const setAttachmentError = useCallback((key: AttachmentSource, state = true) => { + const setAttachmentError = (key: AttachmentSource, state = true) => { const url = convertSourceToString(key); if (!url) { return; @@ -35,13 +35,13 @@ function useAttachmentErrors() { ...prevState, [url]: state, })); - }, []); + }; - const clearAttachmentErrors = useCallback(() => { + const clearAttachmentErrors = () => { setAttachmentErrors({}); - }, []); + }; - const isErrorInAttachment = useCallback((key: AttachmentSource) => attachmentErrors?.[convertSourceToString(key)], [attachmentErrors]); + const isErrorInAttachment = (key: AttachmentSource) => attachmentErrors?.[convertSourceToString(key)]; return {setAttachmentError, clearAttachmentErrors, isErrorInAttachment}; } diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx index 142fefc881bb..25483fc773a9 100644 --- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx +++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx @@ -10,7 +10,7 @@ import CONST from '@src/CONST'; import type {ReactElement} from 'react'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import {FlatList} from 'react-native-gesture-handler'; import Animated, {Easing, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; @@ -41,22 +41,19 @@ function BaseAutoCompleteSuggestionsImpl({ /** * Render a suggestion menu item component. */ - const renderItem = useCallback( - ({item, index}: RenderSuggestionMenuItemProps): ReactElement => ( - StyleUtils.getAutoCompleteSuggestionItemStyle(highlightedSuggestionIndex, CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, hovered, index)} - hoverDimmingValue={1} - onMouseDown={(e) => e.preventDefault()} - onPress={() => onSelect(index)} - onLongPress={() => {}} - accessibilityLabel={accessibilityLabelExtractor(item, index)} - role={CONST.ROLE.MENUITEM} - sentryLabel={CONST.SENTRY_LABEL.BASE_AUTO_COMPLETE_SUGGESTIONS.MENU_ITEM} - > - {renderSuggestionMenuItem(item, index)} - - ), - [accessibilityLabelExtractor, renderSuggestionMenuItem, StyleUtils, highlightedSuggestionIndex, onSelect], + const renderItem = ({item, index}: RenderSuggestionMenuItemProps): ReactElement => ( + StyleUtils.getAutoCompleteSuggestionItemStyle(highlightedSuggestionIndex, CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, hovered, index)} + hoverDimmingValue={1} + onMouseDown={(e) => e.preventDefault()} + onPress={() => onSelect(index)} + onLongPress={() => {}} + accessibilityLabel={accessibilityLabelExtractor(item, index)} + role={CONST.ROLE.MENUITEM} + sentryLabel={CONST.SENTRY_LABEL.BASE_AUTO_COMPLETE_SUGGESTIONS.MENU_ITEM} + > + {renderSuggestionMenuItem(item, index)} + ); const innerHeight = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT * suggestions.length; diff --git a/src/components/AutoSubmitModal.tsx b/src/components/AutoSubmitModal.tsx index 14f23760e0d0..254e9822e836 100644 --- a/src/components/AutoSubmitModal.tsx +++ b/src/components/AutoSubmitModal.tsx @@ -18,7 +18,7 @@ import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type IconAsset from '@src/types/utils/IconAsset'; -import React, {useMemo, useRef} from 'react'; +import React, {useRef} from 'react'; import {View} from 'react-native'; import CenteredModalLayout from './CenteredModalLayout'; @@ -32,26 +32,22 @@ function AutoSubmitModal() { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const illustrations = useMemoizedLazyIllustrations(['PaperAirplane', 'Pencil', 'ReceiptsStackedOnPin']); - const menuSections = useMemo( - () => - [ - { - icon: illustrations.PaperAirplane, - titleTranslationKey: 'autoSubmitModal.submittedExpensesTitle', - descriptionTranslationKey: 'autoSubmitModal.submittedExpensesDescription', - }, - { - icon: illustrations.Pencil, - titleTranslationKey: 'autoSubmitModal.pendingExpensesTitle', - descriptionTranslationKey: 'autoSubmitModal.pendingExpensesDescription', - }, - ] satisfies Array<{ - icon: IconAsset; - titleTranslationKey: TranslationPaths; - descriptionTranslationKey: TranslationPaths; - }>, - [illustrations.PaperAirplane, illustrations.Pencil], - ); + const menuSections = [ + { + icon: illustrations.PaperAirplane, + titleTranslationKey: 'autoSubmitModal.submittedExpensesTitle', + descriptionTranslationKey: 'autoSubmitModal.submittedExpensesDescription', + }, + { + icon: illustrations.Pencil, + titleTranslationKey: 'autoSubmitModal.pendingExpensesTitle', + descriptionTranslationKey: 'autoSubmitModal.pendingExpensesDescription', + }, + ] satisfies Array<{ + icon: IconAsset; + titleTranslationKey: TranslationPaths; + descriptionTranslationKey: TranslationPaths; + }>; const willShowAgainRef = useRef(null); diff --git a/src/components/AvatarCropModal/AvatarCropView.tsx b/src/components/AvatarCropModal/AvatarCropView.tsx index 28c67a1d1e35..2ef9e0f22828 100644 --- a/src/components/AvatarCropModal/AvatarCropView.tsx +++ b/src/components/AvatarCropModal/AvatarCropView.tsx @@ -23,7 +23,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; import type {LayoutChangeEvent} from 'react-native'; import type {PanGestureActiveEvent} from 'react-native-gesture-handler'; -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import {GestureHandlerRootView, usePanGesture} from 'react-native-gesture-handler'; import ImageSize from 'react-native-image-size'; @@ -90,19 +90,19 @@ function AvatarCropView({imageUri = '', imageName = '', imageType = '', onClose, const [isImageInitialized, setIsImageInitialized] = useState(false); // An onLayout callback, that initializes the image container, for proper render of an image - const initializeImageContainer = useCallback((event: LayoutChangeEvent) => { + const initializeImageContainer = (event: LayoutChangeEvent) => { setIsImageContainerInitialized(true); const {height, width} = event.nativeEvent.layout; // Even if the browser height is reduced too much, the relative height should not be negative const relativeHeight = Math.max(height - CONST.AVATAR_CROP_MODAL.CONTAINER_VERTICAL_MARGIN, CONST.AVATAR_CROP_MODAL.INITIAL_SIZE); setImageContainerSize(Math.floor(Math.min(relativeHeight, width))); - }, []); + }; // An onLayout callback, that initializes the slider container size, for proper render of a slider - const initializeSliderContainer = useCallback((event: LayoutChangeEvent) => { + const initializeSliderContainer = (event: LayoutChangeEvent) => { setSliderContainerSize(event.nativeEvent.layout.width); - }, []); + }; // In order to calculate proper image position/size/animation, we have to know its size. // And we have to update image size if image url changes. @@ -134,16 +134,16 @@ function AvatarCropView({imageUri = '', imageName = '', imageType = '', onClose, /** * Validates that value is within the provided mix/max range. */ - const clamp = useCallback((value: number, [min, max]: [number, number]) => { + const clamp = (value: number, [min, max]: [number, number]) => { 'worklet'; return interpolate(value, [min, max], [min, max], 'clamp'); - }, []); + }; /** * Returns current image size taking into account scale and rotation. */ - const getDisplayedImageSize = useCallback(() => { + const getDisplayedImageSize = () => { 'worklet'; let height = imageContainerSize * scale.get(); @@ -158,32 +158,29 @@ function AvatarCropView({imageUri = '', imageName = '', imageType = '', onClose, } return {height, width}; - }, [imageContainerSize, originalImageHeight, originalImageWidth, scale]); + }; /** * Validates the offset to prevent overflow, and updates the image offset. */ - const updateImageOffset = useCallback( - (offsetX: number, offsetY: number) => { - 'worklet'; + const updateImageOffset = (offsetX: number, offsetY: number) => { + 'worklet'; - const {height, width} = getDisplayedImageSize(); - const maxOffsetX = (width - imageContainerSize) / 2; - const maxOffsetY = (height - imageContainerSize) / 2; - translateX.set(clamp(offsetX, [maxOffsetX * -1, maxOffsetX])); - translateY.set(clamp(offsetY, [maxOffsetY * -1, maxOffsetY])); - prevMaxOffsetX.set(maxOffsetX); - prevMaxOffsetY.set(maxOffsetY); - }, - [getDisplayedImageSize, imageContainerSize, translateX, clamp, translateY, prevMaxOffsetX, prevMaxOffsetY], - ); + const {height, width} = getDisplayedImageSize(); + const maxOffsetX = (width - imageContainerSize) / 2; + const maxOffsetY = (height - imageContainerSize) / 2; + translateX.set(clamp(offsetX, [maxOffsetX * -1, maxOffsetX])); + translateY.set(clamp(offsetY, [maxOffsetY * -1, maxOffsetY])); + prevMaxOffsetX.set(maxOffsetX); + prevMaxOffsetY.set(maxOffsetY); + }; - const newScaleValue = useCallback((newSliderValue: number, containerSize: number) => { + const newScaleValue = (newSliderValue: number, containerSize: number) => { 'worklet'; const {MAX_SCALE, MIN_SCALE} = CONST.AVATAR_CROP_MODAL; return (newSliderValue / containerSize) * (MAX_SCALE - MIN_SCALE) + MIN_SCALE; - }, []); + }; /** * Calculates new x & y image translate value on image panning @@ -262,7 +259,7 @@ function AvatarCropView({imageUri = '', imageName = '', imageType = '', onClose, // Rotates the image by changing the rotation value by 90 degrees // and updating the position so the image remains in the same place after rotation - const rotateImage = useCallback(() => { + const rotateImage = () => { rotation.set((value) => value - 90); const oldTranslateX = translateX.get(); @@ -272,11 +269,11 @@ function AvatarCropView({imageUri = '', imageName = '', imageType = '', onClose, const oldOriginalImageHeight = originalImageHeight.get(); originalImageHeight.set(originalImageWidth.get()); originalImageWidth.set(oldOriginalImageHeight); - }, [originalImageHeight, originalImageWidth, rotation, translateX, translateY]); + }; // Crops an image that was provided in the imageUri prop, using the current position/scale // then calls onSave callback - const cropAndSaveImage = useCallback(() => { + const cropAndSaveImage = () => { if (isLoading.get()) { return; } @@ -312,7 +309,7 @@ function AvatarCropView({imageUri = '', imageName = '', imageType = '', onClose, .catch(() => { isLoading.set(false); }); - }, [isLoading, originalImageHeight, originalImageWidth, scale, translateX, imageContainerSize, translateY, imageType, imageName, imageUri, rotation, onSave]); + }; const sliderOnPress = (locationX: number) => { // We are using the worklet directive here and running on the UI thread to ensure the Reanimated diff --git a/src/components/AvatarCropModal/ImageCropView.tsx b/src/components/AvatarCropModal/ImageCropView.tsx index f53f9d932d63..d775278a86e5 100644 --- a/src/components/AvatarCropModal/ImageCropView.tsx +++ b/src/components/AvatarCropModal/ImageCropView.tsx @@ -102,6 +102,5 @@ function ImageCropView({imageUri = '', containerSize = 0, panGesture, maskImage, ImageCropView.displayName = 'ImageCropView'; -// React.memo is needed here to prevent styles recompilation // which sometimes may cause glitches during rerender of the modal -export default React.memo(ImageCropView); +export default ImageCropView; diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index 6b479928c6e9..504bea79b22e 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -8,7 +8,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; import type {GestureResponderEvent, PressableStateCallbackType, StyleProp, TextStyle, ViewStyle} from 'react-native'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; import Icon from './Icon'; @@ -83,17 +83,14 @@ function Badge({ const iconSize = isCondensed || shouldUseXXSmallIcon ? variables.iconSizeXXSmall : variables.iconSizeExtraSmall; - const wrapperStyles: (state: PressableStateCallbackType) => StyleProp = useCallback( - ({pressed}) => [ - styles.defaultBadge, - isCondensed && styles.condensedBadge, - styles.alignSelfCenter, - styles.ml2, - StyleUtils.getBadgeColorStyle(success, error, pressed, environment === CONST.ENVIRONMENT.ADHOC, isStrong), - badgeStyles, - ], - [styles.defaultBadge, styles.condensedBadge, styles.alignSelfCenter, styles.ml2, StyleUtils, success, error, environment, badgeStyles, isCondensed, isStrong], - ); + const wrapperStyles: (state: PressableStateCallbackType) => StyleProp = ({pressed}) => [ + styles.defaultBadge, + isCondensed && styles.condensedBadge, + styles.alignSelfCenter, + styles.ml2, + StyleUtils.getBadgeColorStyle(success, error, pressed, environment === CONST.ENVIRONMENT.ADHOC, isStrong), + badgeStyles, + ]; if (!text && !icon) { return null; diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index 817bfb01e14a..94d0bdfd0587 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -11,7 +11,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; -import React, {memo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import Hoverable from './Hoverable'; @@ -140,6 +140,6 @@ function Banner({ ); } -export default memo(Banner); +export default Banner; export type {BannerProps}; diff --git a/src/components/BlockingViews/BlockingView.tsx b/src/components/BlockingViews/BlockingView.tsx index c7e4d921c95d..c0b6fd64e11a 100644 --- a/src/components/BlockingViews/BlockingView.tsx +++ b/src/components/BlockingViews/BlockingView.tsx @@ -23,7 +23,7 @@ import type {SvgProps} from 'react-native-svg'; import type {WebStyle} from 'react-native-web'; import type {MergeExclusive} from 'type-fest'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import BlockingViewSubtitle from './BlockingViewSubtitle'; @@ -123,10 +123,7 @@ function BlockingView({ const styles = useThemeStyles(); const {translate} = useLocalize(); const SubtitleWrapper = shouldEmbedLinkWithSubtitle ? Text : View; - const subtitleWrapperStyle = useMemo( - () => (shouldEmbedLinkWithSubtitle ? [styles.textAlignCenter] : [styles.alignItemsCenter, styles.justifyContentCenter]), - [shouldEmbedLinkWithSubtitle, styles], - ); + const subtitleWrapperStyle = shouldEmbedLinkWithSubtitle ? [styles.textAlignCenter] : [styles.alignItemsCenter, styles.justifyContentCenter]; const containerStyle = useBottomSafeSafeAreaPaddingStyle({addBottomSafeAreaPadding, addOfflineIndicatorBottomSafeAreaPadding, style: containerStyleProp}); useAbsentPageSpan(); diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 5d5bd3bc762f..b14ba1ce3fef 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -13,7 +13,7 @@ import CONST from '@src/CONST'; import type {StyleProp, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {StyleSheet, View} from 'react-native'; import type {ButtonProps} from './types'; @@ -59,73 +59,58 @@ function Button({ const StyleUtils = useStyleUtils(); const [isHovered, setIsHovered] = useState(false); - const contextValue = useMemo( - () => ({ - isHovered, - variant, - size, - onPress, - isDisabled, - isLoading, - }), - [isHovered, variant, size, onPress, isDisabled, isLoading], - ); - - const buttonVariantStyles = useMemo(() => { - const shouldUseDisabledStyles = isDisabled && !stayNormalOnDisable; - if (!variant) { - return shouldUseDisabledStyles ? [styles.buttonOpacityDisabled, styles.buttonDisabled] : undefined; - } - + const contextValue = { + isHovered, + variant, + size, + onPress, + isDisabled, + isLoading, + }; + + let buttonVariantStyles; + const shouldUseDisabledStyles = isDisabled && !stayNormalOnDisable; + if (!variant) { + buttonVariantStyles = shouldUseDisabledStyles ? [styles.buttonOpacityDisabled, styles.buttonDisabled] : undefined; + } else { const {normal: defaultStyles, disabled: disabledStyles} = StyleUtils.getButtonVariantStyles(styles); - return [defaultStyles[variant], shouldUseDisabledStyles && disabledStyles[variant]]; - }, [isDisabled, stayNormalOnDisable, styles, variant, StyleUtils]); - - const borderRadiusStyles = useMemo, StyleProp>>( - () => ({ - [CONST.BUTTON_REMOVE_BORDER_RADIUS.RIGHT]: styles.noRightBorderRadius, - [CONST.BUTTON_REMOVE_BORDER_RADIUS.LEFT]: styles.noLeftBorderRadius, - [CONST.BUTTON_REMOVE_BORDER_RADIUS.ALL]: [styles.noRightBorderRadius, styles.noLeftBorderRadius], - }), - [styles.noRightBorderRadius, styles.noLeftBorderRadius], - ); - - const horizontalPaddingBySize = useMemo, ViewStyle>>( - () => ({ - [CONST.BUTTON_SIZE.SMALL]: styles.ph2, - [CONST.BUTTON_SIZE.MEDIUM]: styles.ph3, - [CONST.BUTTON_SIZE.LARGE]: styles.ph4, - }), - [styles.ph2, styles.ph3, styles.ph4], - ); - - const buttonStyles = useMemo>( - () => [ - styles.button, - StyleUtils.getButtonSizeStyle(styles, size), - horizontalPaddingBySize[size], - buttonVariantStyles, - removeBorderRadius ? borderRadiusStyles[removeBorderRadius] : undefined, - styles.alignItemsStretch, - innerStyles, - ], - [styles, StyleUtils, size, horizontalPaddingBySize, buttonVariantStyles, removeBorderRadius, borderRadiusStyles, innerStyles], - ); - - const buttonContainerStyles = useMemo>(() => [buttonStyles, blendOpacity && styles.buttonBlendContainer], [buttonStyles, blendOpacity, styles.buttonBlendContainer]); - - const buttonBlendForegroundStyle = useMemo>(() => { - if (!blendOpacity) { - return undefined; - } + buttonVariantStyles = [defaultStyles[variant], shouldUseDisabledStyles && disabledStyles[variant]]; + } + const borderRadiusStyles: Record, StyleProp> = { + [CONST.BUTTON_REMOVE_BORDER_RADIUS.RIGHT]: styles.noRightBorderRadius, + [CONST.BUTTON_REMOVE_BORDER_RADIUS.LEFT]: styles.noLeftBorderRadius, + [CONST.BUTTON_REMOVE_BORDER_RADIUS.ALL]: [styles.noRightBorderRadius, styles.noLeftBorderRadius], + }; + + const horizontalPaddingBySize: Record, ViewStyle> = { + [CONST.BUTTON_SIZE.SMALL]: styles.ph2, + [CONST.BUTTON_SIZE.MEDIUM]: styles.ph3, + [CONST.BUTTON_SIZE.LARGE]: styles.ph4, + }; + + const buttonStyles: StyleProp = [ + styles.button, + StyleUtils.getButtonSizeStyle(styles, size), + horizontalPaddingBySize[size], + buttonVariantStyles, + removeBorderRadius ? borderRadiusStyles[removeBorderRadius] : undefined, + styles.alignItemsStretch, + innerStyles, + ]; + + const buttonContainerStyles: StyleProp = [buttonStyles, blendOpacity && styles.buttonBlendContainer]; + + let buttonBlendForegroundStyle: StyleProp; + if (!blendOpacity) { + buttonBlendForegroundStyle = undefined; + } else { const {backgroundColor, opacity} = StyleSheet.flatten(buttonStyles); - - return { + buttonBlendForegroundStyle = { backgroundColor, opacity, }; - }, [buttonStyles, blendOpacity]); + } let loadingIndicatorColor = theme.text; if (variant === CONST.BUTTON_VARIANT.DANGER) { diff --git a/src/components/Button/primitives/ButtonKeyboardShortcut/index.tsx b/src/components/Button/primitives/ButtonKeyboardShortcut/index.tsx index 2597e0f2669e..e2472eae5621 100644 --- a/src/components/Button/primitives/ButtonKeyboardShortcut/index.tsx +++ b/src/components/Button/primitives/ButtonKeyboardShortcut/index.tsx @@ -10,7 +10,6 @@ import CONST from '@src/CONST'; import type {GestureResponderEvent} from 'react-native'; import {useIsFocused} from '@react-navigation/native'; -import {useCallback, useMemo} from 'react'; import validateSubmitShortcut from './validateSubmitShortcut'; @@ -34,27 +33,21 @@ function ButtonKeyboardShortcut({allowBubble, enterKeyEventListenerPriority, isP const isFocused = useIsFocused(); const activeElementRole = useActiveElementRole(); - const shouldDisableEnterShortcut = useMemo(() => accessibilityRoles.includes(activeElementRole ?? '') && activeElementRole !== CONST.ROLE.PRESENTATION, [activeElementRole]); - - const keyboardShortcutCallback = useCallback( - (event?: GestureResponderEvent | KeyboardEvent) => { - if (!validateSubmitShortcut(isDisabled, isLoading, event)) { - return; - } - onPress(); - }, - [isDisabled, isLoading, onPress], - ); - - const config = useMemo( - () => ({ - isActive: !shouldDisableEnterShortcut && (isFocused || isPressOnEnterActive), - shouldBubble: allowBubble, - priority: enterKeyEventListenerPriority, - shouldPreventDefault: false, - }), - [shouldDisableEnterShortcut, isFocused, isPressOnEnterActive, allowBubble, enterKeyEventListenerPriority], - ); + const shouldDisableEnterShortcut = accessibilityRoles.includes(activeElementRole ?? '') && activeElementRole !== CONST.ROLE.PRESENTATION; + + const keyboardShortcutCallback = (event?: GestureResponderEvent | KeyboardEvent) => { + if (!validateSubmitShortcut(isDisabled, isLoading, event)) { + return; + } + onPress(); + }; + + const config = { + isActive: !shouldDisableEnterShortcut && (isFocused || isPressOnEnterActive), + shouldBubble: allowBubble, + priority: enterKeyEventListenerPriority, + shouldPreventDefault: false, + }; useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, keyboardShortcutCallback, config); diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index ad5566750b70..02b23c582563 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -1,6 +1,5 @@ import type {SharedValue} from 'react-native-reanimated'; -import {useCallback} from 'react'; import {PointerType, useCompetingGestures, useHoverGesture, useTapGesture} from 'react-native-gesture-handler'; import {useDerivedValue, useSharedValue} from 'react-native-reanimated'; import {scheduleOnRN, scheduleOnUI} from 'react-native-worklets'; @@ -207,13 +206,10 @@ function useChartInteractions({ * Called by chart content from handleScaleChange to populate canvas positions. * Must be called with the positions derived from the current d3 scale. */ - const setPointPositions = useCallback( - (ox: number[], oy: number[]) => { - pointOX.set(ox); - pointOY.set(oy); - }, - [pointOX, pointOY], - ); + const setPointPositions = (ox: number[], oy: number[]) => { + pointOX.set(ox); + pointOY.set(oy); + }; const getHitTestArgs = (targetIndex: number, cursorX: number, cursorY: number, targetX: number, targetY: number, currentChartBottom: number): HitTestArgs => { 'worklet'; diff --git a/src/components/Charts/hooks/useDynamicYDomain.ts b/src/components/Charts/hooks/useDynamicYDomain.ts index 41ba6aae3340..fbf226f5bcc3 100644 --- a/src/components/Charts/hooks/useDynamicYDomain.ts +++ b/src/components/Charts/hooks/useDynamicYDomain.ts @@ -1,13 +1,9 @@ import type {ChartDataPoint} from '@components/Charts/types'; -import {useMemo} from 'react'; - /** Keep zero inside the Y-axis domain so bars always grow from a visible baseline, also when all values are negative */ function useDynamicYDomain(data: ChartDataPoint[]): [number, number] { - return useMemo((): [number, number] => { - const values = data.flatMap((point) => Object.values(point.values).filter((value) => value !== undefined)); - return [Math.min(0, ...values), Math.max(0, ...values)]; - }, [data]); + const values = data.flatMap((point) => Object.values(point.values).filter((value) => value !== undefined)); + return [Math.min(0, ...values), Math.max(0, ...values)]; } export default useDynamicYDomain; diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index 008e6cb17f4e..bff16a6127b5 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -22,7 +22,7 @@ import type {FileObject} from '@src/types/utils/Attachment'; import type {MarkdownStyle, MarkdownTextInput} from '@expensify/react-native-live-markdown'; import type {NativeSyntheticEvent, TextInputChangeEvent} from 'react-native'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import {StyleSheet} from 'react-native'; type TextInputPasteEvent = NativeSyntheticEvent<{ @@ -52,7 +52,7 @@ function Composer({ ...props }: ComposerProps) { const textInputRef = useRef(null); - const textContainsOnlyEmojis = useMemo(() => containsOnlyEmojis(Parser.htmlToText(Parser.replace(value ?? ''))), [value]); + const textContainsOnlyEmojis = containsOnlyEmojis(Parser.htmlToText(Parser.replace(value ?? ''))); const theme = useTheme(); const markdownStyle = useMarkdownStyle(textContainsOnlyEmojis, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); const styles = useThemeStyles(); @@ -83,58 +83,45 @@ function Composer({ /** * Set the TextInput Ref - * @param {Element} el + * @param el */ - const setTextInputRef = useCallback( - (el: AnimatedMarkdownTextInputRef | null) => { - textInputRef.current = isInLandscapeMode ? getLandscapeTextInputRefProxy(el) : el; - - if (typeof ref !== 'function' || textInputRef.current === null) { - return; - } - - // This callback prop is used by the parent component using the constructor to - // get a ref to the inner textInput element e.g. if we do - // this.textInput = el} /> this will not - // return a ref to the component, but rather the HTML element by default - ref(textInputRef.current); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [isInLandscapeMode], - ); + const setTextInputRef = (el: AnimatedMarkdownTextInputRef | null) => { + textInputRef.current = isInLandscapeMode ? getLandscapeTextInputRefProxy(el) : el; - const onClear = useCallback( - ({nativeEvent}: TextInputChangeEvent) => { - onClearProp(nativeEvent.text); - }, - [onClearProp], - ); + if (typeof ref !== 'function' || textInputRef.current === null) { + return; + } - const pasteFile = useCallback( - (e: TextInputPasteEvent) => { - const clipboardContent = e.nativeEvent.items.at(0); - if (clipboardContent?.type === 'text/plain') { - return; - } - const mimeType = clipboardContent?.type ?? ''; - const fileURI = clipboardContent?.data; - const baseFileName = fileURI?.split('/').pop() ?? 'file'; - const {fileName: stem, fileExtension: originalFileExtension} = splitExtensionFromFileName(baseFileName); - const fileExtension = originalFileExtension || (getExtensionFromMimeType(mimeType) ?? 'bin'); - const fileName = `${stem}.${fileExtension}`; - let file: FileObject = {uri: fileURI, name: fileName, type: mimeType, size: 0}; - getFileSize(file.uri ?? '') - .then((size) => (file = {...file, size})) - .finally(() => onPasteFile(file)); - }, - [onPasteFile], - ); + // This callback prop is used by the parent component using the constructor to + // get a ref to the inner textInput element e.g. if we do + // this.textInput = el} /> this will not + // return a ref to the component, but rather the HTML element by default + ref(textInputRef.current); + }; - const maxHeightStyle = useMemo( - () => StyleUtils.getComposerMaxHeightStyle(isInLandscapeMode ? CONST.COMPOSER.MAX_LINES_LANDSCAPE_MODE : maxLines, isComposerFullSize), - [StyleUtils, isComposerFullSize, maxLines, isInLandscapeMode], - ); - const composerStyle = useMemo(() => StyleSheet.flatten([style, textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {}]), [style, textContainsOnlyEmojis, styles]); + const onClear = ({nativeEvent}: TextInputChangeEvent) => { + onClearProp(nativeEvent.text); + }; + + const pasteFile = (e: TextInputPasteEvent) => { + const clipboardContent = e.nativeEvent.items.at(0); + if (clipboardContent?.type === 'text/plain') { + return; + } + const mimeType = clipboardContent?.type ?? ''; + const fileURI = clipboardContent?.data; + const baseFileName = fileURI?.split('/').pop() ?? 'file'; + const {fileName: stem, fileExtension: originalFileExtension} = splitExtensionFromFileName(baseFileName); + const fileExtension = originalFileExtension || (getExtensionFromMimeType(mimeType) ?? 'bin'); + const fileName = `${stem}.${fileExtension}`; + let file: FileObject = {uri: fileURI, name: fileName, type: mimeType, size: 0}; + getFileSize(file.uri ?? '') + .then((size) => (file = {...file, size})) + .finally(() => onPasteFile(file)); + }; + + const maxHeightStyle = StyleUtils.getComposerMaxHeightStyle(isInLandscapeMode ? CONST.COMPOSER.MAX_LINES_LANDSCAPE_MODE : maxLines, isComposerFullSize); + const composerStyle = StyleSheet.flatten([style, textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {}]); return ( { + const copyToClipboard = () => { Clipboard.setString(urlToCopy || text || ''); - }, [text, urlToCopy]); + }; return ( - Object.keys(CONST.ALL_COUNTRIES).map((countryISO) => { - const countryName = translate(`allCountries.${countryISO}` as TranslationPaths); - return { - value: countryISO, - keyForList: countryISO, - text: countryName, - isSelected: currentCountry === countryISO, - searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), - }; - }), - [translate, currentCountry], - ); + const countries = Object.keys(CONST.ALL_COUNTRIES).map((countryISO) => { + const countryName = translate(`allCountries.${countryISO}` as TranslationPaths); + return { + value: countryISO, + keyForList: countryISO, + text: countryName, + isSelected: currentCountry === countryISO, + searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), + }; + }); const orderedCountries = moveInitialSelectionToTop(countries, initialSelectedValues); const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; const styles = useThemeStyles(); - const textInputOptions = useMemo( - () => ({ - value: searchValue, - label: translate('common.search'), - onChangeText: setSearchValue, - headerMessage, - }), - [headerMessage, searchValue, translate, setSearchValue], - ); + const textInputOptions = { + value: searchValue, + label: translate('common.search'), + onChangeText: setSearchValue, + headerMessage, + }; return ( ()] = useOnyx(ONYXKEYS.CURRENCY_LIST); const {preferredLocale} = useLocalize(); - const getCurrencySymbol = useCallback( - (currencyCode: string): string | undefined => { - return currencyList[currencyCode]?.symbol; - }, - [currencyList], - ); - - const getCurrencyDecimals = useCallback( - (currencyCode: string | undefined): number => { - const decimals = currencyList[currencyCode ?? '']?.decimals; - return decimals ?? CONST.DEFAULT_CURRENCY_DECIMALS; - }, - [currencyList], - ); - - const convertToDisplayString = useCallback( - (amountInCents: number | undefined, currencyCode: string | undefined): string => { - const sanitizedCurrency = sanitizeCurrencyCode(currencyCode); - const decimals = getCurrencyDecimals(sanitizedCurrency); - const convertedAmount = convertToFrontendAmountAsInteger(amountInCents ?? 0, decimals); - return format(preferredLocale, convertedAmount, { - style: 'currency', - currency: sanitizedCurrency, - - // We are forcing the number of decimals because we override the default number of decimals in the backend for some currencies - // See: https://github.com/Expensify/PHP-Libs/pull/834 - minimumFractionDigits: decimals, - // For currencies that have decimal places > 2, floor to 2 instead as we don't support more than 2 decimal places. - maximumFractionDigits: 2, - }); - }, - [getCurrencyDecimals, preferredLocale], - ); - - const convertToDisplayStringWithoutCurrency = useCallback( - (amountInCents: number, currencyCode: string = CONST.CURRENCY.USD): string => - convertToDisplayStringWithoutCurrencyForLocale(preferredLocale, amountInCents, currencyCode, getCurrencyDecimals), - [getCurrencyDecimals, preferredLocale], - ); - - const stateValue = useMemo( - () => ({ - currencyList, - }), - [currencyList], - ); - - const actionsValue = useMemo( - () => ({ - getCurrencySymbol, - getCurrencyDecimals, - convertToDisplayString, - convertToDisplayStringWithoutCurrency, - }), - [getCurrencySymbol, getCurrencyDecimals, convertToDisplayString, convertToDisplayStringWithoutCurrency], - ); + const getCurrencySymbol = (currencyCode: string): string | undefined => { + return currencyList[currencyCode]?.symbol; + }; + + const getCurrencyDecimals = (currencyCode: string | undefined): number => { + const decimals = currencyList[currencyCode ?? '']?.decimals; + return decimals ?? CONST.DEFAULT_CURRENCY_DECIMALS; + }; + + const convertToDisplayString = (amountInCents: number | undefined, currencyCode: string | undefined): string => { + const sanitizedCurrency = sanitizeCurrencyCode(currencyCode); + const decimals = getCurrencyDecimals(sanitizedCurrency); + const convertedAmount = convertToFrontendAmountAsInteger(amountInCents ?? 0, decimals); + return format(preferredLocale, convertedAmount, { + style: 'currency', + currency: sanitizedCurrency, + + // We are forcing the number of decimals because we override the default number of decimals in the backend for some currencies + // See: https://github.com/Expensify/PHP-Libs/pull/834 + minimumFractionDigits: decimals, + // For currencies that have decimal places > 2, floor to 2 instead as we don't support more than 2 decimal places. + maximumFractionDigits: 2, + }); + }; + + const convertToDisplayStringWithoutCurrency = (amountInCents: number, currencyCode: string = CONST.CURRENCY.USD): string => + convertToDisplayStringWithoutCurrencyForLocale(preferredLocale, amountInCents, currencyCode, getCurrencyDecimals); + + const stateValue: CurrencyListStateContextType = { + currencyList, + }; + + const actionsValue: CurrencyListActionsContextType = { + getCurrencySymbol, + getCurrencyDecimals, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + }; return ( diff --git a/src/components/CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContextProvider.tsx b/src/components/CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContextProvider.tsx index c2645097ef5a..7727006e90d5 100644 --- a/src/components/CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContextProvider.tsx +++ b/src/components/CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContextProvider.tsx @@ -1,23 +1,17 @@ -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {CustomStatusBarAndBackgroundProvider} from './CustomStatusBarAndBackgroundContext'; function CustomStatusBarAndBackgroundContextProvider({children}: React.PropsWithChildren) { const [isRootStatusBarEnabled, setRootStatusBarEnabled] = useState(true); - const stateValue = useMemo( - () => ({ - isRootStatusBarEnabled, - }), - [isRootStatusBarEnabled], - ); + const stateValue = { + isRootStatusBarEnabled, + }; - const actionsValue = useMemo( - () => ({ - setRootStatusBarEnabled, - }), - [], - ); + const actionsValue = { + setRootStatusBarEnabled, + }; return ( { - // Check if this function is either called through the current navigation listener - // react-navigation library has a bug internally, where it can't keep track of the listeners, therefore, sometimes when the useEffect would re-render and we run navigationRef.removeListener the listener isn't removed and we end up with two or more listeners. - // https://github.com/Expensify/App/issues/34154#issuecomment-1898519399 - if (listenerID !== undefined && listenerID !== listenerCount.current) { - return; - } + const updateStatusBarStyle = (listenerID?: number) => { + // Check if this function is either called through the current navigation listener + // react-navigation library has a bug internally, where it can't keep track of the listeners, therefore, sometimes when the useEffect would re-render and we run navigationRef.removeListener the listener isn't removed and we end up with two or more listeners. + // https://github.com/Expensify/App/issues/34154#issuecomment-1898519399 + if (listenerID !== undefined && listenerID !== listenerCount.current) { + return; + } - // Set the status bar color depending on the current route. - // If we don't have any color defined for a route, fall back to - // appBG color. - let currentRoute: ReturnType | undefined; - if (navigationRef.isReady()) { - currentRoute = navigationRef.getCurrentRoute(); - } + // Set the status bar color depending on the current route. + // If we don't have any color defined for a route, fall back to + // appBG color. + let currentRoute: ReturnType | undefined; + if (navigationRef.isReady()) { + currentRoute = navigationRef.getCurrentRoute(); + } - let newStatusBarStyle = theme.statusBarStyle; - let currentScreenBackgroundColor = theme.appBG; - if (currentRoute && 'name' in currentRoute && currentRoute.name in theme.PAGE_THEMES) { - const pageTheme = theme.PAGE_THEMES[currentRoute.name]; - - newStatusBarStyle = pageTheme.statusBarStyle; - const backgroundColorFromRoute = - currentRoute?.params && - typeof currentRoute?.params === 'object' && - 'backgroundColor' in currentRoute.params && - typeof currentRoute.params.backgroundColor === 'string' && - currentRoute.params.backgroundColor; - - // It's possible for backgroundColorFromRoute to be empty string, so we must use "||" to fallback to backgroundColorFallback. - currentScreenBackgroundColor = backgroundColorFromRoute || pageTheme.backgroundColor; - } + let newStatusBarStyle = theme.statusBarStyle; + let currentScreenBackgroundColor = theme.appBG; + if (currentRoute && 'name' in currentRoute && currentRoute.name in theme.PAGE_THEMES) { + const pageTheme = theme.PAGE_THEMES[currentRoute.name]; + + newStatusBarStyle = pageTheme.statusBarStyle; + const backgroundColorFromRoute = + currentRoute?.params && + typeof currentRoute?.params === 'object' && + 'backgroundColor' in currentRoute.params && + typeof currentRoute.params.backgroundColor === 'string' && + currentRoute.params.backgroundColor; + + // It's possible for backgroundColorFromRoute to be empty string, so we must use "||" to fallback to backgroundColorFallback. + currentScreenBackgroundColor = backgroundColorFromRoute || pageTheme.backgroundColor; + } - prevStatusBarBackgroundColor.set(statusBarBackgroundColor.get()); - statusBarBackgroundColor.set(currentScreenBackgroundColor); + prevStatusBarBackgroundColor.set(statusBarBackgroundColor.get()); + statusBarBackgroundColor.set(currentScreenBackgroundColor); - const callUpdateStatusBarAppearance = () => { - updateStatusBarAppearance({statusBarStyle: newStatusBarStyle}); - setStatusBarStyle(newStatusBarStyle); - }; + const callUpdateStatusBarAppearance = () => { + updateStatusBarAppearance({statusBarStyle: newStatusBarStyle}); + setStatusBarStyle(newStatusBarStyle); + }; - const callUpdateStatusBarBackgroundColor = () => { - statusBarAnimation.set(0); - statusBarAnimation.set(withDelay(300, withTiming(1))); - }; + const callUpdateStatusBarBackgroundColor = () => { + statusBarAnimation.set(0); + statusBarAnimation.set(withDelay(300, withTiming(1))); + }; - // Don't update the status bar style if it's the same as the current one, to prevent flashing. - // Force update if the root status bar is back on active or it won't overwrite the nested status bar style - if (!didForceUpdateStatusBarRef.current && !prevIsRootStatusBarEnabled && isRootStatusBarEnabled) { - callUpdateStatusBarAppearance(); - callUpdateStatusBarBackgroundColor(); + // Don't update the status bar style if it's the same as the current one, to prevent flashing. + // Force update if the root status bar is back on active or it won't overwrite the nested status bar style + if (!didForceUpdateStatusBarRef.current && !prevIsRootStatusBarEnabled && isRootStatusBarEnabled) { + callUpdateStatusBarAppearance(); + callUpdateStatusBarBackgroundColor(); - if (!prevIsRootStatusBarEnabled && isRootStatusBarEnabled) { - didForceUpdateStatusBarRef.current = true; - } - return; + if (!prevIsRootStatusBarEnabled && isRootStatusBarEnabled) { + didForceUpdateStatusBarRef.current = true; } + return; + } - if (newStatusBarStyle !== statusBarStyle) { - callUpdateStatusBarAppearance(); - } + if (newStatusBarStyle !== statusBarStyle) { + callUpdateStatusBarAppearance(); + } - if (currentScreenBackgroundColor !== theme.appBG || prevStatusBarBackgroundColor.get() !== theme.appBG) { - callUpdateStatusBarBackgroundColor(); - } - }, - [ - theme.statusBarStyle, - theme.appBG, - theme.PAGE_THEMES, - prevStatusBarBackgroundColor, - statusBarBackgroundColor, - prevIsRootStatusBarEnabled, - isRootStatusBarEnabled, - statusBarStyle, - statusBarAnimation, - ], - ); + if (currentScreenBackgroundColor !== theme.appBG || prevStatusBarBackgroundColor.get() !== theme.appBG) { + callUpdateStatusBarBackgroundColor(); + } + }; useEffect(() => { didForceUpdateStatusBarRef.current = false; diff --git a/src/components/CustomStylesForChildrenProvider.tsx b/src/components/CustomStylesForChildrenProvider.tsx index 3fea548f22bf..3124856b58ce 100644 --- a/src/components/CustomStylesForChildrenProvider.tsx +++ b/src/components/CustomStylesForChildrenProvider.tsx @@ -1,6 +1,6 @@ import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; -import React, {useMemo} from 'react'; +import React from 'react'; type CustomStylesForChildrenContextType = StyleProp | null; @@ -11,7 +11,7 @@ type CustomStylesForChildrenProviderProps = React.PropsWithChildren & { }; function CustomStylesForChildrenProvider({children, style}: CustomStylesForChildrenProviderProps) { - const value = useMemo(() => style, [style]); + const value = style; return {children}; } diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 7c0c88543e14..8ed6d2008275 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -15,7 +15,7 @@ import type {StyleProp, ViewStyle} from 'react-native'; import {addMonths, addYears, clamp, isSameDay, isValid, setMonth, setYear, startOfDay, startOfMonth, subMonths, subYears} from 'date-fns'; import {Str} from 'expensify-common'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; @@ -184,7 +184,7 @@ function CalendarPicker({ const webOnlyMarginStyle = isSmallScreenWidth ? {} : styles.mh1; const headerPaddingStyle = headerContainerStyle ?? themeStyles.ph3; - const getAccessibilityState = useCallback((isSelected: boolean) => ({selected: isSelected}), []); + const getAccessibilityState = (isSelected: boolean) => ({selected: isSelected}); const calendarWeeks = calendarDaysMatrix?.map((week) => ( { + const syncActiveLabelFromStack = () => { const top = labelStackRef.current.at(-1); setActiveLabel((prev) => { const next = top?.text; return prev === next ? prev : next; }); - }, []); - - const pushLabel = useCallback( - (text: string): number => { - const id = nextIdRef.current++; - labelStackRef.current = [...labelStackRef.current, {id, text}]; - initialFocusClaimedRef.current = false; - syncActiveLabelFromStack(); - return id; - }, - [syncActiveLabelFromStack], - ); - - const popLabel = useCallback( - (id: number) => { - labelStackRef.current = labelStackRef.current.filter((entry) => entry.id !== id); - syncActiveLabelFromStack(); - }, - [syncActiveLabelFromStack], - ); - - const claimInitialFocus = useCallback((): boolean => { + }; + + const pushLabel = (text: string): number => { + const id = nextIdRef.current++; + labelStackRef.current = [...labelStackRef.current, {id, text}]; + initialFocusClaimedRef.current = false; + syncActiveLabelFromStack(); + return id; + }; + + const popLabel = (id: number) => { + labelStackRef.current = labelStackRef.current.filter((entry) => entry.id !== id); + syncActiveLabelFromStack(); + }; + + const claimInitialFocus = (): boolean => { if (initialFocusClaimedRef.current) { return false; } initialFocusClaimedRef.current = true; return true; - }, []); - - const data: DialogLabelData = useMemo( - () => ({ - containerRef, - isInsideDialog: hasDialogSemantics, - // Only expose a name when the container is a dialog (wide RHP). - dialogAriaLabel: hasDialogSemantics ? activeLabel : undefined, - }), - [hasDialogSemantics, activeLabel], - ); - - const actions: DialogLabelActions = useMemo( - () => ({ - pushLabel, - popLabel, - claimInitialFocus, - }), - [pushLabel, popLabel, claimInitialFocus], - ); + }; + + const data: DialogLabelData = { + containerRef, + isInsideDialog: hasDialogSemantics, + // Only expose a name when the container is a dialog (wide RHP). + dialogAriaLabel: hasDialogSemantics ? activeLabel : undefined, + }; + + const actions: DialogLabelActions = { + pushLabel, + popLabel, + claimInitialFocus, + }; return ( diff --git a/src/components/DisplayNames/DisplayNamesTooltipItem.tsx b/src/components/DisplayNames/DisplayNamesTooltipItem.tsx index 12e99fdf4b84..fa1a21594ebf 100644 --- a/src/components/DisplayNames/DisplayNamesTooltipItem.tsx +++ b/src/components/DisplayNames/DisplayNamesTooltipItem.tsx @@ -11,7 +11,7 @@ import type {ComponentRef, RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports import type {Text as RNText, StyleProp, TextStyle} from 'react-native'; -import React, {useCallback} from 'react'; +import React from 'react'; type DisplayNamesTooltipItemProps = ForwardedFSClassProps & { index?: number; @@ -53,7 +53,7 @@ function DisplayNamesTooltipItem({ isSelectable = false, }: DisplayNamesTooltipItemProps) { const styles = useThemeStyles(); - const tooltipIndexBridge = useCallback(() => getTooltipShiftX(index), [getTooltipShiftX, index]); + const tooltipIndexBridge = () => getTooltipShiftX(index); return ( containsCustomEmoji(fullTitle) && !containsOnlyCustomEmoji(fullTitle), [fullTitle]); - const title = useMemo(() => { - const processedTitle = shouldParseFullTitle ? Parser.htmlToText(fullTitle) : fullTitle; - return StringUtils.lineBreaksToSpaces(processedTitle) || translate('common.hidden'); - }, [fullTitle, shouldParseFullTitle, translate]); + const titleContainsTextAndCustomEmoji = containsCustomEmoji(fullTitle) && !containsOnlyCustomEmoji(fullTitle); + const processedTitle = shouldParseFullTitle ? Parser.htmlToText(fullTitle) : fullTitle; + const title = StringUtils.lineBreaksToSpaces(processedTitle) || translate('common.hidden'); return ( { - const processedTitle = shouldParseFullTitle ? Parser.htmlToText(fullTitle) : fullTitle; - return StringUtils.lineBreaksToSpaces(processedTitle) || translate('common.hidden'); - }, [fullTitle, shouldParseFullTitle, translate]); + const processedTitle = shouldParseFullTitle ? Parser.htmlToText(fullTitle) : fullTitle; + const title = StringUtils.lineBreaksToSpaces(processedTitle) || translate('common.hidden'); if (!tooltipEnabled) { return ( diff --git a/src/components/DistanceEReceiptPanel.tsx b/src/components/DistanceEReceiptPanel.tsx index 53a8ef6ad82a..db4c6c291d40 100644 --- a/src/components/DistanceEReceiptPanel.tsx +++ b/src/components/DistanceEReceiptPanel.tsx @@ -15,7 +15,7 @@ import type {TranslationPaths} from '@src/languages/types'; import type {Transaction} from '@src/types/onyx'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import Icon from './Icon'; @@ -46,16 +46,11 @@ function DistanceEReceiptPanel({transaction}: DistanceEReceiptPanelProps) { // The thumbnail this card draws is the stored route map. An edit that makes the server rebuild the receipt // invalidates its URL, and a route error means there is no trip to draw, so show the pending map for both. const canShowStoredRouteMap = !hasPendingDistanceReceiptRegeneration(transaction) && !hasDistanceRouteErrors(transaction); - const waypoints = useMemo(() => transaction?.comment?.waypoints ?? {}, [transaction?.comment?.waypoints]); - const sortedWaypoints = useMemo( - () => - // The waypoint keys are sometimes out of order - Object.keys(waypoints) - .sort((keyA, keyB) => getWaypointIndex(keyA) - getWaypointIndex(keyB)) - .map((key) => ({[key]: waypoints[key]})) - .reduce((result, obj) => (obj ? Object.assign(result, obj) : result), {}), - [waypoints], - ); + const waypoints = transaction?.comment?.waypoints ?? {}; + const sortedWaypoints: WaypointCollection = Object.keys(waypoints) + .sort((keyA, keyB) => getWaypointIndex(keyA) - getWaypointIndex(keyB)) + .map((key) => ({[key]: waypoints[key]})) + .reduce((result, obj) => (obj ? Object.assign(result, obj) : result), {}); return ( ) => getAvailableReusableDistanceRoutes(reusableDistanceRoutes, transactions, isOffline).length > 0, - [reusableDistanceRoutes, isOffline], - ); + const hasAvailableReusableRouteSelector = (transactions: OnyxCollection) => getAvailableReusableDistanceRoutes(reusableDistanceRoutes, transactions, isOffline).length > 0; const [hasAvailableReusableRoute = false] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { selector: hasAvailableReusableRouteSelector, }); diff --git a/src/components/EditableCell/EditingCellContext.tsx b/src/components/EditableCell/EditingCellContext.tsx index 75e0f4f6c682..5e986c7b8114 100644 --- a/src/components/EditableCell/EditingCellContext.tsx +++ b/src/components/EditableCell/EditingCellContext.tsx @@ -1,6 +1,6 @@ import type {ReactNode} from 'react'; -import React, {useCallback, useContext, useState} from 'react'; +import React, {useContext, useState} from 'react'; /** * Context for managing inline editable cell state across the component tree. @@ -40,7 +40,7 @@ function EditingCellProvider({children}: EditingCellProviderProps) { const [wasRecentlyEditingCell, setWasRecentlyEditingCell] = useState(false); const [focusedCellId, setFocusedCellId] = useState(null); - const setIsEditingCell = useCallback((value: boolean) => { + const setIsEditingCell = (value: boolean) => { setIsEditingCellState(value); if (!value) { @@ -51,7 +51,7 @@ function EditingCellProvider({children}: EditingCellProviderProps) { setWasRecentlyEditingCell(false); }); } - }, []); + }; const stateValue = { isEditingCell, diff --git a/src/components/EditableCell/useInlineEditState.ts b/src/components/EditableCell/useInlineEditState.ts index af43efb1d036..f91545d9c2d9 100644 --- a/src/components/EditableCell/useInlineEditState.ts +++ b/src/components/EditableCell/useInlineEditState.ts @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useRef, useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; import type {InlineEditSaveResult} from './types'; @@ -37,26 +37,26 @@ function useInlineEditState( setLocalValue(value); } - const startEditing = useCallback(() => { + const startEditing = () => { hasEndedRef.current = false; setIsAwaitingConfirm(false); setIsEditing(true); - }, []); + }; - const closeEditing = useCallback(() => { + const closeEditing = () => { setIsAwaitingConfirm(false); // Reset to the source of truth when edit mode closes so a rejected save, such as an empty merchant, is not left on screen. setLocalValue(value); setIsEditing(false); - }, [value]); + }; - const keepEditing = useCallback(() => { + const keepEditing = () => { hasEndedRef.current = false; setIsAwaitingConfirm(false); onKeepEditing?.(); - }, [onKeepEditing]); + }; - const save = useCallback(() => { + const save = () => { if (hasEndedRef.current) { return; } @@ -90,15 +90,15 @@ function useInlineEditState( keepEditing(); }, ); - }, [localValue, value, onSave, isEqual, closeEditing, keepEditing]); + }; - const cancelEditing = useCallback(() => { + const cancelEditing = () => { if (hasEndedRef.current) { return; } hasEndedRef.current = true; closeEditing(); - }, [closeEditing]); + }; // Cancel editing when permission is revoked (e.g., transaction status changed). A pending save has already // claimed the edit, so revocation is applied once the confirm modal releases it rather than under the modal. diff --git a/src/components/EmojiPicker/CategoryShortcutButton.tsx b/src/components/EmojiPicker/CategoryShortcutButton.tsx index 44790c1606cb..495839a9e96c 100644 --- a/src/components/EmojiPicker/CategoryShortcutButton.tsx +++ b/src/components/EmojiPicker/CategoryShortcutButton.tsx @@ -90,4 +90,4 @@ function CategoryShortcutButton({code, icon, onPress, isSelected = false}: Categ CategoryShortcutButton.displayName = 'CategoryShortcutButton'; -export default React.memo(CategoryShortcutButton); +export default CategoryShortcutButton; diff --git a/src/components/EmojiPicker/EmojiPickerButton.tsx b/src/components/EmojiPicker/EmojiPickerButton.tsx index eade7858788d..08625f6d0519 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.tsx +++ b/src/components/EmojiPicker/EmojiPickerButton.tsx @@ -18,7 +18,7 @@ import CONST from '@src/CONST'; import KeyboardUtils from '@src/utils/keyboard'; import {useIsFocused} from '@react-navigation/native'; -import React, {memo, useEffect, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; type EmojiPickerButtonProps = { /** Flag to disable the emoji picker button */ @@ -103,4 +103,4 @@ function EmojiPickerButton({isDisabled = false, emojiPickerID = '', shiftVertica ); } -export default memo(EmojiPickerButton); +export default EmojiPickerButton; diff --git a/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts b/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts index cb3c2901ca9a..5fec3186b77a 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts +++ b/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts @@ -17,16 +17,16 @@ import calculateModalHeightInLandscapeMode from '@src/utils/calculateModalHeight import type {FlashListRef} from '@shopify/flash-list'; -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; const useEmojiPickerMenu = () => { const emojiListRef = useRef>(null); const [frequentlyUsedEmojis] = useOnyx(ONYXKEYS.FREQUENTLY_USED_EMOJIS); - const allEmojis = useMemo(() => mergeEmojisWithFrequentlyUsedEmojis(emojis, processFrequentlyUsedEmojis(frequentlyUsedEmojis)), [frequentlyUsedEmojis]); - const headerEmojis = useMemo(() => getHeaderEmojis(allEmojis), [allEmojis]); - const headerRowIndices = useMemo(() => headerEmojis.map((headerEmoji) => headerEmoji.index), [headerEmojis]); - const spacersIndexes = useMemo(() => getSpacersIndexes(allEmojis), [allEmojis]); + const allEmojis = mergeEmojisWithFrequentlyUsedEmojis(emojis, processFrequentlyUsedEmojis(frequentlyUsedEmojis)); + const headerEmojis = getHeaderEmojis(allEmojis); + const headerRowIndices = headerEmojis.map((headerEmoji) => headerEmoji.index); + const spacersIndexes = getSpacersIndexes(allEmojis); const [filteredEmojis, setFilteredEmojis] = useState(allEmojis); const [headerIndices, setHeaderIndices] = useState(headerRowIndices); const isListFiltered = allEmojis.length !== filteredEmojis.length; @@ -60,15 +60,12 @@ const useEmojiPickerMenu = () => { /** * Suggest emojis based on the search term */ - const suggestEmojisCallback = useCallback( - (searchTerm: string) => { - const normalizedSearchTerm = searchTerm.toLowerCase().trim().replaceAll(':', ''); - const emojisSuggestions = suggestEmojis(`:${normalizedSearchTerm}`, preferredLocale, allEmojis.length); + const suggestEmojisCallback = (searchTerm: string) => { + const normalizedSearchTerm = searchTerm.toLowerCase().trim().replaceAll(':', ''); + const emojisSuggestions = suggestEmojis(`:${normalizedSearchTerm}`, preferredLocale, allEmojis.length); - return [normalizedSearchTerm, emojisSuggestions] as const; - }, - [allEmojis.length, preferredLocale], - ); + return [normalizedSearchTerm, emojisSuggestions] as const; + }; return { allEmojis, diff --git a/src/components/EmojiPicker/EmojiPickerMenuItem/index.native.tsx b/src/components/EmojiPicker/EmojiPickerMenuItem/index.native.tsx index e36bd3f45ad8..582652eb63bc 100644 --- a/src/components/EmojiPicker/EmojiPickerMenuItem/index.native.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenuItem/index.native.tsx @@ -66,8 +66,4 @@ EmojiPickerMenuItem.displayName = 'EmojiPickerMenuItem'; // Significantly speeds up re-renders of the EmojiPickerMenu's FlatList // by only re-rendering at most two EmojiPickerMenuItems that are highlighted/un-highlighted per user action. -export default React.memo( - EmojiPickerMenuItem, - (prevProps, nextProps) => - prevProps.isHighlighted === nextProps.isHighlighted && prevProps.emoji === nextProps.emoji && prevProps.isUsingKeyboardMovement === nextProps.isUsingKeyboardMovement, -); +export default EmojiPickerMenuItem; diff --git a/src/components/EmojiPicker/EmojiPickerMenuItem/index.tsx b/src/components/EmojiPicker/EmojiPickerMenuItem/index.tsx index 44467ff5b733..6ac5c0ea0421 100644 --- a/src/components/EmojiPicker/EmojiPickerMenuItem/index.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenuItem/index.tsx @@ -92,7 +92,4 @@ EmojiPickerMenuItem.displayName = 'EmojiPickerMenuItem'; // Significantly speeds up re-renders of the EmojiPickerMenu's FlatList // by only re-rendering at most two EmojiPickerMenuItems that are highlighted/un-highlighted per user action. -export default React.memo( - EmojiPickerMenuItem, - (prevProps, nextProps) => prevProps.isFocused === nextProps.isFocused && prevProps.isHighlighted === nextProps.isHighlighted && prevProps.emoji === nextProps.emoji, -); +export default EmojiPickerMenuItem; diff --git a/src/components/EmojiPicker/EmojiSkinToneList.tsx b/src/components/EmojiPicker/EmojiSkinToneList.tsx index 3aba4dfe9a0b..d39c3e809a5b 100644 --- a/src/components/EmojiPicker/EmojiSkinToneList.tsx +++ b/src/components/EmojiPicker/EmojiSkinToneList.tsx @@ -9,7 +9,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import EmojiPickerMenuItem from './EmojiPickerMenuItem'; @@ -26,9 +26,9 @@ function EmojiSkinToneList() { const {translate} = useLocalize(); const [preferredSkinTone, setPreferredSkinTone] = usePreferredEmojiSkinTone(); - const toggleIsSkinToneListVisible = useCallback(() => { + const toggleIsSkinToneListVisible = () => { setIsSkinToneListVisible((prev) => !prev); - }, []); + }; /** * Set the preferred skin tone in Onyx and close the skin tone picker diff --git a/src/components/EmojiWithTooltip/index.android.tsx b/src/components/EmojiWithTooltip/index.android.tsx index 3de9c642d86a..1029a6b7d091 100644 --- a/src/components/EmojiWithTooltip/index.android.tsx +++ b/src/components/EmojiWithTooltip/index.android.tsx @@ -4,12 +4,10 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {containsCustomEmoji} from '@libs/EmojiUtils'; -import {useMemo} from 'react'; - import type EmojiWithTooltipProps from './types'; function EmojiWithTooltip({emojiCode, style = {}}: EmojiWithTooltipProps) { - const isCustomEmoji = useMemo(() => containsCustomEmoji(emojiCode), [emojiCode]); + const isCustomEmoji = containsCustomEmoji(emojiCode); const styles = useThemeStyles(); return {emojiCode}; } diff --git a/src/components/EmojiWithTooltip/index.tsx b/src/components/EmojiWithTooltip/index.tsx index 64ec51e11e34..ec9f688f88b8 100644 --- a/src/components/EmojiWithTooltip/index.tsx +++ b/src/components/EmojiWithTooltip/index.tsx @@ -6,7 +6,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {findEmojiByCode, getLocalizedEmojiName} from '@libs/EmojiUtils'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; import type EmojiWithTooltipProps from './types'; @@ -17,21 +17,18 @@ function EmojiWithTooltip({emojiCode, style = {}}: EmojiWithTooltipProps) { const emoji = findEmojiByCode(emojiCode); const emojiName = getLocalizedEmojiName(emoji?.name, preferredLocale); - const emojiTooltipContent = useCallback( - () => ( - - - - {emojiCode} - - - {`:${emojiName}:`} + const emojiTooltipContent = () => ( + + + + {emojiCode} + - ), - [emojiCode, emojiName, styles.alignItemsCenter, styles.ph2, styles.flexRow, styles.emojiTooltipWrapper, styles.fontColorReactionLabel, styles.onlyEmojisText, styles.textMicro], + {`:${emojiName}:`} + ); return ( diff --git a/src/components/EmptyStateComponent/index.tsx b/src/components/EmptyStateComponent/index.tsx index 8f5d59db0e8d..6f4723711f66 100644 --- a/src/components/EmptyStateComponent/index.tsx +++ b/src/components/EmptyStateComponent/index.tsx @@ -12,7 +12,7 @@ import TextWithEmojiFragment from '@pages/inbox/report/comment/TextWithEmojiFrag import CONST from '@src/CONST'; import isEmpty from 'lodash/isEmpty'; -import React, {useMemo} from 'react'; +import React from 'react'; import {StyleSheet, View} from 'react-native'; import type {EmptyStateComponentProps} from './types'; @@ -37,14 +37,11 @@ function EmptyStateComponent({ const styles = useThemeStyles(); const doesSubtitleContainCustomEmojiAndMore = containsCustomEmoji(subtitle ?? '') && !containsOnlyCustomEmoji(subtitle ?? ''); - const HeaderComponent = useMemo( - () => ( - - ), - [headerMedia, headerContentStyles], + const HeaderComponent = ( + ); return ( diff --git a/src/components/EnvironmentContextProvider/index.tsx b/src/components/EnvironmentContextProvider/index.tsx index 60e968b3f845..20e7fc9f82eb 100644 --- a/src/components/EnvironmentContextProvider/index.tsx +++ b/src/components/EnvironmentContextProvider/index.tsx @@ -3,7 +3,7 @@ import getEnvironment from '@libs/Environment/getEnvironment'; import type {ReactElement, ReactNode} from 'react'; -import React, {createContext, useCallback, useContext, useEffect, useMemo, useState} from 'react'; +import React, {createContext, useContext, useEffect, useState} from 'react'; import type {EnvironmentActionsContextType, EnvironmentStateContextType, EnvironmentValue} from './types'; @@ -76,7 +76,7 @@ const EnvironmentActionsContext = createContext(d function EnvironmentProvider({children}: EnvironmentProviderProps): ReactElement { const [environment, setEnvironment] = useState(defaultEnvironmentStateContextValue.environment); const [environmentURL, setEnvironmentURL] = useState(defaultEnvironmentStateContextValue.environmentURL); - const environmentURLWithoutTrailingSlash = useMemo(() => environmentURL.replaceAll(/\/+$/g, ''), [environmentURL]); + const environmentURLWithoutTrailingSlash = environmentURL.replaceAll(/\/+$/g, ''); useEffect(() => { getEnvironment().then(setEnvironment); @@ -87,22 +87,16 @@ function EnvironmentProvider({children}: EnvironmentProviderProps): ReactElement * Adjusts Expensify links in HTML content to use the current environment URL * instead of the production URL (new.expensify.com). */ - const adjustExpensifyLinksForEnv = useCallback((html: string): string => rewriteProductionNewDotLinks(html, environmentURLWithoutTrailingSlash), [environmentURLWithoutTrailingSlash]); + const adjustExpensifyLinksForEnv = (html: string): string => rewriteProductionNewDotLinks(html, environmentURLWithoutTrailingSlash); - const stateValue = useMemo( - () => ({ - environment, - environmentURL, - }), - [environment, environmentURL], - ); + const stateValue = { + environment, + environmentURL, + }; - const actionsValue = useMemo( - () => ({ - adjustExpensifyLinksForEnv, - }), - [adjustExpensifyLinksForEnv], - ); + const actionsValue = { + adjustExpensifyLinksForEnv, + }; return ( diff --git a/src/components/FeatureTraining/FeatureTraining.tsx b/src/components/FeatureTraining/FeatureTraining.tsx index 822755be5721..1ee3e2365486 100644 --- a/src/components/FeatureTraining/FeatureTraining.tsx +++ b/src/components/FeatureTraining/FeatureTraining.tsx @@ -1,6 +1,6 @@ import variables from '@styles/variables'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; import type {FeatureTrainingActionsValue, FeatureTrainingStateValue} from './context'; import type {FeatureTrainingProps} from './types'; @@ -20,42 +20,36 @@ function FeatureTraining({ const [willShowAgain, setWillShowAgain] = useState(true); const {Wrapper, wrapperProps} = useScrollableWrapper({shouldUseScrollView, width}); - const toggleWillShowAgain = useCallback(() => { + const toggleWillShowAgain = () => { onWillShowAgainChange?.(!willShowAgain); setWillShowAgain((prev) => !prev); - }, [onWillShowAgainChange, willShowAgain]); + }; // Closing after confirm is the consumer's responsibility — call it inside onConfirm when desired. - const handleConfirm = useCallback(() => { + const handleConfirm = () => { onConfirm?.(willShowAgain); - }, [onConfirm, willShowAgain]); + }; - const handleClose = useCallback(() => onClose?.(), [onClose]); + const handleClose = () => onClose?.(); - const stateValue = useMemo( - () => ({ - willShowAgain, - shouldShowLoadingImmediatelyOnPress: undefined, - isCarousel: false, - confirmSentryLabel, - currentPage: undefined, - pageCount: undefined, - isLastPage: undefined, - contentMinHeight: undefined, - }), - [willShowAgain, confirmSentryLabel], - ); + const stateValue: FeatureTrainingStateValue = { + willShowAgain, + shouldShowLoadingImmediatelyOnPress: undefined, + isCarousel: false, + confirmSentryLabel, + currentPage: undefined, + pageCount: undefined, + isLastPage: undefined, + contentMinHeight: undefined, + }; - const actionsValue = useMemo( - () => ({ - toggleWillShowAgain, - handleConfirm, - handleClose, - advance: undefined, - goBack: undefined, - }), - [toggleWillShowAgain, handleConfirm, handleClose], - ); + const actionsValue: FeatureTrainingActionsValue = { + toggleWillShowAgain, + handleConfirm, + handleClose, + advance: undefined, + goBack: undefined, + }; return ( diff --git a/src/components/FeatureTraining/FeatureTrainingCarousel.tsx b/src/components/FeatureTraining/FeatureTrainingCarousel.tsx index 5034899e92ca..26bc4c252061 100644 --- a/src/components/FeatureTraining/FeatureTrainingCarousel.tsx +++ b/src/components/FeatureTraining/FeatureTrainingCarousel.tsx @@ -7,7 +7,7 @@ import type {ViewabilityConfig, ViewToken} from '@src/types/utils/ReactNativeCom import type {ReactElement, ReactNode} from 'react'; import type {LayoutChangeEvent, FlatList as RNFlatList, ViewStyle} from 'react-native'; -import React, {cloneElement, isValidElement, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {cloneElement, isValidElement, useEffect, useRef, useState} from 'react'; import {FlatList, Platform, View} from 'react-native'; import type {FeatureTrainingActionsValue, FeatureTrainingStateValue} from './context'; @@ -95,23 +95,21 @@ function FeatureTrainingCarousel({ const {Wrapper, wrapperProps, setContainerHeight, shouldUseScrollView: usingScrollView, isInLandscapeMode} = useScrollableWrapper({shouldUseScrollView, width}); - const pages = useMemo(() => { - const pageList: SplitPage[] = []; - React.Children.forEach(children, (child) => { - if (!isValidElement(child) || !isPageElement(child)) { - return; - } - pageList.push(splitPageChildren(child.props.children)); - }); - return pageList; - }, [children]); + const pageList: SplitPage[] = []; + React.Children.forEach(children, (child) => { + if (!isValidElement(child) || !isPageElement(child)) { + return; + } + pageList.push(splitPageChildren(child.props.children)); + }); + const pages = pageList; const onPageChangeRef = useRef(onPageChange); useEffect(() => { onPageChangeRef.current = onPageChange; }, [onPageChange]); - const onViewableItemsChanged = useCallback(({viewableItems}: {viewableItems: ViewToken[]}) => { + const onViewableItemsChanged = ({viewableItems}: {viewableItems: ViewToken[]}) => { const entry = viewableItems.at(0); if (entry?.index == null || entry.index === lastReportedPage.current) { return; @@ -119,18 +117,18 @@ function FeatureTrainingCarousel({ lastReportedPage.current = entry.index; setCurrentPage(entry.index); onPageChangeRef.current?.(entry.index); - }, []); + }; - const advance = useCallback(() => { + const advance = () => { horizontalListRef.current?.scrollToIndex({index: Math.min(currentPage + 1, pages.length - 1), animated: true}); - }, [currentPage, pages.length]); + }; - const goBack = useCallback(() => { + const goBack = () => { if (currentPage <= 0) { return; } horizontalListRef.current?.scrollToIndex({index: Math.max(currentPage - 1, 0), animated: true}); - }, [currentPage]); + }; const pageCountRef = useRef(pages.length); useEffect(() => { @@ -147,7 +145,7 @@ function FeatureTrainingCarousel({ horizontalListRef.current?.scrollToOffset({offset: lastReportedPage.current * carouselViewportWidth, animated: false}); }, [carouselViewportWidth]); - const recordPageHeight = useCallback((index: number, measured: number) => { + const recordPageHeight = (index: number, measured: number) => { if (measuredHeightsRef.current[index] === measured) { return; } @@ -156,59 +154,50 @@ function FeatureTrainingCarousel({ return; } setContentMinHeight(Math.max(...Object.values(measuredHeightsRef.current))); - }, []); + }; const isLastPage = pages.length === 0 || currentPage >= pages.length - 1; - const handleConfirm = useCallback(() => { + const handleConfirm = () => { onConfirm?.(false); - }, [onConfirm]); - - const handleClose = useCallback(() => onClose?.(), [onClose]); - - const stateValue = useMemo( - () => ({ - willShowAgain: true, - shouldShowLoadingImmediatelyOnPress: isLastPage ? undefined : false, - isCarousel: true, - confirmSentryLabel, - currentPage, - pageCount: pages.length, - isLastPage, - contentMinHeight, - }), - [isLastPage, confirmSentryLabel, currentPage, pages.length, contentMinHeight], - ); - - const actionsValue = useMemo( - () => ({ - toggleWillShowAgain: () => {}, - handleConfirm, - handleClose, - advance, - goBack, - }), - [handleConfirm, handleClose, advance, goBack], - ); + }; + + const handleClose = () => onClose?.(); + + const stateValue: FeatureTrainingStateValue = { + willShowAgain: true, + shouldShowLoadingImmediatelyOnPress: isLastPage ? undefined : false, + isCarousel: true, + confirmSentryLabel, + currentPage, + pageCount: pages.length, + isLastPage, + contentMinHeight, + }; + + const actionsValue: FeatureTrainingActionsValue = { + toggleWillShowAgain: () => {}, + handleConfirm, + handleClose, + advance, + goBack, + }; const currentPageBody = pages.at(currentPage)?.body ?? null; - const onWrapperLayout = useCallback( - (e: LayoutChangeEvent) => { - const newWidth = e.nativeEvent.layout.width; - if (newWidth === carouselViewportWidth || newWidth <= 0) { - return; - } - setCarouselViewportWidth(newWidth); - if (!usingScrollView) { - return; - } - setContainerHeight(e.nativeEvent.layout.height); - }, - [carouselViewportWidth, usingScrollView, setContainerHeight], - ); + const onWrapperLayout = (e: LayoutChangeEvent) => { + const newWidth = e.nativeEvent.layout.width; + if (newWidth === carouselViewportWidth || newWidth <= 0) { + return; + } + setCarouselViewportWidth(newWidth); + if (!usingScrollView) { + return; + } + setContainerHeight(e.nativeEvent.layout.height); + }; - const probeStyle = useMemo(() => ({position: 'absolute', left: 0, top: 0, width: carouselViewportWidth, opacity: 0}), [carouselViewportWidth]); + const probeStyle: ViewStyle = {position: 'absolute', left: 0, top: 0, width: carouselViewportWidth, opacity: 0}; return ( @@ -289,7 +278,7 @@ type ProbePageProps = { // Body's horizontal margins so the probe text wraps at the same width as the visible page. function ProbePage({index, bodyText, onMeasure}: ProbePageProps) { const styles = useThemeStyles(); - const onLayout = useCallback((event: LayoutChangeEvent) => onMeasure(index, event.nativeEvent.layout.height), [index, onMeasure]); + const onLayout = (event: LayoutChangeEvent) => onMeasure(index, event.nativeEvent.layout.height); return {cloneElement(bodyText, {onLayout})}; } diff --git a/src/components/FeedbackSurvey.tsx b/src/components/FeedbackSurvey.tsx index f50a175456ed..c23d6c94dc44 100644 --- a/src/components/FeedbackSurvey.tsx +++ b/src/components/FeedbackSurvey.tsx @@ -11,7 +11,7 @@ import type ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/FeedbackSurveyForm'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import type {Choice} from './RadioButtons'; @@ -54,15 +54,12 @@ function FeedbackSurvey({title, description, onSubmit, footerText, isNoteRequire const isLoadingDraft = isLoadingOnyxValue(draftResults); - const options = useMemo( - () => [ - {value: CONST.FEEDBACK_SURVEY_OPTIONS.TOO_LIMITED.ID, label: translate(CONST.FEEDBACK_SURVEY_OPTIONS.TOO_LIMITED.TRANSLATION_KEY)}, - {value: CONST.FEEDBACK_SURVEY_OPTIONS.TOO_EXPENSIVE.ID, label: translate(CONST.FEEDBACK_SURVEY_OPTIONS.TOO_EXPENSIVE.TRANSLATION_KEY)}, - {value: CONST.FEEDBACK_SURVEY_OPTIONS.INADEQUATE_SUPPORT.ID, label: translate(CONST.FEEDBACK_SURVEY_OPTIONS.INADEQUATE_SUPPORT.TRANSLATION_KEY)}, - {value: CONST.FEEDBACK_SURVEY_OPTIONS.BUSINESS_CLOSING.ID, label: translate(CONST.FEEDBACK_SURVEY_OPTIONS.BUSINESS_CLOSING.TRANSLATION_KEY)}, - ], - [translate], - ); + const options: Choice[] = [ + {value: CONST.FEEDBACK_SURVEY_OPTIONS.TOO_LIMITED.ID, label: translate(CONST.FEEDBACK_SURVEY_OPTIONS.TOO_LIMITED.TRANSLATION_KEY)}, + {value: CONST.FEEDBACK_SURVEY_OPTIONS.TOO_EXPENSIVE.ID, label: translate(CONST.FEEDBACK_SURVEY_OPTIONS.TOO_EXPENSIVE.TRANSLATION_KEY)}, + {value: CONST.FEEDBACK_SURVEY_OPTIONS.INADEQUATE_SUPPORT.ID, label: translate(CONST.FEEDBACK_SURVEY_OPTIONS.INADEQUATE_SUPPORT.TRANSLATION_KEY)}, + {value: CONST.FEEDBACK_SURVEY_OPTIONS.BUSINESS_CLOSING.ID, label: translate(CONST.FEEDBACK_SURVEY_OPTIONS.BUSINESS_CLOSING.TRANSLATION_KEY)}, + ]; useEffect(() => { if (!draft?.reason || isLoadingDraft) { diff --git a/src/components/FixedFooter.tsx b/src/components/FixedFooter.tsx index ed7bb596088f..07d067a1250f 100644 --- a/src/components/FixedFooter.tsx +++ b/src/components/FixedFooter.tsx @@ -4,7 +4,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import type {ReactNode} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type FixedFooterProps = { @@ -31,10 +31,7 @@ function FixedFooter({style, children, addBottomSafeAreaPadding, addOfflineIndic styleProperty: shouldStickToBottom ? 'bottom' : 'paddingBottom', }); - const footerStyle = useMemo>( - () => [shouldStickToBottom && styles.stickToBottom, bottomSafeAreaPaddingStyle], - [bottomSafeAreaPaddingStyle, shouldStickToBottom, styles.stickToBottom], - ); + const footerStyle: StyleProp = [shouldStickToBottom && styles.stickToBottom, bottomSafeAreaPaddingStyle]; if (!children) { return null; diff --git a/src/components/FlatList/FlatList/index.android.tsx b/src/components/FlatList/FlatList/index.android.tsx index 6651c78ee7d3..abdc45f4412e 100644 --- a/src/components/FlatList/FlatList/index.android.tsx +++ b/src/components/FlatList/FlatList/index.android.tsx @@ -16,7 +16,7 @@ function CustomFlatList({ref, enableAnimatedKeyboardDismissal = false, onMome const lastScrollOffsetRef = useRef(0); const styles = useThemeStyles(); - const onScreenFocus = useCallback(() => { + const onScreenFocus = () => { if (typeof ref === 'function') { return; } @@ -26,15 +26,12 @@ function CustomFlatList({ref, enableAnimatedKeyboardDismissal = false, onMome if (ref.current && lastScrollOffsetRef.current) { ref.current.scrollToOffset({offset: lastScrollOffsetRef.current, animated: false}); } - }, [ref]); + }; - const handleScrollEnd = useCallback( - (event: NativeSyntheticEvent) => { - onMomentumScrollEnd?.(event); - lastScrollOffsetRef.current = event.nativeEvent.contentOffset.y; - }, - [onMomentumScrollEnd], - ); + const handleScrollEnd = (event: NativeSyntheticEvent) => { + onMomentumScrollEnd?.(event); + lastScrollOffsetRef.current = event.nativeEvent.contentOffset.y; + }; const handleScroll = (event: NativeSyntheticEvent) => { lastScrollOffsetRef.current = event.nativeEvent.contentOffset.y; diff --git a/src/components/FlatList/FlatList/index.ios.tsx b/src/components/FlatList/FlatList/index.ios.tsx index 5fc8e9b546a7..3b09f707ae03 100644 --- a/src/components/FlatList/FlatList/index.ios.tsx +++ b/src/components/FlatList/FlatList/index.ios.tsx @@ -7,7 +7,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; -import React, {useCallback, useRef, useState} from 'react'; +import React, {useRef, useState} from 'react'; import {FlatList} from 'react-native'; import type {CustomFlatListProps} from './types'; @@ -26,30 +26,21 @@ function CustomFlatList({ }: CustomFlatListProps) { const [isScrolling, setIsScrolling] = useState(false); const styles = useThemeStyles(); - const handleScrollBegin = useCallback( - (event: NativeSyntheticEvent) => { - onMomentumScrollBegin?.(event); - setIsScrolling(true); - }, - [onMomentumScrollBegin], - ); + const handleScrollBegin = (event: NativeSyntheticEvent) => { + onMomentumScrollBegin?.(event); + setIsScrolling(true); + }; - const handleScrollEnd = useCallback( - (event: NativeSyntheticEvent) => { - onMomentumScrollEnd?.(event); - setIsScrolling(false); - }, - [onMomentumScrollEnd], - ); + const handleScrollEnd = (event: NativeSyntheticEvent) => { + onMomentumScrollEnd?.(event); + setIsScrolling(false); + }; const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal, inverted: restProps.inverted}); - const handleScroll = useCallback( - (e: NativeSyntheticEvent) => { - onScrollProp?.(e); - emitComposerScrollEvents(); - }, - [emitComposerScrollEvents, onScrollProp], - ); + const handleScroll = (e: NativeSyntheticEvent) => { + onScrollProp?.(e); + emitComposerScrollEvents(); + }; const listRef = useRef | null>(null); useFlatListHandle({ diff --git a/src/components/FlatList/FlatList/index.tsx b/src/components/FlatList/FlatList/index.tsx index fc35d965553e..c72a1ba4c91b 100644 --- a/src/components/FlatList/FlatList/index.tsx +++ b/src/components/FlatList/FlatList/index.tsx @@ -9,7 +9,7 @@ import mergeRefs from '@libs/mergeRefs'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; -import React, {useCallback, useEffect, useLayoutEffect, useRef} from 'react'; +import React, {useEffect, useLayoutEffect, useRef} from 'react'; import {FlatList} from 'react-native'; import type {CustomFlatListProps} from './types'; @@ -46,38 +46,35 @@ function MVCPFlatList({ mvcpAutoscrollToTopThresholdRef.current = mvcpAutoscrollToTopThreshold; }, [mvcpAutoscrollToTopThreshold]); - const getScrollOffset = useCallback((): number => { + const getScrollOffset = (): number => { if (!listRef.current) { return 0; } return horizontal ? (getScrollableNode(listRef.current)?.scrollLeft ?? 0) : (getScrollableNode(listRef.current)?.scrollTop ?? 0); - }, [horizontal]); + }; - const getContentView = useCallback(() => getScrollableNode(listRef.current)?.childNodes[0], []); + const getContentView = () => getScrollableNode(listRef.current)?.childNodes[0]; - const scrollToOffset = useCallback( - (offset: number, animated: boolean, interrupt: boolean) => { - const behavior = animated ? 'smooth' : 'instant'; - const node = getScrollableNode(listRef.current); - if (node == null) { - return; - } + const scrollToOffset = (offset: number, animated: boolean, interrupt: boolean) => { + const behavior = animated ? 'smooth' : 'instant'; + const node = getScrollableNode(listRef.current); + if (node == null) { + return; + } - const overflowProp = horizontal ? 'overflowX' : 'overflowY'; - // Stop momentum scrolling on mobile Safari otherwise the scroll position update - // will not work. - if (IS_MOBILE_SAFARI && interrupt) { - node.style[overflowProp] = 'hidden'; - } - node.scroll(horizontal ? {left: offset, behavior} : {top: offset, behavior}); - if (IS_MOBILE_SAFARI && interrupt) { - node.style[overflowProp] = 'scroll'; - } - }, - [horizontal], - ); + const overflowProp = horizontal ? 'overflowX' : 'overflowY'; + // Stop momentum scrolling on mobile Safari otherwise the scroll position update + // will not work. + if (IS_MOBILE_SAFARI && interrupt) { + node.style[overflowProp] = 'hidden'; + } + node.scroll(horizontal ? {left: offset, behavior} : {top: offset, behavior}); + if (IS_MOBILE_SAFARI && interrupt) { + node.style[overflowProp] = 'scroll'; + } + }; - const prepareForMaintainVisibleContentPosition = useCallback(() => { + const prepareForMaintainVisibleContentPosition = () => { if (mvcpMinIndexForVisible == null) { return; } @@ -103,35 +100,32 @@ function MVCPFlatList({ break; } } - }, [getContentView, getScrollOffset, mvcpMinIndexForVisible, horizontal]); + }; - const adjustForMaintainVisibleContentPosition = useCallback( - (animated = true) => { - if (mvcpMinIndexForVisible == null) { - return; - } + const adjustForMaintainVisibleContentPosition = (animated = true) => { + if (mvcpMinIndexForVisible == null) { + return; + } - const firstVisibleView = firstVisibleViewRef.current; - const prevFirstVisibleOffset = prevFirstVisibleOffsetRef.current; - if (firstVisibleView == null || !firstVisibleView.isConnected || prevFirstVisibleOffset == null) { - return; - } + const firstVisibleView = firstVisibleViewRef.current; + const prevFirstVisibleOffset = prevFirstVisibleOffsetRef.current; + if (firstVisibleView == null || !firstVisibleView.isConnected || prevFirstVisibleOffset == null) { + return; + } - const firstVisibleViewOffset = horizontal ? firstVisibleView.offsetLeft : firstVisibleView.offsetTop; - const delta = firstVisibleViewOffset - prevFirstVisibleOffset; - if (Math.abs(delta) > (IS_MOBILE_SAFARI ? 100 : 0.5)) { - const scrollOffset = lastScrollOffsetRef.current; - prevFirstVisibleOffsetRef.current = firstVisibleViewOffset; - scrollToOffset(scrollOffset + delta, false, true); - if (mvcpAutoscrollToTopThresholdRef.current != null && scrollOffset <= mvcpAutoscrollToTopThresholdRef.current) { - scrollToOffset(0, animated, false); - } + const firstVisibleViewOffset = horizontal ? firstVisibleView.offsetLeft : firstVisibleView.offsetTop; + const delta = firstVisibleViewOffset - prevFirstVisibleOffset; + if (Math.abs(delta) > (IS_MOBILE_SAFARI ? 100 : 0.5)) { + const scrollOffset = lastScrollOffsetRef.current; + prevFirstVisibleOffsetRef.current = firstVisibleViewOffset; + scrollToOffset(scrollOffset + delta, false, true); + if (mvcpAutoscrollToTopThresholdRef.current != null && scrollOffset <= mvcpAutoscrollToTopThresholdRef.current) { + scrollToOffset(0, animated, false); } - }, - [scrollToOffset, mvcpMinIndexForVisible, horizontal], - ); + } + }; - const setupMutationObserver = useCallback(() => { + const setupMutationObserver = () => { const contentView = getContentView(); if (!(contentView instanceof Node)) { return; @@ -178,7 +172,7 @@ function MVCPFlatList({ }); mutationObserverRef.current = mutationObserver; - }, [adjustForMaintainVisibleContentPosition, prepareForMaintainVisibleContentPosition, getContentView]); + }; useEffect(() => { if (!isListRenderedRef.current) { @@ -193,19 +187,16 @@ function MVCPFlatList({ }; }, [prepareForMaintainVisibleContentPosition, setupMutationObserver]); - const onRef = useCallback( - (newRef: FlatList) => { - // Make sure to only call refs and re-attach listeners if the node changed. - if (newRef == null || newRef === listRef.current) { - return; - } + const onRef = (newRef: FlatList) => { + // Make sure to only call refs and re-attach listeners if the node changed. + if (newRef == null || newRef === listRef.current) { + return; + } - mergeRefs(listRef, ref)(newRef); - prepareForMaintainVisibleContentPosition(); - setupMutationObserver(); - }, - [prepareForMaintainVisibleContentPosition, ref, setupMutationObserver], - ); + mergeRefs(listRef, ref)(newRef); + prepareForMaintainVisibleContentPosition(); + setupMutationObserver(); + }; useFlatListHandle({ ref, @@ -224,14 +215,11 @@ function MVCPFlatList({ }, []); const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted: restProps.inverted}); - const handleScroll = useCallback( - (e: NativeSyntheticEvent) => { - onScrollProp?.(e); - prepareForMaintainVisibleContentPosition(); - emitComposerScrollEvents(); - }, - [emitComposerScrollEvents, onScrollProp, prepareForMaintainVisibleContentPosition], - ); + const handleScroll = (e: NativeSyntheticEvent) => { + onScrollProp?.(e); + prepareForMaintainVisibleContentPosition(); + emitComposerScrollEvents(); + }; return ( { - if (typeof focusTrapSettings?.active !== 'undefined') { - return focusTrapSettings.active; - } - // Focus trap can't be active on sidebar screens because it would block access to the tab bar. - if (isSidebarScreenName(route.name)) { - return false; - } - - // in top tabs only focus trap for currently shown tab should be active - if (TOP_TAB_SCREENS.find((screen) => screen === route.name)) { - return isFocused; - } - - // Focus trap can't be active on these screens if the layout is wide because they may be displayed side by side. - if (WIDE_LAYOUT_INACTIVE_SCREENS.includes(route.name) && !shouldUseNarrowLayout) { - return false; - } - return isFocused; - }, [isFocused, shouldUseNarrowLayout, route.name, focusTrapSettings?.active]); + // Focus trap can't be active on sidebar screens because it would block access to the tab bar. + // in top tabs only focus trap for currently shown tab should be active + // Focus trap can't be active on these screens if the layout is wide because they may be displayed side by side. + let isActive; + if (typeof focusTrapSettings?.active !== 'undefined') { + isActive = focusTrapSettings.active; + } else { + isActive = + !isSidebarScreenName(route.name) && + (TOP_TAB_SCREENS.find((screen) => screen === route.name) ? isFocused : !(WIDE_LAYOUT_INACTIVE_SCREENS.includes(route.name) && !shouldUseNarrowLayout) && isFocused); + } return ( { - if (!isError || typeof message !== 'string') { - return ''; - } + let errorAnnouncementText; + if (!isError || typeof message !== 'string') { + errorAnnouncementText = ''; + } else { const trimmedMessage = message.trim(); if (!trimmedMessage) { - return ''; + errorAnnouncementText = ''; + } else { + errorAnnouncementText = shouldRenderMessageAsHTML ? Parser.htmlToText(trimmedMessage) : trimmedMessage; } - return shouldRenderMessageAsHTML ? Parser.htmlToText(trimmedMessage) : trimmedMessage; - }, [isError, message, shouldRenderMessageAsHTML]); + } const hasError = errorAnnouncementText.length > 0; @@ -102,21 +103,23 @@ function FormHelpMessage({ announcementKey: errorAnnouncementKey, }); - const HTMLMessage = useMemo(() => { - if (typeof message !== 'string' || !shouldRenderMessageAsHTML) { - return ''; - } - + let HTMLMessage; + if (typeof message !== 'string' || !shouldRenderMessageAsHTML) { + HTMLMessage = ''; + } else { // Messages that are already HTML (for example a saved category description hint) must not be parsed again, // or ExpensiMark re-wraps their mentions and the mention renders blank. - const replacedText = Parser.isHTML(message) ? message : Parser.replace(message, {shouldEscapeText: false}); - + const replacedText = Parser.isHTML(message) + ? message + : Parser.replace(message, { + shouldEscapeText: false, + }); if (isError) { - return `${replacedText}`; + HTMLMessage = `${replacedText}`; + } else { + HTMLMessage = `${replacedText}`; } - - return `${replacedText}`; - }, [isError, message, shouldRenderMessageAsHTML]); + } const errorIconLabel = isError && shouldShowRedDotIndicator ? CONST.ACCESSIBILITY_LABELS.ERROR : undefined; diff --git a/src/components/FullScreenBlockingViewContextProvider.tsx b/src/components/FullScreenBlockingViewContextProvider.tsx index fb98f8b3844a..f0a415008828 100644 --- a/src/components/FullScreenBlockingViewContextProvider.tsx +++ b/src/components/FullScreenBlockingViewContextProvider.tsx @@ -1,4 +1,4 @@ -import React, {createContext, useCallback, useContext, useMemo, useState} from 'react'; +import React, {createContext, useContext, useState} from 'react'; type FullScreenBlockingViewStateContextType = { isBlockingViewVisible: boolean; @@ -34,19 +34,19 @@ const FullScreenBlockingViewActionsContext = createContext>(new Set()); - const addRouteKey = useCallback((key: string) => { + const addRouteKey = (key: string) => { setRouteKeys((prevKeys) => new Set(prevKeys).add(key)); - }, []); + }; - const removeRouteKey = useCallback((key: string) => { + const removeRouteKey = (key: string) => { setRouteKeys((prevKeys) => { const newKeys = new Set(prevKeys); newKeys.delete(key); return newKeys; }); - }, []); + }; - const isBlockingViewVisible = useMemo(() => routeKeys.size > 0, [routeKeys.size]); + const isBlockingViewVisible = routeKeys.size > 0; // Because of the React Compiler we don't need to memoize it manually // eslint-disable-next-line react/jsx-no-constructed-context-values diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AccountManagerLinkRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/AccountManagerLinkRenderer.tsx index 1463550b8118..a6d0d4ff139c 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/AccountManagerLinkRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AccountManagerLinkRenderer.tsx @@ -14,7 +14,7 @@ import ROUTES from '@src/ROUTES'; import type {StyleProp, TextStyle} from 'react-native'; import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; -import React, {useCallback} from 'react'; +import React from 'react'; import {TNodeChildrenRenderer} from 'react-native-render-html'; type AccountManagerLinkRendererProps = CustomRendererProps; @@ -35,12 +35,12 @@ function AccountManagerLinkRenderer({tnode, style}: AccountManagerLinkRendererPr ]; } - const navigateToAccountManager = useCallback(() => { + const navigateToAccountManager = () => { if (!accountManagerReportID) { return; } Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(accountManagerReportID)); - }, [accountManagerReportID]); + }; const handleKeyDown = useEnterKeyHandler(navigateToAccountManager); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx index 6c66cd649a20..23ad03ecbc0d 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx @@ -20,7 +20,7 @@ import type {StyleProp, TextStyle} from 'react-native'; import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; import {Str} from 'expensify-common'; -import React, {useContext, useMemo} from 'react'; +import React, {useContext} from 'react'; import {TNodeChildrenRenderer} from 'react-native-render-html'; import TableLinkColumnContext from './TableLinkColumnContext'; @@ -62,13 +62,7 @@ function AnchorRenderer({tnode, style, key}: AnchorRendererProps) { // The `styles.link` used by the non-comment link path above already handles this, but AnchorForCommentsOnly does not use it. const highContrastUnderlineStyle = theme.isHighContrast ? styles.underline : {}; - const onLinkPress = useMemo(() => { - if (internalNewExpensifyPath || internalExpensifyPath) { - return () => openLink(attrHref, environmentURL, isAttachment); - } - - return undefined; - }, [internalNewExpensifyPath, internalExpensifyPath, attrHref, environmentURL, isAttachment]); + const onLinkPress = internalNewExpensifyPath || internalExpensifyPath ? () => openLink(attrHref, environmentURL, isAttachment) : undefined; // The table row already navigates to this link's destination, so the cell shows the link text as plain content // rather than a second target styled as a link. diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index fa0fefe5b04d..c5d1f84b66ba 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -21,7 +21,7 @@ import ROUTES from '@src/ROUTES'; import type {CustomRendererProps, TBlock} from 'react-native-render-html'; -import React, {memo} from 'react'; +import React from 'react'; function ImageRenderer({tnode}: CustomRendererProps) { const icons = useMemoizedLazyExpensifyIcons(['Document', 'GalleryNotFound']); @@ -147,4 +147,4 @@ function ImageRenderer({tnode}: CustomRendererProps) { ); } -export default memo(ImageRenderer, (prevProps, nextProps) => prevProps.tnode.attributes === nextProps.tnode.attributes); +export default ImageRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx index 10f274332392..d89cc180a128 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx @@ -19,7 +19,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {TextStyle} from 'react-native'; import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; -import React, {useContext, useMemo} from 'react'; +import React, {useContext} from 'react'; import {StyleSheet} from 'react-native'; import MentionReportContext from './MentionReportContext'; @@ -38,10 +38,7 @@ function MentionReportRenderer({style, tnode, TDefaultRenderer, ...defaultRender const [currentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${currentReportIDValue}`); // When we invite someone to a room they don't have the policy object, but we still want them to be able to see and click on report mentions, so we only check if the policyID in the report is from a workspace - const isGroupPolicyReport = useMemo( - () => (!!currentReport && !isEmptyObject(currentReport) && !!currentReport.policyID && currentReport.policyID !== CONST.POLICY.ID_FAKE) || !!policyID, - [currentReport, policyID], - ); + const isGroupPolicyReport = (!!currentReport && !isEmptyObject(currentReport) && !!currentReport.policyID && currentReport.policyID !== CONST.POLICY.ID_FAKE) || !!policyID; const mentionDetails = getReportMentionDetails(htmlAttributeReportID, currentReport, reports, tnode, policyID); if (!mentionDetails) { diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/TableRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/TableRenderer.tsx index 5561ca20fef2..fedd88079bfb 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/TableRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/TableRenderer.tsx @@ -2,7 +2,7 @@ import variables from '@styles/variables'; import type {CustomRendererProps, TBlock, TNode} from 'react-native-render-html'; -import {useContext, useMemo} from 'react'; +import {useContext} from 'react'; import {useContentWidth} from 'react-native-render-html'; import type {CellHorizontalAlignment} from './TableColumnAlignmentContext'; @@ -32,8 +32,8 @@ function getColumnAlignments(tableNode: TNode): CellHorizontalAlignment[] { } function TableRenderer({tnode}: CustomRendererProps) { - const columnAlignments = useMemo(() => getColumnAlignments(tnode), [tnode]); - const linkColumnIndex = useMemo(() => getLinkColumnIndex(tnode), [tnode]); + const columnAlignments = getColumnAlignments(tnode); + const linkColumnIndex = getLinkColumnIndex(tnode); // The comment-level width fills the message exactly; fall back to the HTML content width when the table is rendered // outside a comment. A concrete number is required because the scroller needs a fixed viewport and content width. diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPie.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPie.tsx index 84bf11d2cbff..e059d09f82e8 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPie.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPie.tsx @@ -21,7 +21,7 @@ import useTheme from '@hooks/useTheme'; import type {TNode} from 'react-native-render-html'; -import React, {useMemo} from 'react'; +import React from 'react'; import {HTMLContentModel, useAmbientTRenderEngine} from 'react-native-render-html'; import {Pie} from 'victory-native'; @@ -90,11 +90,10 @@ function VictoryChartPie({tnode}: VictoryChartPieProps) { sliceValues.push({label: dataLabel, value: polarEntry.value}); } - const resolvedLabelLayout = useMemo(() => { - if (!baseLabelItem || !effectiveLabelRadius) { - return {}; - } - + let resolvedLabelLayout; + if (!baseLabelItem || !effectiveLabelRadius) { + resolvedLabelLayout = {}; + } else { const slices = computeSliceAngles(sliceValues, START_ANGLE); const rowHeight = computeLabelBlockHeight(baseLabelItem, typefaces); const designHeight = typeof chartContentStyles.height === 'number' ? chartContentStyles.height : undefined; @@ -107,8 +106,14 @@ function VictoryChartPie({tnode}: VictoryChartPieProps) { const topFor = (columnTitleSafeTop: number) => designHeight ? Math.max(-Math.min(designHeight / 2, effectiveLabelRadius), columnTitleSafeTop + rowHeight / 2 - designHeight / 2) : -effectiveLabelRadius; const plotBounds = { - left: {top: topFor(scaledTitleSafeTop + scaledLeftColumnTopPadding), bottom}, - right: {top: topFor(scaledTitleSafeTop), bottom}, + left: { + top: topFor(scaledTitleSafeTop + scaledLeftColumnTopPadding), + bottom, + }, + right: { + top: topFor(scaledTitleSafeTop), + bottom, + }, }; const textRadius = computeTextRadiusBySide({ slices, @@ -119,9 +124,14 @@ function VictoryChartPie({tnode}: VictoryChartPieProps) { designWidth, edgePadding, }); - - return computePieLabelLayout({slices, rowHeight, labelRadius: effectiveLabelRadius, textRadius, plotBounds}); - }, [sliceValues, baseLabelItem, effectiveLabelRadius, typefaces, chartContentStyles.height, chartContentStyles.width, customLabelByDataLabel, pixelScale]); + resolvedLabelLayout = computePieLabelLayout({ + slices, + rowHeight, + labelRadius: effectiveLabelRadius, + textRadius, + plotBounds, + }); + } return ( { - const isColorfulBackground = (backgroundColor ?? theme.appBG) !== theme.appBG && (backgroundColor ?? theme.highlightBG) !== theme.highlightBG; - return { - titleColor: isColorfulBackground ? theme.textColorfulBackground : undefined, - iconFill: isColorfulBackground ? theme.iconColorfulBackground : undefined, - }; - }, [backgroundColor, theme.appBG, theme.highlightBG, theme.iconColorfulBackground, theme.textColorfulBackground]); + const isColorfulBackground = (backgroundColor ?? theme.appBG) !== theme.appBG && (backgroundColor ?? theme.highlightBG) !== theme.highlightBG; + const {titleColor, iconFill} = { + titleColor: isColorfulBackground ? theme.textColorfulBackground : undefined, + iconFill: isColorfulBackground ? theme.iconColorfulBackground : undefined, + }; return ( { - const stepCounterTranslation = stepCounter ? translate('stepCounter', stepCounter.step, stepCounter.total, stepCounter.text) : undefined; - if (shouldShowReportAvatarWithDisplay) { - return ( - - ); - } - + let middleContent; + const stepCounterTranslation = stepCounter ? translate('stepCounter', stepCounter.step, stepCounter.total, stepCounter.text) : undefined; + if (shouldShowReportAvatarWithDisplay) { + middleContent = ( + + ); + } else { const resolvedSubtitle = stepCounterTranslation ?? subtitle; - - return ( + middleContent = ( {subTitleLink}} ); - }, [ - StyleUtils, - subTitleLink, - shouldUseHeadlineHeader, - report, - shouldEnableDetailPageNavigation, - shouldShowReportAvatarWithDisplay, - stepCounter, - styles.textHeadlineH2, - subtitle, - title, - titleColor, - titleStyles, - numberOfTitleLines, - translate, - openParentReportInCurrentTab, - shouldDisplayStatus, - shouldSkipFocusAfterTransition, - ]); - const ThreeDotMenuButton = useMemo(() => { - if (shouldShowThreeDotsButton) { - return threeDotsMenuItems.length === 1 && shouldMinimizeMenuButton ? ( + } + let ThreeDotMenuButton; + if (shouldShowThreeDotsButton) { + ThreeDotMenuButton = + threeDotsMenuItems.length === 1 && shouldMinimizeMenuButton ? ( ); - } - return null; - }, [ - shouldShowThreeDotsButton, - threeDotsMenuItems, - shouldMinimizeMenuButton, - styles.touchableButtonImage, - theme.icon, - threeDotsMenuIcon, - threeDotsMenuIconFill, - shouldDisableThreeDotsButton, - onThreeDotsButtonPress, - shouldOverlayDots, - threeDotsAnchorAlignment, - shouldSetModalVisibility, - ]); + } else { + ThreeDotMenuButton = null; + } return ( { - setAttachmentLoaded(source as AttachmentSource, true); - if (!onLoad) { - return; - } - if (isLoadedRef.current === true) { - return; - } - - // We override `onLoad`, so both web and native have the same signature - const {width, height} = event.source; - isLoadedRef.current = true; - onLoad({nativeEvent: {width, height}}); - }, - [onLoad, setAttachmentLoaded, source], - ); + const imageLoadedSuccessfully = (event: ImageLoadEventData) => { + setAttachmentLoaded(source as AttachmentSource, true); + if (!onLoad) { + return; + } + if (isLoadedRef.current === true) { + return; + } + + // We override `onLoad`, so both web and native have the same signature + const {width, height} = event.source; + isLoadedRef.current = true; + onLoad({nativeEvent: {width, height}}); + }; return ( { - setAttachmentLoaded?.(source as AttachmentSource, true); - if (!onLoad) { - return; - } + const imageLoadedSuccessfully = (event: ImageLoadEventData) => { + setAttachmentLoaded?.(source as AttachmentSource, true); + if (!onLoad) { + return; + } - // We override `onLoad`, so both web and native have the same signature - const {width, height} = event.source; - onLoad({nativeEvent: {width, height}}); - }, - [onLoad, source, setAttachmentLoaded], - ); + // We override `onLoad`, so both web and native have the same signature + const {width, height} = event.source; + onLoad({nativeEvent: {width, height}}); + }; return ( (typeof url === 'string' ? {uri: url} : url), [url]); + const source = typeof url === 'string' ? {uri: url} : url; const onError = () => { Log.hmmm('Unable to fetch image to calculate size', {url}); @@ -111,4 +111,4 @@ function ImageWithSizeCalculation({ ImageWithSizeCalculation.displayName = 'ImageWithSizeCalculation'; -export default React.memo(ImageWithSizeCalculation); +export default ImageWithSizeCalculation; diff --git a/src/components/KeyboardAvoidingView/index.tsx b/src/components/KeyboardAvoidingView/index.tsx index 039084bfb91a..c2f4096f7903 100644 --- a/src/components/KeyboardAvoidingView/index.tsx +++ b/src/components/KeyboardAvoidingView/index.tsx @@ -1,6 +1,6 @@ import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; -import React, {useMemo} from 'react'; +import React from 'react'; import type {KeyboardAvoidingViewProps} from './types'; @@ -9,10 +9,7 @@ import BaseKeyboardAvoidingView from './BaseKeyboardAvoidingView'; function KeyboardAvoidingView({shouldOffsetBottomSafeAreaPadding = false, keyboardVerticalOffset: keyboardVerticalOffsetProp, ...restProps}: KeyboardAvoidingViewProps) { const {paddingBottom} = useSafeAreaPaddings(true); - const keyboardVerticalOffset = useMemo( - () => (keyboardVerticalOffsetProp ?? 0) + (shouldOffsetBottomSafeAreaPadding ? -paddingBottom : 0), - [keyboardVerticalOffsetProp, paddingBottom, shouldOffsetBottomSafeAreaPadding], - ); + const keyboardVerticalOffset = (keyboardVerticalOffsetProp ?? 0) + (shouldOffsetBottomSafeAreaPadding ? -paddingBottom : 0); return ( { - // TODO: CONCIERGE_LHN_GBR tooltip will be replaced by a tooltip in the #admins room - // https://github.com/Expensify/App/issues/57045#issuecomment-2701455668 - const tooltip = CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.CONCIERGE_LHN_GBR; - const shouldTooltipBeVisible = shouldUseNarrowLayout ? isScreenFocused && isReportsSplitNavigatorLast : isReportsSplitNavigatorLast && !isFullscreenVisible; - - return { - tooltipToRender: tooltip, - shouldShowTooltip: shouldTooltipBeVisible, - }; - }, [isScreenFocused, shouldUseNarrowLayout, isReportsSplitNavigatorLast, isFullscreenVisible]); + // TODO: CONCIERGE_LHN_GBR tooltip will be replaced by a tooltip in the #admins room + // https://github.com/Expensify/App/issues/57045#issuecomment-2701455668 + const tooltip = CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.CONCIERGE_LHN_GBR; + const shouldTooltipBeVisible = shouldUseNarrowLayout ? isScreenFocused && isReportsSplitNavigatorLast : isReportsSplitNavigatorLast && !isFullscreenVisible; + const {tooltipToRender, shouldShowTooltip} = { + tooltipToRender: tooltip, + shouldShowTooltip: shouldTooltipBeVisible, + }; return useProductTrainingContext(tooltipToRender, shouldShowTooltip); } diff --git a/src/components/LocalePicker.tsx b/src/components/LocalePicker.tsx index f8f82989805b..f21dc6238067 100644 --- a/src/components/LocalePicker.tsx +++ b/src/components/LocalePicker.tsx @@ -13,7 +13,7 @@ import {setLocale} from '@userActions/App'; import {LOCALE_TO_LANGUAGE_STRING, SORTED_LOCALES} from '@src/CONST/LOCALES'; import ONYXKEYS from '@src/ONYXKEYS'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import type {PickerSize} from './Picker/types'; @@ -34,15 +34,13 @@ function LocalePicker({size = 'normal'}: LocalePickerProps) { const [account] = useOnyx(ONYXKEYS.ACCOUNT); const icons = useMemoizedLazyExpensifyIcons(['Globe']); - const locales = useMemo(() => { - const sortedLocales = SORTED_LOCALES; - return sortedLocales.map((locale) => ({ - value: locale, - label: LOCALE_TO_LANGUAGE_STRING[locale], - keyForList: locale, - isSelected: preferredLocale === locale, - })); - }, [preferredLocale]); + const sortedLocales = SORTED_LOCALES; + const locales = sortedLocales.map((locale) => ({ + value: locale, + label: LOCALE_TO_LANGUAGE_STRING[locale], + keyForList: locale, + isSelected: preferredLocale === locale, + })); const shouldDisablePicker = AccountUtils.isValidateCodeFormSubmitting(account); diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx index 6157656c1374..38b53eb22a0d 100644 --- a/src/components/MapView/MapView.tsx +++ b/src/components/MapView/MapView.tsx @@ -23,7 +23,7 @@ import type {MapState} from '@rnmapbox/maps'; import {useFocusEffect} from '@react-navigation/native'; import Mapbox, {MarkerView} from '@rnmapbox/maps'; import {getForegroundPermissionsAsync} from 'expo-location'; -import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import {useSharedValue} from 'react-native-reanimated'; @@ -68,7 +68,7 @@ function MapView({ const cameraRef = useRef(null); const pendingFramingRef = useRef(null); const [isIdle, setIsIdle] = useState(false); - const initialLocation = useMemo(() => initialState && {longitude: initialState.location[0], latitude: initialState.location[1]}, [initialState]); + const initialLocation = initialState && {longitude: initialState.location[0], latitude: initialState.location[1]}; const currentPosition = userLocation ?? initialLocation; const currentPositionLongitude = currentPosition?.longitude; const currentPositionLatitude = currentPosition?.latitude; @@ -81,20 +81,14 @@ function MapView({ // location without bothering the user. It will return // false if user has already started dragging the map or // if there are one or more waypoints present. - const shouldPanMapToCurrentPosition = useCallback( - () => !userInteractedWithMap && shouldDisplayCurrentLocation && (!waypoints || waypoints.length === 0), - [userInteractedWithMap, waypoints, shouldDisplayCurrentLocation], - ); + const shouldPanMapToCurrentPosition = () => !userInteractedWithMap && shouldDisplayCurrentLocation && (!waypoints || waypoints.length === 0); - const setCurrentPositionToInitialState: GeolocationErrorCallback = useCallback( - (error) => { - if (error?.code !== GeolocationErrorCode.PERMISSION_DENIED || !initialLocation) { - return; - } - clearUserLocation(); - }, - [initialLocation], - ); + const setCurrentPositionToInitialState: GeolocationErrorCallback = (error) => { + if (error?.code !== GeolocationErrorCode.PERMISSION_DENIED || !initialLocation) { + return; + } + clearUserLocation(); + }; useFocusEffect( useCallback(() => { @@ -254,7 +248,7 @@ function MapView({ // The waypoints can disappear after the user centered on their location, so the button only offers them while they are still a target. const shouldShowWaypointsCenterIcon = isWaypointsNextCenterTarget && hasWaypointsTarget; - const centerMap = useCallback(() => { + const centerMap = () => { pendingFramingRef.current?.cancel(); const waypointCoordinates = waypoints?.map((waypoint) => waypoint.coordinate) ?? []; const shouldCenterOnWaypoints = hasWaypointsTarget && (isWaypointsNextCenterTarget || !canCenterOnCurrentPosition); @@ -287,73 +281,70 @@ function MapView({ animationMode: CONST.MAPBOX.CAMERA_ANIMATION_MODE, zoomLevel: CONST.MAPBOX.SINGLE_MARKER_ZOOM, }); - }, [ - allDirectionCoordinates, - canCenterOnCurrentPosition, - currentPositionLongitude, - currentPositionLatitude, - hasWaypointsTarget, - isWaypointsNextCenterTarget, - mapPadding, - singleWaypointCoordinate, - waypoints, - ]); + }; - const centerCoordinate = useMemo(() => (currentPosition ? [currentPosition.longitude, currentPosition.latitude] : initialState?.location), [currentPosition, initialState?.location]); + const centerCoordinate = currentPosition ? [currentPosition.longitude, currentPosition.latitude] : initialState?.location; - const waypointsBounds = useMemo(() => { - if (!waypoints) { - return undefined; - } + let waypointsBounds; + if (!waypoints) { + waypointsBounds = undefined; + } else { const {northEast, southWest} = utils.getBounds( waypoints.map((waypoint) => waypoint.coordinate), allDirectionCoordinates, ); - return {ne: northEast, sw: southWest}; - }, [waypoints, allDirectionCoordinates]); + waypointsBounds = { + ne: northEast, + sw: southWest, + }; + } const hasWaypoints = !!waypoints?.length; - const defaultSettings: Mapbox.CameraStop | undefined = useMemo(() => { - if (interactive) { - // Start framed on the waypoints the same way the focus effect frames them, so the map does not fly in from the current location - if (waypoints?.length === 1) { - return { - zoomLevel: CONST.MAPBOX.SINGLE_MARKER_ZOOM, - centerCoordinate: waypoints.at(0)?.coordinate, - }; - } - if (waypoints?.length && waypointsBounds) { - return { - bounds: waypointsBounds, - padding: utils.getCameraPadding(mapPadding), - }; - } - if (!centerCoordinate) { - return undefined; - } - return { - zoomLevel: initialState?.zoom, - centerCoordinate, + let defaultSettings: Mapbox.CameraStop | undefined; + if (interactive) { + // Start framed on the waypoints the same way the focus effect frames them, so the map does not fly in from the current location + if (waypoints?.length === 1) { + defaultSettings = { + zoomLevel: CONST.MAPBOX.SINGLE_MARKER_ZOOM, + centerCoordinate: waypoints.at(0)?.coordinate, }; + } else if (waypoints?.length && waypointsBounds) { + defaultSettings = { + bounds: waypointsBounds, + padding: utils.getCameraPadding(mapPadding), + }; + } else { + defaultSettings = !centerCoordinate + ? undefined + : { + zoomLevel: initialState?.zoom, + centerCoordinate, + }; } - if (!waypointsBounds) { - return undefined; - } - return { - bounds: waypointsBounds, - }; - }, [interactive, waypoints, waypointsBounds, mapPadding, centerCoordinate, initialState?.zoom]); + } else { + defaultSettings = !waypointsBounds + ? undefined + : { + bounds: waypointsBounds, + }; + } // The controlled centerCoordinate is applied after defaultSettings, so skip it while waypoints are shown or it would pull the camera back to the current location - const initCenterCoordinate = useMemo(() => (interactive && !hasWaypoints ? centerCoordinate : undefined), [interactive, hasWaypoints, centerCoordinate]); - const initBounds = useMemo(() => (interactive ? undefined : waypointsBounds), [interactive, waypointsBounds]); + const initCenterCoordinate = interactive && !hasWaypoints ? centerCoordinate : undefined; + const initBounds = interactive ? undefined : waypointsBounds; // Localize the map labels to the user's preferred app locale so they match the rest of the app. - const localizeLabels = useMemo<{locale: string} | undefined>(() => { - const language = utils.getNativeMapboxLanguage(preferredLocale); - return language ? {locale: language} : undefined; - }, [preferredLocale]); + const language = utils.getNativeMapboxLanguage(preferredLocale); + const localizeLabels: + | { + locale: string; + } + | undefined = language + ? { + locale: language, + } + : undefined; return !isOffline && isAccessTokenReady && !!defaultSettings ? ( @@ -462,4 +453,4 @@ function MapView({ ); } -export default memo(MapView); +export default MapView; diff --git a/src/components/MapView/MapViewImpl.web.tsx b/src/components/MapView/MapViewImpl.web.tsx index 30e349c05c06..5ba7ea23d62d 100644 --- a/src/components/MapView/MapViewImpl.web.tsx +++ b/src/components/MapView/MapViewImpl.web.tsx @@ -21,14 +21,14 @@ import useNetwork from '@src/hooks/useNetwork'; import getCurrentPosition from '@src/libs/getCurrentPosition'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {MapMouseEvent, MapRef, ViewState, ViewStateChangeEvent} from 'react-map-gl/mapbox'; +import type {MapMouseEvent, MapProps, MapRef, ViewState, ViewStateChangeEvent} from 'react-map-gl/mapbox'; // Explanation: Different Mapbox libraries are required for web and native mobile platforms. // This is why we have separate components for web and native to handle the specific implementations. // For the web version, we use the Mapbox Web library called react-map-gl, while for the native mobile version, // we utilize a different Mapbox library @rnmapbox/maps tailored for mobile development. import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import Map, {Marker} from 'react-map-gl/mapbox'; import {View} from 'react-native'; @@ -73,7 +73,7 @@ function MapViewImpl({ const expensifyIcons = useMemoizedLazyExpensifyIcons(['Crosshair', 'Expand', 'MapCurrentLocation']); const [mapRef, setMapRef] = useState(null); - const initialLocation = useMemo(() => ({longitude: initialState.location[0], latitude: initialState.location[1]}), [initialState]); + const initialLocation = {longitude: initialState.location[0], latitude: initialState.location[1]}; const currentPosition = userLocation ?? initialLocation; const prevUserPosition = usePrevious(currentPosition); const [userInteractedWithMap, setUserInteractedWithMap] = useState(false); @@ -85,7 +85,7 @@ function MapViewImpl({ const [hasValidContainerSize, setHasValidContainerSize] = useState(false); const containerRef = useRef(null); const resetBoundariesRef = useRef<() => void>(() => {}); - const setRef = useCallback((newRef: MapRef | null) => setMapRef(newRef), []); + const setRef = (newRef: MapRef | null) => setMapRef(newRef); const shouldInitializeCurrentPosition = useRef(true); const [lastViewState, setLastViewState] = useState(); @@ -93,20 +93,14 @@ function MapViewImpl({ // location without bothering the user. It will return // false if user has already started dragging the map or // if there are one or more waypoints present. - const shouldPanMapToCurrentPosition = useCallback( - () => !userInteractedWithMap && shouldDisplayCurrentLocation && (!waypoints || waypoints.length === 0), - [userInteractedWithMap, waypoints, shouldDisplayCurrentLocation], - ); + const shouldPanMapToCurrentPosition = () => !userInteractedWithMap && shouldDisplayCurrentLocation && (!waypoints || waypoints.length === 0); - const setCurrentPositionToInitialState: GeolocationErrorCallback = useCallback( - (error) => { - if (error?.code !== GeolocationErrorCode.PERMISSION_DENIED || !initialLocation) { - return; - } - clearUserLocation(); - }, - [initialLocation], - ); + const setCurrentPositionToInitialState: GeolocationErrorCallback = (error) => { + if (error?.code !== GeolocationErrorCode.PERMISSION_DENIED || !initialLocation) { + return; + } + clearUserLocation(); + }; useFocusEffect( useCallback(() => { @@ -152,7 +146,7 @@ function MapViewImpl({ }); }, [currentPosition, mapRef, prevUserPosition.longitude, prevUserPosition.latitude, shouldPanMapToCurrentPosition]); - const resetBoundaries = useCallback(() => { + const resetBoundaries = () => { if (!waypoints || waypoints.length === 0) { return; } @@ -188,7 +182,7 @@ function MapViewImpl({ allDirectionCoordinates, ); map.fitBounds([northEast, southWest], {padding: mapPadding, speed: CONST.MAPBOX.FLY_TO_SPEED}); - }, [waypoints, mapRef, mapPadding, allDirectionCoordinates]); + }; // The ResizeObserver outlives renders, so it reads the latest reset logic through a ref. useEffect(() => { @@ -277,7 +271,7 @@ function MapViewImpl({ // The waypoints can disappear after the user centered on their location, so the button only offers them while they are still a target. const shouldShowWaypointsCenterIcon = isWaypointsNextCenterTarget && hasWaypointsTarget; - const centerMap = useCallback(() => { + const centerMap = () => { if (!mapRef) { return; } @@ -307,90 +301,78 @@ function MapViewImpl({ bearing: 0, speed: CONST.MAPBOX.FLY_TO_SPEED, }); - }, [ - allDirectionCoordinates, - canCenterOnCurrentPosition, - currentPosition?.longitude, - currentPosition?.latitude, - hasWaypointsTarget, - isWaypointsNextCenterTarget, - mapRef, - mapPadding, - singleWaypointCoordinate, - waypoints, - ]); + }; const saveViewState = (event: ViewStateChangeEvent) => { setLastViewState(event.viewState); }; - const initialViewState: Partial | undefined = useMemo(() => { - if (!interactive) { - // With no markers and no route geometry, getBounds returns infinite bounds that crash the map on init. - // Fall back to a plain centered view state until route data arrives. - if (!waypoints?.length && !allDirectionCoordinates.length) { - return { - longitude: currentPosition?.longitude, - latitude: currentPosition?.latitude, - zoom: initialState.zoom, - }; - } + let initialViewState: MapProps['initialViewState']; + if (!interactive) { + // With no markers and no route geometry, getBounds returns infinite bounds that crash the map on init. + // Fall back to a plain centered view state until route data arrives. + if (!waypoints?.length && !allDirectionCoordinates.length) { + initialViewState = { + longitude: currentPosition?.longitude, + latitude: currentPosition?.latitude, + zoom: initialState.zoom, + }; + } else { const {northEast, southWest} = utils.getBounds(waypoints?.map((waypoint) => waypoint.coordinate) ?? [], allDirectionCoordinates); - return { + initialViewState = { zoom: initialState.zoom, bounds: [northEast, southWest], - fitBoundsOptions: {padding: mapPadding}, + fitBoundsOptions: { + padding: mapPadding, + }, }; } - if (lastViewState && waypoints?.length) { - return lastViewState; - } + } else if (lastViewState && waypoints?.length) { + initialViewState = lastViewState; + } else { // Start framed on the waypoints the same way resetBoundaries frames them, so the map does not fly in from the current location const firstWaypointCoordinate = waypoints?.length === 1 ? waypoints.at(0)?.coordinate : undefined; if (firstWaypointCoordinate) { - return { + initialViewState = { longitude: firstWaypointCoordinate[0], latitude: firstWaypointCoordinate[1], zoom: CONST.MAPBOX.SINGLE_MARKER_ZOOM, }; - } - if (waypoints?.length) { + } else if (waypoints?.length) { const {northEast, southWest} = utils.getBounds( waypoints.map((waypoint) => waypoint.coordinate), allDirectionCoordinates, ); - return { + initialViewState = { bounds: [northEast, southWest], fitBoundsOptions: {padding: mapPadding}, }; + } else { + initialViewState = { + longitude: currentPosition?.longitude, + latitude: currentPosition?.latitude, + zoom: initialState.zoom, + }; } - return { - longitude: currentPosition?.longitude, - latitude: currentPosition?.latitude, - zoom: initialState.zoom, - }; - }, [waypoints, allDirectionCoordinates, interactive, currentPosition?.longitude, currentPosition?.latitude, initialState.zoom, mapPadding, lastViewState]); + } // The route layers only need to be interactive when there is an alternate route to pick, so that clicking a route selects it. - const interactiveLayerIds = useMemo(() => (interactive && hasAlternateDirection ? ALTERNATE_DIRECTIONS_LAYER_IDS : undefined), [interactive, hasAlternateDirection]); + const interactiveLayerIds = interactive && hasAlternateDirection ? ALTERNATE_DIRECTIONS_LAYER_IDS : undefined; - const onDrag = useCallback(() => { + const onDrag = () => { setUserInteractedWithMap(true); // Dragging must keep the grabbing cursor even when it starts on top of a route. setIsHoveringDirection(false); setIsWaypointsNextCenterTarget(false); - }, []); + }; - const selectClickedDirection = useCallback( - (event: MapMouseEvent) => { - const isAlternate: unknown = event.features?.at(0)?.properties?.isAlternate; - if (typeof isAlternate !== 'boolean') { - return; - } - setIsAlternateDirectionSelected?.(isAlternate); - }, - [setIsAlternateDirectionSelected], - ); + const selectClickedDirection = (event: MapMouseEvent) => { + const isAlternate: unknown = event.features?.at(0)?.properties?.isAlternate; + if (typeof isAlternate !== 'boolean') { + return; + } + setIsAlternateDirectionSelected?.(isAlternate); + }; return !isOffline && !!accessToken && !!initialViewState ? ( { + const toggleDistanceUnit = () => { userToggledUnit.current = true; setDistanceUnit((currentUnit) => currentUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS ? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES : CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, ); - }, []); + }; return {distanceUnit, toggleDistanceUnit}; } diff --git a/src/components/MenuItem/MenuItem.tsx b/src/components/MenuItem/MenuItem.tsx index 80ee4da53299..1e7a06b837c7 100644 --- a/src/components/MenuItem/MenuItem.tsx +++ b/src/components/MenuItem/MenuItem.tsx @@ -65,7 +65,7 @@ import type {AccessibilityState, GestureResponderEvent, ImageStyle, Role, StyleP import type {AnimatedStyle} from 'react-native-reanimated'; import type {ValueOf} from 'type-fest'; -import React, {useMemo, useRef} from 'react'; +import React, {useRef} from 'react'; import {View} from 'react-native'; import useRemoveNonInteractiveClickHandler from './hooks/useRemoveNonInteractiveClickHandler'; @@ -642,8 +642,12 @@ function MenuItem({ const shouldDimIconRight = iconRight === icons.ArrowRight || !iconRight; const hasIcon = (!!icon || iconType === CONST.ICON_TYPE_WORKSPACE) && !Array.isArray(icon); - // eslint-disable-next-line no-nested-ternary -- Selects ml2/ml3/empty based on icon presence and avatar size - const iconLeftPadding = shouldPutLeftPaddingWhenNoIcon || hasIcon ? (avatarSize === CONST.AVATAR_SIZE.SMALL ? styles.ml2 : styles.ml3) : {}; + let iconLeftPadding; + if (shouldPutLeftPaddingWhenNoIcon || hasIcon) { + iconLeftPadding = avatarSize === CONST.AVATAR_SIZE.SMALL ? styles.ml2 : styles.ml3; + } else { + iconLeftPadding = {}; + } const combinedTitleTextStyle = StyleUtils.combineStyles( [ @@ -699,50 +703,46 @@ function MenuItem({ ); }; - const html = useMemo(() => { - if (!title || !shouldParseTitle) { - return ''; - } - return Parser.replace(title, {shouldEscapeText, disabledRules: excludedMarkdownRules}); - }, [title, shouldParseTitle, shouldEscapeText, excludedMarkdownRules]); - - const helperHtml = useMemo(() => { - if (!helperText || !shouldParseHelperText) { - return ''; - } - return Parser.replace(helperText, {shouldEscapeText}); - }, [helperText, shouldParseHelperText, shouldEscapeText]); + const html = + !title || !shouldParseTitle + ? '' + : Parser.replace(title, { + shouldEscapeText, + disabledRules: excludedMarkdownRules, + }); + + const helperHtml = + !helperText || !shouldParseHelperText + ? '' + : Parser.replace(helperText, { + shouldEscapeText, + }); const shouldRenderTitleAsHTML = shouldRenderAsHTML && !!title && Parser.isHTML(title); const shouldEnableTextSelection = isTitleSelectable && getPlatform() === CONST.PLATFORM.WEB; - const processedTitle = useMemo(() => { - let titleToWrap = ''; - if (shouldRenderAsHTML) { - titleToWrap = title ?? ''; - } - - if (shouldParseTitle) { - titleToWrap = html; - } - - if (shouldTruncateTitle) { - titleToWrap = Parser.truncateHTML(`${titleToWrap}`, characterLimit, {ellipsis: '...'}); - return titleToWrap; - } - - return titleToWrap ? `${titleToWrap}` : ''; - }, [title, shouldRenderAsHTML, shouldParseTitle, characterLimit, shouldTruncateTitle, html]); - - const processedHelperText = useMemo(() => { - let textToWrap = ''; - - if (shouldParseHelperText) { - textToWrap = helperHtml; - } + let processedTitle; + let titleToWrap = ''; + if (shouldRenderAsHTML) { + titleToWrap = title ?? ''; + } + if (shouldParseTitle) { + titleToWrap = html; + } + if (shouldTruncateTitle) { + titleToWrap = Parser.truncateHTML(`${titleToWrap}`, characterLimit, { + ellipsis: '...', + }); + processedTitle = titleToWrap; + } else { + processedTitle = titleToWrap ? `${titleToWrap}` : ''; + } - return textToWrap ? `${textToWrap}` : ''; - }, [shouldParseHelperText, helperHtml]); + let textToWrap = ''; + if (shouldParseHelperText) { + textToWrap = helperHtml; + } + const processedHelperText = textToWrap ? `${textToWrap}` : ''; const hasPressableRightComponent = (iconRight ?? icons.ArrowRight) || (shouldShowRightComponent && rightComponent); diff --git a/src/components/MigratedUserWelcomeModal.tsx b/src/components/MigratedUserWelcomeModal.tsx index 2186265546da..5ad51e5d06b1 100644 --- a/src/components/MigratedUserWelcomeModal.tsx +++ b/src/components/MigratedUserWelcomeModal.tsx @@ -19,7 +19,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import type {FeatureListItem} from './FeatureList'; @@ -39,23 +39,20 @@ function MigratedUserWelcomeModal() { const illustrations = useMemoizedLazyIllustrations(['ChatBubbles', 'ConciergeBot', 'PlanetWithMobileApp', 'MagnifyingGlassReceipt']); const isCurrentUserPolicyAdmin = useIsPaidPolicyAdmin(); - const ExpensifyFeatures = useMemo( - () => [ - { - icon: illustrations.MagnifyingGlassReceipt, - translationKey: 'migratedUserWelcomeModal.features.search', - }, - { - icon: illustrations.ConciergeBot, - translationKey: 'migratedUserWelcomeModal.features.concierge', - }, - { - icon: illustrations.ChatBubbles, - translationKey: 'migratedUserWelcomeModal.features.chat', - }, - ], - [illustrations.ChatBubbles, illustrations.ConciergeBot, illustrations.MagnifyingGlassReceipt], - ); + const ExpensifyFeatures: FeatureListItem[] = [ + { + icon: illustrations.MagnifyingGlassReceipt, + translationKey: 'migratedUserWelcomeModal.features.search', + }, + { + icon: illustrations.ConciergeBot, + translationKey: 'migratedUserWelcomeModal.features.concierge', + }, + { + icon: illustrations.ChatBubbles, + translationKey: 'migratedUserWelcomeModal.features.chat', + }, + ]; const persistDismissal = () => { Log.hmmm('[MigratedUserWelcomeModal] dismissing product training'); diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 4a8f82b2e46f..8df5cd8cd1de 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -30,7 +30,7 @@ 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'; +import React, {useContext, useEffect, useRef, useState} from 'react'; // Animated required for side panel navigation // eslint-disable-next-line no-restricted-imports import {Animated, View} from 'react-native'; @@ -137,27 +137,24 @@ function BaseModal({ * Hides modal * @param callHideCallback - Should we call the onModalHide callback */ - const hideModal = useCallback( - (callHideCallback = true) => { - shouldCallHideModalOnUnmount.current = false; - willAlertModalBecomeVisible(false); - if (areAllModalsHidden()) { - if (shouldSetModalVisibility && !Navigation.isTopmostRouteModalScreen()) { - setModalVisibility(false); - } + const hideModal = (callHideCallback = true) => { + shouldCallHideModalOnUnmount.current = false; + willAlertModalBecomeVisible(false); + if (areAllModalsHidden()) { + if (shouldSetModalVisibility && !Navigation.isTopmostRouteModalScreen()) { + setModalVisibility(false); } - if (callHideCallback) { - onModalHide(); - } - onModalDidClose(); - ComposerFocusManager.refocusAfterModalFullyClosed(uniqueModalId, restoreFocusType); - }, - [shouldSetModalVisibility, onModalHide, restoreFocusType, uniqueModalId], - ); + } + if (callHideCallback) { + onModalHide(); + } + onModalDidClose(); + ComposerFocusManager.refocusAfterModalFullyClosed(uniqueModalId, restoreFocusType); + }; - const handleDismissModal = useCallback(() => { + const handleDismissModal = () => { ComposerFocusManager.setReadyToFocus(uniqueModalId); - }, [uniqueModalId]); + }; useEffect(() => { let removeOnCloseListener: () => void; @@ -206,12 +203,12 @@ function BaseModal({ [], ); - const handleShowModal = useCallback(() => { + const handleShowModal = () => { if (shouldSetModalVisibility) { setModalVisibility(true, type); } onModalShow(); - }, [onModalShow, shouldSetModalVisibility, type]); + }; const handleBackdropPress = (e?: GestureResponderEvent | KeyboardEvent) => { if (e && 'key' in e && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) { diff --git a/src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx b/src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx index fff51efe87fc..eac68a69e7dd 100644 --- a/src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx +++ b/src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import type {ViewStyle} from 'react-native'; import type {CSSStyle} from 'react-native-reanimated'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import Animated, {Keyframe, ReduceMotion} from 'react-native-reanimated'; @@ -28,13 +28,13 @@ function Backdrop({ const styles = useThemeStyles(); const {translate} = useLocalize(); - const Exiting = useMemo(() => { - if (!backdropOpacity) { - return; - } + let Exiting; + if (!backdropOpacity) { + Exiting = undefined; + } else { const FadeOut = new Keyframe(getModalOutAnimation('fadeOut', backdropOpacity)); - return FadeOut.duration(animationOutTiming).reduceMotion(ReduceMotion.Never); - }, [animationOutTiming, backdropOpacity]); + Exiting = FadeOut.duration(animationOutTiming).reduceMotion(ReduceMotion.Never); + } // A CSS animation instead of an entering keyframe: on web, Reanimated hides an entering element until its keyframe starts and can // delete the keyframe before the next animation frame (for example in a background tab), which leaves the backdrop hidden for good. diff --git a/src/components/Modal/ReanimatedModal/Container/index.tsx b/src/components/Modal/ReanimatedModal/Container/index.tsx index 4d0094954ba4..2e58d41f6b20 100644 --- a/src/components/Modal/ReanimatedModal/Container/index.tsx +++ b/src/components/Modal/ReanimatedModal/Container/index.tsx @@ -6,7 +6,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import Animated, {Keyframe} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; @@ -29,25 +29,19 @@ function Container({ }: Partial & ContainerProps) { const styles = useThemeStyles(); - const Entering = useMemo(() => { - const AnimationIn = new Keyframe(getModalInAnimation(animationIn)); + const AnimationIn = new Keyframe(getModalInAnimation(animationIn)); + const Entering = AnimationIn.duration(animationInTiming).withCallback(() => { + 'worklet'; - return AnimationIn.duration(animationInTiming).withCallback(() => { - 'worklet'; + scheduleOnRN(onOpenCallBack); + }); - scheduleOnRN(onOpenCallBack); - }); - }, [animationIn, animationInTiming, onOpenCallBack]); + const AnimationOut = new Keyframe(getModalOutAnimation(animationOut)); + const Exiting = AnimationOut.duration(animationOutTiming).withCallback(() => { + 'worklet'; - const Exiting = useMemo(() => { - const AnimationOut = new Keyframe(getModalOutAnimation(animationOut)); - - return AnimationOut.duration(animationOutTiming).withCallback(() => { - 'worklet'; - - scheduleOnRN(onCloseCallBack); - }); - }, [animationOutTiming, onCloseCallBack, animationOut]); + scheduleOnRN(onCloseCallBack); + }); return ( { + const onBackButtonPressHandler = () => { if (shouldIgnoreBackHandlerDuringTransition && isTransitioning) { return false; } @@ -82,17 +82,14 @@ function ReanimatedModal({ return true; } return false; - }, [isVisibleState, onBackButtonPress, isTransitioning, shouldIgnoreBackHandlerDuringTransition]); + }; - const handleEscape = useCallback( - (e: KeyboardEvent) => { - if (e.key !== 'Escape' || onBackButtonPressHandler() !== true) { - return; - } - e.stopImmediatePropagation(); - }, - [onBackButtonPressHandler], - ); + const handleEscape = (e: KeyboardEvent) => { + if (e.key !== 'Escape' || onBackButtonPressHandler() !== true) { + return; + } + e.stopImmediatePropagation(); + }; useEffect(() => { if (getPlatform() === CONST.PLATFORM.WEB) { @@ -148,11 +145,9 @@ function ReanimatedModal({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isVisible, isContainerOpen, isTransitioning]); - const backdropStyle: ViewStyle = useMemo(() => { - return {width: windowWidth, height: windowHeight, backgroundColor: backdropColor}; - }, [windowWidth, windowHeight, backdropColor]); + const backdropStyle: ViewStyle = {width: windowWidth, height: windowHeight, backgroundColor: backdropColor}; - const onOpenCallBack = useCallback(() => { + const onOpenCallBack = () => { setIsTransitioning(false); setIsContainerOpen(true); if (transitionHandleRef.current) { @@ -160,9 +155,9 @@ function ReanimatedModal({ transitionHandleRef.current = null; } onModalShow(); - }, [onModalShow]); + }; - const onCloseCallBack = useCallback(() => { + const onCloseCallBack = () => { setIsTransitioning(false); setIsContainerOpen(false); if (transitionHandleRef.current) { @@ -176,11 +171,9 @@ function ReanimatedModal({ if (getPlatform() === CONST.PLATFORM.ANDROID) { onModalHide(); } - }, [onModalHide]); + }; - const modalStyle = useMemo(() => { - return {zIndex: StyleSheet.flatten(style)?.zIndex}; - }, [style]); + const modalStyle = {zIndex: StyleSheet.flatten(style)?.zIndex}; const containerView = ( ) => { - const parentReportAction = threadParentReportActionID ? parentReportActions?.[threadParentReportActionID] : undefined; - return isMoneyRequestAction(parentReportAction) ? getOriginalMessage(parentReportAction)?.IOUTransactionID : undefined; - }, - [threadParentReportActionID], - ); + const threadTransactionIDSelector = (parentReportActions: OnyxEntry) => { + const parentReportAction = threadParentReportActionID ? parentReportActions?.[threadParentReportActionID] : undefined; + return isMoneyRequestAction(parentReportAction) ? getOriginalMessage(parentReportAction)?.IOUTransactionID : undefined; + }; const [threadTransactionID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(moneyRequestReport?.parentReportID)}`, {selector: threadTransactionIDSelector}); const anchorTransactionIDFromRoute = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT ? route.params.anchorTransactionID : undefined; diff --git a/src/components/MoneyReportHeaderKYCDropdown.tsx b/src/components/MoneyReportHeaderKYCDropdown.tsx index 697cd19a137c..a0b4189dc700 100644 --- a/src/components/MoneyReportHeaderKYCDropdown.tsx +++ b/src/components/MoneyReportHeaderKYCDropdown.tsx @@ -14,7 +14,7 @@ import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type {ValueOf} from 'type-fest'; -import React, {memo} from 'react'; +import React from 'react'; import type {ButtonWithDropdownMenuRef, DropdownOption} from './ButtonWithDropdownMenu/types'; import type {KYCWallProps} from './KYCWall/types'; @@ -130,4 +130,4 @@ function MoneyReportHeaderKYCDropdown({ ); } -export default memo(MoneyReportHeaderKYCDropdown); +export default MoneyReportHeaderKYCDropdown; diff --git a/src/components/MoneyReportHeaderStatusBar.tsx b/src/components/MoneyReportHeaderStatusBar.tsx index 652e6b035b9b..e4fe15a1e42d 100644 --- a/src/components/MoneyReportHeaderStatusBar.tsx +++ b/src/components/MoneyReportHeaderStatusBar.tsx @@ -14,7 +14,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; import type {ValueOf} from 'type-fest'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import Icon from './Icon'; @@ -32,25 +32,16 @@ function MoneyReportHeaderStatusBar({nextStep}: MoneyReportHeaderStatusBarProps) const theme = useTheme(); const {translate, formatPhoneNumber, dateFnsLocale} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Hourglass', 'Checkmark', 'Stopwatch', 'Box']); - const iconMap: IconMap = useMemo( - () => ({ - [CONST.NEXT_STEP.ICONS.HOURGLASS]: icons.Hourglass, - [CONST.NEXT_STEP.ICONS.CHECKMARK]: icons.Checkmark, - [CONST.NEXT_STEP.ICONS.STOPWATCH]: icons.Stopwatch, - [CONST.NEXT_STEP.ICONS.BOX]: icons.Box, - }), - [icons], - ); + const iconMap: IconMap = { + [CONST.NEXT_STEP.ICONS.HOURGLASS]: icons.Hourglass, + [CONST.NEXT_STEP.ICONS.CHECKMARK]: icons.Checkmark, + [CONST.NEXT_STEP.ICONS.STOPWATCH]: icons.Stopwatch, + [CONST.NEXT_STEP.ICONS.BOX]: icons.Box, + }; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; - const messageContent = useMemo(() => { - if (!nextStep) { - return ''; - } - - return buildNextStepMessage(nextStep, translate, dateFnsLocale, currentUserAccountID, formatPhoneNumber); - }, [nextStep, translate, dateFnsLocale, currentUserAccountID, formatPhoneNumber]); + const messageContent = !nextStep ? '' : buildNextStepMessage(nextStep, translate, dateFnsLocale, currentUserAccountID, formatPhoneNumber); return ( diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationListData.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationListData.ts index 14d4aaf327cb..a4b1e14e53e6 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationListData.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationListData.ts @@ -18,7 +18,7 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import {useIsFocused} from '@react-navigation/native'; -import {useCallback, useEffect, useRef, useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; import type {ConfirmationDistanceState, ConfirmationListData, UseConfirmationListDataParams} from './types'; @@ -100,9 +100,9 @@ function useConfirmationListDataWithPolicy({ // In the new manual expense flow the inline fields live in the list footer, so they can be hidden behind the keyboard. // We let those fields ask the list to scroll them into view when focused. - const scrollFocusedInputIntoView = useCallback((input: MeasurableInput) => { + const scrollFocusedInputIntoView = (input: MeasurableInput) => { listRef.current?.scrollInputIntoView(input); - }, []); + }; const iouAmount = hasValidModifiedAmount(transaction) ? Number(transaction?.modifiedAmount) : (transaction?.amount ?? 0); const iouCurrencyCode = getCurrency(transaction); diff --git a/src/components/MoneyRequestConfirmationList/hooks/useParticipantSection.ts b/src/components/MoneyRequestConfirmationList/hooks/useParticipantSection.ts index 181e38f40d4c..e595cbbc08d5 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useParticipantSection.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useParticipantSection.ts @@ -4,7 +4,7 @@ import usePreferredPolicy from '@hooks/usePreferredPolicy'; import CONST from '@src/CONST'; -import {useCallback, useEffect, useMemo} from 'react'; +import {useEffect} from 'react'; import type {UseParticipantSectionParams} from './types'; @@ -43,12 +43,12 @@ function useParticipantSection({ const selectedParticipants = selectedParticipantsProp.filter((participant) => participant.selected); const payeePersonalDetails = payeePersonalDetailsProp ?? currentUserPersonalDetails; - const participantRowErrors = useMemo(() => { - if (formError !== 'iou.error.noParticipantSelected' && formError !== 'violations.missingAttendees') { - return undefined; - } - return {participants: translate(formError)}; - }, [formError, translate]); + const participantRowErrors = + formError !== 'iou.error.noParticipantSelected' && formError !== 'violations.missingAttendees' + ? undefined + : { + participants: translate(formError), + }; useEffect(() => { if (selectedParticipants.length === 0) { @@ -57,9 +57,9 @@ function useParticipantSection({ clearFormErrors(['iou.error.noParticipantSelected']); }, [selectedParticipants.length, clearFormErrors]); - const dismissParticipantRowError = useCallback(() => { + const dismissParticipantRowError = () => { clearFormErrors(['iou.error.noParticipantSelected', 'violations.missingAttendees']); - }, [clearFormErrors]); + }; const {splitParticipants, getSplitSectionHeader} = useSplitParticipants({ isTypeSplit, diff --git a/src/components/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx index 9343c5caf75f..087a39dee2d0 100644 --- a/src/components/MoneyRequestHeaderSecondaryActions.tsx +++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx @@ -94,7 +94,7 @@ import {hasSeenTourSelector} from '@selectors/Onboarding'; import {loginSelector} from '@selectors/PersonalDetails'; import {billingRestrictionPolicySelector, createFilteredPoliciesInfoSelector, createHasWorkspaceToSubmitToSelector} from '@selectors/Policy'; import {validTransactionDraftsSelector} from '@selectors/TransactionDraft'; -import React, {useMemo, useRef} from 'react'; +import React, {useRef} from 'react'; import type {ButtonWithDropdownMenuRef, DropdownOption} from './ButtonWithDropdownMenu/types'; @@ -195,10 +195,10 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money const activePolicy = useActivePolicy(); const lastWorkspaceNumber = useLastWorkspaceNumber(); const {isRestrictedToPreferredPolicy, preferredPolicyID} = usePreferredPolicy(); - const filteredPoliciesInfoSelector = useMemo(() => createFilteredPoliciesInfoSelector(currentUserEmail), [currentUserEmail]); + const filteredPoliciesInfoSelector = createFilteredPoliciesInfoSelector(currentUserEmail); const [filteredPoliciesInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: filteredPoliciesInfoSelector}); const [preferredPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(preferredPolicyID)}`, {selector: billingRestrictionPolicySelector}); - const draftTransactionIDs = useMemo(() => Object.keys(transactionDrafts ?? {}), [transactionDrafts]); + const draftTransactionIDs = Object.keys(transactionDrafts ?? {}); // Custom hooks const defaultExpensePolicy = useDefaultExpensePolicy(); @@ -212,7 +212,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); const isVendorMatchingBetaEnabled = isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); - const hasWorkspaceToSubmitToSelector = useMemo(() => createHasWorkspaceToSubmitToSelector(currentUserLogin), [currentUserLogin]); + const hasWorkspaceToSubmitToSelector = createHasWorkspaceToSubmitToSelector(currentUserLogin); const [hasWorkspaceToSubmitTo = false] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasWorkspaceToSubmitToSelector}); const {showConfirmModal} = useConfirmModal(); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTableHeader.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTableHeader.tsx index d328b2b08a9e..04ef24ced200 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTableHeader.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTableHeader.tsx @@ -8,7 +8,7 @@ import {isSortableColumnName} from '@libs/ReportUtils'; import CONST from '@src/CONST'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type SearchTableHeaderProps = { @@ -37,29 +37,24 @@ function MoneyRequestReportTableHeader({ }: SearchTableHeaderProps) { const styles = useThemeStyles(); - const columnConfig = useMemo( - () => [ - ...getExpenseHeaders().map((header) => ({ - ...header, - isColumnSortable: isSortableColumnName(header.columnName), - })), - { - columnName: CONST.SEARCH.TABLE_COLUMNS.COMMENTS, - translationKey: undefined, - isColumnSortable: false, - }, - ], - [], - ); - - const orderedColumnConfig = useMemo(() => { - if (columns.length === 0) { - return columnConfig; - } + const columnConfig = [ + ...getExpenseHeaders().map((header) => ({ + ...header, + isColumnSortable: isSortableColumnName(header.columnName), + })), + { + columnName: CONST.SEARCH.TABLE_COLUMNS.COMMENTS, + translationKey: undefined, + isColumnSortable: false, + }, + ]; + let orderedColumnConfig; + if (columns.length === 0) { + orderedColumnConfig = columnConfig; + } else { const configMap = new Map(columnConfig.map((config) => [config.columnName, config])); const ordered: typeof columnConfig = []; - for (const columnName of columns) { const config = configMap.get(columnName); if (config) { @@ -67,20 +62,15 @@ function MoneyRequestReportTableHeader({ configMap.delete(columnName); } } - for (const config of configMap.values()) { ordered.push(config); } + orderedColumnConfig = ordered; + } - return ordered; - }, [columns, columnConfig]); - - const shouldShowColumn = useCallback( - (columnName: SearchColumnType) => { - return columns.includes(columnName); - }, - [columns], - ); + const shouldShowColumn = (columnName: SearchColumnType) => { + return columns.includes(columnName); + }; return ( diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx index 048bc58beab0..dd00ec83bb15 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx @@ -33,7 +33,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {findFocusedRoute, useIsFocused} from '@react-navigation/native'; import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import {fullPolicySnapshotLastModifiedByPolicyIDSelector} from '@selectors/Policy'; -import React, {startTransition, useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {startTransition, useEffect, useRef} from 'react'; import {View} from 'react-native'; const CAROUSEL_PRESERVING_SCREENS = [ @@ -105,25 +105,15 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR // A press made before the sibling's parent action has loaded is parked here and replayed once it arrives. const pendingSiblingRef = useRef<{transactionID: string; originRoute: string} | null>(null); - const {prevTransactionID, nextTransactionID} = useMemo(() => { - if (transactionIDsList.length < 2 || currentTransactionIndex === -1) { - return {prevTransactionID: undefined, nextTransactionID: undefined}; - } + const hasAdjacentTransactions = transactionIDsList.length >= 2 && currentTransactionIndex !== -1; + const prevTransactionID = hasAdjacentTransactions && currentTransactionIndex > 0 ? transactionIDsList.at(currentTransactionIndex - 1) : undefined; + const nextTransactionID = hasAdjacentTransactions ? transactionIDsList.at(currentTransactionIndex + 1) : undefined; - return { - prevTransactionID: currentTransactionIndex > 0 ? transactionIDsList.at(currentTransactionIndex - 1) : undefined, - nextTransactionID: transactionIDsList.at(currentTransactionIndex + 1), - }; - }, [currentTransactionIndex, transactionIDsList]); - - const prevNextTransactionsSelector = useCallback( - (allTransactions: OnyxCollection) => - [currentTransactionID, prevTransactionID, nextTransactionID].map((transactionID) => { - const key = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const; - return allTransactions?.[key] ?? snapshot?.data?.[key]; - }), - [currentTransactionID, nextTransactionID, prevTransactionID, snapshot], - ); + const prevNextTransactionsSelector = (allTransactions: OnyxCollection) => + [currentTransactionID, prevTransactionID, nextTransactionID].map((transactionID) => { + const key = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const; + return allTransactions?.[key] ?? snapshot?.data?.[key]; + }); const [[currentTransaction, prevTransaction, nextTransaction] = getEmptyArray()] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { selector: prevNextTransactionsSelector, @@ -134,32 +124,29 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR // Only the prev/next parent actions are ever read, so resolve them inside the selector instead of returning // a Map of every money request action on the three parent reports (fast-equals compares Maps in O(n^2)). - const parentReportActionsSelector = useCallback( - (allReportActions: OnyxCollection) => { - // An unreported (self-DM) sibling's IOU action lives in the self-DM, since report "0" holds no actions - // of its own. Looking it up there is the only way such a sibling resolves from live data at all. - const isPrevUnreported = prevTransactionReportID === CONST.REPORT.UNREPORTED_REPORT_ID; - const isNextUnreported = nextTransactionReportID === CONST.REPORT.UNREPORTED_REPORT_ID; - const selfDMReportID = isPrevUnreported || isNextUnreported ? findSelfDMReportID() : undefined; - const prevActionsReportID = isPrevUnreported ? selfDMReportID : prevTransactionReportID; - const nextActionsReportID = isNextUnreported ? selfDMReportID : nextTransactionReportID; - // Whether the sibling's parent report has any actions at all, which is what separates "not fetched yet" - // from "fetched, but this expense has no creation action". Only the former is worth waiting for. - const hasPrevParentReportActions = !!Object.keys(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${prevActionsReportID}`] ?? {}).length; - const hasNextParentReportActions = !!Object.keys(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${nextActionsReportID}`] ?? {}).length; - const parentActions: PrevNextParentReportActions = {prevParentReportAction: undefined, nextParentReportAction: undefined}; - if (!prevTransactionID && !nextTransactionID) { - return {...parentActions, hasPrevParentReportActions, hasNextParentReportActions}; - } - const parentReportIDs = new Set([currentTransaction?.reportID, prevActionsReportID, nextActionsReportID]); - for (const parentReportID of parentReportIDs) { - const key = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}` as const; - collectParentReportActions(allReportActions?.[key], prevTransactionID, nextTransactionID, parentActions); - } + const parentReportActionsSelector = (allReportActions: OnyxCollection) => { + // An unreported (self-DM) sibling's IOU action lives in the self-DM, since report "0" holds no actions + // of its own. Looking it up there is the only way such a sibling resolves from live data at all. + const isPrevUnreported = prevTransactionReportID === CONST.REPORT.UNREPORTED_REPORT_ID; + const isNextUnreported = nextTransactionReportID === CONST.REPORT.UNREPORTED_REPORT_ID; + const selfDMReportID = isPrevUnreported || isNextUnreported ? findSelfDMReportID() : undefined; + const prevActionsReportID = isPrevUnreported ? selfDMReportID : prevTransactionReportID; + const nextActionsReportID = isNextUnreported ? selfDMReportID : nextTransactionReportID; + // Whether the sibling's parent report has any actions at all, which is what separates "not fetched yet" + // from "fetched, but this expense has no creation action". Only the former is worth waiting for. + const hasPrevParentReportActions = !!Object.keys(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${prevActionsReportID}`] ?? {}).length; + const hasNextParentReportActions = !!Object.keys(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${nextActionsReportID}`] ?? {}).length; + const parentActions: PrevNextParentReportActions = {prevParentReportAction: undefined, nextParentReportAction: undefined}; + if (!prevTransactionID && !nextTransactionID) { return {...parentActions, hasPrevParentReportActions, hasNextParentReportActions}; - }, - [currentTransaction?.reportID, nextTransactionReportID, nextTransactionID, prevTransactionReportID, prevTransactionID], - ); + } + const parentReportIDs = new Set([currentTransaction?.reportID, prevActionsReportID, nextActionsReportID]); + for (const parentReportID of parentReportIDs) { + const key = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}` as const; + collectParentReportActions(allReportActions?.[key], prevTransactionID, nextTransactionID, parentActions); + } + return {...parentActions, hasPrevParentReportActions, hasNextParentReportActions}; + }; const [reportedParentReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { selector: parentReportActionsSelector, @@ -169,17 +156,18 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR // one), so it only finds what has already been fetched into Onyx. Scanning the snapshot's report actions is // what resolves a sibling whose parent actions were never loaded in this session. const snapshotData = snapshot?.data; - const snapshotParentReportActions = useMemo(() => { - const parentActions: PrevNextParentReportActions = {prevParentReportAction: undefined, nextParentReportAction: undefined}; - if (snapshotData && (prevTransactionID ?? nextTransactionID)) { - for (const [key, reportActionsForReport] of Object.entries(snapshotData)) { - if (key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS)) { - collectParentReportActions(reportActionsForReport as OnyxTypes.ReportActions, prevTransactionID, nextTransactionID, parentActions); - } + const parentActions: PrevNextParentReportActions = { + prevParentReportAction: undefined, + nextParentReportAction: undefined, + }; + if (snapshotData && (prevTransactionID ?? nextTransactionID)) { + for (const [key, reportActionsForReport] of Object.entries(snapshotData)) { + if (key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS)) { + collectParentReportActions(reportActionsForReport as OnyxTypes.ReportActions, prevTransactionID, nextTransactionID, parentActions); } } - return parentActions; - }, [nextTransactionID, prevTransactionID, snapshotData]); + } + const snapshotParentReportActions = parentActions; // Live report actions win over the snapshot: the snapshot only fills in siblings the live pass couldn't resolve // (i.e. unreported ones). A search snapshot is a point-in-time copy, so for a reported transaction it can hold an diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index a1c1a466fc5c..cfb299fa9dbd 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -13,7 +13,7 @@ import type {ViewableItemsChanged, ViewToken} from '@src/types/utils/ReactNative import type {FlashListProps, ListRenderItemInfo} from '@shopify/flash-list'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; -import React, {memo, useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {ExternalScrollFlashListTableHandle} from './ExternalScrollFlashListTable'; @@ -413,4 +413,4 @@ function MoneyRequestReportUnifiedList({ ); } -export default memo(MoneyRequestReportUnifiedList); +export default MoneyRequestReportUnifiedList; diff --git a/src/components/MoneyRequestReportView/MoneyRequestViewReportFields.tsx b/src/components/MoneyRequestReportView/MoneyRequestViewReportFields.tsx index f86f158685b8..f7862af150c7 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestViewReportFields.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestViewReportFields.tsx @@ -34,7 +34,7 @@ import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import ReportFieldInlineInput from './ReportFieldInlineInput'; @@ -140,46 +140,39 @@ function MoneyRequestViewReportFields({report, policy, pendingAction, style, sho deleteReportField(report.reportID, report.fieldList?.[reportField.fieldKey] ?? reportField); }; - const sortedPolicyReportFields = useMemo((): EnrichedPolicyReportField[] => { - const {fieldValues, fieldsByName} = getReportFieldMaps(report, policy?.fieldList ?? {}, reportNameValuePairs); - const fields = Object.values(fieldsByName); - - return fields - .filter((field) => isReportFieldTargetMatchingReport(report, field)) - .filter((reportField) => !shouldHideSingleReportField(reportField)) - .sort(({orderWeight: firstOrderWeight}, {orderWeight: secondOrderWeight}) => firstOrderWeight - secondOrderWeight) - .map((field): EnrichedPolicyReportField => { - const isFieldDisabled = isReportFieldDisabledForUser(report, field, policy, currentUserAccountID, rules); - const isDeletedFormulaField = field.type === CONST.REPORT_FIELD_TYPES.FORMULA && field.deletable; - const fieldKey = getReportFieldKey(field.fieldID); - const isFieldOnPolicy = !!(policy?.fieldList?.[fieldKey] ?? policy?.fieldList?.[field.fieldID]); - const isEditable = !isFieldDisabled || isDeletedFormulaField; - - // An input the user can type into is seeded with the raw value, the same as the report field editor page. - // Seeding it with the computed value would save the resolved text over a `{field:X}` default the first - // time it was edited, so the field would stop following the field it refers to. - const shouldUseRawValue = isEditable && field.type !== CONST.REPORT_FIELD_TYPES.FORMULA; - const fieldValue = shouldUseRawValue - ? (field.value ?? field.defaultValue ?? '') - : resolveReportFieldValue(field, report, policy, fieldValues, fieldsByName, getCurrencyDecimals); - - const violation = isFieldDisabled ? undefined : getFieldViolation(field); - const violationTranslation = getFieldViolationTranslation(field, violation); - - return { - ...field, - fieldValue, - isFieldDisabled: !isEditable, - // Only fields that are no longer on the workspace can be deleted. Their `deletable` flag is not - // checked: once the workspace drops a field, whether it was required no longer applies, and the - // report's copy of a date field can still be flagged as not deletable. - isFieldDeletable: field.fieldID !== CONST.REPORT_FIELD_TITLE_FIELD_ID && !isFieldOnPolicy && isEditable, - fieldKey, - violation, - violationTranslation, - }; - }); - }, [policy, report, currentUserAccountID, reportNameValuePairs, getCurrencyDecimals, rules]); + const {fieldValues, fieldsByName} = getReportFieldMaps(report, policy?.fieldList ?? {}, reportNameValuePairs); + const fields = Object.values(fieldsByName); + const sortedPolicyReportFields: EnrichedPolicyReportField[] = fields + .filter((field) => isReportFieldTargetMatchingReport(report, field)) + .filter((reportField) => !shouldHideSingleReportField(reportField)) + .sort(({orderWeight: firstOrderWeight}, {orderWeight: secondOrderWeight}) => firstOrderWeight - secondOrderWeight) + .map((field): EnrichedPolicyReportField => { + const isFieldDisabled = isReportFieldDisabledForUser(report, field, policy, currentUserAccountID, rules); + const isDeletedFormulaField = field.type === CONST.REPORT_FIELD_TYPES.FORMULA && field.deletable; + const fieldKey = getReportFieldKey(field.fieldID); + const isFieldOnPolicy = !!(policy?.fieldList?.[fieldKey] ?? policy?.fieldList?.[field.fieldID]); + const isEditable = !isFieldDisabled || isDeletedFormulaField; + + // An input the user can type into is seeded with the raw value, the same as the report field editor page. + // Seeding it with the computed value would save the resolved text over a `{field:X}` default the first + // time it was edited, so the field would stop following the field it refers to. + const shouldUseRawValue = isEditable && field.type !== CONST.REPORT_FIELD_TYPES.FORMULA; + const fieldValue = shouldUseRawValue ? (field.value ?? field.defaultValue ?? '') : resolveReportFieldValue(field, report, policy, fieldValues, fieldsByName, getCurrencyDecimals); + const violation = isFieldDisabled ? undefined : getFieldViolation(field); + const violationTranslation = getFieldViolationTranslation(field, violation); + return { + ...field, + fieldValue, + isFieldDisabled: !isEditable, + // Only fields that are no longer on the workspace can be deleted. Their `deletable` flag is not + // checked: once the workspace drops a field, whether it was required no longer applies, and the + // report's copy of a date field can still be flagged as not deletable. + isFieldDeletable: field.fieldID !== CONST.REPORT_FIELD_TITLE_FIELD_ID && !isFieldOnPolicy && isEditable, + fieldKey, + violation, + violationTranslation, + }; + }); // `sortedPolicyReportFields` already excludes fields hidden by `shouldHideSingleReportField`, including the title field. // If no displayable custom fields remain, the early return below hides the section. diff --git a/src/components/MultiGestureCanvas/index.tsx b/src/components/MultiGestureCanvas/index.tsx index fa11a99b3da5..f2fa2b9da0d4 100644 --- a/src/components/MultiGestureCanvas/index.tsx +++ b/src/components/MultiGestureCanvas/index.tsx @@ -7,7 +7,7 @@ import type {Dimensions} from '@src/types/utils/Layout'; import type {NativeGesture, PanGesture} from 'react-native-gesture-handler'; import type {SharedValue} from 'react-native-reanimated'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; import {GestureDetector, useCompetingGestures, useSimultaneousGestures} from 'react-native-gesture-handler'; import Animated, {cancelAnimation, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withSpring} from 'react-native-reanimated'; @@ -102,18 +102,15 @@ function MultiGestureCanvas({ const isTransformGestureActiveFallback = useSharedValue(false); const isTransformGestureActive = isTransformGestureActiveProp ?? isTransformGestureActiveFallback; - const zoomRange = useMemo( - () => ({ - min: zoomRangeProp?.min ?? DEFAULT_ZOOM_RANGE.min, - max: zoomRangeProp?.max ?? DEFAULT_ZOOM_RANGE.max, - }), - [zoomRangeProp?.max, zoomRangeProp?.min], - ); + const zoomRange = { + min: zoomRangeProp?.min ?? DEFAULT_ZOOM_RANGE.min, + max: zoomRangeProp?.max ?? DEFAULT_ZOOM_RANGE.max, + }; // Based on the (original) content size and the canvas size, we calculate the horizontal and vertical scale factors // to fit the content inside the canvas // We later use the lower of the two scale factors to fit the content inside the canvas - const {minScale: minContentScale, maxScale: maxContentScale} = useMemo(() => MultiGestureCanvasUtils.getCanvasFitScale({canvasSize, contentSize}), [canvasSize, contentSize]); + const {minScale: minContentScale, maxScale: maxContentScale} = MultiGestureCanvasUtils.getCanvasFitScale({canvasSize, contentSize}); const zoomScale = useSharedValue(1); @@ -148,50 +145,47 @@ function MultiGestureCanvas({ /** * Stops any currently running decay animation from panning */ - const stopAnimation = useCallback(() => { + const stopAnimation = () => { 'worklet'; cancelAnimation(offsetX); cancelAnimation(offsetY); - }, [offsetX, offsetY]); + }; /** * Resets the canvas to the initial state and animates back smoothly */ - const reset = useCallback( - (animated: boolean, callback?: () => void) => { - 'worklet'; + const reset = (animated: boolean, callback?: () => void) => { + 'worklet'; - stopAnimation(); + stopAnimation(); - offsetX.set(0); - offsetY.set(0); - pinchScale.set(1); + offsetX.set(0); + offsetY.set(0); + pinchScale.set(1); - if (animated) { - panTranslateX.set(withSpring(0, SPRING_CONFIG)); - panTranslateY.set(withSpring(0, SPRING_CONFIG)); - pinchTranslateX.set(withSpring(0, SPRING_CONFIG)); - pinchTranslateY.set(withSpring(0, SPRING_CONFIG)); - zoomScale.set(withSpring(1, SPRING_CONFIG, callback)); + if (animated) { + panTranslateX.set(withSpring(0, SPRING_CONFIG)); + panTranslateY.set(withSpring(0, SPRING_CONFIG)); + pinchTranslateX.set(withSpring(0, SPRING_CONFIG)); + pinchTranslateY.set(withSpring(0, SPRING_CONFIG)); + zoomScale.set(withSpring(1, SPRING_CONFIG, callback)); - return; - } + return; + } - panTranslateX.set(0); - panTranslateY.set(0); - pinchTranslateX.set(0); - pinchTranslateY.set(0); - zoomScale.set(1); + panTranslateX.set(0); + panTranslateY.set(0); + pinchTranslateX.set(0); + pinchTranslateY.set(0); + zoomScale.set(1); - if (callback === undefined) { - return; - } + if (callback === undefined) { + return; + } - callback(); - }, - [offsetX, offsetY, panTranslateX, panTranslateY, pinchScale, pinchTranslateX, pinchTranslateY, stopAnimation, zoomScale], - ); + callback(); + }; // Pinch must be created before pan: on web, handlers end in creation order, and pan has to end last // to re-center the content using the offset that already includes the pinch translation. @@ -287,7 +281,7 @@ function MultiGestureCanvas({ }; }); - const containerStyles = useMemo(() => [styles.flex1, StyleUtils.getMultiGestureCanvasContainerStyle(canvasSize.width)], [StyleUtils, canvasSize.width, styles.flex1]); + const containerStyles = [styles.flex1, StyleUtils.getMultiGestureCanvasContainerStyle(canvasSize.width)]; return ( { + const getBounds = () => { 'worklet'; let horizontalBoundary = 0; @@ -110,12 +109,12 @@ const useCanvasPanGesture = ({ isInHorizontalBoundary, isInVerticalBoundary, }; - }, [canvasSize.width, canvasSize.height, zoomedContentWidth, zoomedContentHeight, offsetX, offsetY]); + }; // We want to smoothly decay/end the gesture by phasing out the pan animation // In case the content is outside of the boundaries of the canvas, // we need to move the content back into the boundaries - const finishPanGesture = useCallback(() => { + const finishPanGesture = () => { 'worklet'; // If the content is centered within the canvas, we don't need to run any animations @@ -183,7 +182,7 @@ const useCanvasPanGesture = ({ // Reset velocity variables after we finished the pan gesture panVelocityX.set(0); panVelocityY.set(0); - }, [getBounds, isSwipingDownToClose, offsetX, offsetY, onSwipeDown, panTranslateX, panTranslateY, panVelocityX, panVelocityY, shouldDisableSwipeDownToClose, zoomScale]); + }; const panGesture = usePanGesture({ manualActivation: true, diff --git a/src/components/MultiGestureCanvas/useCanvasPinchGesture.ts b/src/components/MultiGestureCanvas/useCanvasPinchGesture.ts index 68de69fc6ead..3f2c652baa81 100644 --- a/src/components/MultiGestureCanvas/useCanvasPinchGesture.ts +++ b/src/components/MultiGestureCanvas/useCanvasPinchGesture.ts @@ -1,6 +1,6 @@ import type {PinchGesture} from 'react-native-gesture-handler'; -import {useCallback, useEffect, useState} from 'react'; +import {useEffect, useState} from 'react'; import {GestureStateManager, usePinchGesture} from 'react-native-gesture-handler'; import {useAnimatedReaction, useSharedValue, withSpring} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; @@ -91,17 +91,14 @@ const useCanvasPinchGesture = ({ * Calculates the adjusted focal point of the pinch gesture, * based on the canvas size and the current offset */ - const getAdjustedFocal = useCallback( - (focalX: number, focalY: number) => { - 'worklet'; - - return { - x: focalX - (canvasSize.width / 2 + offsetX.get()), - y: focalY - (canvasSize.height / 2 + offsetY.get()), - }; - }, - [canvasSize.width, canvasSize.height, offsetX, offsetY], - ); + const getAdjustedFocal = (focalX: number, focalY: number) => { + 'worklet'; + + return { + x: focalX - (canvasSize.width / 2 + offsetX.get()), + y: focalY - (canvasSize.height / 2 + offsetY.get()), + }; + }; // The pinch gesture is disabled when we release one of the fingers // On the next render, we need to re-enable the pinch gesture diff --git a/src/components/MultiGestureCanvas/useCanvasTapGestures.ts b/src/components/MultiGestureCanvas/useCanvasTapGestures.ts index 89bfc18578a7..1aa17fa53096 100644 --- a/src/components/MultiGestureCanvas/useCanvasTapGestures.ts +++ b/src/components/MultiGestureCanvas/useCanvasTapGestures.ts @@ -1,6 +1,5 @@ import type {PanGesture, TapGesture} from 'react-native-gesture-handler'; -import {useCallback, useMemo} from 'react'; import {GestureStateManager, useTapGesture} from 'react-native-gesture-handler'; import {withSpring} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; @@ -51,90 +50,87 @@ const useCanvasTapGestures = ({ panGesture, }: UseCanvasTapGesturesProps): {singleTapGesture: TapGesture; doubleTapGesture: TapGesture} => { // The content size after scaling it with minimum scale to fit the content into the canvas - const scaledContentWidth = useMemo(() => contentSize.width * minContentScale, [contentSize.width, minContentScale]); - const scaledContentHeight = useMemo(() => contentSize.height * minContentScale, [contentSize.height, minContentScale]); + const scaledContentWidth = contentSize.width * minContentScale; + const scaledContentHeight = contentSize.height * minContentScale; // On double tap the content should be zoomed to fill, but at least zoomed by DOUBLE_TAP_SCALE — never past the allowed zoom range - const doubleTapScale = useMemo(() => Math.min(zoomRange.max, Math.max(DOUBLE_TAP_SCALE, maxContentScale / minContentScale)), [maxContentScale, minContentScale, zoomRange.max]); - - const zoomToCoordinates = useCallback( - (focalX: number, focalY: number, callback: () => void) => { - 'worklet'; - - stopAnimation(); - isTransformGestureActive.set(true); - - // By how much the canvas is bigger than the content horizontally and vertically per side - const horizontalCanvasOffset = Math.max(0, (canvasSize.width - scaledContentWidth) / 2); - const verticalCanvasOffset = Math.max(0, (canvasSize.height - scaledContentHeight) / 2); - - // We need to adjust the focal point to take into account the canvas offset - // The focal point cannot be outside of the content's bounds - const adjustedFocalPoint = { - x: MultiGestureCanvasUtils.clamp(focalX - horizontalCanvasOffset, 0, scaledContentWidth), - y: MultiGestureCanvasUtils.clamp(focalY - verticalCanvasOffset, 0, scaledContentHeight), - }; - - // The center of the canvas - const canvasCenter = { - x: canvasSize.width / 2, - y: canvasSize.height / 2, - }; - - // The center of the content before zooming - const originalContentCenter = { - x: scaledContentWidth / 2, - y: scaledContentHeight / 2, - }; - - // The size of the content after zooming - const zoomedContentSize = { - width: scaledContentWidth * doubleTapScale, - height: scaledContentHeight * doubleTapScale, - }; - - // The center of the zoomed content - const zoomedContentCenter = { - x: zoomedContentSize.width / 2, - y: zoomedContentSize.height / 2, - }; - - // By how much the zoomed content is bigger/smaller than the canvas. - const zoomedContentOffset = { - x: zoomedContentCenter.x - canvasCenter.x, - y: zoomedContentCenter.y - canvasCenter.y, - }; - - // How much the content needs to be shifted based on the focal point - const shiftingFactor = { - x: adjustedFocalPoint.x / originalContentCenter.x - 1, - y: adjustedFocalPoint.y / originalContentCenter.y - 1, - }; - - // The offset after applying the focal point adjusted shift. - // We need to invert the shift, because the content is moving in the opposite direction (* -1) - const offsetAfterZooming = { - x: zoomedContentOffset.x * (shiftingFactor.x * -1), - y: zoomedContentOffset.y * (shiftingFactor.y * -1), - }; - - // If the zoomed content is less tall than the canvas, we need to reset the vertical offset - if (zoomedContentSize.height < canvasSize.height) { - offsetAfterZooming.y = 0; - } - - offsetX.set(withSpring(offsetAfterZooming.x, SPRING_CONFIG)); - offsetY.set(withSpring(offsetAfterZooming.y, SPRING_CONFIG)); - zoomScale.set( - withSpring(doubleTapScale, SPRING_CONFIG, () => { - callback(); - isTransformGestureActive.set(false); - }), - ); - pinchScale.set(doubleTapScale); - }, - [stopAnimation, isTransformGestureActive, canvasSize.width, canvasSize.height, scaledContentWidth, scaledContentHeight, doubleTapScale, offsetX, offsetY, zoomScale, pinchScale], - ); + const doubleTapScale = Math.min(zoomRange.max, Math.max(DOUBLE_TAP_SCALE, maxContentScale / minContentScale)); + + const zoomToCoordinates = (focalX: number, focalY: number, callback: () => void) => { + 'worklet'; + + stopAnimation(); + isTransformGestureActive.set(true); + + // By how much the canvas is bigger than the content horizontally and vertically per side + const horizontalCanvasOffset = Math.max(0, (canvasSize.width - scaledContentWidth) / 2); + const verticalCanvasOffset = Math.max(0, (canvasSize.height - scaledContentHeight) / 2); + + // We need to adjust the focal point to take into account the canvas offset + // The focal point cannot be outside of the content's bounds + const adjustedFocalPoint = { + x: MultiGestureCanvasUtils.clamp(focalX - horizontalCanvasOffset, 0, scaledContentWidth), + y: MultiGestureCanvasUtils.clamp(focalY - verticalCanvasOffset, 0, scaledContentHeight), + }; + + // The center of the canvas + const canvasCenter = { + x: canvasSize.width / 2, + y: canvasSize.height / 2, + }; + + // The center of the content before zooming + const originalContentCenter = { + x: scaledContentWidth / 2, + y: scaledContentHeight / 2, + }; + + // The size of the content after zooming + const zoomedContentSize = { + width: scaledContentWidth * doubleTapScale, + height: scaledContentHeight * doubleTapScale, + }; + + // The center of the zoomed content + const zoomedContentCenter = { + x: zoomedContentSize.width / 2, + y: zoomedContentSize.height / 2, + }; + + // By how much the zoomed content is bigger/smaller than the canvas. + const zoomedContentOffset = { + x: zoomedContentCenter.x - canvasCenter.x, + y: zoomedContentCenter.y - canvasCenter.y, + }; + + // How much the content needs to be shifted based on the focal point + const shiftingFactor = { + x: adjustedFocalPoint.x / originalContentCenter.x - 1, + y: adjustedFocalPoint.y / originalContentCenter.y - 1, + }; + + // The offset after applying the focal point adjusted shift. + // We need to invert the shift, because the content is moving in the opposite direction (* -1) + const offsetAfterZooming = { + x: zoomedContentOffset.x * (shiftingFactor.x * -1), + y: zoomedContentOffset.y * (shiftingFactor.y * -1), + }; + + // If the zoomed content is less tall than the canvas, we need to reset the vertical offset + if (zoomedContentSize.height < canvasSize.height) { + offsetAfterZooming.y = 0; + } + + offsetX.set(withSpring(offsetAfterZooming.x, SPRING_CONFIG)); + offsetY.set(withSpring(offsetAfterZooming.y, SPRING_CONFIG)); + zoomScale.set( + withSpring(doubleTapScale, SPRING_CONFIG, () => { + callback(); + isTransformGestureActive.set(false); + }), + ); + pinchScale.set(doubleTapScale); + }; const doubleTapGesture = useTapGesture({ numberOfTaps: 2, diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders.tsx b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders.tsx index bcebfbf8e4ac..76159537091a 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders.tsx +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders.tsx @@ -1,6 +1,6 @@ import type {ReactNode} from 'react'; -import React, {useMemo, useReducer} from 'react'; +import React, {useReducer} from 'react'; import MultifactorAuthenticationActionsContext from './MultifactorAuthenticationActionsContext'; import {MultifactorAuthenticationContextProvider} from './MultifactorAuthenticationMainContext'; @@ -13,7 +13,7 @@ type MultifactorAuthenticationProviderProps = { function MultifactorAuthenticationStateProvider({children}: MultifactorAuthenticationProviderProps) { const [state, dispatch] = useReducer(stateReducer, DEFAULT_STATE); - const actions = useMemo(() => ({dispatch}), [dispatch]); + const actions = {dispatch}; return ( diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx index ec7bceaf11d9..f1583076e436 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx @@ -30,7 +30,7 @@ import type {ReactNode} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {CONST as COMMON_CONST} from 'expensify-common'; -import React, {createContext, useCallback, useContext, useEffect, useMemo, useRef} from 'react'; +import React, {createContext, useContext, useEffect, useRef} from 'react'; import Onyx from 'react-native-onyx'; import {useMultifactorAuthenticationActions} from './MultifactorAuthenticationActionsContext'; @@ -97,14 +97,14 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent const startStateRef = useRef(undefined); - const captureCredentialsState = useCallback(async (): Promise => { + const captureCredentialsState = async (): Promise => { const hasLocalCredentials = await biometrics.areLocalCredentialsKnownToServer(); return { hasServerCredentials: biometrics.serverKnownCredentialIDs.length > 0, hasLocalCredentials, hasEverAcceptedSoftPrompt: !!deviceBiometricsState?.hasAcceptedSoftPrompt, }; - }, [biometrics]); + }; /** * Handles the completion of a multifactor authentication scenario. @@ -117,68 +117,65 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent * * @param isSuccessful - Whether the authentication scenario completed successfully */ - const handleCallback = useCallback( - async (isSuccessful: boolean) => { - const {error, scenario, scenarioResponse, payload} = state; + const handleCallback = async (isSuccessful: boolean) => { + const {error, scenario, scenarioResponse, payload} = state; - if (!scenario) { - return; - } + if (!scenario) { + return; + } - const scenarioConfig = scenario; - const callbackInput: MultifactorAuthenticationCallbackInput = { - httpStatusCode: scenarioResponse?.httpStatusCode, - message: scenarioResponse?.reason ?? error?.reason, - body: scenarioResponse?.body, - }; - - const callbackResponse = await scenarioConfig.callback?.(isSuccessful, callbackInput, payload); - - addMFABreadcrumb('Flow completed', { - isSuccessful, - callbackResponse: callbackResponse ?? 'none', - httpStatusCode: scenarioResponse?.httpStatusCode ?? error?.httpStatusCode, - reason: scenarioResponse?.reason ?? error?.reason, - message: scenarioResponse?.message ?? error?.message, - }); + const scenarioConfig = scenario; + const callbackInput: MultifactorAuthenticationCallbackInput = { + httpStatusCode: scenarioResponse?.httpStatusCode, + message: scenarioResponse?.reason ?? error?.reason, + body: scenarioResponse?.body, + }; - const endState = await captureCredentialsState(); - - trackMFAFlowOutcome({ - isSuccessful, - scenario: state.scenarioName, - scenarioResponse, - error, - authenticationMethod: state.authenticationMethod?.name, - isRegistrationComplete: state.isRegistrationComplete, - isAuthorizationComplete: state.isAuthorizationComplete, - softPromptApproved: state.softPromptApproved, - startState: startStateRef.current ?? endState, - endState, - }); + const callbackResponse = await scenarioConfig.callback?.(isSuccessful, callbackInput, payload); - dispatch({type: 'SET_FLOW_COMPLETE', payload: true}); + addMFABreadcrumb('Flow completed', { + isSuccessful, + callbackResponse: callbackResponse ?? 'none', + httpStatusCode: scenarioResponse?.httpStatusCode ?? error?.httpStatusCode, + reason: scenarioResponse?.reason ?? error?.reason, + message: scenarioResponse?.message ?? error?.message, + }); - // If the callback returns SKIP_OUTCOME_SCREEN, the callback handles navigation itself. - // Close the modal so the overlay plays its exit animation and then resets. - if (callbackResponse === CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SKIP_OUTCOME_SCREEN) { - dispatch({type: 'CLOSE_MODAL'}); - return; - } + const endState = await captureCredentialsState(); - // Wait out any callback-initiated transition (e.g. RHP goBack) before pushing the outcome screen. - // Android raced the two animations and the RHP slide-out leaked through the outgoing screen. - const outcomeScreen = isSuccessful ? SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_SUCCESS : SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_FAILURE; - Navigation.runAfterTransition(() => mfaNavigate(outcomeScreen)); - }, - [captureCredentialsState, dispatch, state], - ); + trackMFAFlowOutcome({ + isSuccessful, + scenario: state.scenarioName, + scenarioResponse, + error, + authenticationMethod: state.authenticationMethod?.name, + isRegistrationComplete: state.isRegistrationComplete, + isAuthorizationComplete: state.isAuthorizationComplete, + softPromptApproved: state.softPromptApproved, + startState: startStateRef.current ?? endState, + endState, + }); + + dispatch({type: 'SET_FLOW_COMPLETE', payload: true}); + + // If the callback returns SKIP_OUTCOME_SCREEN, the callback handles navigation itself. + // Close the modal so the overlay plays its exit animation and then resets. + if (callbackResponse === CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SKIP_OUTCOME_SCREEN) { + dispatch({type: 'CLOSE_MODAL'}); + return; + } + + // Wait out any callback-initiated transition (e.g. RHP goBack) before pushing the outcome screen. + // Android raced the two animations and the RHP slide-out leaked through the outgoing screen. + const outcomeScreen = isSuccessful ? SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_SUCCESS : SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_FAILURE; + Navigation.runAfterTransition(() => mfaNavigate(outcomeScreen)); + }; /** * Internal process function that runs after each step. * Uses if statements to determine and execute the next step in the flow. */ - const process = useCallback(async () => { + const process = async () => { const { error, continuableError, @@ -423,7 +420,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // 5. All steps completed - invoke callback to determine whether to show the outcome screen handleCallback(true); - }, [biometrics, dispatch, handleCallback, isOffline, state, promptType]); + }; /** * Drives the MFA state machine forward whenever relevant state changes occur. @@ -483,54 +480,51 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent * * @template T - The type of the multifactor authentication scenario * @param scenario - The MFA scenario to process - * @param {ExecuteScenarioParams} [params] - Optional parameters for the scenario - * @returns {Promise} A promise that resolves when the scenario has been initialized + * @param params - Optional parameters for the scenario + * @returns A promise that resolves when the scenario has been initialized */ - const executeScenario = useCallback( - async (scenario: T, params?: ExecuteScenarioParams): Promise => { - // Rapid double-tap on the trigger can fire executeScenario twice. The reducer's INIT - // case is the authoritative guard (it processes dispatches sequentially against the - // latest state and drops the duplicate). This check exists only as a perf short-circuit - // so the second tap doesn't pay for an extra captureCredentialsState() native call - // and Sentry breadcrumb on the happy path. - if (state.scenario) { - return; - } + const executeScenario = async (scenario: T, params?: ExecuteScenarioParams): Promise => { + // Rapid double-tap on the trigger can fire executeScenario twice. The reducer's INIT + // case is the authoritative guard (it processes dispatches sequentially against the + // latest state and drops the duplicate). This check exists only as a perf short-circuit + // so the second tap doesn't pay for an extra captureCredentialsState() native call + // and Sentry breadcrumb on the happy path. + if (state.scenario) { + return; + } - startStateRef.current = await captureCredentialsState(); + startStateRef.current = await captureCredentialsState(); - const breadcrumbData = { - scenario, - hasPayload: params !== undefined && Object.keys(params).length > 0, - platform, - isOffline, - hasAcceptedSoftPrompt: startStateRef.current.hasEverAcceptedSoftPrompt, - serverHasAnyCredentials: startStateRef.current.hasServerCredentials, - }; - - addMFABreadcrumb('Flow started', breadcrumbData); - trackMFAFlowStart({ + const breadcrumbData = { + scenario, + hasPayload: params !== undefined && Object.keys(params).length > 0, + platform, + isOffline, + hasAcceptedSoftPrompt: startStateRef.current.hasEverAcceptedSoftPrompt, + serverHasAnyCredentials: startStateRef.current.hasServerCredentials, + }; + + addMFABreadcrumb('Flow started', breadcrumbData); + trackMFAFlowStart({ + scenario, + isOffline, + credentialsState: startStateRef.current, + }); + dispatch({ + type: 'INIT', + payload: { scenario, - isOffline, - credentialsState: startStateRef.current, - }); - dispatch({ - type: 'INIT', - payload: { - scenario, - payload: params && Object.keys(params).length > 0 ? params : undefined, - }, - }); - }, - [captureCredentialsState, dispatch, isOffline, platform, state.scenario], - ); + payload: params && Object.keys(params).length > 0 ? params : undefined, + }, + }); + }; /** * Cancel the current authentication flow. Dispatches SET_ERROR which drives process() to handleCallback * and the failure outcome. When the scenario or network is unavailable, process() short-circuits, so we * close the modal directly instead to avoid getting stuck. */ - const cancel = useCallback(async () => { + const cancel = async () => { actionControllerRef.current?.abort(); if (!state.scenario || isOffline) { addMFABreadcrumb('Flow cancelled - closing directly', {hasScenario: !!state.scenario, isOffline}, 'warning'); @@ -550,7 +544,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent type: 'SET_ERROR', payload: createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED, 'User cancelled the MFA flow'), }); - }, [dispatch, isOffline, state.scenario, state.payload]); + }; const requestCancel = () => { if (state.isFlowComplete) { @@ -573,15 +567,12 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent useSyncMfaModalNavigatorWithHistory(state.isModalOpen, requestCancel); - const contextValue: MultifactorAuthenticationContextValue = useMemo( - () => ({ - executeScenario, - requestCancel, - hideCancelConfirm, - confirmCancel, - }), - [executeScenario, requestCancel, hideCancelConfirm, confirmCancel], - ); + const contextValue: MultifactorAuthenticationContextValue = { + executeScenario, + requestCancel, + hideCancelConfirm, + confirmCancel, + }; return {children}; } diff --git a/src/components/MultifactorAuthentication/ValidateCodeResendButton.tsx b/src/components/MultifactorAuthentication/ValidateCodeResendButton.tsx index 2fe4d804892c..56ae0ac4b6f7 100644 --- a/src/components/MultifactorAuthentication/ValidateCodeResendButton.tsx +++ b/src/components/MultifactorAuthentication/ValidateCodeResendButton.tsx @@ -11,7 +11,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import React, {useCallback, useImperativeHandle, useRef, useState} from 'react'; +import React, {useImperativeHandle, useRef, useState} from 'react'; import {View} from 'react-native'; type MultifactorAuthenticationValidateCodeResendButtonHandle = { @@ -41,9 +41,9 @@ function MultifactorAuthenticationValidateCodeResendButton({ const [isCountdownRunning, setIsCountdownRunning] = useState(true); const countdownRef = useRef(null); - const handleCountdownFinish = useCallback(() => { + const handleCountdownFinish = () => { setIsCountdownRunning(false); - }, []); + }; useImperativeHandle(ref, () => ({ resetCountdown: () => { diff --git a/src/components/Navigation/DebugTabView.tsx b/src/components/Navigation/DebugTabView.tsx index f031578e1cce..e429b67bfef4 100644 --- a/src/components/Navigation/DebugTabView.tsx +++ b/src/components/Navigation/DebugTabView.tsx @@ -38,7 +38,7 @@ import type {NavigationState} from '@react-navigation/native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import NAVIGATION_TABS from './NavigationTabBar/NAVIGATION_TABS'; @@ -178,37 +178,22 @@ function DebugTabView({selectedTab}: Props) { return FULL_WIDTH_TAB_ROOT_SCREENS.has(focusedLeaf); }); - const message = useMemo((): TranslationPaths | undefined => { - if (selectedTab === NAVIGATION_TABS.INBOX) { - if (chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO) { - return 'debug.indicatorStatus.theresAReportAwaitingAction'; - } - if (chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR) { - return 'debug.indicatorStatus.theresAReportWithErrors'; - } - } - if (selectedTab === NAVIGATION_TABS.SETTINGS || selectedTab === NAVIGATION_TABS.WORKSPACES) { - return getSettingsMessage(status); - } - }, [selectedTab, chatTabBrickRoad, status]); - - const indicator = useMemo(() => { - if (selectedTab === NAVIGATION_TABS.INBOX) { - if (chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO) { - return theme.success; - } - if (chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR) { - return theme.danger; - } - } - if (selectedTab === NAVIGATION_TABS.SETTINGS || selectedTab === NAVIGATION_TABS.WORKSPACES) { - if (status) { - return indicatorColor; - } + let message: TranslationPaths | undefined; + let indicator; + if (selectedTab === NAVIGATION_TABS.INBOX) { + if (chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO) { + message = 'debug.indicatorStatus.theresAReportAwaitingAction'; + indicator = theme.success; + } else if (chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR) { + message = 'debug.indicatorStatus.theresAReportWithErrors'; + indicator = theme.danger; } - }, [selectedTab, chatTabBrickRoad, theme.success, theme.danger, status, indicatorColor]); + } else if (selectedTab === NAVIGATION_TABS.SETTINGS || selectedTab === NAVIGATION_TABS.WORKSPACES) { + message = getSettingsMessage(status); + indicator = status ? indicatorColor : undefined; + } - const navigateTo = useCallback(() => { + const navigateTo = () => { if (selectedTab === NAVIGATION_TABS.INBOX && !!chatTabBrickRoad) { const reportID = getChatTabBrickRoadReportID(orderedReportIDs, reportAttributes); @@ -223,7 +208,7 @@ function DebugTabView({selectedTab}: Props) { Navigation.navigate(route); } } - }, [selectedTab, chatTabBrickRoad, orderedReportIDs, reportAttributes, status, reimbursementAccount, indicatorPolicyID]); + }; if ( (shouldUseNarrowLayout && !isAtRoot) || diff --git a/src/components/Navigation/QuickCreationActionsBar/index.tsx b/src/components/Navigation/QuickCreationActionsBar/index.tsx index fa8f03f53e2f..3bad67078d9f 100644 --- a/src/components/Navigation/QuickCreationActionsBar/index.tsx +++ b/src/components/Navigation/QuickCreationActionsBar/index.tsx @@ -31,7 +31,7 @@ import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import {emailSelector} from '@selectors/Session'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import {Str} from 'expensify-common'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; function QuickCreationActionsBar() { @@ -57,49 +57,39 @@ function QuickCreationActionsBar() { const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - const travelEnabledPolicy = useMemo( - () => Object.values(allPolicies ?? {}).find((policy) => isWorkspaceProvisionedForTravel(policy?.travelSettings) && isPolicyAccessible(policy, email ?? '')), - [allPolicies, email], - ); + const travelEnabledPolicy = Object.values(allPolicies ?? {}).find((policy) => isWorkspaceProvisionedForTravel(policy?.travelSettings) && isPolicyAccessible(policy, email ?? '')); const shouldShowBookTravel = !!travelEnabledPolicy; const isBlockedFromSpotnanaTravel = isBetaEnabled(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL); const primaryContactMethod = primaryLogin ?? session?.email ?? ''; - const isTravelReady = useMemo(() => { - if (!!isBlockedFromSpotnanaTravel || !primaryContactMethod || Str.isSMSLogin(primaryContactMethod) || !isPaidGroupPolicy(travelEnabledPolicy)) { - return false; - } - - return hasAcceptedTravelTerms(travelEnabledPolicy, travelSettings); - }, [travelEnabledPolicy, isBlockedFromSpotnanaTravel, primaryContactMethod, travelSettings]); + const isTravelReady = + !(!!isBlockedFromSpotnanaTravel || !primaryContactMethod || Str.isSMSLogin(primaryContactMethod) || !isPaidGroupPolicy(travelEnabledPolicy)) && + hasAcceptedTravelTerms(travelEnabledPolicy, travelSettings); - const handleCreateWorkspaceReport = useCallback( - (policy: OnyxEntry, shouldDismissEmptyReportsConfirmation?: boolean) => { - if (!policy?.id) { - return; - } + const handleCreateWorkspaceReport = (policy: OnyxEntry, shouldDismissEmptyReportsConfirmation?: boolean) => { + if (!policy?.id) { + return; + } - const {reportID: createdReportID} = createNewReport( - currentUserPersonalDetails, - hasViolations, - isASAPSubmitBetaEnabled, - policy, - isTrackIntentUser, - getCurrencyDecimals, - rules, - false, - shouldDismissEmptyReportsConfirmation, - ); - // Navigate to the Reports page first so getCreateReportRoute() resolves against - // the Search/Reports fullscreen context before opening the created report modal. - Navigation.navigate(getReportsRootRoute()); - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.navigate(getCreateReportRoute({reportID: createdReportID})); - }); - }, - [currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, isTrackIntentUser, getCurrencyDecimals, rules], - ); + const {reportID: createdReportID} = createNewReport( + currentUserPersonalDetails, + hasViolations, + isASAPSubmitBetaEnabled, + policy, + isTrackIntentUser, + getCurrencyDecimals, + rules, + false, + shouldDismissEmptyReportsConfirmation, + ); + // Navigate to the Reports page first so getCreateReportRoute() resolves against + // the Search/Reports fullscreen context before opening the created report modal. + Navigation.navigate(getReportsRootRoute()); + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(getCreateReportRoute({reportID: createdReportID})); + }); + }; const {createReport, isVisible: isCreateReportReady} = useCreateReport({ onCreateReport: handleCreateWorkspaceReport, @@ -107,36 +97,27 @@ function QuickCreationActionsBar() { shouldHandleNavigationBack: false, }); - const handleExpense = useCallback( - () => - interceptAnonymousUser(() => { - startMoneyRequest(CONST.IOU.TYPE.CREATE, generateReportID(), draftTransactionIDs); - }), - [draftTransactionIDs], - ); + const handleExpense = () => + interceptAnonymousUser(() => { + startMoneyRequest(CONST.IOU.TYPE.CREATE, generateReportID(), draftTransactionIDs); + }); - const handleDistance = useCallback( - () => - interceptAnonymousUser(() => { - startDistanceRequest(CONST.IOU.TYPE.CREATE, generateReportID(), draftTransactionIDs, lastDistanceExpenseType); - }), - [draftTransactionIDs, lastDistanceExpenseType], - ); + const handleDistance = () => + interceptAnonymousUser(() => { + startDistanceRequest(CONST.IOU.TYPE.CREATE, generateReportID(), draftTransactionIDs, lastDistanceExpenseType); + }); - const handleBookTravel = useCallback( - () => - interceptAnonymousUser(() => { - if (isTravelReady) { - if (blockIfDefaultWorkspaceLacksTravel()) { - return; - } - openTravelDotLink(travelEnabledPolicy?.id); + const handleBookTravel = () => + interceptAnonymousUser(() => { + if (isTravelReady) { + if (blockIfDefaultWorkspaceLacksTravel()) { return; } - Navigation.navigate(ROUTES.TRAVEL_MY_TRIPS.getRoute(travelEnabledPolicy?.id)); - }), - [travelEnabledPolicy?.id, isTravelReady, blockIfDefaultWorkspaceLacksTravel], - ); + openTravelDotLink(travelEnabledPolicy?.id); + return; + } + Navigation.navigate(ROUTES.TRAVEL_MY_TRIPS.getRoute(travelEnabledPolicy?.id)); + }); return ( diff --git a/src/components/Navigation/SearchSidebarCollapseStore.ts b/src/components/Navigation/SearchSidebarCollapseStore.ts index 6a621c15e4c0..c33e6a9e1c83 100644 --- a/src/components/Navigation/SearchSidebarCollapseStore.ts +++ b/src/components/Navigation/SearchSidebarCollapseStore.ts @@ -8,7 +8,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {ViewStyle} from 'react-native'; -import {useCallback, useMemo, useSyncExternalStore} from 'react'; +import {useSyncExternalStore} from 'react'; import {Platform} from 'react-native'; const SEARCH_SIDEBAR_COLLAPSE_ANIMATION_DURATION_MS = 220; @@ -73,8 +73,8 @@ function useSearchSidebarCollapse() { const [searchSidebarNVP] = useOnyx(ONYXKEYS.NVP_SEARCH_SIDEBAR); const collapsed = searchSidebarNVP?.isCollapsed ?? false; const peeking = useSyncExternalStore(subscribe, getPeekSnapshot, getPeekSnapshot); - const toggleSidebar = useCallback(() => setSearchSidebarCollapsed(!collapsed), [collapsed]); - const startSidebarPeek = useCallback(() => startPeek(collapsed), [collapsed]); + const toggleSidebar = () => setSearchSidebarCollapsed(!collapsed); + const startSidebarPeek = () => startPeek(collapsed); return { isCollapsed: collapsed, @@ -89,44 +89,38 @@ function useSearchSidebarCollapse() { function useSearchSidebarLayoutWidthStyle() { const {isCollapsed: collapsed} = useSearchSidebarCollapse(); - return useMemo(() => ({...layoutTransitionStyle, height: '100%', width: getSearchSidebarWidth(collapsed ? 1 : 0)}), [collapsed]); + return {...layoutTransitionStyle, height: '100%', width: getSearchSidebarWidth(collapsed ? 1 : 0)}; } function useSearchSidebarVisualWidthStyle() { const {isCollapsed: collapsed, isPeeking: peeking} = useSearchSidebarCollapse(); - return useMemo(() => ({...layoutTransitionStyle, width: getSearchSidebarWidth(collapsed && !peeking ? 1 : 0)}), [collapsed, peeking]); + return {...layoutTransitionStyle, width: getSearchSidebarWidth(collapsed && !peeking ? 1 : 0)}; } function useSearchSidebarContentOffsetStyle() { const {isCollapsed: collapsed} = useSearchSidebarCollapse(); - return useMemo(() => ({...layoutTransitionStyle, marginLeft: getSearchSidebarWidth(collapsed ? 1 : 0)}), [collapsed]); + return {...layoutTransitionStyle, marginLeft: getSearchSidebarWidth(collapsed ? 1 : 0)}; } function useSearchSidebarCollapseFadeStyle() { const {isVisuallyCollapsed} = useSearchSidebarCollapse(); - return useMemo( - () => ({ - ...fadeTransitionStyle, - opacity: isVisuallyCollapsed ? 0 : 1, - transform: [{translateX: isVisuallyCollapsed ? SEARCH_SIDEBAR_COLLAPSE_TRANSLATE_X : 0}], - }), - [isVisuallyCollapsed], - ); + return { + ...fadeTransitionStyle, + opacity: isVisuallyCollapsed ? 0 : 1, + transform: [{translateX: isVisuallyCollapsed ? SEARCH_SIDEBAR_COLLAPSE_TRANSLATE_X : 0}], + }; } function useSearchSidebarToggleButtonStyle() { const {isVisuallyCollapsed} = useSearchSidebarCollapse(); - return useMemo( - () => ({ - ...fadeTransitionStyle, - transform: [{translateX: isVisuallyCollapsed ? TOGGLE_BUTTON_COLLAPSED_TRANSLATE_X : 0}], - }), - [isVisuallyCollapsed], - ); + return { + ...fadeTransitionStyle, + transform: [{translateX: isVisuallyCollapsed ? TOGGLE_BUTTON_COLLAPSED_TRANSLATE_X : 0}], + }; } export { diff --git a/src/components/NavigationBar/index.android.tsx b/src/components/NavigationBar/index.android.tsx index 79a38ba37143..f8c3e2918d1b 100644 --- a/src/components/NavigationBar/index.android.tsx +++ b/src/components/NavigationBar/index.android.tsx @@ -5,7 +5,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import {useMemo} from 'react'; import {View} from 'react-native'; /** NavigationBar renders a semi-translucent background behind the three-button navigation bar on Android. */ @@ -15,7 +14,7 @@ function NavigationBar() { const {insets, paddingBottom} = useSafeAreaPaddings(); const {isOffline} = useNetwork(); - const navigationBarType = useMemo(() => StyleUtils.getNavigationBarType(insets), [StyleUtils, insets]); + const navigationBarType = StyleUtils.getNavigationBarType(insets); const isSoftKeyNavigation = navigationBarType === CONST.NAVIGATION_BAR_TYPE.SOFT_KEYS; return isSoftKeyNavigation ? : null; diff --git a/src/components/NumberWithSymbolForm.tsx b/src/components/NumberWithSymbolForm.tsx index 0d6f29060936..ad1ee6ddc005 100644 --- a/src/components/NumberWithSymbolForm.tsx +++ b/src/components/NumberWithSymbolForm.tsx @@ -27,7 +27,7 @@ import type {ForwardedRef} from 'react'; import type {KeyboardTypeOptions, NativeSyntheticEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; import {View} from 'react-native'; import type {BaseTextInputRef} from './TextInput/BaseTextInput/types'; @@ -254,9 +254,9 @@ function NumberWithSymbolForm({ setMouseUp(); }; - const clearSelection = useCallback(() => { + const clearSelection = () => { setSelection({start: selection.end, end: selection.end}); - }, [selection.end]); + }; /** * Event occurs when a user presses a mouse button over an DOM element. @@ -282,41 +282,38 @@ function NumberWithSymbolForm({ * Sets the selection and the number accordingly to the number passed to the input * @param newNumber - Changed number from user input */ - const setNewNumber = useCallback( - (newNumber: string) => { - // Remove spaces from the newNumber number because Safari on iOS adds spaces when pasting a copied number - // More info: https://github.com/Expensify/App/issues/16974 - const newNumberWithoutSpaces = stripSpacesFromAmount(newNumber); - const rawFinalNumber = newNumberWithoutSpaces.includes('.') ? stripCommaFromAmount(newNumberWithoutSpaces) : replaceCommasWithPeriod(newNumberWithoutSpaces); - - // When allowNegativeInput is true, keep negative sign as-is (for split amounts) - // When allowFlippingAmount is true, strip the negative sign and call toggleNegative - const finalNumber = allowNegativeInput ? rawFinalNumber : handleNegativeAmountFlipping(rawFinalNumber, allowFlippingAmount, toggleNegative); - - // Use a shallow copy of selection to trigger setSelection - // More info: https://github.com/Expensify/App/issues/16385 - if (!validateAmount(finalNumber, decimals, maxLength, allowNegativeInput)) { - setSelection((prevSelection) => ({...prevSelection})); - return; - } + const setNewNumber = (newNumber: string) => { + // Remove spaces from the newNumber number because Safari on iOS adds spaces when pasting a copied number + // More info: https://github.com/Expensify/App/issues/16974 + const newNumberWithoutSpaces = stripSpacesFromAmount(newNumber); + const rawFinalNumber = newNumberWithoutSpaces.includes('.') ? stripCommaFromAmount(newNumberWithoutSpaces) : replaceCommasWithPeriod(newNumberWithoutSpaces); - willSelectionBeUpdatedManually.current = true; - let hasSelectionBeenSet = false; - const strippedNumber = stripCommaFromAmount(finalNumber); - numberRef.current = strippedNumber; - setCurrentNumber((prevNumber) => { - const isForwardDelete = prevNumber.length > strippedNumber.length && forwardDeletePressedRef.current; - if (!hasSelectionBeenSet) { - hasSelectionBeenSet = true; - setSelection((prevSelection) => getNewSelection(prevSelection, isForwardDelete ? strippedNumber.length : prevNumber.length, strippedNumber.length)); - willSelectionBeUpdatedManually.current = false; - } - return strippedNumber; - }); - onInputChange?.(strippedNumber); - }, - [decimals, maxLength, onInputChange, allowFlippingAmount, toggleNegative, allowNegativeInput], - ); + // When allowNegativeInput is true, keep negative sign as-is (for split amounts) + // When allowFlippingAmount is true, strip the negative sign and call toggleNegative + const finalNumber = allowNegativeInput ? rawFinalNumber : handleNegativeAmountFlipping(rawFinalNumber, allowFlippingAmount, toggleNegative); + + // Use a shallow copy of selection to trigger setSelection + // More info: https://github.com/Expensify/App/issues/16385 + if (!validateAmount(finalNumber, decimals, maxLength, allowNegativeInput)) { + setSelection((prevSelection) => ({...prevSelection})); + return; + } + + willSelectionBeUpdatedManually.current = true; + let hasSelectionBeenSet = false; + const strippedNumber = stripCommaFromAmount(finalNumber); + numberRef.current = strippedNumber; + setCurrentNumber((prevNumber) => { + const isForwardDelete = prevNumber.length > strippedNumber.length && forwardDeletePressedRef.current; + if (!hasSelectionBeenSet) { + hasSelectionBeenSet = true; + setSelection((prevSelection) => getNewSelection(prevSelection, isForwardDelete ? strippedNumber.length : prevNumber.length, strippedNumber.length)); + willSelectionBeUpdatedManually.current = false; + } + return strippedNumber; + }); + onInputChange?.(strippedNumber); + }; /** * Set a new number number properly formatted, used for the TextInput @@ -375,37 +372,34 @@ function NumberWithSymbolForm({ * Update number with number or Backspace pressed for BigNumberPad. * Validate new number with decimal number regex up to 6 digits and 2 decimal digit to enable Next button */ - const updateValueNumberPad = useCallback( - (key: string) => { - if (shouldUpdateSelection && !isTextInputFocused(textInput)) { - textInput.current?.focus(); - } - // Backspace button is pressed - if (key === '<' || key === 'Backspace') { - if (currentNumber.length > 0) { - const selectionStart = selection.start === selection.end ? selection.start - 1 : selection.start; - const newNumber = `${currentNumber.substring(0, selectionStart)}${currentNumber.substring(selection.end)}`; - setNewNumber(addLeadingZero(newNumber, allowNegativeInput)); - } - return; + const updateValueNumberPad = (key: string) => { + if (shouldUpdateSelection && !isTextInputFocused(textInput)) { + textInput.current?.focus(); + } + // Backspace button is pressed + if (key === '<' || key === 'Backspace') { + if (currentNumber.length > 0) { + const selectionStart = selection.start === selection.end ? selection.start - 1 : selection.start; + const newNumber = `${currentNumber.substring(0, selectionStart)}${currentNumber.substring(selection.end)}`; + setNewNumber(addLeadingZero(newNumber, allowNegativeInput)); } - const newNumber = addLeadingZero(`${currentNumber.substring(0, selection.start)}${key}${currentNumber.substring(selection.end)}`, allowNegativeInput); - setNewNumber(newNumber); - }, - [currentNumber, selection.start, selection.end, shouldUpdateSelection, setNewNumber, allowNegativeInput], - ); + return; + } + const newNumber = addLeadingZero(`${currentNumber.substring(0, selection.start)}${key}${currentNumber.substring(selection.end)}`, allowNegativeInput); + setNewNumber(newNumber); + }; /** * Update long press number, to remove items pressing on < * * @param value - Changed text from user input */ - const updateLongPressHandlerState = useCallback((value: boolean) => { + const updateLongPressHandlerState = (value: boolean) => { setShouldUpdateSelection(!value); if (!value && !isTextInputFocused(textInput)) { textInput.current?.focus(); } - }, []); + }; /** * Input handler to check for a forward-delete key (or keyboard shortcut) press. @@ -460,16 +454,14 @@ function NumberWithSymbolForm({ }; // Calculate dynamic font size based on the total length of the amount display - const dynamicAmountStyle = useMemo(() => { - const totalLength = formattedNumber.length + (hideSymbol ? 0 : symbol.length) + (isNegative ? 1 : 0); - return StyleUtils.getAmountInputFontSize(totalLength); - }, [StyleUtils, formattedNumber.length, hideSymbol, symbol.length, isNegative]); + const totalLength = formattedNumber.length + (hideSymbol ? 0 : symbol.length) + (isNegative ? 1 : 0); + const dynamicAmountStyle = StyleUtils.getAmountInputFontSize(totalLength); /** * Handles pressing the flip button (+/-) to toggle negative sign * Only available in displayAsTextInput mode for manual expense flow */ - const handleFlipPress = useCallback(() => { + const handleFlipPress = () => { // Toggle the minus sign prefix in the value const isRemovingSign = currentNumber.startsWith('-'); const newValue = isRemovingSign ? currentNumber.slice(1) : `-${currentNumber}`; @@ -490,7 +482,7 @@ function NumberWithSymbolForm({ end: Math.max(prevSelection.end + offset, 0), })); onInputChange?.(newValue); - }, [currentNumber, onInputChange]); + }; // Borderless buttons read as part of the field rather than as controls on top of it, so their labels take the // supporting text color instead of the button's own, matching the icons beside them. @@ -501,71 +493,54 @@ function NumberWithSymbolForm({ * Renders flip (+/-) button and/or currency selection button when enabled * Only shown when clear button is not visible (see TextInput conditional rendering) */ - const textInputRightHandSideComponent = useMemo(() => { - return ( - - {leadingRightHandSideComponent} - {shouldShowFlipButton && allowNegativeInput && canUseTouchScreen && ( - - )} - {shouldShowCurrencyButton && !!currencyOrUnitButtonText && ( - - )} - - ); - }, [ - shouldShowFlipButton, - allowNegativeInput, - disabled, - shouldUseBorderlessButtons, - borderlessButtonTextStyle, - shouldShowCurrencyButton, - leadingRightHandSideComponent, - styles, - icons, - handleFlipPress, - onTrailingDropdownPress, - currencyOrUnitButtonText, - currencyButtonAccessibilityLabel, - translate, - ]); + /> + {translate('iou.flip')} + + )} + {shouldShowCurrencyButton && !!currencyOrUnitButtonText && ( + + )} + + ); if (displayAsTextInput) { return ( diff --git a/src/components/OfflineIndicator.tsx b/src/components/OfflineIndicator.tsx index cfecca387668..e033d1a288e5 100644 --- a/src/components/OfflineIndicator.tsx +++ b/src/components/OfflineIndicator.tsx @@ -9,7 +9,7 @@ import variables from '@styles/variables'; import type {StyleProp, ViewStyle} from 'react-native'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import Icon from './Icon'; @@ -33,7 +33,7 @@ function OfflineIndicator({style, containerStyles: containerStylesProp, addBotto const {isOffline} = useNetwork(); const icons = useMemoizedLazyExpensifyIcons(['OfflineCloud']); - const fallbackStyle = useMemo(() => [styles.offlineIndicatorContainer, containerStylesProp], [styles.offlineIndicatorContainer, containerStylesProp]); + const fallbackStyle = [styles.offlineIndicatorContainer, containerStylesProp]; const containerStyles = useBottomSafeSafeAreaPaddingStyle({ addBottomSafeAreaPadding, addOfflineIndicatorBottomSafeAreaPadding: false, diff --git a/src/components/OptionRow.tsx b/src/components/OptionRow.tsx index 9a446b6a6a65..2348b4138391 100644 --- a/src/components/OptionRow.tsx +++ b/src/components/OptionRow.tsx @@ -14,7 +14,6 @@ import CONST from '@src/CONST'; import type {ComponentRef} from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; -import {deepEqual} from 'fast-equals'; import React, {useEffect, useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; @@ -302,29 +301,6 @@ function OptionRow({ OptionRow.displayName = 'OptionRow'; -export default React.memo( - OptionRow, - (prevProps, nextProps) => - prevProps.isDisabled === nextProps.isDisabled && - prevProps.isMultilineSupported === nextProps.isMultilineSupported && - prevProps.isSelected === nextProps.isSelected && - prevProps.shouldHaveOptionSeparator === nextProps.shouldHaveOptionSeparator && - prevProps.showTitleTooltip === nextProps.showTitleTooltip && - // eslint-disable-next-line rulesdir/no-deep-equal-in-memo -- icons array is created inline in some usages (e.g., BaseReactionList) with unstable references - deepEqual(prevProps.option.icons, nextProps.option.icons) && - prevProps.optionIsFocused === nextProps.optionIsFocused && - prevProps.option.text === nextProps.option.text && - prevProps.option.alternateText === nextProps.option.alternateText && - prevProps.option.descriptiveText === nextProps.option.descriptiveText && - prevProps.option.brickRoadIndicator === nextProps.option.brickRoadIndicator && - prevProps.option.shouldShowSubscript === nextProps.option.shouldShowSubscript && - prevProps.option.ownerAccountID === nextProps.option.ownerAccountID && - prevProps.option.subtitle === nextProps.option.subtitle && - prevProps.option.pendingAction === nextProps.option.pendingAction && - prevProps.option.customIcon === nextProps.option.customIcon && - prevProps.option.tabIndex === nextProps.option.tabIndex && - // eslint-disable-next-line rulesdir/no-deep-equal-in-memo -- amountInputProps origin and reference stability cannot be determined across all usages - deepEqual(prevProps.option.amountInputProps, nextProps.option.amountInputProps), -); +export default OptionRow; export type {OptionRowProps}; diff --git a/src/components/PDFThumbnail/index.native.tsx b/src/components/PDFThumbnail/index.native.tsx index fd2f0cb10f3f..ffd51e09a99d 100644 --- a/src/components/PDFThumbnail/index.native.tsx +++ b/src/components/PDFThumbnail/index.native.tsx @@ -52,4 +52,4 @@ function PDFThumbnail({previewSourceURL, style, enabled = true, fitPolicy = 0, o PDFThumbnail.displayName = 'PDFThumbnail'; -export default React.memo(PDFThumbnail); +export default PDFThumbnail; diff --git a/src/components/PDFThumbnail/index.tsx b/src/components/PDFThumbnail/index.tsx index 28d8ad218ca0..5d5c8ac09b6e 100644 --- a/src/components/PDFThumbnail/index.tsx +++ b/src/components/PDFThumbnail/index.tsx @@ -6,7 +6,7 @@ import type {PDFDocumentProxy} from 'pdfjs-dist'; // eslint-disable-next-line import/extensions import pdfWorkerSource from 'pdfjs-dist/legacy/build/pdf.worker.min.mjs'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import {Document, pdfjs, Thumbnail} from 'react-pdf'; @@ -27,37 +27,34 @@ function PDFThumbnail({previewSourceURL, style, enabled = true, onPassword, onLo const styles = useThemeStyles(); const [failedToLoad, setFailedToLoad] = useState(false); - const thumbnail = useMemo( - () => ( - } - file={previewSourceURL} - options={PDF_DOCUMENT_OPTIONS} - externalLinkTarget="_blank" - onPassword={onPassword} - onLoad={() => { - setFailedToLoad(false); - }} - onLoadSuccess={(pdf: PDFDocumentProxy) => { - if (!onLoadSuccess) { - return; - } - onLoadSuccess(pdf.numPages); - }} - onLoadError={() => { - if (onLoadError) { - onLoadError(); - } - setFailedToLoad(true); - }} - error={() => null} - > - - - - - ), - [previewSourceURL, onPassword, onLoadError, onLoadSuccess], + const thumbnail = ( + } + file={previewSourceURL} + options={PDF_DOCUMENT_OPTIONS} + externalLinkTarget="_blank" + onPassword={onPassword} + onLoad={() => { + setFailedToLoad(false); + }} + onLoadSuccess={(pdf: PDFDocumentProxy) => { + if (!onLoadSuccess) { + return; + } + onLoadSuccess(pdf.numPages); + }} + onLoadError={() => { + if (onLoadError) { + onLoadError(); + } + setFailedToLoad(true); + }} + error={() => null} + > + + + + ); return ( @@ -81,7 +78,7 @@ function PDFThumbnail({previewSourceURL, style, enabled = true, onPassword, onLo PDFThumbnail.displayName = 'PDFThumbnail'; -export default React.memo(PDFThumbnail); +export default PDFThumbnail; // Re-exported so other PDF-rendering components reuse this file's worker setup // instead of importing pdfjs-dist/react-pdf directly. diff --git a/src/components/PDFView/PDFPasswordForm.tsx b/src/components/PDFView/PDFPasswordForm.tsx index d8c8192bd4ee..2a4eacdbb481 100644 --- a/src/components/PDFView/PDFPasswordForm.tsx +++ b/src/components/PDFView/PDFPasswordForm.tsx @@ -13,7 +13,7 @@ import {getBrowser} from '@libs/Browser'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import React, {useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import PDFInfoMessage from './PDFInfoMessage'; @@ -49,15 +49,12 @@ function PDFPasswordForm({isFocused, isPasswordInvalid = false, shouldShowLoadin const focusTimeoutRef = useRef(undefined); - const errorText = useMemo(() => { - if (isPasswordInvalid) { - return translate('attachmentView.passwordIncorrect'); - } - if (validationErrorText) { - return translate(validationErrorText); - } - return ''; - }, [isPasswordInvalid, validationErrorText, translate]); + let errorText; + if (isPasswordInvalid) { + errorText = translate('attachmentView.passwordIncorrect'); + } else { + errorText = validationErrorText ? translate(validationErrorText) : ''; + } useEffect(() => { if (!isFocused) { diff --git a/src/components/PDFView/index.native.tsx b/src/components/PDFView/index.native.tsx index 971fccd3e8b7..ee8712b174d2 100644 --- a/src/components/PDFView/index.native.tsx +++ b/src/components/PDFView/index.native.tsx @@ -19,7 +19,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {StyleProp, ViewStyle} from 'react-native'; -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {Keyboard, Linking, View} from 'react-native'; import PDF from 'react-native-pdf'; @@ -77,7 +77,7 @@ function PDFView({onToggleKeyboard, onLoadComplete, fileName, onPress, isFocused * invalid. */ - const initiatePasswordChallenge = useCallback(() => { + const initiatePasswordChallenge = () => { setShouldShowLoadingIndicator(false); // Render password form, and don't render PDF and loading indicator. @@ -90,7 +90,7 @@ function PDFView({onToggleKeyboard, onLoadComplete, fileName, onPress, isFocused if (password) { setIsPasswordInvalid(true); } - }, [password]); + }; const handleFailureToLoadPDF = ((error: Error) => { if (error.message.match(/password/i)) { @@ -138,21 +138,18 @@ function PDFView({onToggleKeyboard, onLoadComplete, fileName, onPress, isFocused /** * Handle press link event on native apps. */ - const handlePressLink = useCallback( - (url: string) => { - if (!isAllowedPDFLink(url)) { - return; - } - - if (isTravelLink(url) && activePolicyID) { - const postLoginPath = getRelativeUrl(url); - openTravelDotLink(activePolicyID, postLoginPath); - return; - } - Linking.openURL(url); - }, - [activePolicyID], - ); + const handlePressLink = (url: string) => { + if (!isAllowedPDFLink(url)) { + return; + } + + if (isTravelLink(url) && activePolicyID) { + const postLoginPath = getRelativeUrl(url); + openTravelDotLink(activePolicyID, postLoginPath); + return; + } + Linking.openURL(url); + }; function renderPDFView() { // Insets are used to adjust the maxWidth of the PDF view in landscape mode diff --git a/src/components/PDFView/index.tsx b/src/components/PDFView/index.tsx index 1b3f2e53ac16..5d25863410aa 100644 --- a/src/components/PDFView/index.tsx +++ b/src/components/PDFView/index.tsx @@ -26,7 +26,7 @@ import type {Dimensions} from '@src/types/utils/Layout'; import type {CSSProperties, ReactNode} from 'react'; import type {LayoutChangeEvent} from 'react-native'; -import React, {memo, useCallback, useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {PDFPreviewer} from 'react-fast-pdf'; import {View} from 'react-native'; import {useAnimatedReaction, useSharedValue} from 'react-native-reanimated'; @@ -172,16 +172,13 @@ function PDFView({onToggleKeyboard, fileName, onPress, isFocused, onScaleChanged * * @param isKBOpen True if keyboard is open */ - const toggleKeyboardOnSmallScreens = useCallback( - (isKBOpen: boolean) => { - if (!shouldUseNarrowLayout) { - return; - } - setIsKeyboardOpen(isKBOpen); - onToggleKeyboard?.(isKBOpen); - }, - [shouldUseNarrowLayout, onToggleKeyboard], - ); + const toggleKeyboardOnSmallScreens = (isKBOpen: boolean) => { + if (!shouldUseNarrowLayout) { + return; + } + setIsKeyboardOpen(isKBOpen); + onToggleKeyboard?.(isKBOpen); + }; /** * Verify that the canvas limits have been calculated already, if not calculate them and put them in Onyx @@ -320,4 +317,4 @@ function PDFView({onToggleKeyboard, fileName, onPress, isFocused, onScaleChanged ); } -export default memo(PDFView); +export default PDFView; diff --git a/src/components/PercentageForm.tsx b/src/components/PercentageForm.tsx index 9cfe6a287acb..e816d560e49d 100644 --- a/src/components/PercentageForm.tsx +++ b/src/components/PercentageForm.tsx @@ -7,7 +7,7 @@ import CONST from '@src/CONST'; import type {ForwardedRef} from 'react'; -import React, {useCallback, useMemo, useRef} from 'react'; +import React, {useRef} from 'react'; import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types'; @@ -55,28 +55,25 @@ function PercentageForm({ const textInput = useRef(null); - const currentAmount = useMemo(() => (typeof amount === 'string' ? amount : ''), [amount]); + const currentAmount = typeof amount === 'string' ? amount : ''; /** * Sets the amount according to the value passed to the input * @param newAmount - Changed amount from user input */ - const setNewAmount = useCallback( - (newAmount: string) => { - // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value - // More info: https://github.com/Expensify/App/issues/16974 - const newAmountWithoutSpaces = stripSpacesFromAmount(newAmount); - const withLeadingZero = addLeadingZero(newAmountWithoutSpaces, allowNegative); - if (!validatePercentage(withLeadingZero, allowExceedingHundred, allowDecimal, allowNegative)) { - return; - } - - // Convert comma to period for internal representation (commas are used as decimal separators in some locales like Spanish) - const normalizedAmount = replaceCommasWithPeriod(newAmountWithoutSpaces); - onInputChange?.(normalizedAmount); - }, - [allowExceedingHundred, allowDecimal, allowNegative, onInputChange], - ); + const setNewAmount = (newAmount: string) => { + // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value + // More info: https://github.com/Expensify/App/issues/16974 + const newAmountWithoutSpaces = stripSpacesFromAmount(newAmount); + const withLeadingZero = addLeadingZero(newAmountWithoutSpaces, allowNegative); + if (!validatePercentage(withLeadingZero, allowExceedingHundred, allowDecimal, allowNegative)) { + return; + } + + // Convert comma to period for internal representation (commas are used as decimal separators in some locales like Spanish) + const normalizedAmount = replaceCommasWithPeriod(newAmountWithoutSpaces); + onInputChange?.(normalizedAmount); + }; const formattedAmount = replaceAllDigits(currentAmount, toLocaleDigit); diff --git a/src/components/Picker/BasePicker.tsx b/src/components/Picker/BasePicker.tsx index 7122675e9914..f0278dc0760b 100644 --- a/src/components/Picker/BasePicker.tsx +++ b/src/components/Picker/BasePicker.tsx @@ -17,7 +17,7 @@ import type {ComponentRef, ReactElement, ReactNode, RefObject} from 'react'; import type {ScrollView} from 'react-native'; import lodashDefer from 'lodash/defer'; -import React, {useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; import {View} from 'react-native'; import RNPickerSelect from 'react-native-picker-select'; @@ -104,19 +104,23 @@ function BasePickerImpl({ setIsHighlighted(false); }; - const iconToRender = useMemo((): IconToRender => { - if (icon) { - return () => icon(size); - } - - return () => ( + let iconToRender: IconToRender; + if (icon) { + iconToRender = () => icon(size); + } else { + iconToRender = () => ( ); - }, [icon, size, styles, theme.icon, icons.DownArrow]); + } useImperativeHandle(ref, () => ({ /** @@ -169,15 +173,14 @@ function BasePickerImpl({ const selectedItem = items.find((item) => item.value === value); const selectedLabel = selectedItem?.label ?? ''; const defaultAccessibilityLabel = accessibilityLabel ?? label ?? selectedLabel; - const enhancedAccessibilityLabel = useMemo(() => { - if (!defaultAccessibilityLabel) { - return selectedLabel || ''; - } - if (selectedLabel) { - return `${defaultAccessibilityLabel}${shouldAnnounceSelectedLabel ? `, ${selectedLabel}` : ''}, ${translate(isHighlighted ? 'common.expanded' : 'common.collapsed')}`; - } - return defaultAccessibilityLabel; - }, [defaultAccessibilityLabel, selectedLabel, shouldAnnounceSelectedLabel, translate, isHighlighted]); + let enhancedAccessibilityLabel; + if (!defaultAccessibilityLabel) { + enhancedAccessibilityLabel = selectedLabel || ''; + } else { + enhancedAccessibilityLabel = selectedLabel + ? `${defaultAccessibilityLabel}${shouldAnnounceSelectedLabel ? `, ${selectedLabel}` : ''}, ${translate(isHighlighted ? 'common.expanded' : 'common.collapsed')}` + : defaultAccessibilityLabel; + } if (isDisabled && shouldShowOnlyTextWhenDisabled) { return ( diff --git a/src/components/PlaidLink/index.tsx b/src/components/PlaidLink/index.tsx index 83757fb2c20e..5604cf8b26c4 100644 --- a/src/components/PlaidLink/index.tsx +++ b/src/components/PlaidLink/index.tsx @@ -9,7 +9,7 @@ import CONST from '@src/CONST'; import type {PlaidLinkOnSuccessMetadata} from 'react-plaid-link'; -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import {usePlaidLink} from 'react-plaid-link'; @@ -28,13 +28,10 @@ function clearSafariHistoryState() { function PlaidLink({token, onSuccess = () => {}, onError = () => {}, onExit = () => {}, onEvent, receivedRedirectURI}: PlaidLinkProps) { const [isPlaidLoaded, setIsPlaidLoaded] = useState(false); const styles = useThemeStyles(); - const successCallback = useCallback( - (publicToken: string, metadata: PlaidLinkOnSuccessMetadata) => { - clearSafariHistoryState(); - onSuccess({publicToken, metadata}); - }, - [onSuccess], - ); + const successCallback = (publicToken: string, metadata: PlaidLinkOnSuccessMetadata) => { + clearSafariHistoryState(); + onSuccess({publicToken, metadata}); + }; const {open, ready, error} = usePlaidLink({ token, diff --git a/src/components/PopoverMenu/index.tsx b/src/components/PopoverMenu/index.tsx index b771d516a4bf..ebca5649f7f6 100644 --- a/src/components/PopoverMenu/index.tsx +++ b/src/components/PopoverMenu/index.tsx @@ -42,7 +42,7 @@ import type {ComponentRef, ReactNode, RefObject} from 'react'; import type {GestureResponderEvent, LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import {deepEqual} from 'fast-equals'; -import React, {useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useLayoutEffect, useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; import usePopoverMenuFocusManagement from './usePopoverMenuFocusManagement'; @@ -453,25 +453,20 @@ function BasePopoverMenu({ }); const expensifyIcons = useMemoizedLazyExpensifyIcons(['BackArrow', 'ReceiptScan', 'MoneyCircle']); const prevMenuItems = usePrevious(menuItems); - const {currentHeaderText, shouldAlwaysShowHeaderText} = useMemo(() => { - let currentItems = menuItems; - let currentHeader = headerText ?? ''; - let shouldShow = true; - - if (enteredSubMenuIndexes.length > 0) { - for (const index of enteredSubMenuIndexes) { - const item = currentItems.at(index); - if (!item?.subMenuItems) { - break; - } - currentHeader = item.subMenuHeaderText ?? ''; - shouldShow = !!item.subMenuHeaderText; - currentItems = item.subMenuItems; + let headerMenuItems = menuItems; + let currentHeaderText = headerText ?? ''; + let shouldAlwaysShowHeaderText = true; + if (enteredSubMenuIndexes.length > 0) { + for (const index of enteredSubMenuIndexes) { + const item = headerMenuItems.at(index); + if (!item?.subMenuItems) { + break; } + currentHeaderText = item.subMenuHeaderText ?? ''; + shouldAlwaysShowHeaderText = !!item.subMenuHeaderText; + headerMenuItems = item.subMenuItems; } - - return {currentHeaderText: currentHeader, shouldAlwaysShowHeaderText: shouldShow}; - }, [enteredSubMenuIndexes, headerText, menuItems]); + } const [hasKeyBeenPressed, setHasKeyBeenPressed] = useState(false); const { @@ -778,21 +773,31 @@ function BasePopoverMenu({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [menuItems, setFocusedIndex, isSearchEnabled]); - const menuContainerStyle = useMemo(() => { - if (isSmallScreenWidth) { - return shouldEnableMaxHeight && !isInLandscapeMode ? [{maxHeight: CONST.POPOVER_MENU_MAX_HEIGHT_MOBILE}] : []; - } - - const stylesArray: ViewStyle[] = [StyleSheet.flatten(styles.createMenuContainer), {width: variables.compactPopoverMenuWidth}, styles.pv2]; - + let menuContainerStyle; + if (isSmallScreenWidth) { + menuContainerStyle = + shouldEnableMaxHeight && !isInLandscapeMode + ? [ + { + maxHeight: CONST.POPOVER_MENU_MAX_HEIGHT_MOBILE, + }, + ] + : []; + } else { + const stylesArray: ViewStyle[] = [ + StyleSheet.flatten(styles.createMenuContainer), + { + width: variables.compactPopoverMenuWidth, + }, + styles.pv2, + ]; if (shouldUseScrollView && shouldEnableMaxHeight && !isInLandscapeMode) { stylesArray.push({ maxHeight: Math.max(windowHeight - variables.compactPopoverMenuVerticalMargin, CONST.POPOVER_MENU_MAX_HEIGHT), }); } - - return stylesArray; - }, [isSmallScreenWidth, shouldEnableMaxHeight, styles.createMenuContainer, styles.pv2, shouldUseScrollView, windowHeight, isInLandscapeMode]); + menuContainerStyle = stylesArray; + } const {paddingTop, paddingBottom, paddingVertical, ...restScrollContainerStyle} = (StyleSheet.flatten([isSmallScreenWidth ? styles.pv4 : styles.pv2, isSearchEnabled && styles.pt0, scrollContainerStyle]) as ViewStyle) ?? {}; @@ -810,24 +815,11 @@ function BasePopoverMenu({ ...restContainerStyles } = StyleSheet.flatten(containerStyles) ?? {}; - const scrollViewPaddingStyles = useMemo( - () => ({ - paddingTop: paddingTop ?? containerPaddingTop ?? menuContainerPaddingTop, - paddingBottom: paddingBottom ?? containerPaddingBottom ?? menuContainerPaddingBottom, - paddingVertical: paddingVertical ?? containerPaddingVertical ?? menuContainerPaddingVertical ?? 0, - }), - [ - paddingTop, - containerPaddingTop, - menuContainerPaddingTop, - paddingBottom, - containerPaddingBottom, - menuContainerPaddingBottom, - paddingVertical, - containerPaddingVertical, - menuContainerPaddingVertical, - ], - ); + const scrollViewPaddingStyles = { + paddingTop: paddingTop ?? containerPaddingTop ?? menuContainerPaddingTop, + paddingBottom: paddingBottom ?? containerPaddingBottom ?? menuContainerPaddingBottom, + paddingVertical: paddingVertical ?? containerPaddingVertical ?? menuContainerPaddingVertical ?? 0, + }; return ( - // eslint-disable-next-line rulesdir/no-deep-equal-in-memo -- menuItems array is created inline in most usages with unstable references - deepEqual(prevProps.menuItems, nextProps.menuItems) && - prevProps.isVisible === nextProps.isVisible && - // eslint-disable-next-line rulesdir/no-deep-equal-in-memo -- anchorPosition object is created inline in most usages - deepEqual(prevProps.anchorPosition, nextProps.anchorPosition) && - prevProps.anchorRef === nextProps.anchorRef && - prevProps.headerText === nextProps.headerText && - prevProps.searchInputOptions?.label === nextProps.searchInputOptions?.label && - prevProps.searchInputOptions?.value === nextProps.searchInputOptions?.value && - prevProps.searchInputOptions?.onChangeText === nextProps.searchInputOptions?.onChangeText && - prevProps.searchInputOptions?.shouldShowEmptyState === nextProps.searchInputOptions?.shouldShowEmptyState && - prevProps.searchInputOptions?.style === nextProps.searchInputOptions?.style && - prevProps.fromSidebarMediumScreen === nextProps.fromSidebarMediumScreen && - // eslint-disable-next-line rulesdir/no-deep-equal-in-memo -- anchorAlignment object is created inline in most usages - deepEqual(prevProps.anchorAlignment, nextProps.anchorAlignment) && - prevProps.shouldSwitchPositionIfOverflow === nextProps.shouldSwitchPositionIfOverflow && - prevProps.animationIn === nextProps.animationIn && - prevProps.animationOut === nextProps.animationOut && - prevProps.animationInTiming === nextProps.animationInTiming && - prevProps.disableAnimation === nextProps.disableAnimation && - prevProps.withoutOverlay === nextProps.withoutOverlay && - prevProps.shouldEnableNewFocusManagement === nextProps.shouldEnableNewFocusManagement && - prevProps.shouldReturnFocus === nextProps.shouldReturnFocus && - prevProps.restoreFocusType === nextProps.restoreFocusType && - prevProps.shouldSetModalVisibility === nextProps.shouldSetModalVisibility && - prevProps.shouldPutHeaderTextAfterBackButton === nextProps.shouldPutHeaderTextAfterBackButton, -); +export default PopoverMenu; export type {PopoverMenuItem, PopoverMenuProps}; export {getItemKey, buildKeyPathFromIndexPath, resolveIndexPathByKeyPath}; diff --git a/src/components/PopoverProvider/index.tsx b/src/components/PopoverProvider/index.tsx index 297c64992540..33fdde5a47b8 100644 --- a/src/components/PopoverProvider/index.tsx +++ b/src/components/PopoverProvider/index.tsx @@ -1,6 +1,6 @@ import type {RefObject} from 'react'; -import React, {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {createContext, useContext, useEffect, useRef, useState} from 'react'; import type {AnchorRef, AnchorType, PopoverContextProps} from './types'; @@ -44,7 +44,7 @@ function PopoverContextProvider(props: PopoverContextProps) { const [activePopoverAnchor, setActivePopoverAnchor] = useState(null); const [activePopoverExtraAnchorRefs, setActivePopoverExtraAnchorRefs] = useState([]); - const closePopover = useCallback((anchorRef?: RefObject): boolean => { + const closePopover = (anchorRef?: RefObject): boolean => { if (!activePopoverRef.current || (anchorRef && anchorRef !== activePopoverRef.current.anchorRef)) { return false; } @@ -55,7 +55,7 @@ function PopoverContextProvider(props: PopoverContextProps) { setIsOpen(false); setActivePopoverAnchor(null); return true; - }, []); + }; useEffect(() => { const listener = (e: Event) => { @@ -131,21 +131,18 @@ function PopoverContextProvider(props: PopoverContextProps) { }; }, [closePopover]); - const onOpen = useCallback( - (popoverParams: AnchorRef) => { - if (activePopoverRef.current && activePopoverRef.current.ref !== popoverParams?.ref) { - closePopover(activePopoverRef.current.anchorRef); - } - activePopoverRef.current = popoverParams; - setActivePopover(popoverParams); - setActivePopoverAnchor(popoverParams.anchorRef.current); - setIsOpen(true); - }, - [closePopover], - ); + const onOpen = (popoverParams: AnchorRef) => { + if (activePopoverRef.current && activePopoverRef.current.ref !== popoverParams?.ref) { + closePopover(activePopoverRef.current.anchorRef); + } + activePopoverRef.current = popoverParams; + setActivePopover(popoverParams); + setActivePopoverAnchor(popoverParams.anchorRef.current); + setIsOpen(true); + }; // To set the extra anchor refs for the popover when prop-drilling is not possible - const setActivePopoverExtraAnchorRef = useCallback((extraAnchorRef?: RefObject) => { + const setActivePopoverExtraAnchorRef = (extraAnchorRef?: RefObject) => { if (!extraAnchorRef) { return; } @@ -159,25 +156,19 @@ function PopoverContextProvider(props: PopoverContextProps) { } return [...prev, extraAnchorRef]; }); - }, []); - - const actionsContextValue = useMemo( - () => ({ - onOpen, - close: closePopover, - setActivePopoverExtraAnchorRef, - }), - [onOpen, closePopover, setActivePopoverExtraAnchorRef], - ); - - const stateContextValue = useMemo( - () => ({ - isOpen, - popover: activePopover, - popoverAnchor: activePopoverAnchor, - }), - [isOpen, activePopover, activePopoverAnchor], - ); + }; + + const actionsContextValue: PopoverActionsContextType = { + onOpen, + close: closePopover, + setActivePopoverExtraAnchorRef, + }; + + const stateContextValue: PopoverStateContextType = { + isOpen, + popover: activePopover, + popoverAnchor: activePopoverAnchor, + }; return ( diff --git a/src/components/PopoverWithMeasuredContent/PopoverWithMeasuredContentBase.tsx b/src/components/PopoverWithMeasuredContent/PopoverWithMeasuredContentBase.tsx index 9e32ffa57ae5..7a62ce15a88a 100644 --- a/src/components/PopoverWithMeasuredContent/PopoverWithMeasuredContentBase.tsx +++ b/src/components/PopoverWithMeasuredContent/PopoverWithMeasuredContentBase.tsx @@ -14,8 +14,8 @@ import CONST from '@src/CONST'; import type {LayoutChangeEvent} from 'react-native'; -import {circularDeepEqual, deepEqual} from 'fast-equals'; -import React, {useEffect, useMemo, useState} from 'react'; +import {deepEqual} from 'fast-equals'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import type PopoverWithMeasuredContentProps from './types'; @@ -70,7 +70,7 @@ function PopoverWithMeasuredContentBase({ const prevWindowDimensions = usePrevious({windowWidth, windowHeight}); const hasStaticDimensions = popoverDimensions.width > 0 && popoverDimensions.height > 0; - const modalId = useMemo(() => ComposerFocusManager.getId(), []); + const modalId = ComposerFocusManager.getId(); // Static dimensions skip `onLayout`, so sync height changes from the prop. if (hasStaticDimensions && popoverHeight !== popoverDimensions.height) { @@ -124,92 +124,82 @@ function PopoverWithMeasuredContentBase({ } }; - const adjustedAnchorPosition = useMemo(() => { - let horizontalConstraint; - switch (anchorAlignment.horizontal) { - case CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT: - horizontalConstraint = {left: anchorPosition.horizontal - popoverWidth}; - break; - case CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.CENTER: - horizontalConstraint = { - left: Math.floor(anchorPosition.horizontal - popoverWidth / 2), - }; - break; - case CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT: - default: - horizontalConstraint = {left: anchorPosition.horizontal}; - } - - let verticalConstraint; - switch (anchorAlignment.vertical) { - case CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM: - verticalConstraint = {top: anchorPosition.vertical - popoverHeight}; - break; - case CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.CENTER: - verticalConstraint = { - top: Math.floor(anchorPosition.vertical - popoverHeight / 2), - }; - break; - case CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP: - default: - verticalConstraint = {top: anchorPosition.vertical}; - } - return { - ...horizontalConstraint, - ...verticalConstraint, - }; - }, [anchorPosition.horizontal, anchorPosition.vertical, anchorAlignment.horizontal, anchorAlignment.vertical, popoverWidth, popoverHeight]); - - const positionCalculations = useMemo(() => { - const horizontalShift = PopoverWithMeasuredContentUtils.computeHorizontalShift(adjustedAnchorPosition.left, popoverWidth, windowWidth); - const verticalShift = PopoverWithMeasuredContentUtils.computeVerticalShift( - adjustedAnchorPosition.top, - popoverHeight, - windowHeight, - anchorDimensions.height, - shouldSwitchPositionIfOverflow, - ); - return {horizontalShift, verticalShift}; - }, [adjustedAnchorPosition.left, adjustedAnchorPosition.top, popoverWidth, popoverHeight, windowWidth, windowHeight, anchorDimensions.height, shouldSwitchPositionIfOverflow]); - - const shiftedAnchorPosition: PopoverAnchorPosition = useMemo(() => { - const result: PopoverAnchorPosition = { - left: adjustedAnchorPosition.left + positionCalculations.horizontalShift, - }; - - if (shouldMeasureAnchorPositionFromTop) { - result.top = adjustedAnchorPosition.top + positionCalculations.verticalShift; - } - - if (anchorAlignment.vertical === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP) { - const top = adjustedAnchorPosition.top + positionCalculations.verticalShift; - const maxTop = windowHeight - popoverHeight - positionCalculations.verticalShift; - let clampedTop = Math.min(Math.max(positionCalculations.verticalShift, top), maxTop); - if (windowMargin > 0) { - clampedTop = Math.min(Math.max(clampedTop, windowMargin), Math.max(windowHeight - popoverHeight - windowMargin, windowMargin)); - } - result.top = clampedTop; - } - - if (anchorAlignment.vertical === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM) { - const bottom = windowHeight - (adjustedAnchorPosition.top + popoverHeight) - positionCalculations.verticalShift; - // Keep the top edge inside the window when the popover is taller than the space above the anchor. - const maxBottom = windowHeight - popoverHeight - windowMargin; - result.bottom = windowMargin > 0 ? Math.min(Math.max(bottom, windowMargin), Math.max(maxBottom, windowMargin)) : Math.min(bottom, maxBottom); - } + let horizontalConstraint; + switch (anchorAlignment.horizontal) { + case CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT: + horizontalConstraint = { + left: anchorPosition.horizontal - popoverWidth, + }; + break; + case CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.CENTER: + horizontalConstraint = { + left: Math.floor(anchorPosition.horizontal - popoverWidth / 2), + }; + break; + case CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT: + default: + horizontalConstraint = { + left: anchorPosition.horizontal, + }; + } + let verticalConstraint; + switch (anchorAlignment.vertical) { + case CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM: + verticalConstraint = { + top: anchorPosition.vertical - popoverHeight, + }; + break; + case CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.CENTER: + verticalConstraint = { + top: Math.floor(anchorPosition.vertical - popoverHeight / 2), + }; + break; + case CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP: + default: + verticalConstraint = { + top: anchorPosition.vertical, + }; + } + const adjustedAnchorPosition = { + ...horizontalConstraint, + ...verticalConstraint, + }; - return result; - }, [ - adjustedAnchorPosition.left, + const horizontalShift = PopoverWithMeasuredContentUtils.computeHorizontalShift(adjustedAnchorPosition.left, popoverWidth, windowWidth); + const verticalShift = PopoverWithMeasuredContentUtils.computeVerticalShift( adjustedAnchorPosition.top, - positionCalculations.horizontalShift, - positionCalculations.verticalShift, - anchorAlignment.vertical, - windowHeight, popoverHeight, - shouldMeasureAnchorPositionFromTop, - windowMargin, - ]); + windowHeight, + anchorDimensions.height, + shouldSwitchPositionIfOverflow, + ); + const positionCalculations = { + horizontalShift, + verticalShift, + }; + + const result: PopoverAnchorPosition = { + left: adjustedAnchorPosition.left + positionCalculations.horizontalShift, + }; + if (shouldMeasureAnchorPositionFromTop) { + result.top = adjustedAnchorPosition.top + positionCalculations.verticalShift; + } + if (anchorAlignment.vertical === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP) { + const top = adjustedAnchorPosition.top + positionCalculations.verticalShift; + const maxTop = windowHeight - popoverHeight - positionCalculations.verticalShift; + let clampedTop = Math.min(Math.max(positionCalculations.verticalShift, top), maxTop); + if (windowMargin > 0) { + clampedTop = Math.min(Math.max(clampedTop, windowMargin), Math.max(windowHeight - popoverHeight - windowMargin, windowMargin)); + } + result.top = clampedTop; + } + if (anchorAlignment.vertical === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM) { + const bottom = windowHeight - (adjustedAnchorPosition.top + popoverHeight) - positionCalculations.verticalShift; + // Keep the top edge inside the window when the popover is taller than the space above the anchor. + const maxBottom = windowHeight - popoverHeight - windowMargin; + result.bottom = windowMargin > 0 ? Math.min(Math.max(bottom, windowMargin), Math.max(maxBottom, windowMargin)) : Math.min(bottom, maxBottom); + } + const shiftedAnchorPosition: PopoverAnchorPosition = result; return isContentMeasured ? ( { - if (prevProps.isVisible === nextProps.isVisible && nextProps.isVisible === false) { - return true; - } - return circularDeepEqual(prevProps, nextProps); -}); +export default PopoverWithMeasuredContentBase; diff --git a/src/components/PopoverWithMeasuredContent/index.tsx b/src/components/PopoverWithMeasuredContent/index.tsx index bc2882f52c9f..aa8943166624 100644 --- a/src/components/PopoverWithMeasuredContent/index.tsx +++ b/src/components/PopoverWithMeasuredContent/index.tsx @@ -4,7 +4,6 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import CONST from '@src/CONST'; -import {circularDeepEqual} from 'fast-equals'; import React, {useEffect, useState, useTransition} from 'react'; import type PopoverWithMeasuredContentProps from './types'; @@ -56,9 +55,4 @@ function PopoverWithMeasuredContent({shouldWrapModalChildrenInScrollViewIfBottom PopoverWithMeasuredContent.displayName = 'PopoverWithMeasuredContent'; -export default React.memo(PopoverWithMeasuredContent, (prevProps, nextProps) => { - if (prevProps.isVisible === nextProps.isVisible && nextProps.isVisible === false) { - return true; - } - return circularDeepEqual(prevProps, nextProps); -}); +export default PopoverWithMeasuredContent; diff --git a/src/components/PopoverWithoutOverlay/index.tsx b/src/components/PopoverWithoutOverlay/index.tsx index ea03d6b019de..e78a0d0b1c5c 100644 --- a/src/components/PopoverWithoutOverlay/index.tsx +++ b/src/components/PopoverWithoutOverlay/index.tsx @@ -12,7 +12,7 @@ import {onModalDidClose, setCloseModal, willAlertModalBecomeVisible} from '@libs import CONST from '@src/CONST'; import viewRef from '@src/types/utils/viewRef'; -import React, {useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import type PopoverWithoutOverlayProps from './types'; @@ -81,27 +81,14 @@ function PopoverWithoutOverlay({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isVisible]); - const modalPaddingStyles = useMemo( - () => - StyleUtils.getModalPaddingStyles({ - shouldAddBottomSafeAreaMargin, - shouldAddTopSafeAreaMargin, - shouldAddBottomSafeAreaPadding: enableEdgeToEdgeBottomSafeAreaPadding === undefined && shouldAddBottomSafeAreaPadding, - shouldAddTopSafeAreaPadding, - modalContainerStyle, - insets, - }), - [ - StyleUtils, - enableEdgeToEdgeBottomSafeAreaPadding, - insets, - modalContainerStyle, - shouldAddBottomSafeAreaMargin, - shouldAddBottomSafeAreaPadding, - shouldAddTopSafeAreaMargin, - shouldAddTopSafeAreaPadding, - ], - ); + const modalPaddingStyles = StyleUtils.getModalPaddingStyles({ + shouldAddBottomSafeAreaMargin, + shouldAddTopSafeAreaMargin, + shouldAddBottomSafeAreaPadding: enableEdgeToEdgeBottomSafeAreaPadding === undefined && shouldAddBottomSafeAreaPadding, + shouldAddTopSafeAreaPadding, + modalContainerStyle, + insets, + }); if (!isVisible) { return null; diff --git a/src/components/PriorityModeController.tsx b/src/components/PriorityModeController.tsx index 92b447b71099..7d8060d969b6 100644 --- a/src/components/PriorityModeController.tsx +++ b/src/components/PriorityModeController.tsx @@ -23,7 +23,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import {useNavigation} from '@react-navigation/native'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import RenderHTML from './RenderHTML'; @@ -60,7 +60,7 @@ export default function PriorityModeController() { const hasSwitched = useRef(false); const priorityModePageUrl = `${environmentURL}/settings/preferences/priority-mode`; - const handleShowModal = useCallback(() => { + const handleShowModal = () => { if (isModalShown.current) { return; } @@ -81,7 +81,7 @@ export default function PriorityModeController() { }).then(() => { isModalShown.current = false; }); - }, [StyleUtils, illustrations.ThreeLeggedLaptopWoman, priorityModePageUrl, showConfirmModal, styles, translate]); + }; // Listen for state changes and trigger the #focus mode when appropriate useEffect(() => { diff --git a/src/components/ProductTrainingContext/index.tsx b/src/components/ProductTrainingContext/index.tsx index b9db8eaef24b..f81e67e5e030 100644 --- a/src/components/ProductTrainingContext/index.tsx +++ b/src/components/ProductTrainingContext/index.tsx @@ -23,7 +23,7 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding'; import {emailSelector} from '@selectors/Session'; -import React, {createContext, useCallback, useContext, useEffect, useMemo, useState} from 'react'; +import React, {createContext, useContext, useEffect, useState} from 'react'; import {View} from 'react-native'; import type {ProductTrainingTooltipName} from './TOOLTIPS'; @@ -61,19 +61,11 @@ function ProductTrainingContextProvider({children}: ChildrenProps) { const [currentUserLogin, currentUserLoginMetadata] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); const shouldSuppressPromotionalUI = useShouldSuppressPromotionalUI(); - const isUserPolicyEmployee = useMemo(() => { - if (!allPolicies || !currentUserLogin || isLoadingOnyxValue(allPoliciesMetadata, currentUserLoginMetadata)) { - return false; - } - return getActiveEmployeeWorkspaces(allPolicies, currentUserLogin).length > 0; - }, [allPolicies, currentUserLogin, allPoliciesMetadata, currentUserLoginMetadata]); + const isUserPolicyEmployee = + !(!allPolicies || !currentUserLogin || isLoadingOnyxValue(allPoliciesMetadata, currentUserLoginMetadata)) && getActiveEmployeeWorkspaces(allPolicies, currentUserLogin).length > 0; - const isUserPolicyAdmin = useMemo(() => { - if (!allPolicies || !currentUserLogin || isLoadingOnyxValue(allPoliciesMetadata, currentUserLoginMetadata)) { - return false; - } - return getActiveAdminWorkspaces(allPolicies, currentUserLogin).length > 0; - }, [allPolicies, currentUserLogin, allPoliciesMetadata, currentUserLoginMetadata]); + const isUserPolicyAdmin = + !(!allPolicies || !currentUserLogin || isLoadingOnyxValue(allPoliciesMetadata, currentUserLoginMetadata)) && getActiveAdminWorkspaces(allPolicies, currentUserLogin).length > 0; const [dismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); @@ -84,18 +76,15 @@ function ProductTrainingContextProvider({children}: ChildrenProps) { const [activeTooltips, setActiveTooltips] = useState>(new Set()); - const unregisterTooltip = useCallback( - (tooltipName: ProductTrainingTooltipName) => { - setActiveTooltips((prev) => { - const next = new Set(prev); - next.delete(tooltipName); - return next; - }); - }, - [setActiveTooltips], - ); - - const determineVisibleTooltip = useCallback(() => { + const unregisterTooltip = (tooltipName: ProductTrainingTooltipName) => { + setActiveTooltips((prev) => { + const next = new Set(prev); + next.delete(tooltipName); + return next; + }); + }; + + const determineVisibleTooltip = () => { if (activeTooltips.size === 0) { return null; } @@ -114,122 +103,93 @@ function ProductTrainingContextProvider({children}: ChildrenProps) { } return highestPriorityTooltip.name; - }, [activeTooltips]); + }; - const isUserInPaidPolicy = useMemo(() => { - if (!allPolicies || !currentUserLogin || isLoadingOnyxValue(allPoliciesMetadata, currentUserLoginMetadata)) { + const isUserInPaidPolicy = !(!allPolicies || !currentUserLogin || isLoadingOnyxValue(allPoliciesMetadata, currentUserLoginMetadata)) && hasAnyPaidPolicy(allPolicies); + + const shouldTooltipBeVisible = (tooltipName: ProductTrainingTooltipName) => { + if (isLoadingOnyxValue(isOnboardingCompletedMetadata) || isLoadingApp) { return false; } - return hasAnyPaidPolicy(allPolicies); - }, [allPolicies, currentUserLogin, allPoliciesMetadata, currentUserLoginMetadata]); - - const shouldTooltipBeVisible = useCallback( - (tooltipName: ProductTrainingTooltipName) => { - if (isLoadingOnyxValue(isOnboardingCompletedMetadata) || isLoadingApp) { - return false; - } - - const tooltipConfig = TOOLTIPS[tooltipName]; - - const isDismissed = isProductTrainingElementDismissed(tooltipName, dismissedProductTraining); - - // Tooltips with a reappear window are shown again once that long has passed since the last dismissal. - const canReappear = !!tooltipConfig.reappearsAfterMs && hasDismissalExpired(tooltipName, dismissedProductTraining, tooltipConfig.reappearsAfterMs); - - if (isDismissed && !canReappear) { - return false; - } - - // if hasBeenAddedToNudgeMigration is true, and welcome modal is not dismissed, don't show tooltip - if (hasBeenAddedToNudgeMigration && !dismissedProductTraining?.[CONST.MIGRATED_USER_WELCOME_MODAL]) { - return false; - } - if (isOnboardingCompleted === false) { - return false; - } - - // Mileage rate tooltip is exempt from the general "hide when modal visible" rule so it can show on the confirmation surface. - // Hide it when a popover or bottom sheet opens (e.g. inline date picker) since those set modal.isPopover. - if (tooltipName === CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MILEAGE_RATE_AUTO_UPDATED && isModalVisible && modal?.isPopover) { - return false; - } - - // We need to make an exception for these tooltips because it is shown in a modal, otherwise it would be hidden if a modal is visible - if ( - tooltipName !== CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.SCAN_TEST_DRIVE_CONFIRMATION && - tooltipName !== CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.GPS_TOOLTIP && - tooltipName !== CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.HAS_FILTER_NEGATION && - tooltipName !== CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MILEAGE_RATE_AUTO_UPDATED && - isModalVisible - ) { - return false; - } - - return tooltipConfig.shouldShow({ - shouldUseNarrowLayout, - isUserPolicyEmployee, - isUserPolicyAdmin, - hasBeenAddedToNudgeMigration, - isUserInPaidPolicy, - }); - }, - [ - isOnboardingCompletedMetadata, - isLoadingApp, - dismissedProductTraining, - hasBeenAddedToNudgeMigration, - isOnboardingCompleted, - isModalVisible, - modal?.isPopover, + + const tooltipConfig = TOOLTIPS[tooltipName]; + + const isDismissed = isProductTrainingElementDismissed(tooltipName, dismissedProductTraining); + + // Tooltips with a reappear window are shown again once that long has passed since the last dismissal. + const canReappear = !!tooltipConfig.reappearsAfterMs && hasDismissalExpired(tooltipName, dismissedProductTraining, tooltipConfig.reappearsAfterMs); + + if (isDismissed && !canReappear) { + return false; + } + + // if hasBeenAddedToNudgeMigration is true, and welcome modal is not dismissed, don't show tooltip + if (hasBeenAddedToNudgeMigration && !dismissedProductTraining?.[CONST.MIGRATED_USER_WELCOME_MODAL]) { + return false; + } + if (isOnboardingCompleted === false) { + return false; + } + + // Mileage rate tooltip is exempt from the general "hide when modal visible" rule so it can show on the confirmation surface. + // Hide it when a popover or bottom sheet opens (e.g. inline date picker) since those set modal.isPopover. + if (tooltipName === CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MILEAGE_RATE_AUTO_UPDATED && isModalVisible && modal?.isPopover) { + return false; + } + + // We need to make an exception for these tooltips because it is shown in a modal, otherwise it would be hidden if a modal is visible + if ( + tooltipName !== CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.SCAN_TEST_DRIVE_CONFIRMATION && + tooltipName !== CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.GPS_TOOLTIP && + tooltipName !== CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.HAS_FILTER_NEGATION && + tooltipName !== CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MILEAGE_RATE_AUTO_UPDATED && + isModalVisible + ) { + return false; + } + + return tooltipConfig.shouldShow({ shouldUseNarrowLayout, isUserPolicyEmployee, isUserPolicyAdmin, + hasBeenAddedToNudgeMigration, isUserInPaidPolicy, - ], - ); - - const registerTooltip = useCallback( - (tooltipName: ProductTrainingTooltipName) => { - const shouldRegister = shouldTooltipBeVisible(tooltipName); - if (!shouldRegister) { - return; - } - setActiveTooltips((prev) => new Set([...prev, tooltipName])); - }, - [shouldTooltipBeVisible], - ); - - const shouldRenderTooltip = useCallback( - (tooltipName: ProductTrainingTooltipName) => { - // Supportal agents and copilots should not see product-training tooltips on behalf of another account. - if (shouldSuppressPromotionalUI) { - return false; - } - // First check base conditions - const shouldShow = shouldTooltipBeVisible(tooltipName); - if (!shouldShow) { - return false; - } - const visibleTooltip = determineVisibleTooltip(); - - // If this is the highest priority visible tooltip, show it - if (tooltipName === visibleTooltip) { - return true; - } + }); + }; + + const registerTooltip = (tooltipName: ProductTrainingTooltipName) => { + const shouldRegister = shouldTooltipBeVisible(tooltipName); + if (!shouldRegister) { + return; + } + setActiveTooltips((prev) => new Set([...prev, tooltipName])); + }; + const shouldRenderTooltip = (tooltipName: ProductTrainingTooltipName) => { + // Supportal agents and copilots should not see product-training tooltips on behalf of another account. + if (shouldSuppressPromotionalUI) { return false; - }, - [shouldSuppressPromotionalUI, shouldTooltipBeVisible, determineVisibleTooltip], - ); - - const contextValue = useMemo( - () => ({ - shouldRenderTooltip, - registerTooltip, - unregisterTooltip, - }), - [shouldRenderTooltip, registerTooltip, unregisterTooltip], - ); + } + // First check base conditions + const shouldShow = shouldTooltipBeVisible(tooltipName); + if (!shouldShow) { + return false; + } + const visibleTooltip = determineVisibleTooltip(); + + // If this is the highest priority visible tooltip, show it + if (tooltipName === visibleTooltip) { + return true; + } + + return false; + }; + + const contextValue = { + shouldRenderTooltip, + registerTooltip, + unregisterTooltip, + }; return {children}; } @@ -258,23 +218,18 @@ const useProductTrainingContext = (tooltipName: ProductTrainingTooltipName, shou }; }, [tooltipName, registerTooltip, unregisterTooltip, shouldShow]); - const shouldShowProductTrainingTooltip = useMemo(() => { - return shouldShow && shouldRenderTooltip(tooltipName) && !shouldHideToolTip; - }, [shouldRenderTooltip, tooltipName, shouldShow, shouldHideToolTip]); - - const hideTooltip = useCallback( - (isDismissedUsingCloseButton = false) => { - if (!shouldShowProductTrainingTooltip) { - return; - } - const tooltip = TOOLTIPS[tooltipName]; - tooltip.onHideTooltip(isDismissedUsingCloseButton); - unregisterTooltip(tooltipName); - }, - [tooltipName, shouldShowProductTrainingTooltip, unregisterTooltip], - ); + const shouldShowProductTrainingTooltip = shouldShow && shouldRenderTooltip(tooltipName) && !shouldHideToolTip; - const renderProductTrainingTooltip = useCallback(() => { + const hideTooltip = (isDismissedUsingCloseButton = false) => { + if (!shouldShowProductTrainingTooltip) { + return; + } + const tooltip = TOOLTIPS[tooltipName]; + tooltip.onHideTooltip(isDismissedUsingCloseButton); + unregisterTooltip(tooltipName); + }; + + const renderProductTrainingTooltip = () => { const tooltip = TOOLTIPS[tooltipName]; const content = typeof tooltip.content === 'function' ? tooltip.content() : tooltip.content; @@ -309,30 +264,11 @@ const useProductTrainingContext = (tooltipName: ProductTrainingTooltipName, shou ); - }, [ - tooltipName, - styles.alignItemsCenter, - styles.flexRow, - styles.justifyContentCenter, - styles.textAlignCenter, - styles.gap3, - styles.pv2, - styles.ph2, - styles.renderHTML, - styles.dFlex, - styles.flexShrink1, - theme.tooltipHighlightText, - theme.icon, - translate, - config.onShown, - hideTooltip, - expensifyIcons.Close, - expensifyIcons.Lightbulb, - ]); - - const hideProductTrainingTooltip = useCallback(() => { + }; + + const hideProductTrainingTooltip = () => { hideTooltip(false); - }, [hideTooltip]); + }; return { renderProductTrainingTooltip, diff --git a/src/components/PushRowWithModal/PushRowModal.tsx b/src/components/PushRowWithModal/PushRowModal.tsx index 098c21b91cde..00f833d1b6f7 100644 --- a/src/components/PushRowWithModal/PushRowModal.tsx +++ b/src/components/PushRowWithModal/PushRowModal.tsx @@ -15,7 +15,7 @@ import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; -import React, {useMemo} from 'react'; +import React from 'react'; type PushRowModalProps = { isVisible: boolean; @@ -52,17 +52,13 @@ function PushRowModal({isVisible, selectedOptions, canSelectMultiple = false, on const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const initialSelectedValues = useInitialSelection(selectedOptions, {isVisible}); - const options = useMemo( - () => - Object.entries(optionsList).map(([key, value]) => ({ - value: key, - text: value, - keyForList: key, - isSelected: selectedOptions.includes(key), - searchValue: StringUtils.sanitizeString(value), - })), - [optionsList, selectedOptions], - ); + const options = Object.entries(optionsList).map(([key, value]) => ({ + value: key, + text: value, + keyForList: key, + isSelected: selectedOptions.includes(key), + searchValue: StringUtils.sanitizeString(value), + })); const orderedOptions = moveInitialSelectionToTop(options, initialSelectedValues); @@ -80,15 +76,12 @@ function PushRowModal({isVisible, selectedOptions, canSelectMultiple = false, on const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? options : orderedOptions); - const textInputOptions = useMemo( - () => ({ - headerMessage: debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '', - label: searchInputTitle, - value: searchValue, - onChangeText: setSearchValue, - }), - [debouncedSearchValue, searchInputTitle, searchResults.length, searchValue, setSearchValue, translate], - ); + const textInputOptions = { + headerMessage: debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '', + label: searchInputTitle, + value: searchValue, + onChangeText: setSearchValue, + }; return ( { - 'worklet'; + const parserWorklet = (text: string) => { + 'worklet'; - if (parser) { - return parser(text); - } + if (parser) { + return parser(text); + } - return parseExpensiMarkWithShortMentions(text, mentionsSharedVal.get(), currentUserMentions); - }, - [currentUserMentions, mentionsSharedVal, parser], - ); + return parseExpensiMarkWithShortMentions(text, mentionsSharedVal.get(), currentUserMentions); + }; useEffect(() => { scheduleOnLiveMarkdownRuntime(() => { diff --git a/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.tsx b/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.tsx index 3109ab040be6..131b210be7e3 100644 --- a/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.tsx +++ b/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.tsx @@ -18,7 +18,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportActionReactions} from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; import type {BaseQuickEmojiReactionsProps} from './types'; @@ -37,12 +37,9 @@ function BaseQuickEmojiReactions({ const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE); const [emojiReactions = getEmptyObject()] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`); - const selectEmojiWithReaction = useCallback( - (emoji: Emoji, skinTone: number) => { - onEmojiSelected(emoji, emojiReactions, skinTone); - }, - [onEmojiSelected, emojiReactions], - ); + const selectEmojiWithReaction = (emoji: Emoji, skinTone: number) => { + onEmojiSelected(emoji, emojiReactions, skinTone); + }; return ( diff --git a/src/components/Reactions/ReactionTooltipContent.tsx b/src/components/Reactions/ReactionTooltipContent.tsx index 3e6763346326..6d8b09b8fe83 100644 --- a/src/components/Reactions/ReactionTooltipContent.tsx +++ b/src/components/Reactions/ReactionTooltipContent.tsx @@ -70,4 +70,4 @@ function ReactionTooltipContent({accountIDs, emojiCodes, emojiName, currentUserA ReactionTooltipContent.displayName = 'ReactionTooltipContent'; -export default React.memo(ReactionTooltipContent); +export default ReactionTooltipContent; diff --git a/src/components/ReceiptCropView/index.tsx b/src/components/ReceiptCropView/index.tsx index 3cbd5a6be7e0..8ed7dbf7cbd0 100644 --- a/src/components/ReceiptCropView/index.tsx +++ b/src/components/ReceiptCropView/index.tsx @@ -15,7 +15,7 @@ import type {ComponentRef} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import type {PanGestureActiveEvent} 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 {GestureDetector, GestureHandlerRootView, usePanGesture} from 'react-native-gesture-handler'; import ImageSize from 'react-native-image-size'; @@ -93,18 +93,22 @@ function ReceiptCropView({imageUri, onCropChange, initialCrop, isAuthTokenRequir }, [imageUri, isAuthTokenRequired]); // Calculate scale factors to convert display coordinates to image coordinates - const {scaleX, scaleY, displayWidth, displayHeight, imageOffsetX, imageOffsetY} = useMemo(() => { - if (!containerSize.width || !containerSize.height || !imageSize.width || !imageSize.height) { - return {scaleX: 1, scaleY: 1, displayWidth: 0, displayHeight: 0, imageOffsetX: 0, imageOffsetY: 0}; - } - + let imageLayout; + if (!containerSize.width || !containerSize.height || !imageSize.width || !imageSize.height) { + imageLayout = { + scaleX: 1, + scaleY: 1, + displayWidth: 0, + displayHeight: 0, + imageOffsetX: 0, + imageOffsetY: 0, + }; + } else { // Calculate how the image is scaled to fit the container const imageAspectRatio = imageSize.width / imageSize.height; const containerAspectRatio = containerSize.width / containerSize.height; - let calculatedDisplayWidth: number; let calculatedDisplayHeight: number; - if (imageAspectRatio > containerAspectRatio) { // Image is wider - fit to width calculatedDisplayWidth = containerSize.width; @@ -128,8 +132,7 @@ function ReceiptCropView({imageUri, onCropChange, initialCrop, isAuthTokenRequir displayHeightSV.set(calculatedDisplayHeight); imageOffsetXSV.set(calculatedImageOffsetX); imageOffsetYSV.set(calculatedImageOffsetY); - - return { + imageLayout = { scaleX: calculatedScaleX, scaleY: calculatedScaleY, displayWidth: calculatedDisplayWidth, @@ -137,7 +140,8 @@ function ReceiptCropView({imageUri, onCropChange, initialCrop, isAuthTokenRequir imageOffsetX: calculatedImageOffsetX, imageOffsetY: calculatedImageOffsetY, }; - }, [containerSize.width, containerSize.height, imageSize.width, imageSize.height, displayWidthSV, displayHeightSV, imageOffsetXSV, imageOffsetYSV]); + } + const {scaleX, scaleY, displayWidth, displayHeight, imageOffsetX, imageOffsetY} = imageLayout; // Initialize crop rectangle when dimensions are available useEffect(() => { @@ -246,80 +250,71 @@ function ReceiptCropView({imageUri, onCropChange, initialCrop, isAuthTokenRequir onCropChange?.(crop); }, [displayWidth, displayHeight, scaleX, scaleY, imageOffsetX, imageOffsetY, cropX, cropY, cropWidth, cropHeight, onCropChange]); - const onContainerLayout = useCallback( - (event: LayoutChangeEvent) => { - const {width, height} = event.nativeEvent.layout; - setContainerSize({width, height}); - containerWidthSV.set(width); - containerHeightSV.set(height); - }, - [containerWidthSV, containerHeightSV], - ); + const onContainerLayout = (event: LayoutChangeEvent) => { + const {width, height} = event.nativeEvent.layout; + setContainerSize({width, height}); + containerWidthSV.set(width); + containerHeightSV.set(height); + }; - const onImageLoad = useCallback( - (event: {nativeEvent: {width: number; height: number}}) => { - const {width, height} = event.nativeEvent; - if (!width || !height || (imageSize.width === width && imageSize.height === height)) { - return; - } - if (!hasImageDimensions) { - setHasImageDimensions(true); - } - if (!isImageInitialized.current) { - setImageSize({width, height}); - } - }, - [hasImageDimensions, imageSize.width, imageSize.height], - ); + const onImageLoad = (event: {nativeEvent: {width: number; height: number}}) => { + const {width, height} = event.nativeEvent; + if (!width || !height || (imageSize.width === width && imageSize.height === height)) { + return; + } + if (!hasImageDimensions) { + setHasImageDimensions(true); + } + if (!isImageInitialized.current) { + setImageSize({width, height}); + } + }; /** * Clamp a value between min and max */ - const clamp = useCallback((value: number, min: number, max: number) => { + const clamp = (value: number, min: number, max: number) => { 'worklet'; return Math.max(min, Math.min(max, value)); - }, []); - - const applyCropBoundsAndNotify = useCallback( - (newX: number, newY: number, newWidth: number, newHeight: number, imageLeft: number, imageRight: number, imageTop: number, imageBottom: number) => { - // clamp to image bounds - let x = newX; - let y = newY; - let w = newWidth; - let h = newHeight; - - if (x < imageLeft) { - const diff = imageLeft - x; - x = imageLeft; - w -= diff; - } - if (x + w > imageRight) { - w = imageRight - x; - } - if (y < imageTop) { - const diff = imageTop - y; - y = imageTop; - h -= diff; - } - if (y + h > imageBottom) { - h = imageBottom - y; - } + }; - cropX.set(x); - cropY.set(y); - cropWidth.set(w); - cropHeight.set(h); - - onCropChange?.({ - x: (x - imageOffsetX) * scaleX, - y: (y - imageOffsetY) * scaleY, - width: w * scaleX, - height: h * scaleY, - }); - }, - [cropX, cropY, cropWidth, cropHeight, imageOffsetX, scaleX, imageOffsetY, scaleY, onCropChange], - ); + const applyCropBoundsAndNotify = (newX: number, newY: number, newWidth: number, newHeight: number, imageLeft: number, imageRight: number, imageTop: number, imageBottom: number) => { + // clamp to image bounds + let x = newX; + let y = newY; + let w = newWidth; + let h = newHeight; + + if (x < imageLeft) { + const diff = imageLeft - x; + x = imageLeft; + w -= diff; + } + if (x + w > imageRight) { + w = imageRight - x; + } + if (y < imageTop) { + const diff = imageTop - y; + y = imageTop; + h -= diff; + } + if (y + h > imageBottom) { + h = imageBottom - y; + } + + cropX.set(x); + cropY.set(y); + cropWidth.set(w); + cropHeight.set(h); + + onCropChange?.({ + x: (x - imageOffsetX) * scaleX, + y: (y - imageOffsetY) * scaleY, + width: w * scaleX, + height: h * scaleY, + }); + }; /** * Resize the crop area when a handle is dragged. Edge handles move one edge, corner handles move the two edges that meet at the corner. diff --git a/src/components/RenderHTML.tsx b/src/components/RenderHTML.tsx index ea7d0aaa5388..acb574817125 100644 --- a/src/components/RenderHTML.tsx +++ b/src/components/RenderHTML.tsx @@ -6,7 +6,7 @@ import Parser from '@libs/Parser'; import type {RenderersProps} from 'react-native-render-html'; -import React, {useMemo} from 'react'; +import React from 'react'; import {RenderHTMLConfigProvider, RenderHTMLSource, useSharedProps} from 'react-native-render-html'; import htmlRenderers from './HTMLEngineProvider/HTMLRenderers'; @@ -71,30 +71,24 @@ function RenderHTML({html: htmlParam, onLinkPress, onConciergeLinkPress, isSelec const styles = useThemeStyles(); const {windowWidth} = useWindowDimensions(); - const html = useMemo(() => { - return ( - Parser.replace(htmlParam, {shouldEscapeText: false, filterRules: ['emoji']}) - // Escape brackets when pasting a link, since unescaped [] can break Markdown link syntax - .replaceAll(RE_BRACKET_ESCAPE, (m) => (m.at(7) === '1' ? '[' : ']')) - // Remove double tag if exists and keep the outermost tag (always the original tag). - .replaceAll(RE_EMOJI_OPEN_OR_CLOSE, '$1$2') - // Strip orphaned
tags inside
    and
      that would render as extra empty items - .replaceAll(RE_BR_CLEANUP, '$1$2') - ); - }, [htmlParam]); - - const renderersProps = useMemo(() => { - return { - a: { - onPress: onLinkPress, - }, - // Custom HTML renderer keys must use hyphenated tag names per react-native-render-html API - /* eslint-disable @typescript-eslint/naming-convention */ - 'concierge-link': { - onPress: onConciergeLinkPress, - }, - }; - }, [onLinkPress, onConciergeLinkPress]); + const html = Parser.replace(htmlParam, {shouldEscapeText: false, filterRules: ['emoji']}) + // Escape brackets when pasting a link, since unescaped [] can break Markdown link syntax + .replaceAll(RE_BRACKET_ESCAPE, (m) => (m.at(7) === '1' ? '[' : ']')) + // Remove double tag if exists and keep the outermost tag (always the original tag). + .replaceAll(RE_EMOJI_OPEN_OR_CLOSE, '$1$2') + // Strip orphaned
      tags inside
        and
          that would render as extra empty items + .replaceAll(RE_BR_CLEANUP, '$1$2'); + + const renderersProps = { + a: { + onPress: onLinkPress, + }, + // Custom HTML renderer keys must use hyphenated tag names per react-native-render-html API + /* eslint-disable @typescript-eslint/naming-convention */ + 'concierge-link': { + onPress: onConciergeLinkPress, + }, + }; const renderers = { /* eslint-disable @typescript-eslint/naming-convention */ diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index 91f9f43044f8..ccfadb35274a 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -19,8 +19,6 @@ import type {OriginalMessageIOU, Policy, Report, ReportAction, ReportActions, Tr import type {OnyxEntry} from 'react-native-onyx'; -import {useMemo} from 'react'; - function getSplitAuthor(transaction: Transaction, splits?: Array>) { const {originalTransactionID, source} = transaction.comment ?? {}; @@ -309,10 +307,10 @@ function useReportPreviewSenderID({iouReport, action, chatReport}: {action: Onyx const hasFinishedInitialReportActionsLoad = hasOnceLoadedReportActions === true || isLoadingInitialReportActions === false; const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(shouldFetchData ? action?.childReportID : undefined); - const transactions = useMemo(() => { - if (!shouldFetchData) { - return undefined; - } + let transactions; + if (!shouldFetchData) { + transactions = undefined; + } else { const activeMoneyRequestCount = iouReport?.transactionCount ?? action?.childMoneyRequestCount ?? 0; const allReportTransactions = Object.values(reportTransactions ?? {}).filter((transaction): transaction is Transaction => !!transaction); const nonDeletedTransactionsIncludingOrphans = getAllNonDeletedTransactions(reportTransactions, iouActions ?? [], false, true); @@ -320,13 +318,12 @@ function useReportPreviewSenderID({iouReport, action, chatReport}: {action: Onyx nonDeletedTransactionsIncludingOrphans.length < allReportTransactions.length ? getAllNonDeletedTransactions(reportTransactions, iouActions ?? []) : nonDeletedTransactionsIncludingOrphans; - if (filteredTransactions.length < allReportTransactions.length && filteredTransactions.length < activeMoneyRequestCount) { - return nonDeletedTransactionsIncludingOrphans; + transactions = nonDeletedTransactionsIncludingOrphans; + } else { + transactions = filteredTransactions; } - - return filteredTransactions; - }, [reportTransactions, iouActions, shouldFetchData, iouReport?.transactionCount, action?.childMoneyRequestCount]); + } // Only pending scans read the chat's last actor, so other previews skip a subscription that changes on every message. const hasPendingScan = !!transactions?.some(hasPendingScanStateAndUnknownDirection); diff --git a/src/components/ReportActionItem/ExportWithDropdownMenu.tsx b/src/components/ReportActionItem/ExportWithDropdownMenu.tsx index 03089217468e..4ceeefb02a31 100644 --- a/src/components/ReportActionItem/ExportWithDropdownMenu.tsx +++ b/src/components/ReportActionItem/ExportWithDropdownMenu.tsx @@ -26,7 +26,7 @@ import type WithSentryLabel from '@src/types/utils/SentryLabel'; import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useMemo} from 'react'; +import React from 'react'; import {StyleSheet} from 'react-native'; type ExportWithDropdownMenuProps = WithSentryLabel & { @@ -81,35 +81,35 @@ function ExportWithDropdownMenu({ const flattenedWrapperStyle = StyleSheet.flatten([styles.flex1, wrapperStyle]); const connectionNameFriendly = getAccountingIntegrationDisplayName(policy, connectionName, translate); - const dropdownOptions: Array> = useMemo(() => { - const optionTemplate = { - icon: iconToDisplay, - disabled: !canBeExported, - displayInDefaultIconColor: true, - iconWidth: variables.iconSizeMenuItem, - iconHeight: variables.iconSizeMenuItem, - additionalIconStyles: styles.integrationIcon, - }; - const options = [ - { - value: CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION, - text: translate('workspace.common.exportIntegrationSelected', {connectionName, connectionNameFriendly}), - ...optionTemplate, - }, - { - value: CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED, - text: translate('workspace.common.markAsEntered'), - ...optionTemplate, - }, - ]; - const exportMethod = report?.policyID ? exportMethods?.[report.policyID] : null; - if (exportMethod) { - options.sort((method) => (method.value === exportMethod ? -1 : 0)); - } - return options; - // We do not include exportMethods not to re-render the component when the preferred export method changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [canBeExported, iconToDisplay, connectionName, connectionNameFriendly, report?.policyID, translate]); + const optionTemplate = { + icon: iconToDisplay, + disabled: !canBeExported, + displayInDefaultIconColor: true, + iconWidth: variables.iconSizeMenuItem, + iconHeight: variables.iconSizeMenuItem, + additionalIconStyles: styles.integrationIcon, + }; + const dropdownOptions: Array> = [ + { + value: CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION, + text: translate('workspace.common.exportIntegrationSelected', { + connectionName, + connectionNameFriendly, + }), + ...optionTemplate, + }, + { + value: CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED, + text: translate('workspace.common.markAsEntered'), + ...optionTemplate, + }, + ]; + const exportMethod = report?.policyID ? exportMethods?.[report.policyID] : null; + // The split button tracks selection by index, so saving a preference must not reorder its options. + const defaultSelectedIndex = Math.max( + 0, + dropdownOptions.findIndex(({value}) => value === exportMethod), + ); const handleExport = (exportType: ReportExportType) => { if (!reportID) { @@ -158,6 +158,7 @@ function ExportWithDropdownMenu({ }} onOptionSelected={({value}) => savePreferredExportMethod(value)} options={dropdownOptions} + defaultSelectedIndex={defaultSelectedIndex} style={[shouldUseNarrowLayout && styles.flexGrow1]} wrapperStyle={flattenedWrapperStyle} size={CONST.BUTTON_SIZE.MEDIUM} diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index a3ab9bc5a161..4c6fb1b18e14 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -43,7 +43,7 @@ import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type {StyleProp, TextStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type BreakdownRow = { @@ -134,10 +134,8 @@ function MoneyReportView({ StyleUtils.getColorStyle(theme.textSupporting), ]; - const policyReportFields = useMemo(() => { - const {fieldsByName} = getReportFieldMaps(report, policy?.fieldList ?? {}, reportNameValuePairs); - return Object.values(fieldsByName).filter((field) => isReportFieldTargetMatchingReport(report, field)); - }, [policy?.fieldList, report, reportNameValuePairs]); + const {fieldsByName} = getReportFieldMaps(report, policy?.fieldList ?? {}, reportNameValuePairs); + const policyReportFields = Object.values(fieldsByName).filter((field) => isReportFieldTargetMatchingReport(report, field)); const isOnlyTitleFieldEnabled = policyReportFields.every(shouldHideSingleReportField); const isClosedExpenseReportWithNoExpenses = isClosedExpenseReportWithNoExpensesReportUtils(report, transactions.length > 0); @@ -146,20 +144,16 @@ function MoneyReportView({ const hasPendingAction = transactions.some(getTransactionPendingAction); - const renderThreadDivider = useMemo( - () => - shouldHideThreadDividerLine ? ( - - ) : ( - - ), - [shouldHideThreadDividerLine, report?.reportID, styles.reportHorizontalRule], + const renderThreadDivider = shouldHideThreadDividerLine ? ( + + ) : ( + ); const icons = useMemoizedLazyExpensifyIcons(['Checkmark']); diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index ee660591a846..c3587be39616 100644 --- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx +++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx @@ -101,7 +101,7 @@ import {hasSeenTourSelector} from '@selectors/Onboarding'; import {transactionThreadReportIDSelector} from '@selectors/ReportAction'; import {Str} from 'expensify-common'; import mapValues from 'lodash/mapValues'; -import React, {useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import HoveredDistanceEReceipt from './HoveredDistanceEReceipt'; @@ -179,13 +179,13 @@ function MoneyRequestReceiptView({ const iouReportTransactions = Object.values(iouReportTransactionsCollection); const isTrackExpense = !mergeTransactionID && isTrackExpenseReportNew(report, parentReport, parentReportAction); const moneyRequestReport = parentReport; - const linkedTransactionID = useMemo(() => { - if (!parentReportAction) { - return undefined; - } + let linkedTransactionID; + if (!parentReportAction) { + linkedTransactionID = undefined; + } else { const originalMessage = parentReportAction && isMoneyRequestAction(parentReportAction) ? getOriginalMessage(parentReportAction) : undefined; - return originalMessage?.IOUTransactionID; - }, [parentReportAction]); + linkedTransactionID = originalMessage?.IOUTransactionID; + } const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(linkedTransactionID)}`); const [transactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`); @@ -307,16 +307,12 @@ function MoneyRequestReceiptView({ const {validateFiles, PDFValidationComponent} = useFilesValidation(onAttachmentFilesValidated); - const iouType = useMemo(() => { - if (isTrackExpense) { - return CONST.IOU.TYPE.TRACK; - } - if (isInvoice) { - return CONST.IOU.TYPE.INVOICE; - } - - return CONST.IOU.TYPE.SUBMIT; - }, [isTrackExpense, isInvoice]); + let iouType; + if (isTrackExpense) { + iouType = CONST.IOU.TYPE.TRACK; + } else { + iouType = isInvoice ? CONST.IOU.TYPE.INVOICE : CONST.IOU.TYPE.SUBMIT; + } const receiptURIs = hasReceipt ? getThumbnailAndImageURIs(displayedTransaction) : undefined; const isEReceiptTransaction = !!displayedTransaction && !hasReceiptSource(displayedTransaction) && hasEReceipt(displayedTransaction); @@ -346,69 +342,48 @@ function MoneyRequestReceiptView({ const routeDistanceMeters = transaction?.comment?.customUnit?.routeDistanceMeters; const distanceUnit = transaction?.comment?.customUnit?.distanceUnit; - const [receiptImageViolations, receiptViolations] = useMemo(() => { - const imageViolations = []; - const allViolations = []; - const filteredViolations = filterReceiptViolations(transactionViolations ?? []); - - for (const violation of filteredViolations) { - const isReceiptFieldViolation = receiptFieldViolationNames.has(violation.name); - const isReceiptImageViolation = receiptImageViolationNames.has(violation.name); - const isRTERViolation = violation.name === CONST.VIOLATIONS.RTER; - if (isReceiptFieldViolation || isReceiptImageViolation || isRTERViolation) { - const cardID = violation.data?.cardID; - const card = cardID ? cardList?.[cardID] : undefined; - const violationMessage = ViolationsUtils.getViolationTranslation({ - dateFnsLocale, - violation, - translate, - convertToDisplayString, - canEdit: isActionTakenByCurrentUser && isEditable, - companyCardPageURL, - connectionLink, - card, - isMarkAsCash, - routeDistanceMeters, - distanceUnit, - policyVendors, - transactionCurrency: transaction?.currency, - }); - allViolations.push(violationMessage); - if (isReceiptImageViolation || isRTERViolation) { - imageViolations.push(violationMessage); - } + const imageViolations = []; + const allViolations = []; + const filteredViolations = filterReceiptViolations(transactionViolations ?? []); + for (const violation of filteredViolations) { + const isReceiptFieldViolation = receiptFieldViolationNames.has(violation.name); + const isReceiptImageViolation = receiptImageViolationNames.has(violation.name); + const isRTERViolation = violation.name === CONST.VIOLATIONS.RTER; + if (isReceiptFieldViolation || isReceiptImageViolation || isRTERViolation) { + const cardID = violation.data?.cardID; + const card = cardID ? cardList?.[cardID] : undefined; + const violationMessage = ViolationsUtils.getViolationTranslation({ + dateFnsLocale, + violation, + translate, + convertToDisplayString, + canEdit: isActionTakenByCurrentUser && isEditable, + companyCardPageURL, + connectionLink, + card, + isMarkAsCash, + routeDistanceMeters, + distanceUnit, + policyVendors, + transactionCurrency: transaction?.currency, + }); + allViolations.push(violationMessage); + if (isReceiptImageViolation || isRTERViolation) { + imageViolations.push(violationMessage); } } - return [imageViolations, allViolations]; - }, [ - transactionViolations, - translate, - convertToDisplayString, - isActionTakenByCurrentUser, - isEditable, - companyCardPageURL, - connectionLink, - cardList, - isMarkAsCash, - routeDistanceMeters, - distanceUnit, - policyVendors, - transaction?.currency, - dateFnsLocale, - ]); + } + const [receiptImageViolations, receiptViolations] = [imageViolations, allViolations]; const receiptRequiredViolation = transactionViolations?.some((violation) => violation.name === CONST.VIOLATIONS.RECEIPT_REQUIRED); const itemizedReceiptRequiredViolation = transactionViolations?.some((violation) => violation.name === CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED); const customRulesViolation = transactionViolations?.some((violation) => violation.name === CONST.VIOLATIONS.CUSTOM_RULES); - const errorsWithoutReportCreation = useMemo( - () => ({ - ...(transaction?.errorFields?.route ?? transaction?.errorFields?.waypoints ?? transaction?.errors), - ...parentReportAction?.errors, - }), - [transaction?.errorFields?.route, transaction?.errorFields?.waypoints, transaction?.errors, parentReportAction?.errors], - ); - const reportCreationError = useMemo(() => (getCreationReportErrors(report) ? getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage') : {}), [report]); + const errorsWithoutReportCreation = { + ...(transaction?.errorFields?.route ?? transaction?.errorFields?.waypoints ?? transaction?.errors), + ...parentReportAction?.errors, + }; + const reportCreationError = getCreationReportErrors(report) ? getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage') : {}; const hasReceiptUploadError = Object.values(errorsWithoutReportCreation).some((error) => isReceiptError(error)); // Whether to show receipt audit result (e.g.`Verified`, `Issue Found`) and messages (e.g. `Receipt not verified. Please confirm accuracy.`) @@ -420,31 +395,27 @@ function MoneyRequestReceiptView({ isGroupPolicyByType(policy?.type); const shouldShowReceiptAudit = !isInvoice && (shouldShowReceiptEmptyState || hasReceipt || hasReceiptUploadError); - const fallbackReceiptError = useMemo(() => { - // Map/route distance expenses carry a generated map e-receipt (no real uploaded file), so they should not - // surface a receipt-upload error on a create/report failure - the transaction's own generic error should be - // shown instead. Odometer / pure-manual distance flows can have a real uploaded file, so the fallback is kept - // for them (isMapBasedDistanceRequest is false for those). - if (hasReceiptUploadError || isEmptyObject(reportCreationError) || !hasReceipt || !transaction?.receipt || isMapDistanceRequest) { - return {}; - } - - return getMicroSecondOnyxErrorObject({ - error: CONST.IOU.RECEIPT_ERROR, - source: transaction.receipt.source?.toString() ?? '', - filename: transaction.receipt.filename ?? '', - }); - }, [hasReceiptUploadError, reportCreationError, hasReceipt, transaction, isMapDistanceRequest]); - - const errors = useMemo(() => { - if (hasReceiptUploadError) { - return errorsWithoutReportCreation; - } - if (!isEmptyObject(fallbackReceiptError)) { - return {...errorsWithoutReportCreation, ...fallbackReceiptError}; - } - return isEmptyObject(errorsWithoutReportCreation) ? reportCreationError : errorsWithoutReportCreation; - }, [hasReceiptUploadError, fallbackReceiptError, errorsWithoutReportCreation, reportCreationError]); + // Map/route distance expenses carry a generated map e-receipt (no real uploaded file), so they should not + // surface a receipt-upload error on a create/report failure - the transaction's own generic error should be + // shown instead. Odometer / pure-manual distance flows can have a real uploaded file, so the fallback is kept + // for them (isMapBasedDistanceRequest is false for those). + const fallbackReceiptError = + hasReceiptUploadError || isEmptyObject(reportCreationError) || !hasReceipt || !transaction?.receipt || isMapDistanceRequest + ? {} + : getMicroSecondOnyxErrorObject({ + error: CONST.IOU.RECEIPT_ERROR, + source: transaction.receipt.source?.toString() ?? '', + filename: transaction.receipt.filename ?? '', + }); + + let errors; + if (hasReceiptUploadError) { + errors = errorsWithoutReportCreation; + } else if (!isEmptyObject(fallbackReceiptError)) { + errors = {...errorsWithoutReportCreation, ...fallbackReceiptError}; + } else { + errors = isEmptyObject(errorsWithoutReportCreation) ? reportCreationError : errorsWithoutReportCreation; + } const showReceiptErrorWithEmptyState = shouldShowReceiptEmptyState && !hasReceipt && !isEmptyObject(errors); const {showConfirmModal} = useConfirmModal(); @@ -492,13 +463,10 @@ function MoneyRequestReceiptView({ ).catch((error: unknown) => Log.alert('[ReceiptRetry] Retry failed unexpectedly', {error})); }; - const transactionAndReportActionErrors = useMemo( - () => ({ - ...transaction?.errors, - ...parentReportAction?.errors, - }), - [transaction?.errors, parentReportAction?.errors], - ); + const transactionAndReportActionErrors = { + ...transaction?.errors, + ...parentReportAction?.errors, + }; const dismissReceiptError = () => { if (!report?.reportID) { @@ -613,7 +581,6 @@ function MoneyRequestReceiptView({ }; let receiptStyle: StyleProp; - if (fillSpace && shouldShowReceiptEmptyState) { receiptStyle = styles.h100; } else if (fillSpace) { diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx index 280a4d2433e1..b1d613582f52 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx @@ -207,7 +207,7 @@ function MoneyRequestReportPreviewProvider({ onOrderedTransactionsChange, }); - const openReportFromPreview = useCallback(() => { + const openReportFromPreview = () => { if (!iouReportID) { return; } @@ -232,13 +232,13 @@ function MoneyRequestReportPreviewProvider({ } else { Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo})); } - }, [iouReportID, isSmallScreenWidth, onCancelPendingPress]); + }; // Only the pay flow opens this menu; approve surfaces its partial/full choice in the approve dropdown instead. - const onHoldMenuOpen = useCallback((paymentType?: PaymentMethodType, canPay?: boolean, methodID?: number) => { + const onHoldMenuOpen = (paymentType?: PaymentMethodType, canPay?: boolean, methodID?: number) => { setHoldMenu({paymentType, canPay: !!canPay, methodID}); - }, []); - const onHoldMenuClose = useCallback(() => setHoldMenu(null), []); + }; + const onHoldMenuClose = () => setHoldMenu(null); const shouldShowCarouselArrows = !shouldUseNarrowLayout && !shouldShowAccessPlaceHolder && transactions.length > 2 && reportPreviewStyles.expenseCountVisible; const buttonMaxWidth = diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHeader.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHeader.tsx index cba808917a03..81514556e501 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHeader.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHeader.tsx @@ -23,7 +23,7 @@ import type {ReportAttributesDerivedValue} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {reportNameSelector} from '@selectors/ReportAttributes'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import Animated from 'react-native-reanimated'; @@ -45,10 +45,10 @@ function ReportPreviewHeader() { const {goToPrevious, goToNext} = useReportPreviewActions(); const numberOfRequests = transactions.length; - const selectReportName = useCallback((attributes: OnyxEntry) => reportNameSelector(attributes, iouReportID), [iouReportID]); + const selectReportName = (attributes: OnyxEntry) => reportNameSelector(attributes, iouReportID); const [derivedReportName] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {selector: selectReportName}); const reportName = derivedReportName ?? iouReport?.reportName ?? ''; - const formattedReportName = useMemo(() => Parser.htmlToText(reportName || (action.childReportName ?? '')), [reportName, action.childReportName]); + const formattedReportName = Parser.htmlToText(reportName || (action.childReportName ?? '')); /* Show subtitle if at least one of the expenses is not being smart scanned, and either: @@ -58,40 +58,28 @@ function ReportPreviewHeader() { * There is an edge case when there is only one distance expense with a pending route and amount = 0. In this case, we don't want to show the merchant or description because it says: "Pending route...", which is already displayed in the amount field. */ - const expenseCount = useMemo( - () => - translate('iou.expenseCount', { - count: numberOfRequests, - }), - [translate, numberOfRequests], - ); + const expenseCount = translate('iou.expenseCount', { + count: numberOfRequests, + }); const reportStateNum = iouReport?.stateNum ?? action?.childStateNum; const reportStatusNum = iouReport?.statusNum ?? action?.childStatusNum; - const reportStatus = useMemo( - () => - getReportStatusTranslation({ - stateNum: reportStateNum, - statusNum: reportStatusNum, - translate, - }), - [reportStateNum, reportStatusNum, translate], - ); + const reportStatus = getReportStatusTranslation({ + stateNum: reportStateNum, + statusNum: reportStatusNum, + translate, + }); const shouldShowReportStatus = !!reportStatus && !!expenseCount; - const reportStatusColorStyle = useMemo(() => getReportStatusColorStyle(theme, reportStateNum, reportStatusNum), [reportStateNum, reportStatusNum, theme]); + const reportStatusColorStyle = getReportStatusColorStyle(theme, reportStateNum, reportStatusNum); - const reportStatusTooltip = useMemo( - () => - getReportStatusTooltipTranslation({ - stateNum: reportStateNum, - statusNum: reportStatusNum, - translate, - }), - [reportStateNum, reportStatusNum, translate], - ); + const reportStatusTooltip = getReportStatusTooltipTranslation({ + stateNum: reportStateNum, + statusNum: reportStatusNum, + translate, + }); return ( diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx index 1482b2942598..523cafc82191 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx @@ -46,7 +46,7 @@ import type {LayoutChangeEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useIsFocused} from '@react-navigation/core'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import type {MoneyRequestReportPreviewProps} from './types'; @@ -96,21 +96,21 @@ function MoneyRequestReportPreview({ const allReportTransactions = Object.values(reportTransactionsCollection ?? {}).filter((transaction): transaction is Transaction => !!transaction); const transactions = allReportTransactions.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); const orderedTransactionsRef = useRef([]); - const handleOrderedTransactionsChange = useCallback((orderedTransactions: Transaction[]) => { + const handleOrderedTransactionsChange = (orderedTransactions: Transaction[]) => { orderedTransactionsRef.current = orderedTransactions; - }, []); + }; const [hasIOUReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(iouReportID)}`, { selector: hasReportActionsSelector, }); const pendingExpenseTransactionRef = useRef<{transaction: Transaction; originRoute: string} | null>(null); // Subscribing to the whole action list re-rendered every card on each write, and a split writes a burst of // them. Narrow it to the one action a deferred press is waiting for, so nothing pending means nothing changes. - const pendingPressActionCountSelector = useCallback((reportActions: OnyxEntry) => { + const pendingPressActionCountSelector = (reportActions: OnyxEntry) => { if (!pendingExpenseTransactionRef.current) { return undefined; } return Object.keys(reportActions ?? {}).length; - }, []); + }; const [pendingPressActionCount] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(iouReportID)}`, { selector: pendingPressActionCountSelector, }); @@ -133,48 +133,34 @@ function MoneyRequestReportPreview({ const widthsRef = useRef<{currentWidth: number | null; currentWrapperWidth: number | null}>({currentWidth: null, currentWrapperWidth: null}); const [widths, setWidths] = useState({currentWidth: 0, currentWrapperWidth: 0}); - const updateWidths = useCallback(() => { + const updateWidths = () => { const {currentWidth, currentWrapperWidth} = widthsRef.current; if (currentWidth && currentWrapperWidth) { setWidths({currentWidth, currentWrapperWidth}); } - }, []); - - const onCarouselLayout = useCallback( - (e: LayoutChangeEvent) => { - const newWidth = e.nativeEvent.layout.width; - if (widthsRef.current.currentWidth !== newWidth) { - widthsRef.current.currentWidth = newWidth; - updateWidths(); - } - }, - [updateWidths], - ); - const onWrapperLayout = useCallback( - (e: LayoutChangeEvent) => { - const newWrapperWidth = e.nativeEvent.layout.width; - if (widthsRef.current.currentWrapperWidth !== newWrapperWidth) { - widthsRef.current.currentWrapperWidth = newWrapperWidth; - updateWidths(); - } - }, - [updateWidths], - ); + }; - const reportPreviewStyles = useMemo( - () => StyleUtils.getMoneyRequestReportPreviewStyle(shouldUseNarrowLayout, transactions.length, widths.currentWidth, widths.currentWrapperWidth), - [StyleUtils, widths, shouldUseNarrowLayout, transactions.length], - ); - const shouldShowPayerAndReceiver = useMemo(() => { - if (!isIOUReport(iouReport) && action.childType !== CONST.REPORT.TYPE.IOU) { - return false; + const onCarouselLayout = (e: LayoutChangeEvent) => { + const newWidth = e.nativeEvent.layout.width; + if (widthsRef.current.currentWidth !== newWidth) { + widthsRef.current.currentWidth = newWidth; + updateWidths(); + } + }; + const onWrapperLayout = (e: LayoutChangeEvent) => { + const newWrapperWidth = e.nativeEvent.layout.width; + if (widthsRef.current.currentWrapperWidth !== newWrapperWidth) { + widthsRef.current.currentWrapperWidth = newWrapperWidth; + updateWidths(); } + }; - return transactions.some((transaction) => (Number(transaction?.modifiedAmount) || transaction?.amount) < 0); - }, [transactions, action.childType, iouReport]); + const reportPreviewStyles = StyleUtils.getMoneyRequestReportPreviewStyle(shouldUseNarrowLayout, transactions.length, widths.currentWidth, widths.currentWrapperWidth); + const shouldShowPayerAndReceiver = + !(!isIOUReport(iouReport) && action.childType !== CONST.REPORT.TYPE.IOU) && transactions.some((transaction) => (Number(transaction?.modifiedAmount) || transaction?.amount) < 0); - const cancelPendingPress = useCallback(() => { + const cancelPendingPress = () => { pendingExpenseTransactionRef.current = null; if (!cascadeTimerRef.current) { return; @@ -182,9 +168,9 @@ function MoneyRequestReportPreview({ clearTimeout(cascadeTimerRef.current.timer); cascadeTimerRef.current.release(); cascadeTimerRef.current = null; - }, []); + }; - const openReportFromPreview = useCallback(() => { + const openReportFromPreview = () => { if (!iouReportID || contextMenuRef.current?.isContextMenuOpening) { return; } @@ -210,7 +196,7 @@ function MoneyRequestReportPreview({ } else { Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo})); } - }, [cancelPendingPress, iouReportID, isSmallScreenWidth]); + }; const [hasOnceLoadedReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${chatReportID}`, { selector: hasOnceLoadedReportActionsSelector, }); @@ -237,181 +223,159 @@ function MoneyRequestReportPreview({ const transactionPreviewContainerStyles = [styles.h100, reportPreviewStyles.transactionPreviewCarouselStyle]; - const resolveChildReportID = useCallback( - (transaction: Transaction) => { - let transactionIOUAction = getIOUActionForReportID(transaction.reportID, transaction.transactionID); - if (transactionIOUAction && !isLiveIOUAction(transactionIOUAction)) { - const liveIOUAction = getIOUActionForTransactionID(Object.values(getAllReportActions(transaction.reportID) ?? {}).filter(isLiveIOUAction), transaction.transactionID); - if (!liveIOUAction) { - return undefined; - } - transactionIOUAction = liveIOUAction; + const resolveChildReportID = (transaction: Transaction) => { + let transactionIOUAction = getIOUActionForReportID(transaction.reportID, transaction.transactionID); + if (transactionIOUAction && !isLiveIOUAction(transactionIOUAction)) { + const liveIOUAction = getIOUActionForTransactionID(Object.values(getAllReportActions(transaction.reportID) ?? {}).filter(isLiveIOUAction), transaction.transactionID); + if (!liveIOUAction) { + return undefined; } - let childReportID = transactionIOUAction?.childReportID ?? transaction.transactionThreadReportID; - if (childReportID) { - const existingThread = getReportOrDraftReport(childReportID); - if (existingThread && !existingThread.reportID) { - return undefined; - } - setOptimisticTransactionThread(childReportID, iouReport?.reportID ?? transaction.reportID, transactionIOUAction?.reportActionID, iouReport?.policyID ?? policyID); - } else if (transactionIOUAction?.reportActionID) { - const transactionID = isMoneyRequestAction(transactionIOUAction) ? getOriginalMessage(transactionIOUAction)?.IOUTransactionID : undefined; - if (transactionID) { - childReportID = createTransactionThreadReport({ - introSelected, - conciergeChat, - currentUserLogin: currentUserEmail ?? '', - currentUserAccountID, - iouReport, - iouReportAction: transactionIOUAction, - personalDetails: personalDetailsList, - fullPolicySnapshotLastModified, - })?.reportID; - } + transactionIOUAction = liveIOUAction; + } + let childReportID = transactionIOUAction?.childReportID ?? transaction.transactionThreadReportID; + if (childReportID) { + const existingThread = getReportOrDraftReport(childReportID); + if (existingThread && !existingThread.reportID) { + return undefined; } - return childReportID; - }, - [conciergeChat, currentUserAccountID, currentUserEmail, fullPolicySnapshotLastModified, introSelected, iouReport, personalDetailsList, policyID], - ); + setOptimisticTransactionThread(childReportID, iouReport?.reportID ?? transaction.reportID, transactionIOUAction?.reportActionID, iouReport?.policyID ?? policyID); + } else if (transactionIOUAction?.reportActionID) { + const transactionID = isMoneyRequestAction(transactionIOUAction) ? getOriginalMessage(transactionIOUAction)?.IOUTransactionID : undefined; + if (transactionID) { + childReportID = createTransactionThreadReport({ + introSelected, + conciergeChat, + currentUserLogin: currentUserEmail ?? '', + currentUserAccountID, + iouReport, + iouReportAction: transactionIOUAction, + personalDetails: personalDetailsList, + fullPolicySnapshotLastModified, + })?.reportID; + } + } + return childReportID; + }; // `routeAtPress` is captured when the user pressed, not read live: a second press inside the cascade window // runs after the first press already navigated, so the live route is the report we opened and `backTo` would // point at itself. - const navigateToExpense = useCallback( - (childReportID: string, routeAtPress: string) => { - startSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${childReportID}`, { - name: 'MoneyRequestReportPreview.Transaction', - op: CONST.TELEMETRY.SPAN_OPEN_REPORT, - }); - - const openableTransactionIDs = (orderedTransactionsRef.current.length > 0 ? orderedTransactionsRef.current : transactions) - .filter((pressedTransaction) => !isTransactionPendingDelete(pressedTransaction)) - .map((pressedTransaction) => pressedTransaction.transactionID); - - // This press owns the carousel it seeds. Without a source the list was unowned, so no screen could - // release it and every other writer was free to overwrite it while the expense was still open. - const carouselSource = CAROUSEL_SOURCE.reportPreview(iouReportID); - - if (isSmallScreenWidth && iouReportID) { - const {wasPressedFromReport, backTo} = resolvePressOrigin(routeAtPress, `r/${iouReportID}`); - const reportRoute = ROUTES.REPORT_WITH_ID.getRoute(iouReportID, undefined, undefined, backTo); - if (!wasPressedFromReport) { - Navigation.navigate(reportRoute); - } - setActiveTransactionIDs(openableTransactionIDs, {source: carouselSource}); - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: reportRoute})); - return; - } + const navigateToExpense = (childReportID: string, routeAtPress: string) => { + startSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${childReportID}`, { + name: 'MoneyRequestReportPreview.Transaction', + op: CONST.TELEMETRY.SPAN_OPEN_REPORT, + }); - if (isSmallScreenWidth) { - setActiveTransactionIDs(openableTransactionIDs, {source: carouselSource}); - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: routeAtPress})); - return; + const openableTransactionIDs = (orderedTransactionsRef.current.length > 0 ? orderedTransactionsRef.current : transactions) + .filter((pressedTransaction) => !isTransactionPendingDelete(pressedTransaction)) + .map((pressedTransaction) => pressedTransaction.transactionID); + + // This press owns the carousel it seeds. Without a source the list was unowned, so no screen could + // release it and every other writer was free to overwrite it while the expense was still open. + const carouselSource = CAROUSEL_SOURCE.reportPreview(iouReportID); + + if (isSmallScreenWidth && iouReportID) { + const {wasPressedFromReport, backTo} = resolvePressOrigin(routeAtPress, `r/${iouReportID}`); + const reportRoute = ROUTES.REPORT_WITH_ID.getRoute(iouReportID, undefined, undefined, backTo); + if (!wasPressedFromReport) { + Navigation.navigate(reportRoute); } + setActiveTransactionIDs(openableTransactionIDs, {source: carouselSource}); + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: reportRoute})); + return; + } - if (iouReportID) { - const {wasPressedFromReport, backTo} = resolvePressOrigin(routeAtPress, `e/${iouReportID}`); - const reportRoute = ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo}); - markReportRHPWidth(iouReportID, 'super-wide'); - if (!wasPressedFromReport) { - Navigation.navigate(reportRoute); - } - const seeded = setActiveTransactionIDs(openableTransactionIDs, {source: carouselSource}); - markReportRHPWidth(childReportID, 'wide'); - const release = () => { - unmarkReportRHPWidth(childReportID); - // Only clears if the report still carries our hint, so it can't undo one set by the report itself. - unmarkReportRHPWidth(iouReportID, 'super-wide'); - seeded.then(() => { - if (getActiveTransactionIDs().ids !== openableTransactionIDs) { - return; - } - clearActiveTransactionIDsForSource(carouselSource); - }); - }; - const timer = setTimeout(() => { - cascadeTimerRef.current = null; - if (!Navigation.isActiveRoute(reportRoute)) { - release(); + if (isSmallScreenWidth) { + setActiveTransactionIDs(openableTransactionIDs, {source: carouselSource}); + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: routeAtPress})); + return; + } + + if (iouReportID) { + const {wasPressedFromReport, backTo} = resolvePressOrigin(routeAtPress, `e/${iouReportID}`); + const reportRoute = ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo}); + markReportRHPWidth(iouReportID, 'super-wide'); + if (!wasPressedFromReport) { + Navigation.navigate(reportRoute); + } + const seeded = setActiveTransactionIDs(openableTransactionIDs, {source: carouselSource}); + markReportRHPWidth(childReportID, 'wide'); + const release = () => { + unmarkReportRHPWidth(childReportID); + // Only clears if the report still carries our hint, so it can't undo one set by the report itself. + unmarkReportRHPWidth(iouReportID, 'super-wide'); + seeded.then(() => { + if (getActiveTransactionIDs().ids !== openableTransactionIDs) { return; } - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: reportRoute})); - }, PRESSED_EXPENSE_CASCADE_DELAY); - cascadeTimerRef.current = {timer, release}; - return; - } + clearActiveTransactionIDsForSource(carouselSource); + }); + }; + const timer = setTimeout(() => { + cascadeTimerRef.current = null; + if (!Navigation.isActiveRoute(reportRoute)) { + release(); + return; + } + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: reportRoute})); + }, PRESSED_EXPENSE_CASCADE_DELAY); + cascadeTimerRef.current = {timer, release}; + return; + } - setActiveTransactionIDs(openableTransactionIDs, {source: carouselSource}).then(() => { - markReportRHPWidth(childReportID, 'wide'); - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: routeAtPress})); - }); - }, - [isSmallScreenWidth, iouReportID, markReportRHPWidth, unmarkReportRHPWidth, transactions], - ); + setActiveTransactionIDs(openableTransactionIDs, {source: carouselSource}).then(() => { + markReportRHPWidth(childReportID, 'wide'); + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: routeAtPress})); + }); + }; - const openTransactionFromPreview = useCallback( - (transaction: Transaction) => { - if (contextMenuRef.current?.isContextMenuOpening) { - return; - } + const openTransactionFromPreview = (transaction: Transaction) => { + if (contextMenuRef.current?.isContextMenuOpening) { + return; + } - const routeAtPress = Navigation.getActiveRoute(); + const routeAtPress = Navigation.getActiveRoute(); - pendingExpenseTransactionRef.current = null; - if (cascadeTimerRef.current) { - clearTimeout(cascadeTimerRef.current.timer); - cascadeTimerRef.current.release(); - cascadeTimerRef.current = null; - } + pendingExpenseTransactionRef.current = null; + if (cascadeTimerRef.current) { + clearTimeout(cascadeTimerRef.current.timer); + cascadeTimerRef.current.release(); + cascadeTimerRef.current = null; + } - if (transactions.length <= 1) { - openReportFromPreview(); - return; - } + if (transactions.length <= 1) { + openReportFromPreview(); + return; + } - if (transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - openReportFromPreview(); - return; - } + if (transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + openReportFromPreview(); + return; + } - const isIOUActionLoaded = !!getIOUActionForReportID(transaction.reportID, transaction.transactionID); - const childReportID = resolveChildReportID(transaction); - const fullPolicySnapshot = policyID && fullPolicySnapshotLastModified ? {policyID, lastModified: fullPolicySnapshotLastModified} : undefined; - if (childReportID) { - if (!isIOUActionLoaded && iouReportID) { - if (isOffline) { - openReportFromPreview(); - return; - } - openReport({reportID: iouReportID, introSelected, conciergeChat, currentUserAccountID, hasReportActions: !!hasIOUReportActions, fullPolicySnapshot}); + const isIOUActionLoaded = !!getIOUActionForReportID(transaction.reportID, transaction.transactionID); + const childReportID = resolveChildReportID(transaction); + const fullPolicySnapshot = policyID && fullPolicySnapshotLastModified ? {policyID, lastModified: fullPolicySnapshotLastModified} : undefined; + if (childReportID) { + if (!isIOUActionLoaded && iouReportID) { + if (isOffline) { + openReportFromPreview(); + return; } - navigateToExpense(childReportID, routeAtPress); - return; - } - - if (!isIOUActionLoaded && iouReportID && !isOffline) { - pendingExpenseTransactionRef.current = {transaction, originRoute: routeAtPress}; openReport({reportID: iouReportID, introSelected, conciergeChat, currentUserAccountID, hasReportActions: !!hasIOUReportActions, fullPolicySnapshot}); - return; } + navigateToExpense(childReportID, routeAtPress); + return; + } - openReportFromPreview(); - }, - [ - conciergeChat, - currentUserAccountID, - fullPolicySnapshotLastModified, - hasIOUReportActions, - introSelected, - iouReportID, - isOffline, - navigateToExpense, - openReportFromPreview, - policyID, - resolveChildReportID, - transactions.length, - ], - ); + if (!isIOUActionLoaded && iouReportID && !isOffline) { + pendingExpenseTransactionRef.current = {transaction, originRoute: routeAtPress}; + openReport({reportID: iouReportID, introSelected, conciergeChat, currentUserAccountID, hasReportActions: !!hasIOUReportActions, fullPolicySnapshot}); + return; + } + + openReportFromPreview(); + }; useEffect(() => { const pendingPress = pendingExpenseTransactionRef.current; diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/usePreviewMessageAnimation.ts b/src/components/ReportActionItem/MoneyRequestReportPreview/usePreviewMessageAnimation.ts index b147d41a77e6..47756266f324 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/usePreviewMessageAnimation.ts +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/usePreviewMessageAnimation.ts @@ -9,7 +9,7 @@ import type {PersonalDetails, Policy, Report} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {useEffect, useMemo} from 'react'; +import {useEffect} from 'react'; import {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; type UsePreviewMessageAnimationParams = { @@ -97,68 +97,49 @@ function usePreviewMessageAnimation({ opacity: previewMessageOpacity.get(), })); - const previewMessage = useMemo(() => { - if (isScanning) { - return totalDisplaySpend ? `${translate('common.receipt')} ${CONST.DOT_SEPARATOR} ${translate('common.scanning')}` : `${translate('common.receipt')}`; - } - if (numberOfPendingRequests === 1 && numberOfRequests === 1) { - return `${translate('common.receipt')} ${CONST.DOT_SEPARATOR} ${translate('iou.pending')}`; - } - if (shouldShowRTERViolationMessage) { - return `${translate('common.receipt')} ${CONST.DOT_SEPARATOR} ${translate('iou.pendingMatch')}`; - } - + let previewMessage; + if (isScanning) { + previewMessage = totalDisplaySpend ? `${translate('common.receipt')} ${CONST.DOT_SEPARATOR} ${translate('common.scanning')}` : `${translate('common.receipt')}`; + } else if (numberOfPendingRequests === 1 && numberOfRequests === 1) { + previewMessage = `${translate('common.receipt')} ${CONST.DOT_SEPARATOR} ${translate('iou.pending')}`; + } else if (shouldShowRTERViolationMessage) { + previewMessage = `${translate('common.receipt')} ${CONST.DOT_SEPARATOR} ${translate('iou.pendingMatch')}`; + } else { let payerOrApproverName; if (isPolicyExpenseChat || isTripRoom) { - payerOrApproverName = getPolicyName({report: chatReport, policy, unavailableTranslation: translate('workspace.common.unavailable')}); - } else if (isInvoiceRoom) { - payerOrApproverName = getInvoicePayerName(chatReport, translate, formatPhoneNumber, invoiceReceiverPersonalDetail, invoiceReceiverPolicy); - } else { - payerOrApproverName = getDisplayNameForParticipant({ - accountID: managerID, - shouldUseShortForm: true, - formatPhoneNumber, - hiddenTranslation: translate('common.hidden'), + payerOrApproverName = getPolicyName({ + report: chatReport, + policy, + unavailableTranslation: translate('workspace.common.unavailable'), }); + } else { + payerOrApproverName = isInvoiceRoom + ? getInvoicePayerName(chatReport, translate, formatPhoneNumber, invoiceReceiverPersonalDetail, invoiceReceiverPolicy) + : getDisplayNameForParticipant({ + accountID: managerID, + shouldUseShortForm: true, + formatPhoneNumber, + hiddenTranslation: translate('common.hidden'), + }); } - if (isApproved) { - return translate('iou.managerApproved', payerOrApproverName); - } - let paymentVerb: TranslationPaths = 'iou.payerOwes'; - if (iouSettled || iouReport?.isWaitingOnBankAccount) { - paymentVerb = 'iou.payerPaid'; - } else if (hasNonReimbursableTransactions) { - paymentVerb = 'iou.payerSpent'; - payerOrApproverName = getDisplayNameForParticipant({ - accountID: chatReport?.ownerAccountID, - shouldUseShortForm: true, - formatPhoneNumber, - hiddenTranslation: translate('common.hidden'), - }); + previewMessage = translate('iou.managerApproved', payerOrApproverName); + } else { + let paymentVerb: TranslationPaths = 'iou.payerOwes'; + if (iouSettled || iouReport?.isWaitingOnBankAccount) { + paymentVerb = 'iou.payerPaid'; + } else if (hasNonReimbursableTransactions) { + paymentVerb = 'iou.payerSpent'; + payerOrApproverName = getDisplayNameForParticipant({ + accountID: chatReport?.ownerAccountID, + shouldUseShortForm: true, + formatPhoneNumber, + hiddenTranslation: translate('common.hidden'), + }); + } + previewMessage = translate(paymentVerb, payerOrApproverName); } - return translate(paymentVerb, payerOrApproverName); - }, [ - isScanning, - numberOfPendingRequests, - numberOfRequests, - shouldShowRTERViolationMessage, - isPolicyExpenseChat, - isTripRoom, - isInvoiceRoom, - isApproved, - iouSettled, - iouReport?.isWaitingOnBankAccount, - hasNonReimbursableTransactions, - translate, - totalDisplaySpend, - chatReport, - policy, - invoiceReceiverPolicy, - invoiceReceiverPersonalDetail, - managerID, - formatPhoneNumber, - ]); + } useEffect(() => { if (!isPaidAnimationRunning || isApprovedAnimationRunning || isSubmittingAnimationRunning) { diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx index 62554eecdb87..b1dd181c3ff3 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx @@ -18,7 +18,7 @@ import type {ViewToken} from '@src/types/utils/ReactNativeCompat'; import type {FlashListRef, ListRenderItem, ListRenderItemInfo} from '@shopify/flash-list'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {MoneyRequestReportPreviewStyleType} from './types'; @@ -84,10 +84,10 @@ function useReportPreviewCarousel({ const [ownerLogin] = usePersonalDetail(iouReport?.ownerAccountID, loginSelector); const isFocusedRef = useIsFocusedRef(); - const sortedTransactions = useMemo(() => { - if (shouldShowAccessPlaceHolder) { - return []; - } + let sortedTransactions: Transaction[]; + if (shouldShowAccessPlaceHolder) { + sortedTransactions = []; + } else { const sorted = [...transactions].sort((a, b) => { const rbrComparison = compareByRBR( a, @@ -105,10 +105,10 @@ function useReportPreviewCarousel({ // Tiebreak by date (ascending — oldest first) so position is stable across RBR state changes return localeCompare(getCreated(a), getCreated(b)); }); - return sorted; - }, [shouldShowAccessPlaceHolder, transactions, transactionViolations, currentUserDetails?.login, currentUserDetails?.accountID, iouReport, ownerLogin, policy, localeCompare]); + sortedTransactions = sorted; + } - const carouselTransactions = useMemo(() => sortedTransactions.slice(0, MAX_PREVIEWS_NUMBER + 1), [sortedTransactions]); + const carouselTransactions = sortedTransactions.slice(0, MAX_PREVIEWS_NUMBER + 1); const prevCarouselTransactionLength = useRef(0); useEffect(() => { @@ -129,9 +129,9 @@ function useReportPreviewCarousel({ // Expose a callback ref instead of the ref object so the ref does not flow through the hook's return value // (React Compiler forbids reading/passing refs during render). - const setCarouselRef = useCallback((node: FlashListRef | null) => { + const setCarouselRef = (node: FlashListRef | null) => { carouselRef.current = node; - }, []); + }; const prevTransactionCountForScroll = useRef(carouselTransactions.length); const [carouselKey, setCarouselKey] = useState(0); @@ -147,15 +147,11 @@ function useReportPreviewCarousel({ prevTransactionCountForScroll.current = carouselTransactions.length; }, [carouselTransactions.length]); - const visibleItemsOnEndCount = useMemo(() => { - const lastItemWidth = transactions.length > MAX_PREVIEWS_NUMBER ? footerWidth : reportPreviewStyles.transactionPreviewCarouselStyle.width; - const lastItemWithGap = lastItemWidth + styles.gap2.gap; - const itemWithGap = reportPreviewStyles.transactionPreviewCarouselStyle.width + styles.gap2.gap; - return Math.floor((currentWidth - 2 * styles.pl2.paddingLeft - lastItemWithGap) / itemWithGap) + 1; - }, [transactions.length, footerWidth, reportPreviewStyles.transactionPreviewCarouselStyle.width, styles.gap2.gap, styles.pl2.paddingLeft, currentWidth]); - const viewabilityConfig = useMemo(() => { - return {itemVisiblePercentThreshold: 100}; - }, []); + const lastItemWidth = transactions.length > MAX_PREVIEWS_NUMBER ? footerWidth : reportPreviewStyles.transactionPreviewCarouselStyle.width; + const lastItemWithGap = lastItemWidth + styles.gap2.gap; + const itemWithGap = reportPreviewStyles.transactionPreviewCarouselStyle.width + styles.gap2.gap; + const visibleItemsOnEndCount = Math.floor((currentWidth - 2 * styles.pl2.paddingLeft - lastItemWithGap) / itemWithGap) + 1; + const viewabilityConfig = {itemVisiblePercentThreshold: 100}; const carouselTransactionsRef = useRef(carouselTransactions); @@ -193,14 +189,14 @@ function useReportPreviewCarousel({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [newTransactionIDs]); - const onViewableItemsChanged = useCallback(({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { + const onViewableItemsChanged = ({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { const newIndex = viewableItems.at(0)?.index; if (typeof newIndex === 'number') { setCurrentIndex(newIndex); } const viewableItemsIndexes = viewableItems.map((item) => item.index).filter((item): item is number => typeof item === 'number'); setCurrentVisibleItems(viewableItemsIndexes); - }, []); + }; const snapOffsets = carouselTransactions.map((_, index) => index * (reportPreviewStyles.transactionPreviewCarouselStyle.width + styles.transactionsCarouselGap.width)); @@ -266,7 +262,7 @@ function useReportPreviewCarousel({ setOptimisticIndex(undefined); }, [carouselTransactions.length, currentIndex, currentVisibleItems, currentVisibleItems.length, optimisticIndex, visibleItemsOnEndCount]); - const adjustScroll = useCallback(() => { + const adjustScroll = () => { // Workaround for a known React Native bug on Android (https://github.com/facebook/react-native/issues/27504): // When the FlatList is scrolled to the end and the last item is deleted, a blank space is left behind. // To fix this, we detect when onEndReached is triggered due to an item deletion, @@ -276,7 +272,7 @@ function useReportPreviewCarousel({ } prevCarouselTransactionLength.current = carouselTransactions.length; carouselRef.current?.scrollToEnd(); - }, [carouselTransactions.length]); + }; const renderSeparator = () => ; diff --git a/src/components/ReportActionItem/TaskView.tsx b/src/components/ReportActionItem/TaskView.tsx index 54d1510b76f8..686a50b3e92e 100644 --- a/src/components/ReportActionItem/TaskView.tsx +++ b/src/components/ReportActionItem/TaskView.tsx @@ -47,7 +47,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {delegateEmailSelector} from '@selectors/Account'; import {hasSeenTourSelector} from '@selectors/Onboarding'; -import React, {useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; type TaskViewProps = { @@ -106,27 +106,21 @@ function TaskView({report, parentReport, action}: TaskViewProps) { const disableState = !isTaskModifiable; const isDisableInteractive = disableState || !isOpen; const accountID = currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID; - const contextMenuStateValue = useMemo( - () => ({ - anchor: null, - report, - action, - transactionThreadReport: undefined, - isDisabled: true, - shouldDisplayContextMenu: false, - }), - [report, action], - ); + const contextMenuStateValue = { + anchor: null, + report, + action, + transactionThreadReport: undefined, + isDisabled: true, + shouldDisplayContextMenu: false, + }; - const contextMenuActionsValue = useMemo( - () => ({ - checkIfContextMenuActive: () => {}, - onShowContextMenu: (callback: () => void) => callback(), - }), - [], - ); + const contextMenuActionsValue = { + checkIfContextMenuActive: () => {}, + onShowContextMenu: (callback: () => void) => callback(), + }; - const attachmentContextValue = useMemo(() => ({type: CONST.ATTACHMENT_TYPE.ONBOARDING, accountID}), [accountID]); + const attachmentContextValue = {type: CONST.ATTACHMENT_TYPE.ONBOARDING, accountID}; return ( diff --git a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx index 66b34ce94dd5..ab8b95e4b99b 100644 --- a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx +++ b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx @@ -47,7 +47,7 @@ import {cardByIdSelector} from '@src/selectors/Card'; import {getStableReportSelector} from '@src/selectors/Report'; import truncate from 'lodash/truncate'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import Animated from 'react-native-reanimated'; @@ -85,10 +85,7 @@ function TransactionPreviewContent({ const {convertToDisplayString, getCurrencyDecimals} = useCurrencyListActions(); const {environmentURL} = useEnvironment(); const isParentPolicyExpenseChat = isPolicyExpenseChat(chatReport); - const transactionDetails = useMemo>( - () => getTransactionDetails(transaction, undefined, policy, isParentPolicyExpenseChat) ?? {}, - [transaction, policy, isParentPolicyExpenseChat], - ); + const transactionDetails: Partial = getTransactionDetails(transaction, undefined, policy, isParentPolicyExpenseChat) ?? {}; const {amount, comment: requestComment, merchant, category, currency: requestCurrency} = transactionDetails; const [originalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transaction?.comment?.originalTransactionID)}`); // Only when the expense is actually held: the hold is appended to the RBR message on its own, so it must not also be picked @@ -108,32 +105,25 @@ function TransactionPreviewContent({ const currentUserEmail = currentUserDetails.email ?? ''; const currentUserLogin = currentUserDetails.login; const currentUserAccountID = currentUserDetails.accountID; - const transactionPreviewCommonArguments = useMemo( - () => ({ - iouReport: report, - iouReportOwnerLogin: ownerLogin, - policy, - transaction, - action, - isBillSplit, - violations, - transactionDetails, - }), - [action, report, ownerLogin, policy, isBillSplit, transaction, transactionDetails, violations], - ); + const transactionPreviewCommonArguments = { + iouReport: report, + iouReportOwnerLogin: ownerLogin, + policy, + transaction, + action, + isBillSplit, + violations, + transactionDetails, + }; - const conditionals = useMemo( - () => - createTransactionPreviewConditionals({ - ...transactionPreviewCommonArguments, - areThereDuplicates, - isReportAPolicyExpenseChat: isParentPolicyExpenseChat, - currentUserEmail, - currentUserAccountID, - reportActions, - }), - [areThereDuplicates, transactionPreviewCommonArguments, isParentPolicyExpenseChat, currentUserEmail, currentUserAccountID, reportActions], - ); + const conditionals = createTransactionPreviewConditionals({ + ...transactionPreviewCommonArguments, + areThereDuplicates, + isReportAPolicyExpenseChat: isParentPolicyExpenseChat, + currentUserEmail, + currentUserAccountID, + reportActions, + }); const {shouldShowRBR, shouldShowMerchant, shouldShowSplitShare, shouldShowCategory, shouldShowSkeleton, shouldShowDescription} = conditionals; @@ -163,19 +153,15 @@ function TransactionPreviewContent({ }) : undefined; - const previewText = useMemo( - () => - getTransactionPreviewTextAndTranslationPaths({ - dateFnsLocale, - ...transactionPreviewCommonArguments, - shouldShowRBR, - violationMessage, - reportActions, - originalTransaction, - convertToDisplayString, - }), - [transactionPreviewCommonArguments, shouldShowRBR, violationMessage, reportActions, originalTransaction, convertToDisplayString, dateFnsLocale], - ); + const previewText = getTransactionPreviewTextAndTranslationPaths({ + dateFnsLocale, + ...transactionPreviewCommonArguments, + shouldShowRBR, + violationMessage, + reportActions, + originalTransaction, + convertToDisplayString, + }); const getTranslatedText = (item: TranslationPathOrText) => (item.translationPath ? translate(item.translationPath) : (item.text ?? '')); // The hold comes last, after whatever else flagged the expense, e.g. "Category missing • This expense was put on hold". @@ -210,71 +196,43 @@ function TransactionPreviewContent({ const participantAccountIDs = isMoneyRequestAction(action) && isBillSplit ? (getOriginalMessage(action)?.participantAccountIDs ?? []) : [managerID, ownerAccountID]; const isCardTransaction = isCardTransactionUtils(transaction); - // Compute the from/to data only for IOU reports - const {from, to} = useMemo(() => { - if (!shouldShowPayerAndReceiver) { - return { - from: undefined, - to: undefined, - }; - } - - // For IOU or Split, we want the unprocessed amount because it is important whether the amount was positive or negative - const payerAndReceiver = getIOUPayerAndReceiver(managerID, ownerAccountID, personalDetails, transactionRawAmount); - - return { - from: payerAndReceiver.from, - to: payerAndReceiver.to, - }; - }, [managerID, ownerAccountID, personalDetails, shouldShowPayerAndReceiver, transactionRawAmount]); + // For IOU or Split, the raw amount determines which participant is paying. + const payerAndReceiver = shouldShowPayerAndReceiver ? getIOUPayerAndReceiver(managerID, ownerAccountID, personalDetails, transactionRawAmount) : undefined; + const from = payerAndReceiver?.from; + const to = payerAndReceiver?.to; const shouldShowIOUHeader = !!from && !!to; // If available, retrieve the split share from the splits object of the transaction, if not, display an even share. const actorAccountID = action?.actorAccountID; - const splitShare = useMemo(() => { - if (!shouldShowSplitShare) { - return 0; - } - + let splitShare; + if (!shouldShowSplitShare) { + splitShare = 0; + } else { const splitAmount = transaction?.comment?.splits?.find((split) => split.accountID === sessionAccountID)?.amount; if (splitAmount !== undefined) { - return splitAmount; - } - - let originalParticipantCount = participantAccountIDs.length; - - if (isBillSplit) { - // Try to get the participant count from transaction splits data - const transactionSplitsCount = transaction?.comment?.splits?.length; - if (transactionSplitsCount && transactionSplitsCount > 0) { - originalParticipantCount = transactionSplitsCount; - } else if (isMoneyRequestAction(action)) { - originalParticipantCount = getOriginalMessage(action)?.participantAccountIDs?.length ?? participantAccountIDs.length; + splitShare = splitAmount; + } else { + let originalParticipantCount = participantAccountIDs.length; + if (isBillSplit) { + // Try to get the participant count from transaction splits data + const transactionSplitsCount = transaction?.comment?.splits?.length; + if (transactionSplitsCount && transactionSplitsCount > 0) { + originalParticipantCount = transactionSplitsCount; + } else if (isMoneyRequestAction(action)) { + originalParticipantCount = getOriginalMessage(action)?.participantAccountIDs?.length ?? participantAccountIDs.length; + } } + splitShare = calculateAmount( + isParentPolicyExpenseChat ? 1 : originalParticipantCount - 1, + amount ?? 0, + requestCurrency ?? '', + actorAccountID === sessionAccountID, + false, + getCurrencyDecimals, + ); } - - return calculateAmount( - isParentPolicyExpenseChat ? 1 : originalParticipantCount - 1, - amount ?? 0, - requestCurrency ?? '', - actorAccountID === sessionAccountID, - false, - getCurrencyDecimals, - ); - }, [ - shouldShowSplitShare, - isParentPolicyExpenseChat, - participantAccountIDs.length, - transaction?.comment?.splits, - amount, - requestCurrency, - sessionAccountID, - isBillSplit, - action, - actorAccountID, - getCurrencyDecimals, - ]); + } const animatedHighlightStyle = useAnimatedHighlightStyle({ shouldHighlight, diff --git a/src/components/ReportActionItem/TransactionPreview/index.tsx b/src/components/ReportActionItem/TransactionPreview/index.tsx index b1850100660e..37ec736ed04b 100644 --- a/src/components/ReportActionItem/TransactionPreview/index.tsx +++ b/src/components/ReportActionItem/TransactionPreview/index.tsx @@ -32,7 +32,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {GestureResponderEvent} from 'react-native'; import {useRoute} from '@react-navigation/native'; -import React, {memo, useCallback, useMemo} from 'react'; +import React from 'react'; import type {TransactionPreviewProps} from './types'; @@ -61,9 +61,9 @@ function TransactionPreview(props: TransactionPreviewProps) { const personalDetails = usePersonalDetails(); // Get transaction violations for given transaction id from onyx, find duplicated transactions violations and get duplicates - const allDuplicateIDs = useMemo(() => violations?.find((violation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION)?.data?.duplicates ?? [], [violations]); + const allDuplicateIDs = violations?.find((violation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION)?.data?.duplicates ?? []; const [allDuplicates] = useTransactionsByID(allDuplicateIDs); - const duplicates = useMemo(() => removeSettledAndApprovedTransactions(allDuplicates ?? []), [allDuplicates]); + const duplicates = removeSettledAndApprovedTransactions(allDuplicates ?? []); const sessionAccountID = session?.accountID; const areThereDuplicates = allDuplicateIDs.length > 0 && duplicates.length > 0 && allDuplicateIDs.length === duplicates.length; @@ -80,10 +80,10 @@ function TransactionPreview(props: TransactionPreviewProps) { showContextMenuForReport(event, contextMenuAnchorRef, contextMenuReportID, contextMenuAction, checkIfContextMenuActive, originalReportID); }; - const offlineWithFeedbackOnClose = useCallback(() => { + const offlineWithFeedbackOnClose = () => { clearWalletTermsError(); clearIOUError(chatReportID); - }, [chatReportID]); + }; const navigateToReviewFields = () => Navigation.navigate(getReviewNavigationRoute(Navigation.getActiveRoute(), route.params?.reportID, transaction, duplicates, policy, policyCategories, policyTags ?? {}, report)); @@ -160,4 +160,4 @@ function TransactionPreview(props: TransactionPreviewProps) { ); } -export default memo(TransactionPreview); +export default TransactionPreview; diff --git a/src/components/ReportActionItem/TripDetailsView.tsx b/src/components/ReportActionItem/TripDetailsView.tsx index 6aeea8dcdc94..3bd809ef353c 100644 --- a/src/components/ReportActionItem/TripDetailsView.tsx +++ b/src/components/ReportActionItem/TripDetailsView.tsx @@ -34,7 +34,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {differenceInCalendarDays} from 'date-fns'; import {Str} from 'expensify-common'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type ReservationViewProps = { @@ -82,25 +82,22 @@ function ReservationView({reservation, transactionID, tripRoomReportID, sequence const formattedDate = getFormattedDate(); - const bottomDescription = useMemo(() => { - const code = getTripReservationCode(reservation); - if (reservation.type === CONST.RESERVATION_TYPE.FLIGHT) { - const longName = reservation.company?.longName ? `${reservation.company?.longName} • ` : ''; - const shortName = reservation?.company?.shortName ? `${reservation?.company?.shortName} ` : ''; - return `${code}${longName}${shortName}${reservation.route?.number}`; - } - if (reservation.type === CONST.RESERVATION_TYPE.HOTEL) { - return `${code}${StringUtils.removeDoubleQuotes(reservation.start.address)}`; - } - if (reservation.type === CONST.RESERVATION_TYPE.CAR) { - const vendor = reservation.vendor ? `${reservation.vendor} • ` : ''; - return `${vendor}${reservation.start.location}`; - } - if (reservation.type === CONST.RESERVATION_TYPE.TRAIN) { - return reservation.route?.name; - } - return StringUtils.removeDoubleQuotes(reservation.start.address) ?? reservation.start.location; - }, [reservation]); + let bottomDescription; + const code = getTripReservationCode(reservation); + if (reservation.type === CONST.RESERVATION_TYPE.FLIGHT) { + const longName = reservation.company?.longName ? `${reservation.company?.longName} • ` : ''; + const shortName = reservation?.company?.shortName ? `${reservation?.company?.shortName} ` : ''; + bottomDescription = `${code}${longName}${shortName}${reservation.route?.number}`; + } else if (reservation.type === CONST.RESERVATION_TYPE.HOTEL) { + bottomDescription = `${code}${StringUtils.removeDoubleQuotes(reservation.start.address)}`; + } else if (reservation.type === CONST.RESERVATION_TYPE.CAR) { + const vendor = reservation.vendor ? `${reservation.vendor} • ` : ''; + bottomDescription = `${vendor}${reservation.start.location}`; + } else if (reservation.type === CONST.RESERVATION_TYPE.TRAIN) { + bottomDescription = reservation.route?.name; + } else { + bottomDescription = StringUtils.removeDoubleQuotes(reservation.start.address) ?? reservation.start.location; + } const titleTextStyle = isCancelled ? [styles.textStrong, styles.textSupporting] : styles.textStrong; @@ -193,59 +190,53 @@ function TripDetailsView({tripRoomReport, shouldShowHorizontalRule, tripTransact const {convertToDisplayString} = useCurrencyListActions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const getTripDescription = useCallback( - (amount: number, currency: string, reservations: ReservationData[]) => { - const trips = `${reservations.length} ${reservations.length === 1 ? translate('travel.trip') : translate('travel.trips')}`; - return `${convertToDisplayString(amount, currency)} • ${trips.toLowerCase()}`; - }, - [convertToDisplayString, translate], - ); + const getTripDescription = (amount: number, currency: string, reservations: ReservationData[]) => { + const trips = `${reservations.length} ${reservations.length === 1 ? translate('travel.trip') : translate('travel.trips')}`; + return `${convertToDisplayString(amount, currency)} • ${trips.toLowerCase()}`; + }; - const getTripTitle = useCallback( - (reservations: ReservationData[]) => { - if (reservations.length === 0) { - return ''; - } + const getTripTitle = (reservations: ReservationData[]) => { + if (reservations.length === 0) { + return ''; + } - const firstReservation = reservations.at(0)?.reservation; - const lastReservation = reservations.at(reservations.length - 1)?.reservation; + const firstReservation = reservations.at(0)?.reservation; + const lastReservation = reservations.at(reservations.length - 1)?.reservation; - if (!lastReservation || !firstReservation) { - return ''; - } + if (!lastReservation || !firstReservation) { + return ''; + } - switch (firstReservation?.type) { - case CONST.RESERVATION_TYPE.FLIGHT: { - const destinationReservation = reservations.findLast((reservation) => reservation.reservation.legId === firstReservation.legId); - if (!destinationReservation) { - return ''; - } - return `${translate('travel.flightTo')} ${formatTransitLocationLabel(destinationReservation.reservation.end, true)}`; + switch (firstReservation?.type) { + case CONST.RESERVATION_TYPE.FLIGHT: { + const destinationReservation = reservations.findLast((reservation) => reservation.reservation.legId === firstReservation.legId); + if (!destinationReservation) { + return ''; } - case CONST.RESERVATION_TYPE.TRAIN: - if (reservations.length === 2 && firstReservation.start.shortName === lastReservation.end.shortName) { - return `${translate('travel.trainTo')} ${Str.recapitalize(lastReservation.start.longName ?? '')}`; - } - return `${translate('travel.trainTo')} ${Str.recapitalize(lastReservation.end.longName ?? '')}`; - case CONST.RESERVATION_TYPE.HOTEL: { - const nights = differenceInCalendarDays(new Date(lastReservation?.end.date), new Date(firstReservation.start.date)); - return `${nights} ${nights > 1 ? translate('travel.nightsIn') : translate('travel.nightIn')} ${Str.recapitalize(firstReservation.start.longName ?? '')}`; + return `${translate('travel.flightTo')} ${formatTransitLocationLabel(destinationReservation.reservation.end, true)}`; + } + case CONST.RESERVATION_TYPE.TRAIN: + if (reservations.length === 2 && firstReservation.start.shortName === lastReservation.end.shortName) { + return `${translate('travel.trainTo')} ${Str.recapitalize(lastReservation.start.longName ?? '')}`; } - case CONST.RESERVATION_TYPE.CAR: { - const days = differenceInCalendarDays(new Date(lastReservation.end.date), new Date(firstReservation.start.date)); - - if (days > 0) { - return `${days} ${days > 1 ? translate('common.days') : translate('common.day')}${translate('travel.carRental')}`; - } + return `${translate('travel.trainTo')} ${Str.recapitalize(lastReservation.end.longName ?? '')}`; + case CONST.RESERVATION_TYPE.HOTEL: { + const nights = differenceInCalendarDays(new Date(lastReservation?.end.date), new Date(firstReservation.start.date)); + return `${nights} ${nights > 1 ? translate('travel.nightsIn') : translate('travel.nightIn')} ${Str.recapitalize(firstReservation.start.longName ?? '')}`; + } + case CONST.RESERVATION_TYPE.CAR: { + const days = differenceInCalendarDays(new Date(lastReservation.end.date), new Date(firstReservation.start.date)); - return `${DateUtils.getFormattedDurationBetweenDates(translate, new Date(firstReservation.start.date), new Date(lastReservation.end.date))}${translate('travel.carRental')}`; + if (days > 0) { + return `${days} ${days > 1 ? translate('common.days') : translate('common.day')}${translate('travel.carRental')}`; } - default: - return ''; + + return `${DateUtils.getFormattedDurationBetweenDates(translate, new Date(firstReservation.start.date), new Date(lastReservation.end.date))}${translate('travel.carRental')}`; } - }, - [translate], - ); + default: + return ''; + } + }; const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${tripRoomReport?.reportID}`); diff --git a/src/components/ReportActionItem/TripRoomPreview.tsx b/src/components/ReportActionItem/TripRoomPreview.tsx index 2e892fa12f01..dd178dde2557 100644 --- a/src/components/ReportActionItem/TripRoomPreview.tsx +++ b/src/components/ReportActionItem/TripRoomPreview.tsx @@ -34,7 +34,7 @@ import type {ListRenderItemInfo, StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {Str} from 'expensify-common'; -import React, {useMemo} from 'react'; +import React from 'react'; import {FlatList, View} from 'react-native'; type TripRoomPreviewProps = { @@ -142,16 +142,12 @@ function TripRoomPreview({action, containerStyles, isHovered = false}: TripRoomP const {totalDisplaySpend = 0, currency = reportCurrency} = chatReport ? getTripTotal(chatReport, reportNameValuePairs) : {}; - const displayAmount = useMemo(() => { - if (totalDisplaySpend) { - return convertToDisplayString(totalDisplaySpend, currency); - } - - return convertToDisplayString( - tripTransactions?.reduce((acc, transaction) => acc + Math.abs(transaction.amount), 0), - currency, - ); - }, [convertToDisplayString, currency, totalDisplaySpend, tripTransactions]); + const displayAmount = totalDisplaySpend + ? convertToDisplayString(totalDisplaySpend, currency) + : convertToDisplayString( + tripTransactions?.reduce((acc, transaction) => acc + Math.abs(transaction.amount), 0), + currency, + ); const navigateToTrip = () => Navigation.navigate(getReportRouteForCurrentContext({reportID: chatReportID})); const renderItem = ({item}: ListRenderItemInfo) => ( diff --git a/src/components/ReportSearchHeader/index.tsx b/src/components/ReportSearchHeader/index.tsx index ca6aada11ab3..7470490a8fff 100755 --- a/src/components/ReportSearchHeader/index.tsx +++ b/src/components/ReportSearchHeader/index.tsx @@ -5,7 +5,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import type ReportSearchHeaderProps from './types'; @@ -14,40 +14,24 @@ function ReportSearchHeader({report, style, transactions, avatarBorderColor, isT const styles = useThemeStyles(); const {isLargeScreenWidth} = useResponsiveLayout(); - const statusContainerStyle = useMemo(() => { - return [isLargeScreenWidth ? styles.mt1 : styles.mt0Half, report?.shouldShowStatusAsPending && styles.offlineFeedbackPending]; - }, [isLargeScreenWidth, styles.mt1, styles.mt0Half, report?.shouldShowStatusAsPending, styles.offlineFeedbackPending]); - - const middleContent = useMemo(() => { - return ( - - ); - }, [ - report, - transactions, - avatarBorderColor, - isTitleSelectable, - styles.fontWeightNormal, - styles.textLineHeightNormal, - styles.minHeight4, - styles.mt1, - isLargeScreenWidth, - styles.textMicro, - statusContainerStyle, - ]); + const statusContainerStyle = [isLargeScreenWidth ? styles.mt1 : styles.mt0Half, report?.shouldShowStatusAsPending && styles.offlineFeedbackPending]; + + const middleContent = ( + + ); return ( { - host?.openReportSubmitToPopover(reportID, options); - }, - [host], - ); + return (reportID: string | undefined, options?: ReportSubmitToPopoverOpenOptions) => { + host?.openReportSubmitToPopover(reportID, options); + }; } type ReportSubmitToPopoverRootProps = { @@ -97,7 +94,7 @@ function ReportSubmitToPopoverHost({children, anchorAlignment}: ReportSubmitToPo const [activeReportID, setActiveReportID] = useState(); const pendingOpenRef = useRef<{reportID: string; options?: ReportSubmitToPopoverOpenOptions; anchorRef?: RefObject | null>} | null>(null); - const registerAnchor = useCallback((reportID: string | undefined, anchorRef: RefObject | null>) => { + const registerAnchor = (reportID: string | undefined, anchorRef: RefObject | null>) => { if (!reportID) { return () => {}; } @@ -120,9 +117,9 @@ function ReportSubmitToPopoverHost({children, anchorAlignment}: ReportSubmitToPo anchorRegistryRef.current.delete(reportID); } }; - }, []); + }; - const getAnchorRef = useCallback(() => { + const getAnchorRef = () => { if (activeAnchorRefRef.current) { return activeAnchorRefRef.current; } @@ -137,7 +134,7 @@ function ReportSubmitToPopoverHost({children, anchorAlignment}: ReportSubmitToPo } return anchors.values().next().value ?? null; - }, [activeReportID]); + }; const { reportSubmitToPopover, @@ -151,34 +148,28 @@ function ReportSubmitToPopoverHost({children, anchorAlignment}: ReportSubmitToPo getAnchorRef, }); - const searchSubmitPopoverGuard = useMemo( - () => ({ - isReportSubmitToPopoverVisible, - isReportSubmitToDismissGuardActive, - shouldDisableSearchSubmitPress: isReportSubmitToPopoverVisible || isReportSubmitToDismissGuardActive, - consumeIgnoreNextSearchSubmitPress, - }), - [isReportSubmitToPopoverVisible, isReportSubmitToDismissGuardActive, consumeIgnoreNextSearchSubmitPress], - ); + const searchSubmitPopoverGuard = { + isReportSubmitToPopoverVisible, + isReportSubmitToDismissGuardActive, + shouldDisableSearchSubmitPress: isReportSubmitToPopoverVisible || isReportSubmitToDismissGuardActive, + consumeIgnoreNextSearchSubmitPress, + }; - const openReportSubmitToPopoverForHost = useCallback( - (reportID: string | undefined, options?: ReportSubmitToPopoverOpenOptions, anchorRef?: RefObject | null>) => { - if (!reportID) { - return; - } + const openReportSubmitToPopoverForHost = (reportID: string | undefined, options?: ReportSubmitToPopoverOpenOptions, anchorRef?: RefObject | null>) => { + if (!reportID) { + return; + } - activeAnchorRefRef.current = anchorRef ?? null; + activeAnchorRefRef.current = anchorRef ?? null; - if (activeReportID === reportID) { - openPopoverForActiveReport(options); - return; - } + if (activeReportID === reportID) { + openPopoverForActiveReport(options); + return; + } - pendingOpenRef.current = {reportID, options, anchorRef}; - setActiveReportID(reportID); - }, - [activeReportID, openPopoverForActiveReport], - ); + pendingOpenRef.current = {reportID, options, anchorRef}; + setActiveReportID(reportID); + }; useLayoutEffect(() => { const pending = pendingOpenRef.current; @@ -191,13 +182,10 @@ function ReportSubmitToPopoverHost({children, anchorAlignment}: ReportSubmitToPo openPopoverForActiveReport(pending.options); }, [activeReportID, openPopoverForActiveReport]); - const hostContextValue = useMemo( - () => ({ - registerAnchor, - openReportSubmitToPopover: openReportSubmitToPopoverForHost, - }), - [registerAnchor, openReportSubmitToPopoverForHost], - ); + const hostContextValue = { + registerAnchor, + openReportSubmitToPopover: openReportSubmitToPopoverForHost, + }; return ( @@ -240,12 +228,9 @@ function ReportSubmitToPopoverRootWithHost({reportID, host, children}: {reportID useEffect(() => host.registerAnchor(reportID, anchorRef), [host, reportID]); - const openReportSubmitToPopover = useCallback( - (options?: ReportSubmitToPopoverOpenOptions) => { - host.openReportSubmitToPopover(reportID, options, anchorRef); - }, - [host, reportID, anchorRef], - ); + const openReportSubmitToPopover = (options?: ReportSubmitToPopoverOpenOptions) => { + host.openReportSubmitToPopover(reportID, options, anchorRef); + }; return ( @@ -262,15 +247,12 @@ function ReportSubmitToPopoverRootWithLocalPopover({reportID, onSubmitSuccess, a anchorAlignment, }); - const searchSubmitPopoverGuard = useMemo( - () => ({ - isReportSubmitToPopoverVisible, - isReportSubmitToDismissGuardActive, - shouldDisableSearchSubmitPress: isReportSubmitToPopoverVisible || isReportSubmitToDismissGuardActive, - consumeIgnoreNextSearchSubmitPress, - }), - [isReportSubmitToPopoverVisible, isReportSubmitToDismissGuardActive, consumeIgnoreNextSearchSubmitPress], - ); + const searchSubmitPopoverGuard = { + isReportSubmitToPopoverVisible, + isReportSubmitToDismissGuardActive, + shouldDisableSearchSubmitPress: isReportSubmitToPopoverVisible || isReportSubmitToDismissGuardActive, + consumeIgnoreNextSearchSubmitPress, + }; return ( diff --git a/src/components/RequireFieldsRules/FieldRequirementSettingRow.tsx b/src/components/RequireFieldsRules/FieldRequirementSettingRow.tsx index dd851517f860..830fd4644363 100644 --- a/src/components/RequireFieldsRules/FieldRequirementSettingRow.tsx +++ b/src/components/RequireFieldsRules/FieldRequirementSettingRow.tsx @@ -28,7 +28,7 @@ import type {RequireFieldsRuleForm, RequireFieldsRuleSettingFieldKey} from '@src import type {PolicyCategory} from '@src/types/onyx'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; import FieldRequirementsDirectionToggle from './FieldRequirementsDirectionToggle'; @@ -78,18 +78,15 @@ function FieldRequirementSettingRow({ !isLoadingOnyxValue(dismissedProductTrainingMetadata) && !isProductTrainingElementDismissed(couplingTooltipName, dismissedProductTraining); - const hideCouplingTooltip = useCallback( - (isDismissedUsingCloseButton = false) => { - if (!couplingTooltipName) { - return; - } + const hideCouplingTooltip = (isDismissedUsingCloseButton = false) => { + if (!couplingTooltipName) { + return; + } - dismissProductTraining(couplingTooltipName, isDismissedUsingCloseButton); - }, - [couplingTooltipName], - ); + dismissProductTraining(couplingTooltipName, isDismissedUsingCloseButton); + }; - const renderCouplingTooltipContent = useCallback(() => { + const renderCouplingTooltipContent = () => { return ( @@ -119,24 +116,7 @@ function FieldRequirementSettingRow({ ); - }, [ - couplingTooltip, - expensifyIcons.Close, - expensifyIcons.Lightbulb, - hideCouplingTooltip, - styles.alignItemsCenter, - styles.flexRow, - styles.flexShrink1, - styles.gap3, - styles.justifyContentCenter, - styles.ph2, - styles.productTrainingTooltipText, - styles.pv2, - styles.textAlignCenter, - theme.icon, - theme.tooltipHighlightText, - translate, - ]); + }; const handleSelectSetting = (newSetting: FieldRequirementsDirection | undefined) => { if (!canWriteRules || isCouplingDisabled) { diff --git a/src/components/RoomHeaderAvatars.tsx b/src/components/RoomHeaderAvatars.tsx index 054c482c5149..2212656ad809 100644 --- a/src/components/RoomHeaderAvatars.tsx +++ b/src/components/RoomHeaderAvatars.tsx @@ -18,7 +18,7 @@ import type {Icon} from '@src/types/onyx/OnyxCommon'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {memo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import AvatarFromIcon from './Avatar/AvatarFromIcon'; @@ -179,4 +179,4 @@ function RoomHeaderAvatars({icons, report, policy, participants, currentUserAcco ); } -export default memo(RoomHeaderAvatars); +export default RoomHeaderAvatars; diff --git a/src/components/ScreenWrapper/ScreenWrapperOfflineIndicators.tsx b/src/components/ScreenWrapper/ScreenWrapperOfflineIndicators.tsx index 2b9945d9dae7..77d530e66402 100644 --- a/src/components/ScreenWrapper/ScreenWrapperOfflineIndicators.tsx +++ b/src/components/ScreenWrapper/ScreenWrapperOfflineIndicators.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import type {ReactNode} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type ScreenWrapperOfflineIndicatorsProps = { @@ -51,7 +51,7 @@ function ScreenWrapperOfflineIndicators({ const {isOffline} = useNetwork(); const {insets} = useSafeAreaPaddings(true); - const navigationBarType = useMemo(() => StyleUtils.getNavigationBarType(insets), [StyleUtils, insets]); + const navigationBarType = StyleUtils.getNavigationBarType(insets); const isSoftKeyNavigation = navigationBarType === CONST.NAVIGATION_BAR_TYPE.SOFT_KEYS; /** @@ -61,13 +61,13 @@ function ScreenWrapperOfflineIndicators({ * By default, the background color of the small screen offline indicator is translucent. * If `isOfflineIndicatorTranslucent` is set to true, an opaque background color is applied. */ - const smallScreenOfflineIndicatorBackgroundStyle = useMemo(() => { - const showOfflineIndicatorBackground = !extraContent && (isSoftKeyNavigation || isOffline); - if (!showOfflineIndicatorBackground) { - return undefined; - } - return isOfflineIndicatorTranslucent ? styles.translucentNavigationBarBG : styles.appBG; - }, [extraContent, isOffline, isOfflineIndicatorTranslucent, isSoftKeyNavigation, styles.appBG, styles.translucentNavigationBarBG]); + let smallScreenOfflineIndicatorBackgroundStyle; + const showOfflineIndicatorBackground = !extraContent && (isSoftKeyNavigation || isOffline); + if (!showOfflineIndicatorBackground) { + smallScreenOfflineIndicatorBackgroundStyle = undefined; + } else { + smallScreenOfflineIndicatorBackgroundStyle = isOfflineIndicatorTranslucent ? styles.translucentNavigationBarBG : styles.appBG; + } /** * This style includes the bottom safe area padding for the small screen offline indicator. @@ -88,30 +88,18 @@ function ScreenWrapperOfflineIndicators({ * It always applies the bottom safe area padding as well as the background style, if the device has soft keys. * In this case, we want the whole container (including the bottom safe area padding) to have translucent/opaque background. */ - const smallScreenOfflineIndicatorContainerStyle = useMemo( - () => [ - smallScreenOfflineIndicatorBottomSafeAreaStyle, - shouldSmallScreenOfflineIndicatorStickToBottom && styles.stickToBottom, - !isSoftKeyNavigation && smallScreenOfflineIndicatorBackgroundStyle, - ], - [ - smallScreenOfflineIndicatorBottomSafeAreaStyle, - shouldSmallScreenOfflineIndicatorStickToBottom, - styles.stickToBottom, - isSoftKeyNavigation, - smallScreenOfflineIndicatorBackgroundStyle, - ], - ); + const smallScreenOfflineIndicatorContainerStyle = [ + smallScreenOfflineIndicatorBottomSafeAreaStyle, + shouldSmallScreenOfflineIndicatorStickToBottom && styles.stickToBottom, + !isSoftKeyNavigation && smallScreenOfflineIndicatorBackgroundStyle, + ]; /** * This style includes the styles applied to the small screen offline indicator component. * If the device has soft keys, we only want to apply the background style to the small screen offline indicator component, * rather than the whole container, because otherwise the navigation bar would be extra opaque, since it already has a translucent background. */ - const smallScreenOfflineIndicatorStyle = useMemo( - () => [styles.pl5, isSoftKeyNavigation && smallScreenOfflineIndicatorBackgroundStyle, offlineIndicatorStyle], - [isSoftKeyNavigation, smallScreenOfflineIndicatorBackgroundStyle, offlineIndicatorStyle, styles.pl5], - ); + const smallScreenOfflineIndicatorStyle = [styles.pl5, isSoftKeyNavigation && smallScreenOfflineIndicatorBackgroundStyle, offlineIndicatorStyle]; return ( <> diff --git a/src/components/ScreenWrapper/index.tsx b/src/components/ScreenWrapper/index.tsx index a1644c687ecb..82481fca50ea 100644 --- a/src/components/ScreenWrapper/index.tsx +++ b/src/components/ScreenWrapper/index.tsx @@ -38,7 +38,7 @@ import type {EdgeInsets} from 'react-native-safe-area-context'; import {NavigationRouteContext, useFocusEffect, useIsFocused, useNavigation, usePreventRemove} from '@react-navigation/native'; import {isSingleNewDotEntrySelector} from '@selectors/HybridApp'; -import React, {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {createContext, useCallback, useContext, useEffect, useRef, useState} from 'react'; import {DeviceEventEmitter, Keyboard} from 'react-native'; import type {ScreenWrapperContainerProps} from './ScreenWrapperContainer'; @@ -143,10 +143,7 @@ function ScreenWrapper({ // We disable legacy bottom safe area padding handling, if we are using edge-to-edge mode. const includeSafeAreaPaddingBottom = isUsingEdgeToEdgeMode ? false : includeSafeAreaPaddingBottomProp; const isSafeAreaTopPaddingApplied = includePaddingTop; - const statusContextValue = useMemo( - () => ({didScreenTransitionEnd, isSafeAreaTopPaddingApplied, isSafeAreaBottomPaddingApplied: includeSafeAreaPaddingBottom}), - [didScreenTransitionEnd, includeSafeAreaPaddingBottom, isSafeAreaTopPaddingApplied], - ); + const statusContextValue = {didScreenTransitionEnd, isSafeAreaTopPaddingApplied, isSafeAreaBottomPaddingApplied: includeSafeAreaPaddingBottom}; // This context allows us to disable the safe area padding offsetting the offline indicator in scrollable components like 'ScrollView', 'SelectionList' or 'FormProvider'. // This is useful e.g. for the RightModalNavigator, where we want to avoid the safe area padding offsetting the offline indicator because we only show the offline indicator on small screens. @@ -155,17 +152,14 @@ function ScreenWrapper({ const shouldMoveAccessibilityFocus = isMobileWebNarrowLayout && isInNarrowPane; const shouldHideFromAccessibility = isMobileWebNarrowLayout && !isFocused; const {addSafeAreaPadding, showOnSmallScreens, showOnWideScreens} = useContext(ScreenWrapperOfflineIndicatorContext); - const offlineIndicatorContextValue = useMemo(() => { - const newAddSafeAreaPadding = isInNarrowPane ? isSmallScreenWidth : addSafeAreaPadding; - - return { - // Allows for individual screens to disable the offline indicator safe area padding for the screen and all nested ScreenWrapper components. - addSafeAreaPadding: disableOfflineIndicatorSafeAreaPadding === undefined ? (newAddSafeAreaPadding ?? true) : !disableOfflineIndicatorSafeAreaPadding, - // Prevent any nested ScreenWrapper components from rendering another offline indicator. - showOnSmallScreens: false, - showOnWideScreens: false, - }; - }, [addSafeAreaPadding, disableOfflineIndicatorSafeAreaPadding, isInNarrowPane, isSmallScreenWidth]); + const newAddSafeAreaPadding = isInNarrowPane ? isSmallScreenWidth : addSafeAreaPadding; + const offlineIndicatorContextValue = { + // Allows for individual screens to disable the offline indicator safe area padding for the screen and all nested ScreenWrapper components. + addSafeAreaPadding: disableOfflineIndicatorSafeAreaPadding === undefined ? (newAddSafeAreaPadding ?? true) : !disableOfflineIndicatorSafeAreaPadding, + // Prevent any nested ScreenWrapper components from rendering another offline indicator. + showOnSmallScreens: false, + showOnWideScreens: false, + }; /** If there is no bottom content, the mobile offline indicator will stick to the bottom of the screen by default. */ const displayStickySmallScreenOfflineIndicator = shouldSmallScreenOfflineIndicatorStickToBottom && !bottomContent; @@ -279,12 +273,7 @@ function ScreenWrapper({ }, [navigation]), ); - const ChildrenContent = useMemo(() => { - return ( - // If props.children is a function, call it to provide the insets to the children. - typeof children === 'function' ? children({insets, safeAreaPaddingBottomStyle, didScreenTransitionEnd}) : children - ); - }, [children, insets, safeAreaPaddingBottomStyle, didScreenTransitionEnd]); + const ChildrenContent = typeof children === 'function' ? children({insets, safeAreaPaddingBottomStyle, didScreenTransitionEnd}) : children; return ( diff --git a/src/components/ScrollOffsetContextProvider.tsx b/src/components/ScrollOffsetContextProvider.tsx index 49792e8d05c5..9f385744da96 100644 --- a/src/components/ScrollOffsetContextProvider.tsx +++ b/src/components/ScrollOffsetContextProvider.tsx @@ -11,7 +11,7 @@ import SCREENS from '@src/SCREENS'; import type {ParamListBase} from '@react-navigation/native'; import {findFocusedRoute} from '@react-navigation/native'; -import React, {createContext, useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {createContext, useEffect, useRef} from 'react'; type ScrollOffsetContextValue = { /** Save scroll offset of FlashList on given screen */ @@ -82,18 +82,18 @@ function ScrollOffsetContextProvider({children}: ScrollOffsetContextProviderProp } }, [priorityMode, previousPriorityMode, inboxTab, previousInboxTab]); - const saveScrollOffset: ScrollOffsetContextValue['saveScrollOffset'] = useCallback((route, scrollOffset) => { + const saveScrollOffset: ScrollOffsetContextValue['saveScrollOffset'] = (route, scrollOffset) => { scrollOffsetsRef.current[getKey(route)] = scrollOffset; - }, []); + }; - const getScrollOffset: ScrollOffsetContextValue['getScrollOffset'] = useCallback((route) => { + const getScrollOffset: ScrollOffsetContextValue['getScrollOffset'] = (route) => { if (!scrollOffsetsRef.current) { return; } return scrollOffsetsRef.current[getKey(route)]; - }, []); + }; - const cleanScrollOffsets = useCallback((keys: string[], shouldDelete: (key: string) => boolean) => { + const cleanScrollOffsets = (keys: string[], shouldDelete: (key: string) => boolean) => { for (const key of keys) { if (!shouldDelete(key)) { continue; @@ -101,53 +101,47 @@ function ScrollOffsetContextProvider({children}: ScrollOffsetContextProviderProp delete scrollOffsetsRef.current[key]; } - }, []); + }; - const cleanStaleScrollOffsets: ScrollOffsetContextValue['cleanStaleScrollOffsets'] = useCallback( - (state) => { - const sidebarRoutes = state.routes.filter((route) => isSidebarScreenName(route.name)); - const existingScreenKeys = new Set(sidebarRoutes.map(getKey)); + const cleanStaleScrollOffsets: ScrollOffsetContextValue['cleanStaleScrollOffsets'] = (state) => { + const sidebarRoutes = state.routes.filter((route) => isSidebarScreenName(route.name)); + const existingScreenKeys = new Set(sidebarRoutes.map(getKey)); - const focusedRoute = findFocusedRoute(state); - const routeName = focusedRoute?.name; + const focusedRoute = findFocusedRoute(state); + const routeName = focusedRoute?.name; - const isSearchScreen = routeName === SCREENS.SEARCH.ROOT; - const isSearchMoneyRequestReport = - routeName === SCREENS.RIGHT_MODAL.EXPENSE_REPORT || routeName === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT || routeName === SCREENS.RIGHT_MODAL.SEARCH_REPORT; + const isSearchScreen = routeName === SCREENS.SEARCH.ROOT; + const isSearchMoneyRequestReport = + routeName === SCREENS.RIGHT_MODAL.EXPENSE_REPORT || routeName === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT || routeName === SCREENS.RIGHT_MODAL.SEARCH_REPORT; - const scrollOffsetKeys = Object.keys(scrollOffsetsRef.current); + const scrollOffsetKeys = Object.keys(scrollOffsetsRef.current); - if (isSearchScreen || isSearchMoneyRequestReport) { - const currentKey = focusedRoute ? getKey(focusedRoute) : null; - cleanScrollOffsets(scrollOffsetKeys, (key) => key.startsWith(SCREENS.SEARCH.ROOT) && key !== currentKey && !isSearchMoneyRequestReport); - return; - } - cleanScrollOffsets(scrollOffsetKeys, (key) => !existingScreenKeys.has(key)); - }, - [cleanScrollOffsets], - ); + if (isSearchScreen || isSearchMoneyRequestReport) { + const currentKey = focusedRoute ? getKey(focusedRoute) : null; + cleanScrollOffsets(scrollOffsetKeys, (key) => key.startsWith(SCREENS.SEARCH.ROOT) && key !== currentKey && !isSearchMoneyRequestReport); + return; + } + cleanScrollOffsets(scrollOffsetKeys, (key) => !existingScreenKeys.has(key)); + }; - const saveScrollIndex: ScrollOffsetContextValue['saveScrollIndex'] = useCallback((route, scrollIndex) => { + const saveScrollIndex: ScrollOffsetContextValue['saveScrollIndex'] = (route, scrollIndex) => { scrollOffsetsRef.current[getKey(route)] = scrollIndex; - }, []); + }; - const getScrollIndex: ScrollOffsetContextValue['getScrollIndex'] = useCallback((route) => { + const getScrollIndex: ScrollOffsetContextValue['getScrollIndex'] = (route) => { if (!scrollOffsetsRef.current) { return; } return scrollOffsetsRef.current[getKey(route)]; - }, []); - - const contextValue = useMemo( - (): ScrollOffsetContextValue => ({ - saveScrollOffset, - getScrollOffset, - cleanStaleScrollOffsets, - saveScrollIndex, - getScrollIndex, - }), - [saveScrollOffset, getScrollOffset, cleanStaleScrollOffsets, saveScrollIndex, getScrollIndex], - ); + }; + + const contextValue: ScrollOffsetContextValue = { + saveScrollOffset, + getScrollOffset, + cleanStaleScrollOffsets, + saveScrollIndex, + getScrollIndex, + }; return {children}; } diff --git a/src/components/ScrollViewWithContext.tsx b/src/components/ScrollViewWithContext.tsx index d44a833c643f..7d5b875b2d89 100644 --- a/src/components/ScrollViewWithContext.tsx +++ b/src/components/ScrollViewWithContext.tsx @@ -4,7 +4,7 @@ import type {ComponentRef, ForwardedRef, ReactNode} from 'react'; // eslint-disable-next-line no-restricted-imports import type {NativeScrollEvent, NativeSyntheticEvent, ScrollView as RNScrollView} from 'react-native'; -import React, {createContext, useMemo, useRef, useState} from 'react'; +import React, {createContext, useRef, useState} from 'react'; import type {ScrollViewProps} from './ScrollView'; @@ -45,13 +45,10 @@ function ScrollViewWithContext({onScroll, scrollEventThrottle, children, ref, .. setContentOffsetY(event.nativeEvent.contentOffset.y); }; - const contextValue = useMemo( - () => ({ - scrollViewRef, - contentOffsetY, - }), - [scrollViewRef, contentOffsetY], - ); + const contextValue = { + scrollViewRef, + contentOffsetY, + }; return ( ({...getEmptyDateValues(), ...defaultDateValues}), [defaultDateValues]); + const normalizedDefaultDateValues = {...getEmptyDateValues(), ...defaultDateValues}; const searchDatePresetFilterBaseRef = useRef(null); const [selectedDateModifierState, setSelectedDateModifierState] = useState(null); const [shouldShowRangeError, setShouldShowRangeError] = useState(false); @@ -114,45 +114,36 @@ function DateFilterBase({ ); }, [normalizedDefaultDateValues, dateFnsLocale]); - const handleDateValuesChange = useCallback( - (values: SearchDateValues) => { - setRangeDisplayText( - getDateRangeDisplayValueFromFormValue( - dateFnsLocale, - values[CONST.SEARCH.DATE_MODIFIERS.RANGE], - values[CONST.SEARCH.DATE_MODIFIERS.AFTER], - values[CONST.SEARCH.DATE_MODIFIERS.BEFORE], - ), - ); - onDateValuesChange?.(values); - }, - [onDateValuesChange, dateFnsLocale], - ); + const handleDateValuesChange = (values: SearchDateValues) => { + setRangeDisplayText( + getDateRangeDisplayValueFromFormValue( + dateFnsLocale, + values[CONST.SEARCH.DATE_MODIFIERS.RANGE], + values[CONST.SEARCH.DATE_MODIFIERS.AFTER], + values[CONST.SEARCH.DATE_MODIFIERS.BEFORE], + ), + ); + onDateValuesChange?.(values); + }; const isDateModifierControlled = selectedDateModifierProp !== undefined; const selectedDateModifier = isDateModifierControlled ? selectedDateModifierProp : selectedDateModifierState; - const setSelectedDateModifier = useCallback( - (dateModifier: SearchDateModifier | null) => { - if (isDateModifierControlled) { - onSelectDateModifier?.(dateModifier); - return; - } - setSelectedDateModifierState(dateModifier); - }, - [isDateModifierControlled, onSelectDateModifier], - ); + const setSelectedDateModifier = (dateModifier: SearchDateModifier | null) => { + if (isDateModifierControlled) { + onSelectDateModifier?.(dateModifier); + return; + } + setSelectedDateModifierState(dateModifier); + }; - const handleSelectDateModifier = useCallback( - (dateModifier: SearchDateModifier | null) => { - setSelectedDateModifier(dateModifier); - onDateModifierChange?.(!!dateModifier); - onDateValuesChange?.(searchDatePresetFilterBaseRef.current?.getDateValues() ?? getEmptyDateValues()); - }, - [onDateModifierChange, onDateValuesChange, setSelectedDateModifier], - ); + const handleSelectDateModifier = (dateModifier: SearchDateModifier | null) => { + setSelectedDateModifier(dateModifier); + onDateModifierChange?.(!!dateModifier); + onDateValuesChange?.(searchDatePresetFilterBaseRef.current?.getDateValues() ?? getEmptyDateValues()); + }; - const goBack = useCallback(() => { + const goBack = () => { if (selectedDateModifier) { if (selectedDateModifier === CONST.SEARCH.DATE_MODIFIERS.RANGE) { searchDatePresetFilterBaseRef.current?.restoreRangeToEntrySnapshot(); @@ -164,11 +155,11 @@ function DateFilterBase({ } onBackButtonPress?.(); - }, [onBackButtonPress, onDateModifierChange, selectedDateModifier, setSelectedDateModifier]); + }; const computedTitle = getDateModifierTitle(selectedDateModifier, title ?? '', translate); - const reset = useCallback(() => { + const reset = () => { if (!searchDatePresetFilterBaseRef.current) { return; } @@ -187,9 +178,9 @@ function DateFilterBase({ const dateValues = searchDatePresetFilterBaseRef.current.getDateValues(); setShouldShowRangeError(false); onReset?.(dateValues); - }, [onDateModifierChange, onReset, selectedDateModifier, setSelectedDateModifier]); + }; - const save = useCallback(() => { + const save = () => { if (!searchDatePresetFilterBaseRef.current) { return; } @@ -210,7 +201,7 @@ function DateFilterBase({ const dateValues = searchDatePresetFilterBaseRef.current.getDateValues(); startWithLoading(() => onSubmit(dateValues)); - }, [onDateModifierChange, onSubmit, selectedDateModifier, setSelectedDateModifier, startWithLoading]); + }; useImperativeHandle( ref, diff --git a/src/components/Search/FilterComponents/DatePresetFilterBase.tsx b/src/components/Search/FilterComponents/DatePresetFilterBase.tsx index a3e8e2517eca..c26bd3c05e14 100644 --- a/src/components/Search/FilterComponents/DatePresetFilterBase.tsx +++ b/src/components/Search/FilterComponents/DatePresetFilterBase.tsx @@ -19,7 +19,7 @@ import CONST from '@src/CONST'; import type {Ref} from 'react'; import {format} from 'date-fns'; -import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; import {View} from 'react-native'; import RangeDatePicker from './RangeDatePicker'; @@ -133,50 +133,41 @@ function DatePresetFilterBase({ const shouldShowHorizontalRule = !!presets?.length; const customDateTitle = translate('search.filters.date.customDate'); const customRangeTitle = translate('search.filters.date.customRange'); - const normalizedDefaultDateValues = useMemo(() => normalizeDateValues(defaultDateValues), [defaultDateValues]); + const normalizedDefaultDateValues = normalizeDateValues(defaultDateValues); - const getRangeDisplayTextFromDateValues = useCallback( - (dateValues: SearchDateValues) => { - const rangeValue = dateValues[CONST.SEARCH.DATE_MODIFIERS.RANGE]; - if (!rangeValue) { - return ''; - } - return getDateRangeDisplayValueFromFormValue(dateFnsLocale, rangeValue, dateValues[CONST.SEARCH.DATE_MODIFIERS.AFTER], dateValues[CONST.SEARCH.DATE_MODIFIERS.BEFORE]); - }, - [dateFnsLocale], - ); + const getRangeDisplayTextFromDateValues = (dateValues: SearchDateValues) => { + const rangeValue = dateValues[CONST.SEARCH.DATE_MODIFIERS.RANGE]; + if (!rangeValue) { + return ''; + } + return getDateRangeDisplayValueFromFormValue(dateFnsLocale, rangeValue, dateValues[CONST.SEARCH.DATE_MODIFIERS.AFTER], dateValues[CONST.SEARCH.DATE_MODIFIERS.BEFORE]); + }; - const getRangeEphemeralValuesFromDateValues = useCallback((dateValues: SearchDateValues) => { + const getRangeEphemeralValuesFromDateValues = (dateValues: SearchDateValues) => { const rangeBoundaries = getRangeBoundariesFromFormValue(dateValues[CONST.SEARCH.DATE_MODIFIERS.RANGE]); return { from: rangeBoundaries.from, to: rangeBoundaries.to, }; - }, []); + }; - const notifyDateValuesChange = useCallback( - (values: SearchDateValues) => { - onDateValuesChange?.(values); - }, - [onDateValuesChange], - ); + const notifyDateValuesChange = (values: SearchDateValues) => { + onDateValuesChange?.(values); + }; const [dateValues, setDateValues] = useState(normalizedDefaultDateValues); const dateValuesRef = useRef(normalizedDefaultDateValues); const [focusedPresetKey, setFocusedPresetKey] = useState(null); - const updateDateValues = useCallback( - (updater: SearchDateValues | ((prevDateValues: SearchDateValues) => SearchDateValues), shouldNotify = true) => { - const nextDateValues = typeof updater === 'function' ? updater(dateValuesRef.current) : updater; - dateValuesRef.current = nextDateValues; - setDateValues(nextDateValues); - - if (shouldNotify) { - notifyDateValuesChange(nextDateValues); - } - }, - [notifyDateValuesChange], - ); + const updateDateValues = (updater: SearchDateValues | ((prevDateValues: SearchDateValues) => SearchDateValues), shouldNotify = true) => { + const nextDateValues = typeof updater === 'function' ? updater(dateValuesRef.current) : updater; + dateValuesRef.current = nextDateValues; + setDateValues(nextDateValues); + + if (shouldNotify) { + notifyDateValuesChange(nextDateValues); + } + }; useEffect(() => { if (isSearchAdvancedFiltersFormLoading) { @@ -199,58 +190,46 @@ function DatePresetFilterBase({ setDateValues(normalizedDefaultDateValues); }, [isSearchAdvancedFiltersFormLoading, normalizedDefaultDateValues]); - const setDateValue = useCallback( - (dateModifier: SearchDateModifier, value: string | undefined) => { - updateDateValues((prevDateValues) => { - if (dateModifier === CONST.SEARCH.DATE_MODIFIERS.ON && isSearchDatePreset(value)) { - return getExclusiveDateValues(dateModifier, value); - } - - if (dateModifier === CONST.SEARCH.DATE_MODIFIERS.RANGE) { - return {...prevDateValues, [dateModifier]: value}; - } - - if (dateModifier !== CONST.SEARCH.DATE_MODIFIERS.ON && isSearchDatePreset(prevDateValues[CONST.SEARCH.DATE_MODIFIERS.ON])) { - return { - ...prevDateValues, - [dateModifier]: value, - [CONST.SEARCH.DATE_MODIFIERS.ON]: undefined, - }; - } + const setDateValue = (dateModifier: SearchDateModifier, value: string | undefined) => { + updateDateValues((prevDateValues) => { + if (dateModifier === CONST.SEARCH.DATE_MODIFIERS.ON && isSearchDatePreset(value)) { + return getExclusiveDateValues(dateModifier, value); + } + if (dateModifier === CONST.SEARCH.DATE_MODIFIERS.RANGE) { return {...prevDateValues, [dateModifier]: value}; - }); - }, - [updateDateValues], - ); + } - const setExclusiveDateValue = useCallback( - (dateModifier: SearchDateModifier, value: string | undefined) => { - updateDateValues(getExclusiveDateValues(dateModifier, value)); - }, - [updateDateValues], - ); + if (dateModifier !== CONST.SEARCH.DATE_MODIFIERS.ON && isSearchDatePreset(prevDateValues[CONST.SEARCH.DATE_MODIFIERS.ON])) { + return { + ...prevDateValues, + [dateModifier]: value, + [CONST.SEARCH.DATE_MODIFIERS.ON]: undefined, + }; + } - const dateDisplayValues = useMemo(() => { - const dateOn = dateValues[CONST.SEARCH.DATE_MODIFIERS.ON]; - const dateAfter = dateValues[CONST.SEARCH.DATE_MODIFIERS.AFTER]; - const dateBefore = dateValues[CONST.SEARCH.DATE_MODIFIERS.BEFORE]; + return {...prevDateValues, [dateModifier]: value}; + }); + }; - return { - // dateOn could be a preset e.g. Last month which should not be displayed as the On field - [CONST.SEARCH.DATE_MODIFIERS.ON]: isSearchDatePreset(dateOn) ? undefined : dateOn, - [CONST.SEARCH.DATE_MODIFIERS.AFTER]: dateAfter, - [CONST.SEARCH.DATE_MODIFIERS.BEFORE]: dateBefore, - [CONST.SEARCH.DATE_MODIFIERS.RANGE]: dateValues[CONST.SEARCH.DATE_MODIFIERS.RANGE] ? 'range' : undefined, - }; - }, [dateValues]); + const setExclusiveDateValue = (dateModifier: SearchDateModifier, value: string | undefined) => { + updateDateValues(getExclusiveDateValues(dateModifier, value)); + }; - const getInitialEphemeralDateValue = useCallback((dateModifier: SearchDateModifier | null) => (dateModifier ? dateDisplayValues[dateModifier] : undefined), [dateDisplayValues]); + const dateOn = dateValues[CONST.SEARCH.DATE_MODIFIERS.ON]; + const dateAfter = dateValues[CONST.SEARCH.DATE_MODIFIERS.AFTER]; + const dateBefore = dateValues[CONST.SEARCH.DATE_MODIFIERS.BEFORE]; + const dateDisplayValues: SearchDateValues = { + // dateOn could be a preset e.g. Last month which should not be displayed as the On field + [CONST.SEARCH.DATE_MODIFIERS.ON]: isSearchDatePreset(dateOn) ? undefined : dateOn, + [CONST.SEARCH.DATE_MODIFIERS.AFTER]: dateAfter, + [CONST.SEARCH.DATE_MODIFIERS.BEFORE]: dateBefore, + [CONST.SEARCH.DATE_MODIFIERS.RANGE]: dateValues[CONST.SEARCH.DATE_MODIFIERS.RANGE] ? 'range' : undefined, + }; + + const getInitialEphemeralDateValue = (dateModifier: SearchDateModifier | null) => (dateModifier ? dateDisplayValues[dateModifier] : undefined); const [ephemeralDateValue, setEphemeralDateValue] = useState(() => getInitialEphemeralDateValue(selectedDateModifier)); - const resetEphemeralDateValue = useCallback( - (dateModifier: SearchDateModifier | null) => setEphemeralDateValue(getInitialEphemeralDateValue(dateModifier)), - [getInitialEphemeralDateValue], - ); + const resetEphemeralDateValue = (dateModifier: SearchDateModifier | null) => setEphemeralDateValue(getInitialEphemeralDateValue(dateModifier)); const [rangeEphemeralValues, setRangeEphemeralValues] = useState<{from?: string; to?: string}>(() => getRangeEphemeralValuesFromDateValues(normalizedDefaultDateValues)); @@ -268,27 +247,24 @@ function DatePresetFilterBase({ // Used to discard unsaved range picks when the user leaves Range mode without saving. const rangeEntrySnapshotRef = useRef(undefined); - const selectDateModifier = useCallback( - (dateModifier: SearchDateModifier | null) => { - const isSwitchingBetweenCustomDateModifiers = isCustomDateModifier(selectedDateModifier) && isCustomDateModifier(dateModifier); - if (!isSwitchingBetweenCustomDateModifiers) { - resetEphemeralDateValue(dateModifier); - } + const selectDateModifier = (dateModifier: SearchDateModifier | null) => { + const isSwitchingBetweenCustomDateModifiers = isCustomDateModifier(selectedDateModifier) && isCustomDateModifier(dateModifier); + if (!isSwitchingBetweenCustomDateModifiers) { + resetEphemeralDateValue(dateModifier); + } - onRangeValidationErrorChange?.(false); + onRangeValidationErrorChange?.(false); - if (dateModifier === CONST.SEARCH.DATE_MODIFIERS.RANGE) { - const currentDateValues = dateValuesRef.current; - rangeEntrySnapshotRef.current = currentDateValues[CONST.SEARCH.DATE_MODIFIERS.RANGE]; - setRangeEphemeralValues(getRangeEphemeralValuesFromDateValues(currentDateValues)); - } + if (dateModifier === CONST.SEARCH.DATE_MODIFIERS.RANGE) { + const currentDateValues = dateValuesRef.current; + rangeEntrySnapshotRef.current = currentDateValues[CONST.SEARCH.DATE_MODIFIERS.RANGE]; + setRangeEphemeralValues(getRangeEphemeralValuesFromDateValues(currentDateValues)); + } - onSelectDateModifier(dateModifier); - }, - [getRangeEphemeralValuesFromDateValues, onRangeValidationErrorChange, onSelectDateModifier, resetEphemeralDateValue, selectedDateModifier], - ); + onSelectDateModifier(dateModifier); + }; - const validate = useCallback(() => { + const validate = () => { if (selectedDateModifier !== CONST.SEARCH.DATE_MODIFIERS.RANGE) { onRangeValidationErrorChange?.(false); return true; @@ -297,7 +273,7 @@ function DatePresetFilterBase({ const isValid = !!(rangeEphemeralValues.from && rangeEphemeralValues.to); onRangeValidationErrorChange?.(!isValid); return isValid; - }, [onRangeValidationErrorChange, rangeEphemeralValues.from, rangeEphemeralValues.to, selectedDateModifier]); + }; useImperativeHandle( ref, @@ -385,18 +361,17 @@ function DatePresetFilterBase({ const rangeDescription = getRangeDisplayTextFromDateValues(dateValues) || undefined; const customDateModifier = getCustomDateModifierFromDateValues(dateValues, allowedCustomDateModifiers); - const customDateDescription = useMemo(() => { - const customDateValue = dateDisplayValues[customDateModifier]; - if (!customDateValue) { - return undefined; - } - - return `${translate(`common.${customDateModifier.toLowerCase() as SearchDateModifierLower}`)} ${customDateValue}`; - }, [customDateModifier, dateDisplayValues, translate]); + let customDateDescription; + const customDateValue = dateDisplayValues[customDateModifier]; + if (!customDateValue) { + customDateDescription = undefined; + } else { + customDateDescription = `${translate(`common.${customDateModifier.toLowerCase() as SearchDateModifierLower}`)} ${customDateValue}`; + } const handleSingleDateSelected = (date: Date) => { setEphemeralDateValue(format(date, CONST.DATE.FNS_FORMAT_STRING)); }; - const selectCustomDateMode = useCallback(() => selectDateModifier(customDateModifier), [customDateModifier, selectDateModifier]); + const selectCustomDateMode = () => selectDateModifier(customDateModifier); if (!selectedDateModifier) { return ( diff --git a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx index 7b6ade5671d3..7504e221098a 100644 --- a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx @@ -20,7 +20,7 @@ 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'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import SelectedDateModifierHeader from './SelectedDateModifierHeader'; @@ -54,22 +54,18 @@ function DateSelectPopup({label, value, presets, allowedCustomDateModifiers = CO const [rangeText, setRangeText] = useState(() => getDateRangeDisplayValueFromFormValue(dateFnsLocale, value[CONST.SEARCH.DATE_MODIFIERS.RANGE], value[CONST.SEARCH.DATE_MODIFIERS.AFTER], value[CONST.SEARCH.DATE_MODIFIERS.BEFORE]), ); - const syncedRangeText = useMemo( - () => - getDateRangeDisplayValueFromFormValue( - dateFnsLocale, - value[CONST.SEARCH.DATE_MODIFIERS.RANGE], - value[CONST.SEARCH.DATE_MODIFIERS.AFTER], - value[CONST.SEARCH.DATE_MODIFIERS.BEFORE], - ), - [value, dateFnsLocale], + const syncedRangeText = getDateRangeDisplayValueFromFormValue( + dateFnsLocale, + value[CONST.SEARCH.DATE_MODIFIERS.RANGE], + value[CONST.SEARCH.DATE_MODIFIERS.AFTER], + value[CONST.SEARCH.DATE_MODIFIERS.BEFORE], ); const displayedRangeText = selectedDateModifier ? rangeText : syncedRangeText; const selectedDateModifierTitle = getDateModifierTitle(selectedDateModifier, '', translate); - const updateRangeText = useCallback(() => { + const updateRangeText = () => { setRangeText(searchDatePresetFilterBaseRef.current?.getRangeDisplayText() ?? ''); - }, []); + }; // Widen the popover when Range is selected, reset when not useEffect(() => { @@ -80,20 +76,20 @@ function DateSelectPopup({label, value, presets, allowedCustomDateModifiers = CO } }, [selectedDateModifier, setPopoverWidth]); - const clearSelection = useCallback(() => { + const clearSelection = () => { setSelectedDateModifier(null); setShouldShowRangeError(false); - }, []); + }; - const handleBackPress = useCallback(() => { + const handleBackPress = () => { if (searchDatePresetFilterBaseRef.current && selectedDateModifier === CONST.SEARCH.DATE_MODIFIERS.RANGE) { searchDatePresetFilterBaseRef.current.restoreRangeToEntrySnapshot(); updateRangeText(); } clearSelection(); - }, [clearSelection, selectedDateModifier, updateRangeText]); + }; - const applyChanges = useCallback(() => { + const applyChanges = () => { if (!searchDatePresetFilterBaseRef.current) { return; } @@ -112,7 +108,7 @@ function DateSelectPopup({label, value, presets, allowedCustomDateModifiers = CO const dateValues = searchDatePresetFilterBaseRef.current.getDateValues(); onChange(dateValues); closeOverlay(); - }, [closeOverlay, onChange, selectedDateModifier]); + }; const maxPopupHeight = Math.round(windowHeight * 0.875); diff --git a/src/components/Search/FilterDropdowns/GroupByPopup.tsx b/src/components/Search/FilterDropdowns/GroupByPopup.tsx index c7f3e99fe4d7..0e3e0741116d 100644 --- a/src/components/Search/FilterDropdowns/GroupByPopup.tsx +++ b/src/components/Search/FilterDropdowns/GroupByPopup.tsx @@ -11,7 +11,7 @@ import type {GroupBySection} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import BasePopup from './BasePopup'; @@ -34,44 +34,38 @@ function GroupByPopup({value, sections, onBackButtonPress, closeOverlay, onChang const styles = useThemeStyles(); const [selectedItem, setSelectedItem] = useState(value); - const allSelectableItems = useMemo(() => sections.flatMap((section) => section.options), [sections]); - - const listSections = useMemo( - () => - sections.map((section) => ({ - sectionIndex: section.sectionIndex, - ...(section.sectionIndex > 0 ? {customHeader: } : {}), - data: section.options.map>((item) => ({ - text: item.text, - keyForList: item.value, - isSelected: item.value === selectedItem?.value, - })), - })), - [sections, selectedItem?.value, styles.dividerLine], - ); - - const itemCount = Math.max( - 1, - listSections.reduce((count, section) => count + section.data.length + (section.data.length > 0 && !!section.customHeader ? 1 : 0), 0), - ); - - const updateSelectedItem = useCallback( - (item: ListItem) => { - const newItem = allSelectableItems.find((option) => option.value === item.keyForList) ?? null; - setSelectedItem(newItem); - }, - [allSelectableItems], - ); - - const applyChanges = useCallback(() => { + const allSelectableItems = sections.flatMap((section) => section.options); + + const listSections = sections.map((section) => ({ + sectionIndex: section.sectionIndex, + ...(section.sectionIndex > 0 ? {customHeader: } : {}), + data: section.options.map>((item) => ({ + text: item.text, + keyForList: item.value, + isSelected: item.value === selectedItem?.value, + })), + })); + + let sectionItemCount = 0; + for (const section of listSections) { + sectionItemCount += section.data.length + (section.data.length > 0 && !!section.customHeader ? 1 : 0); + } + const itemCount = Math.max(1, sectionItemCount); + + const updateSelectedItem = (item: ListItem) => { + const newItem = allSelectableItems.find((option) => option.value === item.keyForList) ?? null; + setSelectedItem(newItem); + }; + + const applyChanges = () => { onChange(selectedItem); closeOverlay(); - }, [closeOverlay, onChange, selectedItem]); + }; - const resetChanges = useCallback(() => { + const resetChanges = () => { onChange(null); closeOverlay(); - }, [closeOverlay, onChange]); + }; return ( ({ }: SingleSelectPopupProps) { const [selectedItem, setSelectedItem] = useState(value); - const applyChanges = useCallback(() => { + const applyChanges = () => { onChange(selectedItem); closeOverlay(); - }, [closeOverlay, onChange, selectedItem]); + }; - const resetChanges = useCallback(() => { + const resetChanges = () => { onChange(items.find((item) => item.value === defaultValue)); closeOverlay(); - }, [closeOverlay, onChange, defaultValue, items]); + }; return ( getAllTaxRates(policies), [policies]); + const taxRates = getAllTaxRates(policies); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const { @@ -279,90 +279,63 @@ function SearchAutocompleteList({ } }, [isLoadingOptions]); - const searchOptions = useMemo(() => { - if (listOptions === null) { - return defaultListOptions; - } - return getSearchOptions({ - dateFnsLocale, - convertToDisplayString, - convertToDisplayStringWithoutCurrency, - options: listOptions, - draftComments, - isDefaultRoomsBetaEnabled, - isUsedInChatFinder: true, - includeReadOnly: true, - searchQuery: autocompleteQueryValue, - maxResults: CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS, - includeUserToInvite: true, - includeRecentReports: true, - includeCurrentUser: true, - countryCode, - shouldShowGBR: false, - shouldUnreadBeBold: true, - loginList, - visibleReportActionsData, - currentUserAccountID, - currentUserEmail, - policyCollection: policies, - personalDetails, - reportAttributesDerived: reportAttributes, - sortedActions, - transactionThreadIDs, - lastActions, - currentUserLogin: currentUserEmail, - localeCompare, - formatPhoneNumber, - conciergeReportID, - allPolicyTags, - isTrackIntentUser, - translate, - getReportByID, - rules, - }).options; - }, [ - listOptions, - draftComments, - isDefaultRoomsBetaEnabled, - autocompleteQueryValue, - countryCode, - loginList, - visibleReportActionsData, - currentUserAccountID, - currentUserEmail, - policies, - allPolicyTags, - personalDetails, - reportAttributes, - sortedActions, - transactionThreadIDs, - lastActions, - localeCompare, - formatPhoneNumber, - conciergeReportID, - isTrackIntentUser, - translate, - getReportByID, - dateFnsLocale, - convertToDisplayString, - convertToDisplayStringWithoutCurrency, - rules, - ]); + const searchOptions = + listOptions === null + ? defaultListOptions + : getSearchOptions({ + dateFnsLocale, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + options: listOptions, + draftComments, + isDefaultRoomsBetaEnabled, + isUsedInChatFinder: true, + includeReadOnly: true, + searchQuery: autocompleteQueryValue, + maxResults: CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS, + includeUserToInvite: true, + includeRecentReports: true, + includeCurrentUser: true, + countryCode, + shouldShowGBR: false, + shouldUnreadBeBold: true, + loginList, + visibleReportActionsData, + currentUserAccountID, + currentUserEmail, + policyCollection: policies, + personalDetails, + reportAttributesDerived: reportAttributes, + sortedActions, + transactionThreadIDs, + lastActions, + currentUserLogin: currentUserEmail, + localeCompare, + formatPhoneNumber, + conciergeReportID, + allPolicyTags, + isTrackIntentUser, + translate, + getReportByID, + rules, + }).options; // Deduped once and read everywhere the order is needed, so a repeated reportID ranks at its first position. - const orderedSearchResultReportIDs = useMemo(() => (searchResultReportIDs?.length ? [...new Set(searchResultReportIDs)] : []), [searchResultReportIDs]); - - const serverReportsOptions = useMemo(() => { - if (!hasActiveSearchResults || listOptions === null || orderedSearchResultReportIDs.length === 0) { - return CONST.EMPTY_ARRAY; - } + const orderedSearchResultReportIDs: string[] = searchResultReportIDs?.length ? [...new Set(searchResultReportIDs)] : []; + let serverReportsOptions; + if (!hasActiveSearchResults || listOptions === null || orderedSearchResultReportIDs.length === 0) { + serverReportsOptions = CONST.EMPTY_ARRAY; + } else { const orderedReportIDs = orderedSearchResultReportIDs.slice(0, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS); const reportIDs = new Set(orderedReportIDs); const options = getSearchOptions({ dateFnsLocale, convertToDisplayString, - options: {reports: listOptions.reports.filter((option) => reportIDs.has(option.reportID)), personalDetails: []}, + options: { + reports: listOptions.reports.filter((option) => reportIDs.has(option.reportID)), + personalDetails: [], + }, draftComments, isDefaultRoomsBetaEnabled, isUsedInChatFinder: true, @@ -392,30 +365,8 @@ function SearchAutocompleteList({ formatPhoneNumber, }).options; const optionsByReportID = new Map(options.recentReports.map((option) => [option.reportID, option])); - return orderedReportIDs.map((reportID) => optionsByReportID.get(reportID)).filter((option): option is OptionData => !!option && !option.isSelfDM); - }, [ - hasActiveSearchResults, - listOptions, - orderedSearchResultReportIDs, - dateFnsLocale, - convertToDisplayString, - draftComments, - isDefaultRoomsBetaEnabled, - countryCode, - loginList, - visibleReportActionsData, - currentUserAccountID, - currentUserEmail, - policies, - personalDetails, - sortedActions, - conciergeReportID, - isTrackIntentUser, - translate, - getReportByID, - rules, - formatPhoneNumber, - ]); + serverReportsOptions = orderedReportIDs.map((reportID) => optionsByReportID.get(reportID)).filter((option): option is OptionData => !!option && !option.isSelfDM); + } const [isInitialRender, setIsInitialRender] = useState(true); @@ -505,63 +456,43 @@ function SearchAutocompleteList({ const autocompleteQueryWithoutFilters = getQueryWithoutFilters(autocompleteQueryValue); - const recentSearchesData = useMemo(() => { - const sortedRecentSearches = Object.entries(recentSearches ?? {}).sort(([, firstRecentSearch], [, secondRecentSearch]) => - localeCompare(secondRecentSearch.timestamp, firstRecentSearch.timestamp), - ); - - return sortedRecentSearches.slice(0, 5).map(([recentSearchHash, {query}]) => { - const searchQueryJSON = buildSearchQueryJSON(query); - return { - text: searchQueryJSON - ? buildUserReadableQueryString({ - queryJSON: searchQueryJSON, - PersonalDetails: personalDetails, - reports, - taxRates, - cardList: allCards, - cardFeeds: allFeeds, - policies, - currentUserAccountID, - autoCompleteWithSpace: false, - translate, - formatPhoneNumber, - feedKeysWithCards, - reportAttributes, - bankAccountList, - }) - : query, - singleIcon: expensifyIcons.History, - searchQuery: query, - keyForList: recentSearchHash, - searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, - }; - }); - }, [ - recentSearches, - localeCompare, - personalDetails, - reports, - taxRates, - allCards, - allFeeds, - policies, - currentUserAccountID, - translate, - formatPhoneNumber, - feedKeysWithCards, - reportAttributes, - bankAccountList, - expensifyIcons.History, - ]); + const sortedRecentSearches = Object.entries(recentSearches ?? {}).sort(([, firstRecentSearch], [, secondRecentSearch]) => + localeCompare(secondRecentSearch.timestamp, firstRecentSearch.timestamp), + ); + const recentSearchesData = sortedRecentSearches.slice(0, 5).map(([recentSearchHash, {query}]) => { + const searchQueryJSON = buildSearchQueryJSON(query); + return { + text: searchQueryJSON + ? buildUserReadableQueryString({ + queryJSON: searchQueryJSON, + PersonalDetails: personalDetails, + reports, + taxRates, + cardList: allCards, + cardFeeds: allFeeds, + policies, + currentUserAccountID, + autoCompleteWithSpace: false, + translate, + formatPhoneNumber, + feedKeysWithCards, + reportAttributes, + bankAccountList, + }) + : query, + singleIcon: expensifyIcons.History, + searchQuery: query, + keyForList: recentSearchHash, + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, + }; + }); // The client's own matches, before Auth's order is merged in. Kept separate so the frozen rank below can snapshot // the client's order: snapshotting the merged list would bake the previous query's server order into "Recent chats". - const localReportsOptions = useMemo(() => { - if (!hasActiveSearchResults) { - return []; - } - + let localReportsOptions: OptionData[]; + if (!hasActiveSearchResults) { + localReportsOptions = []; + } else { // searchOptions/autocompleteQueryValue are debounced. For a query -> query change this still returns the // previous query's matches during the debounce window (rows stay visible, preserving focus/Enter/arrow keys). // For the empty -> query transition hasActiveSearchResults is false until the debounced query lands, so @@ -570,26 +501,21 @@ function SearchAutocompleteList({ sortByReportTypeInSearch: true, preferChatRoomsOverThreads: true, }); - const reportOptions: OptionData[] = [...orderedOptions.recentReports, ...orderedOptions.personalDetails]; if (searchOptions.userToInvite) { reportOptions.push(searchOptions.userToInvite); } + localReportsOptions = reportOptions; + } - return reportOptions; - }, [autocompleteQueryValue, activePolicyID, hasActiveSearchResults, searchOptions]); - - const recentReportsOptions = useMemo(() => { - if (!hasActiveSearchResults) { - return searchOptions.recentReports.slice(0, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS); - } - + let recentReportsOptions; + if (!hasActiveSearchResults) { + recentReportsOptions = searchOptions.recentReports.slice(0, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS); + } else { const reportOptions: OptionData[] = [...localReportsOptions]; - if (orderedSearchResultReportIDs.length > 0) { const matchedReportIDs = new Set(reportOptions.map((option) => option.reportID).filter(Boolean)); reportOptions.push(...serverReportsOptions.filter((option) => !matchedReportIDs.has(option.reportID))); - const rankByReportID = new Map(orderedSearchResultReportIDs.map((reportID, index) => [reportID, index])); const rankOf = (option: OptionData) => { if (option.isSelfDM) { @@ -603,10 +529,11 @@ function SearchAutocompleteList({ // Preserve locally matched rows first. Auth orders only the remaining slots, so a server response cannot // remove chats, contacts, or invite options that were already visible locally. The combined list remains capped at 20. if (orderedSearchResultReportIDs.length > 0) { - return reportOptions; + recentReportsOptions = reportOptions; + } else { + recentReportsOptions = reportOptions.slice(0, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS); } - return reportOptions.slice(0, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS); - }, [hasActiveSearchResults, localReportsOptions, orderedSearchResultReportIDs, searchOptions, serverReportsOptions]); + } // Locked rank map (stable key -> originalIndex) capturing the order of the client's own matches at the moment the // query settled, so reports merged into Onyx later do not shift the rows already visible in the top section. @@ -675,154 +602,148 @@ function SearchAutocompleteList({ }, [autocompleteQueryWithoutFilters, debounceHandleSearch, handleSearch, hasUpstreamDebounce]); /* Sections generation */ - const {sections, styledRecentReports, suggestionsCount} = useMemo(() => { - const nextSections: Array> = []; - let sectionIndex = 0; - let nextSuggestionsCount = 0; - - const pushSection = (section: Section) => { - nextSections.push(section); - nextSuggestionsCount += section.data.filter((item) => item.keyForList !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.FIND_ITEM).length; - }; - - if (searchQueryItems && searchQueryItems.length > 0) { - pushSection({data: searchQueryItems as AutocompleteListItem[], sectionIndex: sectionIndex++}); - } - - const additionalSections = getAdditionalSections?.(searchOptions, sectionIndex); - - if (additionalSections) { - for (const section of additionalSections) { - pushSection(section); - sectionIndex++; - } - } - - if (!hasEffectiveInputQuery && recentSearchesData && recentSearchesData.length > 0) { - pushSection({title: translate('search.recentSearches'), data: recentSearchesData as AutocompleteListItem[], sectionIndex: sectionIndex++}); + const nextSections: Array> = []; + let sectionIndex = 0; + let nextSuggestionsCount = 0; + const pushSection = (section: Section) => { + nextSections.push(section); + nextSuggestionsCount += section.data.filter((item) => item.keyForList !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.FIND_ITEM).length; + }; + if (searchQueryItems && searchQueryItems.length > 0) { + pushSection({ + data: searchQueryItems as AutocompleteListItem[], + sectionIndex: sectionIndex++, + }); + } + const additionalSections = getAdditionalSections?.(searchOptions, sectionIndex); + if (additionalSections) { + for (const section of additionalSections) { + pushSection(section); + sectionIndex++; } - - const nextStyledRecentReports = recentReportsOptions.map((option) => { - const report = getReportOrDraftReport(option.reportID, undefined, undefined, undefined, reports?.[`${ONYXKEYS.COLLECTION.REPORT}${option.reportID}`]); - const reportAction = getReportAction(report?.parentReportID, report?.parentReportActionID); - const shouldParserToHTML = !!reportAction && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT; - const keyForList = option.keyForList ?? option.reportID ?? (option.accountID ? String(option.accountID) : undefined); - return { - ...option, - keyForList, - pressableStyle: styles.br2, - text: StringUtils.lineBreaksToSpaces(shouldParserToHTML ? Parser.htmlToText(option.text ?? '') : (option.text ?? '')), - } as AutocompleteListItem; + } + if (!hasEffectiveInputQuery && recentSearchesData && recentSearchesData.length > 0) { + pushSection({ + title: translate('search.recentSearches'), + data: recentSearchesData as AutocompleteListItem[], + sectionIndex: sectionIndex++, }); - - const skeletonHeader = ( - - ); - - if (!hasActiveSearchResults) { - // No active (debounced) query yet: single "Recent chats" section. This also covers the debounce window - // right after the user starts typing, so we keep recent chats visible instead of flashing search rows. - if (!isLoadingOptions) { - pushSection({title: translate('search.recentChats'), data: nextStyledRecentReports, sectionIndex: sectionIndex++}); - } else { - pushSection({ - title: translate('search.recentChats'), - data: [], - sectionIndex: sectionIndex++, - customHeader: skeletonHeader, - }); - } + } + const nextStyledRecentReports = recentReportsOptions.map((option) => { + const report = getReportOrDraftReport(option.reportID, undefined, undefined, undefined, reports?.[`${ONYXKEYS.COLLECTION.REPORT}${option.reportID}`]); + const reportAction = getReportAction(report?.parentReportID, report?.parentReportActionID); + const shouldParserToHTML = !!reportAction && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT; + const keyForList = option.keyForList ?? option.reportID ?? (option.accountID ? String(option.accountID) : undefined); + return { + ...option, + keyForList, + pressableStyle: styles.br2, + text: StringUtils.lineBreaksToSpaces(shouldParserToHTML ? Parser.htmlToText(option.text ?? '') : (option.text ?? '')), + } as AutocompleteListItem; + }); + const skeletonHeader = ( + + ); + if (!hasActiveSearchResults) { + // No active (debounced) query yet: single "Recent chats" section. This also covers the debounce window + // right after the user starts typing, so we keep recent chats visible instead of flashing search rows. + if (!isLoadingOptions) { + pushSection({ + title: translate('search.recentChats'), + data: nextStyledRecentReports, + sectionIndex: sectionIndex++, + }); } else { - // Active search uses one shared 20-row budget: preserve locally matched rows first, then fill remaining - // slots with Auth-ranked server results. Auth orders the server section; it does not evict local rows. - const localRows: AutocompleteListItem[] = []; - const serverRows: AutocompleteListItem[] = []; - for (const item of nextStyledRecentReports) { - const stableKey = getStableRankKey(item); - if (item.isSelfDM || (stableKey && frozenLocalRank.has(stableKey))) { - localRows.push(item); - } else { - serverRows.push(item); - } - } - // Sort the local section by the rank captured at query-change time so it cannot - // reorder when the API returns. - localRows.sort((a, b) => { - if (a.isSelfDM !== b.isSelfDM) { - return a.isSelfDM ? -1 : 1; - } - return (frozenLocalRank.get(getStableRankKey(a) ?? '') ?? 0) - (frozenLocalRank.get(getStableRankKey(b) ?? '') ?? 0); + pushSection({ + title: translate('search.recentChats'), + data: [], + sectionIndex: sectionIndex++, + customHeader: skeletonHeader, }); - - if (localRows.length > 0 || !isLoadingOptions) { - pushSection({title: translate('search.recentChats'), data: localRows, sectionIndex: sectionIndex++}); + } + } else { + // Active search uses one shared 20-row budget: preserve locally matched rows first, then fill remaining + // slots with Auth-ranked server results. Auth orders the server section; it does not evict local rows. + const localRows: AutocompleteListItem[] = []; + const serverRows: AutocompleteListItem[] = []; + for (const item of nextStyledRecentReports) { + const stableKey = getStableRankKey(item); + if (item.isSelfDM || (stableKey && frozenLocalRank.has(stableKey))) { + localRows.push(item); } else { - // Options are still loading and no local results matched — show a skeleton so the - // user sees feedback instead of a bare section header. - pushSection({ - title: undefined, - data: [], - sectionIndex: sectionIndex++, - customHeader: skeletonHeader, - }); + serverRows.push(item); } - - const remainingServerRows = serverRows.slice(0, Math.max(CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - localRows.length, 0)); - if (remainingServerRows.length > 0) { - pushSection({ - title: translate('search.serverResults'), - data: remainingServerRows, - sectionIndex: sectionIndex++, - }); + } + // Sort the local section by the rank captured at query-change time so it cannot + // reorder when the API returns. + localRows.sort((a, b) => { + if (a.isSelfDM !== b.isSelfDM) { + return a.isSelfDM ? -1 : 1; } + return (frozenLocalRank.get(getStableRankKey(a) ?? '') ?? 0) - (frozenLocalRank.get(getStableRankKey(b) ?? '') ?? 0); + }); + if (localRows.length > 0 || !isLoadingOptions) { + pushSection({ + title: translate('search.recentChats'), + data: localRows, + sectionIndex: sectionIndex++, + }); + } else { + // Options are still loading and no local results matched — show a skeleton so the + // user sees feedback instead of a bare section header. + pushSection({ + title: undefined, + data: [], + sectionIndex: sectionIndex++, + customHeader: skeletonHeader, + }); } - - if (autocompleteSuggestions.length > 0) { - const autocompleteData: AutocompleteListItem[] = autocompleteSuggestions.map(({filterKey, text, autocompleteID, mapKey, workspaceIcon}) => { - return { - text: getAutocompleteDisplayText(filterKey, text), - mapKey: mapKey ? getSubstitutionMapKey(mapKey, text) : undefined, - singleIcon: expensifyIcons.MagnifyingGlass, - searchQuery: text, - autocompleteID, - keyForList: autocompleteID ?? text, // in case we have a unique identifier then use it because text might not be unique - searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION, - // For report-backed `in:` suggestions, show the owning workspace on the right of the row so identically - // named rooms (e.g. #admins) in different workspaces can be told apart. - rightElement: workspaceIcon ? ( - - ) : undefined, - }; + const remainingServerRows = serverRows.slice(0, Math.max(CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - localRows.length, 0)); + if (remainingServerRows.length > 0) { + pushSection({ + title: translate('search.serverResults'), + data: remainingServerRows, + sectionIndex: sectionIndex++, }); - - pushSection({title: translate('search.suggestions'), data: autocompleteData, sectionIndex: sectionIndex++}); } - - return {sections: nextSections, styledRecentReports: nextStyledRecentReports, suggestionsCount: nextSuggestionsCount}; - }, [ - hasEffectiveInputQuery, - hasActiveSearchResults, - autocompleteSuggestions, - expensifyIcons, - frozenLocalRank, - getAdditionalSections, - recentReportsOptions, - recentSearchesData, - searchOptions, - searchQueryItems, - styles, - translate, - isLoadingOptions, - reports, - ]); + } + if (autocompleteSuggestions.length > 0) { + const autocompleteData: AutocompleteListItem[] = autocompleteSuggestions.map(({filterKey, text, autocompleteID, mapKey, workspaceIcon}) => { + return { + text: getAutocompleteDisplayText(filterKey, text), + mapKey: mapKey ? getSubstitutionMapKey(mapKey, text) : undefined, + singleIcon: expensifyIcons.MagnifyingGlass, + searchQuery: text, + autocompleteID, + keyForList: autocompleteID ?? text, + // in case we have a unique identifier then use it because text might not be unique + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION, + // For report-backed `in:` suggestions, show the owning workspace on the right of the row so identically + // named rooms (e.g. #admins) in different workspaces can be told apart. + rightElement: workspaceIcon ? ( + + ) : undefined, + }; + }); + pushSection({ + title: translate('search.suggestions'), + data: autocompleteData, + sectionIndex: sectionIndex++, + }); + } + const {sections, styledRecentReports, suggestionsCount} = { + sections: nextSections, + styledRecentReports: nextStyledRecentReports, + suggestionsCount: nextSuggestionsCount, + }; const trimmedAutocompleteQueryValue = autocompleteQueryValue.trim(); @@ -1002,5 +923,5 @@ function SearchAutocompleteList({ SearchAutocompleteList.displayName = 'SearchAutocompleteList'; -export default React.memo(SearchAutocompleteList); +export default SearchAutocompleteList; export type {GetAdditionalSectionsCallback, SearchAutocompleteListProps}; diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index 6e3351afc27a..e27ffc59fa1d 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -29,7 +29,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; -import React, {useContext, useMemo, useRef} from 'react'; +import React, {useContext, useRef} from 'react'; import {View} from 'react-native'; import type {BulkPaySelectionData, SearchQueryJSON, SelectedTransactions} from './types'; @@ -99,32 +99,33 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { const isExpenseReportType = queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; const popoverUseScrollView = shouldPopoverUseScrollView(dropdownButtonsOptions); - const {selectedItemsCount, excludedItemsCount} = useMemo(() => { - const getItemsCount = (transactionsToCount: typeof selectedTransactions, cashBackCount: number) => { - if (isExpenseReportType) { - const reportIDs = new Set( - Object.values(transactionsToCount) - .map((transaction) => transaction?.reportID) - .filter((reportID): reportID is string => !!reportID), - ); - return reportIDs.size; + const getItemsCount = (transactionsToCount: typeof selectedTransactions, cashBackCount: number) => { + if (isExpenseReportType) { + const reportIDs = new Set( + Object.values(transactionsToCount) + .map((transaction) => transaction?.reportID) + .filter((reportID): reportID is string => !!reportID), + ); + return reportIDs.size; + } + return Object.keys(transactionsToCount).reduce((count, key) => { + if (key.startsWith(CONST.SEARCH.GROUP_PREFIX)) { + const group = searchData?.[key as keyof typeof searchData] as + | { + count?: number; + isCashBack?: boolean; + } + | undefined; + return count + (group?.isCashBack ? cashBackCount : (group?.count ?? 0)); } - - return Object.keys(transactionsToCount).reduce((count, key) => { - if (key.startsWith(CONST.SEARCH.GROUP_PREFIX)) { - const group = searchData?.[key as keyof typeof searchData] as {count?: number; isCashBack?: boolean} | undefined; - return count + (group?.isCashBack ? cashBackCount : (group?.count ?? 0)); - } - return count + 1; - }, 0); - }; - - return { - selectedItemsCount: getItemsCount(selectedTransactions, 1), - // Excluded items come off the server count, which only sums expenses, so a cash back row takes nothing off. - excludedItemsCount: getItemsCount(excludedTransactions, 0), - }; - }, [excludedTransactions, selectedTransactions, isExpenseReportType, searchData]); + return count + 1; + }, 0); + }; + const {selectedItemsCount, excludedItemsCount} = { + selectedItemsCount: getItemsCount(selectedTransactions, 1), + // Excluded items come off the server count, which only sums expenses, so a cash back row takes nothing off. + excludedItemsCount: getItemsCount(excludedTransactions, 0), + }; const payBulkSelectedItem = (subItem: PopoverMenuItem, triggerKYCFlow: (kycParams: ContinueActionParams) => void) => handleBulkPayItemSelected({ @@ -161,10 +162,10 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { // The server count is the only source for how many items "select all" covers, so keep the button loading until it // arrives. Offline or on error it never will, so fall back to the count of the items we do have selected. const isAllMatchingItemsCountLoading = areAllMatchingItemsSelected && typeof relevantAllMatchingCount !== 'number' && !isOffline && !hasSearchErrors; + // Show the matching-report total minus excluded reports once it lands. Before then, or while offline, fall back + // to the loaded-page selected report count. let selectedAllMatchingItemsCount: number; if (isExpenseReportType) { - // Show the matching-report total minus excluded reports once it lands. Before then, or while offline, fall back - // to the loaded-page selected report count. selectedAllMatchingItemsCount = typeof allMatchingReportsCount === 'number' ? Math.max(allMatchingReportsCount - excludedItemsCount, 0) : selectedItemsCount; } else if (typeof allMatchingItemsCount !== 'number') { selectedAllMatchingItemsCount = selectedItemsCount; diff --git a/src/components/Search/SearchList/BaseSearchList/index.native.tsx b/src/components/Search/SearchList/BaseSearchList/index.native.tsx index 6845ec83318c..80c02d552e43 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.native.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.native.tsx @@ -2,7 +2,7 @@ import useTabRootScrollProps from '@components/Navigation/TabBarBottomContent/us import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; import {FlashList} from '@shopify/flash-list'; -import React, {useCallback} from 'react'; +import React from 'react'; import Animated from 'react-native-reanimated'; import type BaseSearchListProps from './types'; @@ -25,12 +25,9 @@ function BaseSearchList({ getItemType, }: BaseSearchListProps) { const tabRootScrollProps = useTabRootScrollProps(contentContainerStyle); - const renderItemWithoutKeyboardFocus = useCallback( - ({item, index}: {item: SearchListItem; index: number}) => { - return renderItem(item, index, undefined); - }, - [renderItem], - ); + const renderItemWithoutKeyboardFocus = ({item, index}: {item: SearchListItem; index: number}) => { + return renderItem(item, index, undefined); + }; return ( { + const setHasKeyBeenPressed = () => { if (hasKeyBeenPressed.current) { return; } // We need to track whether a key has been pressed to enable focus syncing only if a key has been pressed. // This is to avoid the default behavior of web showing blue border on click of items after a page refresh. hasKeyBeenPressed.current = true; - }, []); + }; const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ initialFocusedIndex: -1, @@ -140,31 +140,28 @@ function BaseSearchList({ const renderItemWithKeyboardFocus = ({item, index}: {item: SearchListItem; index: number}) => renderItem(item, index, getOnFocus(index)); - const selectFocusedOption = useCallback( - (event?: GestureResponderEvent | KeyboardEvent) => { - // Allow event propagation during cell editing so Enter can trigger TextInput.onSubmitEditing. - // When not editing, stop propagation to prevent unintended button activation and handle row selection. - if (isEditingCell) { - return; - } + const selectFocusedOption = (event?: GestureResponderEvent | KeyboardEvent) => { + // Allow event propagation during cell editing so Enter can trigger TextInput.onSubmitEditing. + // When not editing, stop propagation to prevent unintended button activation and handle row selection. + if (isEditingCell) { + return; + } - // If a cell has keyboard focus (via Tab), let the Enter event propagate to trigger the cell's onPress - if (focusedCellId) { - return; - } + // If a cell has keyboard focus (via Tab), let the Enter event propagate to trigger the cell's onPress + if (focusedCellId) { + return; + } - event?.stopPropagation(); + event?.stopPropagation(); - const focusedItem = data.at(focusedIndex); + const focusedItem = data.at(focusedIndex); - if (!focusedItem) { - return; - } + if (!focusedItem) { + return; + } - onSelectRow(focusedItem); - }, - [data, focusedCellId, focusedIndex, isEditingCell, onSelectRow], - ); + onSelectRow(focusedItem); + }; useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, { captureOnInputs: true, @@ -181,10 +178,7 @@ function BaseSearchList({ return () => removeKeyDownPressListener(setHasKeyBeenPressed); }, [setHasKeyBeenPressed]); - const extraData = useMemo( - () => [columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], - [columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], - ); + const extraData = [columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy]; return ( ({ const searchData = currentSearchResults?.data; - const snapshotReport = useMemo(() => { - return (searchData?.[`${ONYXKEYS.COLLECTION.REPORT}${reportItem.reportID}`] ?? {}) as Report; - }, [searchData, reportItem.reportID]); + const snapshotReport = (searchData?.[`${ONYXKEYS.COLLECTION.REPORT}${reportItem.reportID}`] ?? {}) as Report; const [parentChatReport] = useOnyxWithoutSnapshots(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(snapshotReport?.chatReportID ?? reportItem.parentReportID)}`); - const snapshotChatReport = useMemo(() => { - const chatReportID = snapshotReport?.chatReportID ?? reportItem.parentReportID; - return chatReportID ? searchData?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`] : undefined; - }, [searchData, snapshotReport?.chatReportID, reportItem.parentReportID]); + const chatReportID = snapshotReport?.chatReportID ?? reportItem.parentReportID; + const snapshotChatReport = chatReportID ? searchData?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`] : undefined; const chatReport = parentChatReport ?? snapshotChatReport; const [chatReportActions] = useOnyxWithoutSnapshots( `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(chatReport?.reportID ?? snapshotReport?.chatReportID ?? snapshotReport.parentReportID)}`, ); - const snapshotPolicy = useMemo(() => { - return (searchData?.[`${ONYXKEYS.COLLECTION.POLICY}${reportItem.policyID}`] ?? {}) as Policy; - }, [searchData, reportItem.policyID]); + const snapshotPolicy = (searchData?.[`${ONYXKEYS.COLLECTION.POLICY}${reportItem.policyID}`] ?? {}) as Policy; - const reportActions = useMemo(() => { - const actionsData = searchData?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportItem.reportID}`]; - return actionsData ? Object.values(actionsData) : []; - }, [searchData, reportItem.reportID]); + const actionsData = searchData?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportItem.reportID}`]; + const reportActions = actionsData ? Object.values(actionsData) : []; const liveReportItem = useLiveRowCapabilities({ item: reportItem, @@ -172,9 +164,7 @@ function ExpenseReportListItemInner({ enabled: !!searchData, }); - const isDisabledCheckbox = useMemo(() => { - return reportItem.isDisabled ?? reportItem.isDisabledCheckbox; - }, [reportItem.isDisabled, reportItem.isDisabledCheckbox]); + const isDisabledCheckbox = reportItem.isDisabled ?? reportItem.isDisabledCheckbox; // Prefer live Onyx data over stale search snapshot for pending delete check. const reportForPendingDeleteCheck = parentReport ?? reportItem; @@ -189,19 +179,17 @@ function ExpenseReportListItemInner({ // Sync missingAttendees violation at render time for each transaction in the report // This ensures violations show immediately when category settings change, without needing to click the row - const hasSyncedMissingAttendeesViolation = useMemo(() => { - if (!isAttendeeTrackingEnabled(policyForViolations)) { - return false; - } - + let hasSyncedMissingAttendeesViolation; + if (!isAttendeeTrackingEnabled(policyForViolations)) { + hasSyncedMissingAttendeesViolation = false; + } else { const isInvoice = isInvoiceReport(reportItem) || reportItem.type === CONST.REPORT.TYPE.INVOICE; - return reportItem?.transactions?.some((transaction) => { + hasSyncedMissingAttendeesViolation = reportItem?.transactions?.some((transaction) => { const relevantViolations = (transaction.violations ?? []).filter( (violation) => !isViolationDismissed(transaction, violation, currentUserDetails.email ?? '', currentUserDetails.accountID, reportForViolations, submitterLogin, policyForViolations) && shouldShowViolation(reportForViolations, policyForViolations, violation.name, currentUserDetails.email ?? '', currentUserDetails.accountID, false, transaction), ); - const violations = syncMissingAttendeesViolation( relevantViolations, policyCategories, @@ -214,7 +202,7 @@ function ExpenseReportListItemInner({ ); return violations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_ATTENDEES); }); - }, [reportItem, policyCategories, policyForViolations, reportForViolations, currentUserDetails, submitterLogin]); + } const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); @@ -223,7 +211,7 @@ function ExpenseReportListItemInner({ const openReportSubmitToPopover = useOpenReportSubmitToPopover(); const {shouldDisableSearchSubmitPress, consumeIgnoreNextSearchSubmitPress} = useSearchSubmitPopoverGuard(); const {transactions: reportTransactions, violations: reportViolations} = useTransactionsAndViolationsForReport(reportItem.reportID); - const liveReportTransactions = useMemo(() => Object.values(reportTransactions), [reportTransactions]); + const liveReportTransactions = Object.values(reportTransactions); const confirmSubmitReportViolations = useConfirmSubmitReportViolations({ reportID: reportItem.reportID, report: reportForViolations, @@ -270,7 +258,7 @@ function ExpenseReportListItemInner({ chatReportPolicyID: chatReport?.policyID, }); - const handleOnButtonPress = useCallback(() => { + const handleOnButtonPress = () => { handleActionButtonPress({ isASAPSubmitBetaEnabled, getCurrencyDecimals, @@ -341,75 +329,25 @@ function ExpenseReportListItemInner({ conciergeChat, bankAccountList, }); - }, [ - currentSearchHash, - reportItem, - isASAPSubmitBetaEnabled, - liveReportItem, - onSelectRow, - searchData, - snapshotReport, - chatReport, - snapshotPolicy, - submitterLogin, - parentPolicy, - parentReport, - lastPaymentMethod, - userBillingGracePeriodEnds, - personalPolicyID, - currentSearchKey, - isDelegateAccessRestricted, - showDelegateNoAccessModal, - showHoldMenu, - liveReportTransactions, - ownerBillingGracePeriodEnd, - amountOwed, - openReportSubmitToPopover, - shouldDisableSearchSubmitPress, - consumeIgnoreNextSearchSubmitPress, - showConfirmModal, - translate, - convertToDisplayString, - getCurrencyDecimals, - currentUserAccountID, - currentUserLogin, - introSelected, - isSelfTourViewed, - activePolicy, - chatReportPolicy, - chatReportActions, - delegateEmail, - delegateAccountID, - isTrackIntentUser, - liveViolationsForSnapshotTransactions, - rules, - getPersonalDetailsByLogin, - conciergeChat, - bankAccountList, - shouldShowMarkAsDoneCopy, - confirmSubmitReportViolations, - ]); + }; const handleSelectionButtonPress = (shiftKey?: boolean) => { onSelectionButtonPress?.(item, undefined, shiftKey); }; - const listItemPressableStyle = useMemo( - () => [ - styles.selectionListPressableItemWrapper, - isLargeScreenWidth && styles.pv3, - isLargeScreenWidth && styles.ph3, - // Background is applied on the parent wrapper, so keep this transparent - styles.bgTransparent, - isSelected && styles.activeComponentBG, - styles.mh0, - isPendingDelete && styles.cursorDisabled, - isLargeScreenWidth ? StyleUtils.getSearchTableRowPressableStyle(!!isLastItem, isSelected, {vertical: variables.tableRowPaddingVertical}) : styles.noBorderRadius, - !isLargeScreenWidth && isFirstItem && [styles.tableTopRadius, styles.overflowHidden], - !isLargeScreenWidth && isLastItem && [styles.tableBottomRadius, styles.overflowHidden], - ], - [styles, isSelected, isLargeScreenWidth, isFirstItem, isLastItem, isPendingDelete, StyleUtils], - ); + const listItemPressableStyle = [ + styles.selectionListPressableItemWrapper, + isLargeScreenWidth && styles.pv3, + isLargeScreenWidth && styles.ph3, + // Background is applied on the parent wrapper, so keep this transparent + styles.bgTransparent, + isSelected && styles.activeComponentBG, + styles.mh0, + isPendingDelete && styles.cursorDisabled, + isLargeScreenWidth ? StyleUtils.getSearchTableRowPressableStyle(!!isLastItem, isSelected, {vertical: variables.tableRowPaddingVertical}) : styles.noBorderRadius, + !isLargeScreenWidth && isFirstItem && [styles.tableTopRadius, styles.overflowHidden], + !isLargeScreenWidth && isLastItem && [styles.tableBottomRadius, styles.overflowHidden], + ]; const shouldShowViolationDescription = isOpenExpenseReport(reportItem) || isProcessingReport(reportItem); @@ -435,31 +373,14 @@ function ExpenseReportListItemInner({ const hasVisibleReportViolations = hasLiveTransactions ? liveHasVisibleViolations : fallbackHasVisibleViolations; const hasAnyVisibleViolations = hasVisibleReportViolations || hasSyncedMissingAttendeesViolation; - const getDescription = useMemo(() => { - if (reportItem?.isRejectedReport) { - return ( - - - {translate('iou.rejectReport.rejectedReportMessage')} - - ); - } - if (!hasAnyVisibleViolations || !shouldShowViolationDescription) { - return; - } - return ( + let getDescription; + if (reportItem?.isRejectedReport) { + getDescription = ( ({ width={12} height={12} /> - - {translate('reportViolations.reportContainsExpensesWithViolations')} + + {translate('iou.rejectReport.rejectedReportMessage')} ); - }, [ - reportItem?.isRejectedReport, - hasAnyVisibleViolations, - shouldShowViolationDescription, - styles.flexRow, - styles.alignItemsCenter, - styles.mt2, - styles.mr1, - styles.textMicro, - styles.mutedNormalTextLabel, - styles.userSelectNone, - isLargeScreenWidth, - expensifyIcons.DotIndicator, - theme.danger, - theme.textError, - translate, - ]); + } else { + getDescription = + !hasAnyVisibleViolations || !shouldShowViolationDescription ? undefined : ( + + + + {translate('reportViolations.reportContainsExpensesWithViolations')} + + + ); + } // Full label for the button (its whole announcement); just a row identifier for the group, whose cells are reachable. const rowAccessibilityLabel = canSelectMultiple ? liveReportItem.reportName : getExpenseReportRowAccessibilityLabel(liveReportItem, {translate, dateFnsLocale, convertToDisplayString}); diff --git a/src/components/Search/SearchList/ListItem/GroupChildrenContent.tsx b/src/components/Search/SearchList/ListItem/GroupChildrenContent.tsx index ffbfec542e8c..2668ef77e289 100644 --- a/src/components/Search/SearchList/ListItem/GroupChildrenContent.tsx +++ b/src/components/Search/SearchList/ListItem/GroupChildrenContent.tsx @@ -11,7 +11,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; import {useIsFocused} from '@react-navigation/native'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import type {GroupChildrenContentProps, TransactionListItemType} from './types'; @@ -108,18 +108,16 @@ function GroupChildrenContent({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isScreenFocused]); - const filteredViolations: Record = useMemo(() => { - if (!snapshotData) { - return {}; - } - + let filteredViolations: Record; + if (!snapshotData) { + filteredViolations = {}; + } else { const groupViolations: Record = {}; for (const [key, value] of Object.entries(snapshotData)) { if (key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS)) { groupViolations[key] = value as TransactionViolations; } } - const result: Record = {}; for (const key of Object.keys(snapshotData)) { if (!key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) { @@ -129,10 +127,8 @@ function GroupChildrenContent({ if (!snapshotTransaction || typeof snapshotTransaction !== 'object' || !('transactionID' in snapshotTransaction) || !('reportID' in snapshotTransaction)) { continue; } - const report = snapshotData[`${ONYXKEYS.COLLECTION.REPORT}${snapshotTransaction.reportID}`]; const policy = snapshotData[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; - if (report && policy) { const transactionViolations = groupViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${snapshotTransaction.transactionID}`]; if (transactionViolations) { @@ -151,8 +147,8 @@ function GroupChildrenContent({ } } } - return result; - }, [snapshotData, currentUserDetails.email, currentUserDetails.accountID]); + filteredViolations = result; + } const onExpandedRowLongPress = (transaction: TransactionListItemType) => { onLongPressRow?.(transaction); diff --git a/src/components/Search/SearchList/ListItem/GroupHeader.tsx b/src/components/Search/SearchList/ListItem/GroupHeader.tsx index 93d65989866c..cdca1fe922f4 100644 --- a/src/components/Search/SearchList/ListItem/GroupHeader.tsx +++ b/src/components/Search/SearchList/ListItem/GroupHeader.tsx @@ -39,7 +39,7 @@ import type {ComponentRef} from 'react'; import type {NativeSyntheticEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useMemo, useRef} from 'react'; +import React, {useRef} from 'react'; import {View} from 'react-native'; // eslint-disable-next-line no-restricted-imports import {useOnyx as useOnyxWithoutSnapshots} from 'react-native-onyx'; @@ -128,15 +128,12 @@ function GroupHeader({ const [parentReportAction] = useOnyxWithoutSnapshots(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oneTransactionReportID}`, { selector: (reportActions: OnyxEntry): OnyxEntry => reportActions?.[`${oneTransactionItem?.reportAction?.reportActionID}`], }); - const transactionPreviewData: TransactionPreviewData = useMemo( - () => ({ - hasParentReport: !!parentReport, - hasTransaction: !!oneTransaction, - hasParentReportAction: !!parentReportAction, - hasTransactionThreadReport: !!oneTransactionThreadReport, - }), - [parentReport, oneTransaction, parentReportAction, oneTransactionThreadReport], - ); + const transactionPreviewData: TransactionPreviewData = { + hasParentReport: !!parentReport, + hasTransaction: !!oneTransaction, + hasParentReportAction: !!parentReportAction, + hasTransactionThreadReport: !!oneTransactionThreadReport, + }; const [transactionsSnapshot] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${groupItem.transactionsQueryJSON?.hash}`); const snapshotData = transactionsSnapshot?.data; diff --git a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx index 5ad38d5815aa..72cbd86be067 100644 --- a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx @@ -45,7 +45,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import {transactionViolationsByIDsSelector} from '@selectors/TransactionViolations'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; // Use the original useOnyx hook to get the real-time personal details list data from Onyx and not from the snapshot // eslint-disable-next-line no-restricted-imports @@ -266,16 +266,10 @@ function ReportListItemHeaderInner({ const {isLargeScreenWidth} = useResponsiveLayout(); const thereIsFromAndTo = !!reportItem?.from && !!reportItem?.to; const showUserInfo = (reportItem.type === CONST.REPORT.TYPE.IOU && thereIsFromAndTo) || (reportItem.type === CONST.REPORT.TYPE.EXPENSE && !!reportItem?.from); - const snapshotReport = useMemo(() => { - return (snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportItem.reportID}`] ?? {}) as Report; - }, [snapshot, reportItem.reportID]); - const snapshotPolicy = useMemo(() => { - return (snapshot?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${reportItem.policyID}`] ?? {}) as Policy; - }, [snapshot, reportItem.policyID]); - const snapshotChatReport = useMemo(() => { - const chatReportID = snapshotReport?.chatReportID ?? reportItem.parentReportID; - return chatReportID ? snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`] : undefined; - }, [snapshot, snapshotReport?.chatReportID, reportItem.parentReportID]); + const snapshotReport = (snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportItem.reportID}`] ?? {}) as Report; + const snapshotPolicy = (snapshot?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${reportItem.policyID}`] ?? {}) as Policy; + const chatReportID = snapshotReport?.chatReportID ?? reportItem.parentReportID; + const snapshotChatReport = chatReportID ? snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`] : undefined; const [parentPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(snapshotReport?.policyID ?? reportItem.policyID)}`); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(snapshotReport?.reportID ?? reportItem.reportID)}`); const [submitterLogin] = useAllPersonalDetailsWithoutSnapshots(personalDetailsLoginSelector(reportItem.ownerAccountID)); diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx index 0c73c5e2162f..8958f5107e68 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx @@ -50,7 +50,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import type {TransactionGroupListExpandedProps, TransactionListItemType} from './types'; @@ -111,31 +111,26 @@ function TransactionGroupListExpandedImpl({ const visibleTransactions = isExpenseReportType ? transactions.slice(0, transactionsVisibleLimit) : transactions; - const neededReportIDs = useMemo(() => { - const ids = new Set(); - for (const transaction of visibleTransactions) { - if (transaction.reportID) { - ids.add(`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`); - } - if (transaction.reportAction?.childReportID) { - ids.add(`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportAction.childReportID}`); - } + const ids = new Set(); + for (const transaction of visibleTransactions) { + if (transaction.reportID) { + ids.add(`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`); } - return ids; - }, [visibleTransactions]); - - const reportsSelector = useCallback( - (reports: OnyxCollection) => { - const result: OnyxCollection = {}; - for (const key of Object.keys(reports ?? {})) { - if (neededReportIDs.has(key)) { - result[key] = reports?.[key]; - } + if (transaction.reportAction?.childReportID) { + ids.add(`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportAction.childReportID}`); + } + } + const neededReportIDs = ids; + + const reportsSelector = (reports: OnyxCollection) => { + const result: OnyxCollection = {}; + for (const key of Object.keys(reports ?? {})) { + if (neededReportIDs.has(key)) { + result[key] = reports?.[key]; } - return result; - }, - [neededReportIDs], - ); + } + return result; + }; const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: reportsSelector}); const isLastTransaction = (index: number) => { diff --git a/src/components/Search/SearchList/SearchSelectAllMenu.tsx b/src/components/Search/SearchList/SearchSelectAllMenu.tsx index 705a38f1bc90..57e3fd71185f 100644 --- a/src/components/Search/SearchList/SearchSelectAllMenu.tsx +++ b/src/components/Search/SearchList/SearchSelectAllMenu.tsx @@ -15,7 +15,7 @@ import CONST from '@src/CONST'; import type {ComponentRef} from 'react'; import type {View} from 'react-native'; -import React, {useCallback, useMemo, useRef, useState} from 'react'; +import React, {useRef, useState} from 'react'; type SearchSelectAllMenuProps = { isSelectAllChecked: boolean | undefined; @@ -39,27 +39,24 @@ function SearchSelectAllMenu({isSelectAllChecked, isIndeterminate, selectedItems const shouldOpenSelectAllMenu = selectedItemsLength === 0 && !!currentSearchResults?.search?.hasMoreResults; - const closeSelectAllMenu = useCallback(() => setIsSelectAllMenuVisible(false), []); + const closeSelectAllMenu = () => setIsSelectAllMenuVisible(false); - const selectAllOnPage = useCallback(() => { + const selectAllOnPage = () => { closeSelectAllMenu(); onAllCheckboxPress(); - }, [closeSelectAllMenu, onAllCheckboxPress]); + }; - const selectAllMatching = useCallback(() => { + const selectAllMatching = () => { closeSelectAllMenu(); selectAllMatchingItems(true); - }, [closeSelectAllMenu, selectAllMatchingItems]); - - const selectAllMenuItems = useMemo( - (): PopoverMenuItem[] => [ - {icon: expensifyIcons.Checkmark, text: translate('search.exportAll.selectAllOnThisPage'), onSelected: selectAllOnPage}, - {icon: expensifyIcons.CheckSquare, text: translate('workspace.people.selectAll'), onSelected: selectAllMatching}, - ], - [expensifyIcons.CheckSquare, expensifyIcons.Checkmark, selectAllMatching, selectAllOnPage, translate], - ); + }; + + const selectAllMenuItems: PopoverMenuItem[] = [ + {icon: expensifyIcons.Checkmark, text: translate('search.exportAll.selectAllOnThisPage'), onSelected: selectAllOnPage}, + {icon: expensifyIcons.CheckSquare, text: translate('workspace.people.selectAll'), onSelected: selectAllMatching}, + ]; - const showSelectAllMenu = useCallback(() => { + const showSelectAllMenu = () => { if (!selectAllAnchorRef.current) { return; } @@ -71,16 +68,16 @@ function SearchSelectAllMenu({isSelectAllChecked, isIndeterminate, selectedItems setSelectAllMenuPosition(position); setIsSelectAllMenuVisible(true); }); - }, [calculatePopoverPosition]); + }; - const handleSelectAllPress = useCallback(() => { + const handleSelectAllPress = () => { if (shouldOpenSelectAllMenu) { showSelectAllMenu(); return; } onAllCheckboxPress(); - }, [onAllCheckboxPress, shouldOpenSelectAllMenu, showSelectAllMenu]); + }; return ( <> diff --git a/src/components/Search/SearchPageFooter.tsx b/src/components/Search/SearchPageFooter.tsx index 253a62ab3def..9638e0f56e09 100644 --- a/src/components/Search/SearchPageFooter.tsx +++ b/src/components/Search/SearchPageFooter.tsx @@ -18,7 +18,7 @@ import CONST from '@src/CONST'; import type {LayoutChangeEvent} from 'react-native'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {StyleSheet, View} from 'react-native'; import type {SingleSelectItem} from './FilterComponents/SingleSelect'; @@ -109,7 +109,7 @@ function SearchPageFooter({ setLoadedTotalHeight(height); }; - const valueTextStyle = useMemo(() => (isOffline ? [styles.textLabelSupporting, styles.labelStrong] : [styles.labelStrong]), [isOffline, styles]); + const valueTextStyle = isOffline ? [styles.textLabelSupporting, styles.labelStrong] : [styles.labelStrong]; // The SearchList registers a global Enter shortcut that opens the focused expense. While the footer button is focused, // claim Enter at top priority without bubbling so Enter only opens the footer's display menu instead of also opening the expense. diff --git a/src/components/Search/SearchPageHeader/SearchActionsBarCreateButton.tsx b/src/components/Search/SearchPageHeader/SearchActionsBarCreateButton.tsx index 8ab3aa1353fc..26500fe5355a 100644 --- a/src/components/Search/SearchPageHeader/SearchActionsBarCreateButton.tsx +++ b/src/components/Search/SearchPageHeader/SearchActionsBarCreateButton.tsx @@ -30,7 +30,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; -import React, {useCallback, useMemo, useRef, useState} from 'react'; +import React, {useRef, useState} from 'react'; import {View} from 'react-native'; function SearchActionsBarCreateButton() { @@ -53,41 +53,38 @@ function SearchActionsBarCreateButton() { const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - const handleCreateWorkspaceReport = useCallback( - (policy: OnyxEntry, shouldDismissEmptyReportsConfirmation?: boolean) => { - if (!policy?.id) { - return; - } - - const {reportID: createdReportID} = createNewReport( - currentUserPersonalDetails, - hasViolations, - isASAPSubmitBetaEnabled, - policy, - isTrackIntentUser, - getCurrencyDecimals, - rules, - false, - shouldDismissEmptyReportsConfirmation, + const handleCreateWorkspaceReport = (policy: OnyxEntry, shouldDismissEmptyReportsConfirmation?: boolean) => { + if (!policy?.id) { + return; + } + + const {reportID: createdReportID} = createNewReport( + currentUserPersonalDetails, + hasViolations, + isASAPSubmitBetaEnabled, + policy, + isTrackIntentUser, + getCurrencyDecimals, + rules, + false, + shouldDismissEmptyReportsConfirmation, + ); + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate( + isSearchTopmostFullScreenRoute() + ? ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: createdReportID, backTo: Navigation.getActiveRoute()}) + : ROUTES.REPORT_WITH_ID.getRoute(createdReportID, undefined, undefined, Navigation.getActiveRoute()), ); - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.navigate( - isSearchTopmostFullScreenRoute() - ? ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: createdReportID, backTo: Navigation.getActiveRoute()}) - : ROUTES.REPORT_WITH_ID.getRoute(createdReportID, undefined, undefined, Navigation.getActiveRoute()), - ); - }); - }, - [currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, isTrackIntentUser, getCurrencyDecimals, rules], - ); + }); + }; const {createReport, isVisible: isCreateReportVisible} = useCreateReport({ onCreateReport: handleCreateWorkspaceReport, shouldSkipEmptyReportConfirmation: !isCreateMenuActive, }); - const hideCreateMenu = useCallback(() => setIsCreateMenuActive(false), []); - const showCreateMenu = useCallback(() => { + const hideCreateMenu = () => setIsCreateMenuActive(false); + const showCreateMenu = () => { if (!createButtonRef.current) { return; } @@ -98,38 +95,35 @@ function SearchActionsBarCreateButton() { setCreateMenuPosition(position); setIsCreateMenuActive(true); }); - }, [calculatePopoverPosition]); - - const createMenuItems = useMemo( - (): PopoverMenuItem[] => [ - { - icon: getIconForAction(CONST.IOU.TYPE.CREATE, expensifyIcons), - text: translate('iou.createExpense'), - onSelected: () => - interceptAnonymousUser(() => { - startMoneyRequest(CONST.IOU.TYPE.CREATE, generateReportID(), draftTransactionIDs); - }), - }, - { - icon: expensifyIcons.Location, - text: translate('iou.trackDistance'), - onSelected: () => - interceptAnonymousUser(() => { - startDistanceRequest(CONST.IOU.TYPE.CREATE, generateReportID(), draftTransactionIDs); - }), - }, - ...(isCreateReportVisible - ? [ - { - icon: expensifyIcons.Document, - text: translate('report.newReport.createReport'), - onSelected: createReport, - }, - ] - : []), - ], - [translate, expensifyIcons, draftTransactionIDs, createReport, isCreateReportVisible], - ); + }; + + const createMenuItems: PopoverMenuItem[] = [ + { + icon: getIconForAction(CONST.IOU.TYPE.CREATE, expensifyIcons), + text: translate('iou.createExpense'), + onSelected: () => + interceptAnonymousUser(() => { + startMoneyRequest(CONST.IOU.TYPE.CREATE, generateReportID(), draftTransactionIDs); + }), + }, + { + icon: expensifyIcons.Location, + text: translate('iou.trackDistance'), + onSelected: () => + interceptAnonymousUser(() => { + startDistanceRequest(CONST.IOU.TYPE.CREATE, generateReportID(), draftTransactionIDs); + }), + }, + ...(isCreateReportVisible + ? [ + { + icon: expensifyIcons.Document, + text: translate('report.newReport.createReport'), + onSelected: createReport, + }, + ] + : []), + ]; return ( diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index f3c270348d14..93d941907e8b 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -69,7 +69,7 @@ import type {ValueOf} from 'type-fest'; import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import {pendingDeleteMemberAccountIDsSelector} from '@selectors/ReportMetaData'; import {deepEqual} from 'fast-equals'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {SubstitutionMap} from './getQueryWithSubstitutions'; @@ -253,155 +253,129 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla return {[reportKey]: contextualReport}; })(); - const getAdditionalSections: GetAdditionalSectionsCallback = useCallback( - ({recentReports}, sectionIndex) => { - const getNavigationSuggestionsSection = () => (navigationSuggestions.length ? [{sectionIndex, data: navigationSuggestions}] : undefined); + const getAdditionalSections: GetAdditionalSectionsCallback = ({recentReports}, sectionIndex) => { + const getNavigationSuggestionsSection = () => (navigationSuggestions.length ? [{sectionIndex, data: navigationSuggestions}] : undefined); - if (!contextualReportID) { - return getNavigationSuggestionsSection(); - } + if (!contextualReportID) { + return getNavigationSuggestionsSection(); + } - // We will only show the contextual search suggestion if the user has not typed anything - if (textInputValue) { - return getNavigationSuggestionsSection(); - } + // We will only show the contextual search suggestion if the user has not typed anything + if (textInputValue) { + return getNavigationSuggestionsSection(); + } - if (!isSearchRouterDisplayed && !isSearchRouterScreen) { + if (!isSearchRouterDisplayed && !isSearchRouterScreen) { + return undefined; + } + let reportForContextualSearch = recentReports.find((option) => option.reportID === contextualReportID); + const reportAction = getReportAction(contextualReport?.parentReportID, contextualReport?.parentReportActionID); + const shouldParserToHTML = !!reportAction && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT; + if (!reportForContextualSearch) { + if (!contextualReport || isHiddenForCurrentUser(contextualReport)) { return undefined; } - let reportForContextualSearch = recentReports.find((option) => option.reportID === contextualReportID); - const reportAction = getReportAction(contextualReport?.parentReportID, contextualReport?.parentReportActionID); - const shouldParserToHTML = !!reportAction && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT; - if (!reportForContextualSearch) { - if (!contextualReport || isHiddenForCurrentUser(contextualReport)) { - return undefined; - } - const option = createOptionFromReport({ - dateFnsLocale, - convertToDisplayString, - convertToDisplayStringWithoutCurrency, - report: contextualReport, - personalDetails, - privateIsArchived: contextualReportNVP, - rules, - policy: contextualReportPolicy, - sortedActions, - transactionThreadIDs, - lastActions, - currentUserAccountID, - currentUserLogin: currentUserPersonalDetails.login, - conciergeReportID, - reportAttributesDerived: reportAttributes, - config: { - showPersonalDetails: isOneOnOneChat(contextualReport), - }, - isTrackIntentUser, - pendingDeleteMemberAccountIDs: contextualReportPendingDeleteMemberAccountIDs, - formatPhoneNumber, - }); - reportForContextualSearch = option; - } + const option = createOptionFromReport({ + dateFnsLocale, + convertToDisplayString, + convertToDisplayStringWithoutCurrency, + report: contextualReport, + personalDetails, + privateIsArchived: contextualReportNVP, + rules, + policy: contextualReportPolicy, + sortedActions, + transactionThreadIDs, + lastActions, + currentUserAccountID, + currentUserLogin: currentUserPersonalDetails.login, + conciergeReportID, + reportAttributesDerived: reportAttributes, + config: { + showPersonalDetails: isOneOnOneChat(contextualReport), + }, + isTrackIntentUser, + pendingDeleteMemberAccountIDs: contextualReportPendingDeleteMemberAccountIDs, + formatPhoneNumber, + }); + reportForContextualSearch = option; + } - const reportQueryValue = reportForContextualSearch.text ?? reportForContextualSearch.alternateText ?? reportForContextualSearch.reportID; + const reportQueryValue = reportForContextualSearch.text ?? reportForContextualSearch.alternateText ?? reportForContextualSearch.reportID; - let roomType: ValueOf = CONST.SEARCH.DATA_TYPES.CHAT; - let autocompleteID: string | undefined = reportForContextualSearch.reportID; + let roomType: ValueOf = CONST.SEARCH.DATA_TYPES.CHAT; + let autocompleteID: string | undefined = reportForContextualSearch.reportID; - if (reportForContextualSearch.isInvoiceRoom) { - roomType = CONST.SEARCH.DATA_TYPES.INVOICE; - const report = reportForContextualSearch as SearchOption; - if (report.item?.invoiceReceiver && report.item.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) { - autocompleteID = report.item.invoiceReceiver.accountID.toString(); - } else { - autocompleteID = ''; - } + if (reportForContextualSearch.isInvoiceRoom) { + roomType = CONST.SEARCH.DATA_TYPES.INVOICE; + const report = reportForContextualSearch as SearchOption; + if (report.item?.invoiceReceiver && report.item.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) { + autocompleteID = report.item.invoiceReceiver.accountID.toString(); + } else { + autocompleteID = ''; } - if (reportForContextualSearch.isPolicyExpenseChat) { - roomType = CONST.SEARCH.DATA_TYPES.EXPENSE; - if (reportForContextualSearch.policyID) { - autocompleteID = reportForContextualSearch.policyID; - } else { - autocompleteID = ''; - } + } + if (reportForContextualSearch.isPolicyExpenseChat) { + roomType = CONST.SEARCH.DATA_TYPES.EXPENSE; + if (reportForContextualSearch.policyID) { + autocompleteID = reportForContextualSearch.policyID; + } else { + autocompleteID = ''; } + } - return [ - { - sectionIndex, - data: [ - { - text: StringUtils.lineBreaksToSpaces( - `${translate('search.searchIn')} ${ - shouldParserToHTML - ? Parser.htmlToText(reportForContextualSearch.text ?? reportForContextualSearch.alternateText ?? '') - : (reportForContextualSearch.text ?? reportForContextualSearch.alternateText ?? '') - }`, - ), - singleIcon: expensifyIcons.MagnifyingGlass, - searchQuery: reportQueryValue, - autocompleteID, - keyForList: 'contextualSearch', - searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION, - roomType, - policyID: reportForContextualSearch.policyID, - }, - ], - }, - ]; - }, - [ - contextualReportID, - conciergeReportID, - navigationSuggestions, - textInputValue, - isSearchRouterDisplayed, - isSearchRouterScreen, - translate, - expensifyIcons.MagnifyingGlass, - contextualReport, - personalDetails, - sortedActions, - transactionThreadIDs, - lastActions, - currentUserPersonalDetails.login, - contextualReportNVP, - contextualReportPolicy, - reportAttributes, - isTrackIntentUser, - dateFnsLocale, - currentUserAccountID, - convertToDisplayString, - formatPhoneNumber, - convertToDisplayStringWithoutCurrency, - rules, - contextualReportPendingDeleteMemberAccountIDs, - ], - ); - - const searchQueryItems = textInputValue?.trim() - ? [ - { - text: textInputValue, - singleIcon: expensifyIcons.MagnifyingGlass, - searchQuery: textInputValue, - keyForList: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.FIND_ITEM, - searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, - }, - ...(shouldShowAskConcierge - ? [ - { - text: translate('search.askConcierge', textInputValue), - singleIcon: expensifyIcons.ConciergeAvatar, - shouldIconApplyFill: false, - searchQuery: textInputValue, - keyForList: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.ASK_CONCIERGE, - searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.ASK_CONCIERGE, - }, - ] - : []), - ] - : undefined; + return [ + { + sectionIndex, + data: [ + { + text: StringUtils.lineBreaksToSpaces( + `${translate('search.searchIn')} ${ + shouldParserToHTML + ? Parser.htmlToText(reportForContextualSearch.text ?? reportForContextualSearch.alternateText ?? '') + : (reportForContextualSearch.text ?? reportForContextualSearch.alternateText ?? '') + }`, + ), + singleIcon: expensifyIcons.MagnifyingGlass, + searchQuery: reportQueryValue, + autocompleteID, + keyForList: 'contextualSearch', + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION, + roomType, + policyID: reportForContextualSearch.policyID, + }, + ], + }, + ]; + }; + + let searchQueryItems; + if (textInputValue?.trim()) { + searchQueryItems = [ + { + text: textInputValue, + singleIcon: expensifyIcons.MagnifyingGlass, + searchQuery: textInputValue, + keyForList: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.FIND_ITEM, + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, + }, + ...(shouldShowAskConcierge + ? [ + { + text: translate('search.askConcierge', textInputValue), + singleIcon: expensifyIcons.ConciergeAvatar, + shouldIconApplyFill: false, + searchQuery: textInputValue, + keyForList: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.ASK_CONCIERGE, + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.ASK_CONCIERGE, + }, + ] + : []), + ]; + } else { + searchQueryItems = undefined; + } const shouldScrollRef = useRef(false); // Trigger scrollToRight when input value changes and shouldScroll is true @@ -414,171 +388,131 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla shouldScrollRef.current = false; }, [textInputValue]); - const onSearchQueryChange = useCallback( - // `isTypedInput` is false when the query is set without typing in the input, such as by picking a suggestion - (userQuery: string, autoScrollToRight = false, inputEventTimeStamp?: number, isTypedInput = true) => { - if (autoScrollToRight) { - shouldScrollRef.current = true; - } - const singleLineUserQuery = StringUtils.lineBreaksToSpaces(userQuery, true); - const updatedUserQuery = getAutocompleteQueryWithComma(textInputValue, singleLineUserQuery); - setTextInputValue(updatedUserQuery); - // A same-value update does not restart the debounce timer, so it must not move the span start either. - if (updatedUserQuery !== lastAutocompleteQueryRef.current) { - // The list may still be evaluating the previous debounced query, and that measurement would now include this input. - cancelSearchRouterQuerySpan( - updatedUserQuery.trim() ? CONST.TELEMETRY.SEARCH_ROUTER_QUERY_CANCEL_REASON.SUPERSEDED : CONST.TELEMETRY.SEARCH_ROUTER_QUERY_CANCEL_REASON.CLEARED, - ); - lastAutocompleteQueryRef.current = updatedUserQuery; - queryChangedAtRef.current = getSearchRouterQueryChangedAt(inputEventTimeStamp, performance.now()); - isQueryChangeTypedRef.current = isTypedInput; - } - setAutocompleteQueryValue(updatedUserQuery); + // `isTypedInput` is false when the query is set without typing in the input, such as by picking a suggestion + const onSearchQueryChange = (userQuery: string, autoScrollToRight = false, inputEventTimeStamp?: number, isTypedInput = true) => { + if (autoScrollToRight) { + shouldScrollRef.current = true; + } + const singleLineUserQuery = StringUtils.lineBreaksToSpaces(userQuery, true); + const updatedUserQuery = getAutocompleteQueryWithComma(textInputValue, singleLineUserQuery); + setTextInputValue(updatedUserQuery); + // A same-value update does not restart the debounce timer, so it must not move the span start either. + if (updatedUserQuery !== lastAutocompleteQueryRef.current) { + // The list may still be evaluating the previous debounced query, and that measurement would now include this input. + cancelSearchRouterQuerySpan(updatedUserQuery.trim() ? CONST.TELEMETRY.SEARCH_ROUTER_QUERY_CANCEL_REASON.SUPERSEDED : CONST.TELEMETRY.SEARCH_ROUTER_QUERY_CANCEL_REASON.CLEARED); + lastAutocompleteQueryRef.current = updatedUserQuery; + queryChangedAtRef.current = getSearchRouterQueryChangedAt(inputEventTimeStamp, performance.now()); + isQueryChangeTypedRef.current = isTypedInput; + } + setAutocompleteQueryValue(updatedUserQuery); - const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(singleLineUserQuery, autocompleteSubstitutions); - if (!deepEqual(autocompleteSubstitutions, updatedSubstitutionsMap)) { - setAutocompleteSubstitutions(updatedSubstitutionsMap); - } - }, - [autocompleteSubstitutions, setAutocompleteQueryValue, setTextInputValue, textInputValue], - ); + const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(singleLineUserQuery, autocompleteSubstitutions); + if (!deepEqual(autocompleteSubstitutions, updatedSubstitutionsMap)) { + setAutocompleteSubstitutions(updatedSubstitutionsMap); + } + }; + + const submitSearch = (queryString: SearchQueryString, shouldSkipAmountConversion = false) => { + const queryWithSubstitutions = getQueryWithSubstitutions(queryString, autocompleteSubstitutions, currentUserAccountID, getPersonalDetailsByLogin()); + const updatedQuery = getQueryWithUpdatedValues(queryWithSubstitutions, shouldSkipAmountConversion, policies); + if (!updatedQuery) { + return; + } + + // Reset the search query flag when performing a new search + setShouldResetSearchQuery(false); + + onRouterClose(); + setSearchContext(true); + const updatedQueryJSON = buildSearchQueryJSON(updatedQuery); + const searchKey = updatedQueryJSON?.hash === currentSearchHash ? currentSearchKey : getSearchKeyForQuery(updatedQueryJSON); + Navigation.navigate( + ROUTES.SEARCH_ROOT.getRoute({ + query: updatedQuery, + rawQuery: shouldSkipAmountConversion || !isFromSearchPageSearchButton ? undefined : queryWithSubstitutions, + searchKey, + }), + ); + + setTextInputValue(''); + lastAutocompleteQueryRef.current = ''; + setAutocompleteQueryValue(''); + }; - const submitSearch = useCallback( - (queryString: SearchQueryString, shouldSkipAmountConversion = false) => { - const queryWithSubstitutions = getQueryWithSubstitutions(queryString, autocompleteSubstitutions, currentUserAccountID, getPersonalDetailsByLogin()); - const updatedQuery = getQueryWithUpdatedValues(queryWithSubstitutions, shouldSkipAmountConversion, policies); - if (!updatedQuery) { + const onListItemPress = (item: OptionData | SearchQueryItem) => { + const setFocusAndScrollToRight = () => { + if (!textInputRef.current) { return; } + textInputRef.current.focus(); + scrollToRight(textInputRef.current); + }; - // Reset the search query flag when performing a new search - setShouldResetSearchQuery(false); - - onRouterClose(); - setSearchContext(true); - const updatedQueryJSON = buildSearchQueryJSON(updatedQuery); - const searchKey = updatedQueryJSON?.hash === currentSearchHash ? currentSearchKey : getSearchKeyForQuery(updatedQueryJSON); - Navigation.navigate( - ROUTES.SEARCH_ROOT.getRoute({ - query: updatedQuery, - rawQuery: shouldSkipAmountConversion || !isFromSearchPageSearchButton ? undefined : queryWithSubstitutions, - searchKey, - }), - ); - - setTextInputValue(''); - lastAutocompleteQueryRef.current = ''; - setAutocompleteQueryValue(''); - }, - [ - autocompleteSubstitutions, - currentUserAccountID, - getPersonalDetailsByLogin, - onRouterClose, - setAutocompleteQueryValue, - setTextInputValue, - setShouldResetSearchQuery, - getSearchKeyForQuery, - currentSearchHash, - currentSearchKey, - isFromSearchPageSearchButton, - policies, - ], - ); + if (isSearchQueryItem(item)) { + if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.NAVIGATE && item.action) { + onRouterClose(item.action); + return; + } - const onListItemPress = useCallback( - (item: OptionData | SearchQueryItem) => { - const setFocusAndScrollToRight = () => { - if (!textInputRef.current) { - return; - } - textInputRef.current.focus(); - scrollToRight(textInputRef.current); - }; - - if (isSearchQueryItem(item)) { - if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.NAVIGATE && item.action) { - onRouterClose(item.action); - return; - } + if (!item.searchQuery) { + return; + } - if (!item.searchQuery) { - return; - } + if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) { + const searchQuery = getContextualSearchQuery(item, contextualPoliciesMap, contextualReportsMap); + const newSearchQuery = `${searchQuery}\u00A0`; + onSearchQueryChange(newSearchQuery, true, undefined, false); + setSelection({start: newSearchQuery.length, end: newSearchQuery.length}); - if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) { - const searchQuery = getContextualSearchQuery(item, contextualPoliciesMap, contextualReportsMap); - const newSearchQuery = `${searchQuery}\u00A0`; - onSearchQueryChange(newSearchQuery, true, undefined, false); - setSelection({start: newSearchQuery.length, end: newSearchQuery.length}); - - const autocompleteKey = getContextualSearchAutocompleteKey(item, contextualPoliciesMap, contextualReportsMap); - if (autocompleteKey && item.autocompleteID) { - const substitutions = {...autocompleteSubstitutions, [autocompleteKey]: item.autocompleteID}; - setAutocompleteSubstitutions(substitutions); - } - setFocusAndScrollToRight(); - } else if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) { - const fieldKey = item.mapKey?.includes(':') ? item.mapKey.split(':').at(0) : item.mapKey; - const trimmedUserSearchQuery = getTrimmedUserSearchQueryPreservingComma(textInputValue, fieldKey); - const newSearchQuery = `${trimmedUserSearchQuery}${sanitizeSearchValue(item.searchQuery)}\u00A0`; - onSearchQueryChange(newSearchQuery, true, undefined, false); - setSelection({start: newSearchQuery.length, end: newSearchQuery.length}); - - updateAutocompleteSubstitutionsForSelection({ - newSearchQuery, - fieldKey, - mapKey: item.mapKey, - searchQuery: item.searchQuery, - autocompleteID: item.autocompleteID, - substitutions: autocompleteSubstitutions, - setAutocompleteSubstitutions, - }); - setFocusAndScrollToRight(); - } else if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.ASK_CONCIERGE) { - const {searchQuery} = item; - askConcierge(searchQuery); - onRouterClose(); - } else { - submitSearch(item.searchQuery, item.keyForList !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.FIND_ITEM); - } - } else { - if (item?.reportID) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(item.reportID)); - } else if ('login' in item) { - navigateToAndOpenReport({ - userLogins: item.login ? [item.login] : [], - personalDetails, - currentUserAccountID, - introSelected, - isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, - hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, - conciergeChat, - isSupportalSession, - shouldDismissModal: false, - }); + const autocompleteKey = getContextualSearchAutocompleteKey(item, contextualPoliciesMap, contextualReportsMap); + if (autocompleteKey && item.autocompleteID) { + const substitutions = {...autocompleteSubstitutions, [autocompleteKey]: item.autocompleteID}; + setAutocompleteSubstitutions(substitutions); } + setFocusAndScrollToRight(); + } else if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) { + const fieldKey = item.mapKey?.includes(':') ? item.mapKey.split(':').at(0) : item.mapKey; + const trimmedUserSearchQuery = getTrimmedUserSearchQueryPreservingComma(textInputValue, fieldKey); + const newSearchQuery = `${trimmedUserSearchQuery}${sanitizeSearchValue(item.searchQuery)}\u00A0`; + onSearchQueryChange(newSearchQuery, true, undefined, false); + setSelection({start: newSearchQuery.length, end: newSearchQuery.length}); + + updateAutocompleteSubstitutionsForSelection({ + newSearchQuery, + fieldKey, + mapKey: item.mapKey, + searchQuery: item.searchQuery, + autocompleteID: item.autocompleteID, + substitutions: autocompleteSubstitutions, + setAutocompleteSubstitutions, + }); + setFocusAndScrollToRight(); + } else if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.ASK_CONCIERGE) { + const {searchQuery} = item; + askConcierge(searchQuery); onRouterClose(); + } else { + submitSearch(item.searchQuery, item.keyForList !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.FIND_ITEM); } - }, - [ - autocompleteSubstitutions, - onRouterClose, - personalDetails, - onSearchQueryChange, - submitSearch, - textInputValue, - currentUserAccountID, - introSelected, - guidedSetupAndTourStatus?.isSelfTourViewed, - guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, - conciergeChat, - isSupportalSession, - contextualPoliciesMap, - contextualReportsMap, - askConcierge, - ], - ); + } else { + if (item?.reportID) { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(item.reportID)); + } else if ('login' in item) { + navigateToAndOpenReport({ + userLogins: item.login ? [item.login] : [], + personalDetails, + currentUserAccountID, + introSelected, + isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, + hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, + conciergeChat, + isSupportalSession, + shouldDismissModal: false, + }); + } + onRouterClose(); + } + }; useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => { onRouterClose(); diff --git a/src/components/Search/SearchScopeProvider.tsx b/src/components/Search/SearchScopeProvider.tsx index 9990ce9e7a43..c7819a25691a 100644 --- a/src/components/Search/SearchScopeProvider.tsx +++ b/src/components/Search/SearchScopeProvider.tsx @@ -1,6 +1,6 @@ import type ChildrenProps from '@src/types/utils/ChildrenProps'; -import React, {useContext, useMemo} from 'react'; +import React, {useContext} from 'react'; const defaultSearchContext = { isOnSearch: false, @@ -9,12 +9,9 @@ const defaultSearchContext = { const SearchScopeContext = React.createContext(defaultSearchContext); function SearchScopeProvider({children, isOnSearch = true}: ChildrenProps) { - const searchContext = useMemo( - () => ({ - isOnSearch, - }), - [isOnSearch], - ); + const searchContext = { + isOnSearch, + }; return {children}; } diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 8a9c5378cc55..fedeeef53f1d 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -19,7 +19,7 @@ import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {accountIDSelector} from '@selectors/Session'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {SearchFooterCount, SearchFooterTotal, SelectedTransactionInfo, SelectedTransactions} from './types'; @@ -231,23 +231,17 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp const metadataReportCount = metadata?.reportCount; const metadataCurrency = metadata?.currency; const metadataTotal = isSnapshotTotalFromEarlierBreakdown ? answeredFooterTotals?.totals[requestedFooterTotal] : metadata?.total; - const selectedTransactionsKeys = useMemo(() => Object.keys(selectedTransactions ?? {}), [selectedTransactions]); - const excludedTransactionsKeys = useMemo(() => Object.keys(excludedTransactions), [excludedTransactions]); + const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); + const excludedTransactionsKeys = Object.keys(excludedTransactions); const isExpenseType = currentSearchQueryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE; - const selectedExpenseCount = useMemo( - () => getTransactionCount(selectedTransactionsKeys, selectedTransactions, currentSearchResults?.data), - [currentSearchResults?.data, selectedTransactions, selectedTransactionsKeys], - ); + const selectedExpenseCount = getTransactionCount(selectedTransactionsKeys, selectedTransactions, currentSearchResults?.data); // The Reports footer is also expense-based, so excluding one report removes each of its child expenses here. - const excludedExpenseCount = useMemo( - () => (isExpenseType || isReportsSearch ? getTransactionCount(excludedTransactionsKeys, excludedTransactions, currentSearchResults?.data) : 0), - [currentSearchResults?.data, excludedTransactions, excludedTransactionsKeys, isExpenseType, isReportsSearch], - ); + const excludedExpenseCount = isExpenseType || isReportsSearch ? getTransactionCount(excludedTransactionsKeys, excludedTransactions, currentSearchResults?.data) : 0; - const excludedReportCount = useMemo(() => { - if (!isReportsSearch || excludedTransactionsKeys.length === 0) { - return 0; - } + let excludedReportCount; + if (!isReportsSearch || excludedTransactionsKeys.length === 0) { + excludedReportCount = 0; + } else { const excludedKeys = new Set(excludedTransactionsKeys); let count = 0; for (const [reportID, reportTransactions] of currentSearchTransactionsByReportID ?? []) { @@ -264,135 +258,101 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp count += 1; } } - return count; - }, [currentSearchTransactionsByReportID, excludedTransactions, excludedTransactionsKeys, isReportsSearch]); + excludedReportCount = count; + } // Individually-selected transactions (loose rows in a grouped view, or every row on a flat search). - const selectedTransactionIDs = useMemo( - () => selectedTransactionsKeys.map((key) => selectedTransactions[key]?.transaction?.transactionID).filter((transactionID): transactionID is string => !!transactionID), - [selectedTransactions, selectedTransactionsKeys], - ); - const excludedTransactionIDs = useMemo( - () => excludedTransactionsKeys.map((key) => excludedTransactions[key]?.transaction?.transactionID).filter((transactionID): transactionID is string => !!transactionID), - [excludedTransactions, excludedTransactionsKeys], - ); - const selectedReportIDs = useMemo(() => { - if (!isReportsSearch) { - return EMPTY_REPORT_IDS; - } - return selectedReports.map((report) => report.reportID).filter((reportID): reportID is string => !!reportID); - }, [isReportsSearch, selectedReports]); - const excludedReportIDs = useMemo(() => { - if (!isReportsSearch) { - return EMPTY_REPORT_IDS; - } - return [ - ...new Set( - Object.values(excludedTransactions) - .map((transaction) => transaction.reportID) - .filter((reportID): reportID is string => !!reportID), - ), - ]; - }, [excludedTransactions, isReportsSearch]); - const selectedGroupKeys = useMemo(() => selectedTransactionsKeys.filter(isGroupEntry), [selectedTransactionsKeys]); - const excludedGroupKeys = useMemo(() => excludedTransactionsKeys.filter(isGroupEntry), [excludedTransactionsKeys]); + const selectedTransactionIDs = selectedTransactionsKeys + .map((key) => selectedTransactions[key]?.transaction?.transactionID) + .filter((transactionID): transactionID is string => !!transactionID); + const excludedTransactionIDs = excludedTransactionsKeys + .map((key) => excludedTransactions[key]?.transaction?.transactionID) + .filter((transactionID): transactionID is string => !!transactionID); + const selectedReportIDs = !isReportsSearch ? EMPTY_REPORT_IDS : selectedReports.map((report) => report.reportID).filter((reportID): reportID is string => !!reportID); + const excludedReportIDs = !isReportsSearch + ? EMPTY_REPORT_IDS + : [ + ...new Set( + Object.values(excludedTransactions) + .map((transaction) => transaction.reportID) + .filter((reportID): reportID is string => !!reportID), + ), + ]; + const selectedGroupKeys = selectedTransactionsKeys.filter(isGroupEntry); + const excludedGroupKeys = excludedTransactionsKeys.filter(isGroupEntry); // Live default-currency source figures, keyed the same way as the conversion cache, captured from the current // selection/snapshot on every render. The freshness checks below compare these to the figures stamped when each // conversion was requested. - const transactionSourceByID = useMemo(() => { - const sources: Record = {}; - for (const transactions of [selectedTransactions, excludedTransactions]) { - for (const [key, transaction] of Object.entries(transactions)) { - const transactionID = transaction.transaction?.transactionID; - if (!isGroupEntry(key) && transactionID) { - sources[transactionID] = getEntrySource(transaction); - } + const transactionSourceByID: Record = {}; + const groupSourceByKey: Record = {}; + for (const transactions of [selectedTransactions, excludedTransactions]) { + for (const [key, transaction] of Object.entries(transactions)) { + const transactionID = transaction.transaction?.transactionID; + if (!isGroupEntry(key) && transactionID) { + transactionSourceByID[transactionID] = getEntrySource(transaction); } - } - return sources; - }, [excludedTransactions, selectedTransactions]); - const groupSourceByKey = useMemo(() => { - const sources: Record = {}; - for (const transactions of [selectedTransactions, excludedTransactions]) { - for (const [key, transaction] of Object.entries(transactions)) { - if (isGroupEntry(key)) { - sources[key] = getEntrySource(transaction); - } + if (isGroupEntry(key)) { + groupSourceByKey[key] = getEntrySource(transaction); } } - return sources; - }, [excludedTransactions, selectedTransactions]); + } // Source figures for every loaded group, not just the selected ones. The grouped response caches every group's // converted value, so stamping them all lets a later selection of another group reuse the cache instead of // re-running the grouped query. Uses the same expense-signed figure as getEntrySource so a stamp always matches // the live source the freshness checks compare against. - const loadedGroupSourceByKey = useMemo(() => { - const data = currentSearchResults?.data; - if (!isGroupedSearch || !data) { - return EMPTY_SOURCES; - } - const sources: Record = {}; + let loadedGroupSourceByKey; + const data = currentSearchResults?.data; + if (!isGroupedSearch || !data) { + loadedGroupSourceByKey = EMPTY_SOURCES; + } else { + const loadedSources: Record = {}; for (const key of Object.keys(data)) { if (!isGroupEntry(key)) { continue; } const group: unknown = data[key]; if (group && typeof group === 'object' && 'total' in group && typeof group.total === 'number') { - sources[key] = -group.total; + loadedSources[key] = -group.total; } } - return sources; - }, [currentSearchResults?.data, isGroupedSearch]); - const reportSourceByID = useMemo(() => { - if (!isReportsSearch) { - return EMPTY_SOURCES; - } - const sources: Record = {}; + loadedGroupSourceByKey = loadedSources; + } + let reportSourceByID; + if (!isReportsSearch) { + reportSourceByID = EMPTY_SOURCES; + } else { + const reportSources: Record = {}; for (const report of selectedReports) { if (report.reportID) { - sources[report.reportID] = report.total; + reportSources[report.reportID] = report.total; } } for (const transaction of Object.values(excludedTransactions)) { if (transaction.reportID) { - sources[transaction.reportID] = (sources[transaction.reportID] ?? 0) + getEntrySource(transaction); + reportSources[transaction.reportID] = (reportSources[transaction.reportID] ?? 0) + getEntrySource(transaction); } } - return sources; - }, [excludedTransactions, isReportsSearch, selectedReports]); + reportSourceByID = reportSources; + } // A conversion is fresh only when its converted figure is cached AND the source it was stamped against still equals // the live source — so an inline edit that moves the live source makes it stale and triggers a refetch below. - const isTransactionFresh = useCallback( - (transactionID: string, currency: string) => - convertedTransactions?.[transactionID]?.[currency] !== undefined && conversionSources?.transactions?.[transactionID]?.[currency] === transactionSourceByID[transactionID], - [conversionSources, convertedTransactions, transactionSourceByID], - ); - const isGroupFresh = useCallback( - (key: string, currency: string) => convertedGroups?.[key]?.[currency] !== undefined && conversionSources?.groups?.[key]?.[currency] === groupSourceByKey[key], - [conversionSources, convertedGroups, groupSourceByKey], - ); - const isReportFresh = useCallback( - (reportID: string, currency: string) => convertedReports?.[reportID]?.[currency] !== undefined && conversionSources?.reports?.[reportID]?.[currency] === reportSourceByID[reportID], - [conversionSources, convertedReports, reportSourceByID], - ); + const isTransactionFresh = (transactionID: string, currency: string) => + convertedTransactions?.[transactionID]?.[currency] !== undefined && conversionSources?.transactions?.[transactionID]?.[currency] === transactionSourceByID[transactionID]; + const isGroupFresh = (key: string, currency: string) => convertedGroups?.[key]?.[currency] !== undefined && conversionSources?.groups?.[key]?.[currency] === groupSourceByKey[key]; + const isReportFresh = (reportID: string, currency: string) => + convertedReports?.[reportID]?.[currency] !== undefined && conversionSources?.reports?.[reportID]?.[currency] === reportSourceByID[reportID]; // A conversion has been requested for the current live source when the stamp (written optimistically at request // time) matches it — the converted value may still be in flight. The fetch effect fires on this weaker predicate, // not on freshness: freshness also requires the value, so using it would fire a duplicate request when the // effect re-runs off its own optimistic stamp merge. - const wasTransactionRequested = useCallback( - (transactionID: string, currency: string) => conversionSources?.transactions?.[transactionID]?.[currency] === transactionSourceByID[transactionID], - [conversionSources, transactionSourceByID], - ); - const wasGroupRequested = useCallback((key: string, currency: string) => conversionSources?.groups?.[key]?.[currency] === groupSourceByKey[key], [conversionSources, groupSourceByKey]); - const wasReportRequested = useCallback( - (reportID: string, currency: string) => - conversionSources?.reports?.[reportID]?.[currency] === reportSourceByID[reportID] || pendingReportConversionSources.current[reportID]?.[currency] === reportSourceByID[reportID], - [conversionSources, reportSourceByID], - ); + const wasTransactionRequested = (transactionID: string, currency: string) => conversionSources?.transactions?.[transactionID]?.[currency] === transactionSourceByID[transactionID]; + const wasGroupRequested = (key: string, currency: string) => conversionSources?.groups?.[key]?.[currency] === groupSourceByKey[key]; + const wasReportRequested = (reportID: string, currency: string) => + conversionSources?.reports?.[reportID]?.[currency] === reportSourceByID[reportID] || pendingReportConversionSources.current[reportID]?.[currency] === reportSourceByID[reportID]; // A cash back row holds no expenses, so leaving it out still matches the server count while the server total nets it. // An unloaded page can still hold one, and matching counts mean that page has no expenses left to select. @@ -464,18 +424,15 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp // groups and/or individual transactions. const hasConvertibleSelection = isReportsSearch ? selectedReportIDs.length > 0 : selectedGroupKeys.length > 0 || selectedTransactionIDs.length > 0; - const areAllSelectedConverted = useMemo(() => { - if (!hasCustomFooterCurrency || !selectedCurrency) { - return false; - } - return isReportsSearch + const areAllSelectedConverted = + !(!hasCustomFooterCurrency || !selectedCurrency) && + (isReportsSearch ? areAllSelectedReportsConverted(selectedReportIDs, (reportID) => isReportFresh(reportID, selectedCurrency)) : areAllSelectedEntriesConverted( selectedTransactions, (key) => isGroupFresh(key, selectedCurrency), (transactionID) => isTransactionFresh(transactionID, selectedCurrency), - ); - }, [hasCustomFooterCurrency, isGroupFresh, isReportFresh, isReportsSearch, isTransactionFresh, selectedCurrency, selectedReportIDs, selectedTransactions]); + )); const hasExcludedExpenses = isExpenseType && excludedTransactionsKeys.length > 0; const hasExcludedReports = isReportsSearch && excludedReportIDs.length > 0; @@ -485,19 +442,15 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp // A collapsed group is excluded as one entry with no expenses to classify, so nothing tells how much of it falls in // the breakdown. No figure is shown rather than one that still counts the group. const hasExcludedGroupUnderBreakdown = excludedGroupKeys.length > 0 && footerTotalBreakdown !== CONST.SEARCH.FOOTER_TOTAL.TOTAL; - const areAllExcludedConverted = useMemo(() => { - if (!hasCustomFooterCurrency || !selectedCurrency || (!hasExcludedExpenses && !hasExcludedReports)) { - return false; - } - if (hasExcludedReports) { - return areAllSelectedReportsConverted(excludedReportIDs, (reportID) => isReportFresh(reportID, selectedCurrency)); - } - return areAllSelectedEntriesConverted( - excludedTransactions, - (key) => isGroupFresh(key, selectedCurrency), - (transactionID) => isTransactionFresh(transactionID, selectedCurrency), - ); - }, [excludedReportIDs, excludedTransactions, hasCustomFooterCurrency, hasExcludedExpenses, hasExcludedReports, isGroupFresh, isReportFresh, isTransactionFresh, selectedCurrency]); + const areAllExcludedConverted = + !(!hasCustomFooterCurrency || !selectedCurrency || (!hasExcludedExpenses && !hasExcludedReports)) && + (hasExcludedReports + ? areAllSelectedReportsConverted(excludedReportIDs, (reportID) => isReportFresh(reportID, selectedCurrency)) + : areAllSelectedEntriesConverted( + excludedTransactions, + (key) => isGroupFresh(key, selectedCurrency), + (transactionID) => isTransactionFresh(transactionID, selectedCurrency), + )); // Show the loading skeleton only while a conversion can still arrive — there is something to convert, the request // can be made (online) and hasn't failed. Otherwise the footer stays on the default-currency total instead of a @@ -725,14 +678,16 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp applyFooterSelection({footerCount: nextCountType}); }; - const footerData = useMemo(() => { - if (!shouldAllowFooterTotals && selectedTransactionsKeys.length === 0) { - return {count: undefined, total: undefined, currency: undefined}; - } - + let footerData; + if (!shouldAllowFooterTotals && selectedTransactionsKeys.length === 0) { + footerData = { + count: undefined, + total: undefined, + currency: undefined, + }; + } else { const selectedTransactionItems = Object.values(selectedTransactions); const fallbackCurrency = effectiveDefaultCurrency ?? selectedTransactionItems.at(0)?.groupCurrency ?? selectedTransactionItems.at(0)?.currency; - if (shouldUseClientTotal) { const shouldUseConvertedSelectedTotal = hasCustomFooterCurrency && areAllSelectedConverted && !hasConversionFailed && !!selectedCurrency; @@ -744,109 +699,76 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp // from the reports' own expenses instead. A Reports search converts by report, so those expenses have no // converted figures of their own, so the breakdown stays in the default currency and is labelled as such. const shouldConvertSelectedTotal = shouldUseConvertedSelectedTotal && !(isReportsSearch && footerTotalBreakdown !== CONST.SEARCH.FOOTER_TOTAL.TOTAL); - - let total; - if (shouldConvertSelectedTotal && isReportsSearch && selectedCurrency) { - total = selectedReportIDs.reduce((acc, reportID) => acc - (convertedReports?.[reportID]?.[selectedCurrency] ?? 0), 0); - } else { - total = selectedTransactionsKeys.reduce((acc, key) => { - const transaction = selectedTransactions[key]; - if (!doesTransactionMatchFooterTotal(transaction.transaction, footerTotalBreakdown)) { - return acc; - } - let convertedAmount; - if (shouldConvertSelectedTotal && selectedCurrency) { - if (isGroupEntry(key)) { - convertedAmount = convertedGroups?.[key]?.[selectedCurrency]; - } else if (transaction.transaction?.transactionID) { - convertedAmount = convertedTransactions?.[transaction.transaction.transactionID]?.[selectedCurrency]; - } - } - return acc - (convertedAmount ?? getEntrySource(transaction)); - }, 0); - } - - return {count: selectedExpenseCount, total, currency: shouldConvertSelectedTotal ? selectedCurrency : fallbackCurrency}; - } - - if (hasExcludedGroupUnderBreakdown) { - return { + const total = + shouldConvertSelectedTotal && isReportsSearch && selectedCurrency + ? selectedReportIDs.reduce((acc, reportID) => acc - (convertedReports?.[reportID]?.[selectedCurrency] ?? 0), 0) + : selectedTransactionsKeys.reduce((acc, key) => { + const transaction = selectedTransactions[key]; + if (!doesTransactionMatchFooterTotal(transaction.transaction, footerTotalBreakdown)) { + return acc; + } + let convertedAmount; + if (shouldConvertSelectedTotal && selectedCurrency) { + if (isGroupEntry(key)) { + convertedAmount = convertedGroups?.[key]?.[selectedCurrency]; + } else if (transaction.transaction?.transactionID) { + convertedAmount = convertedTransactions?.[transaction.transaction.transactionID]?.[selectedCurrency]; + } + } + return acc - (convertedAmount ?? getEntrySource(transaction)); + }, 0); + footerData = { + count: selectedExpenseCount, + total, + currency: shouldConvertSelectedTotal ? selectedCurrency : fallbackCurrency, + }; + } else if (hasExcludedGroupUnderBreakdown) { + footerData = { count: metadataCount === undefined ? undefined : Math.max(metadataCount - excludedExpenseCount, 0), total: undefined, currency: effectiveDefaultCurrency ?? metadataCurrency, }; - } - - const excludedKeysForTotal = excludedTransactionsKeys.filter((key) => doesTransactionMatchFooterTotal(excludedTransactions[key]?.transaction, footerTotalBreakdown)); - if ( - hasCustomFooterCurrency && - isSearchTotalFresh && - canConvertExcludedReports && - ((!hasExcludedExpenses && !hasExcludedReports) || areAllExcludedConverted) && - !hasConversionFailed && - selectedCurrencyConvertedTotal - ) { - let excludedConvertedTotal = 0; - if (hasExcludedReports && selectedCurrency) { - excludedConvertedTotal = excludedReportIDs.reduce((total, reportID) => total - (convertedReports?.[reportID]?.[selectedCurrency] ?? 0), 0); - } else if (hasExcludedExpenses) { - excludedConvertedTotal = excludedKeysForTotal.reduce((total, key) => { - const transaction = excludedTransactions[key]; - const transactionID = transaction.transaction?.transactionID; - let convertedAmount; - if (isGroupEntry(key)) { - convertedAmount = convertedGroups?.[key]?.[selectedCurrency]; - } else if (transactionID) { - convertedAmount = convertedTransactions?.[transactionID]?.[selectedCurrency]; - } - return total - (convertedAmount ?? getEntrySource(transaction)); - }, 0); + } else { + const excludedKeysForTotal = excludedTransactionsKeys.filter((key) => doesTransactionMatchFooterTotal(excludedTransactions[key]?.transaction, footerTotalBreakdown)); + if ( + hasCustomFooterCurrency && + isSearchTotalFresh && + canConvertExcludedReports && + ((!hasExcludedExpenses && !hasExcludedReports) || areAllExcludedConverted) && + !hasConversionFailed && + selectedCurrencyConvertedTotal + ) { + let excludedConvertedTotal = 0; + if (hasExcludedReports && selectedCurrency) { + excludedConvertedTotal = excludedReportIDs.reduce((total, reportID) => total - (convertedReports?.[reportID]?.[selectedCurrency] ?? 0), 0); + } else if (hasExcludedExpenses) { + excludedConvertedTotal = excludedKeysForTotal.reduce((total, key) => { + const transaction = excludedTransactions[key]; + const transactionID = transaction.transaction?.transactionID; + let convertedAmount; + if (isGroupEntry(key)) { + convertedAmount = convertedGroups?.[key]?.[selectedCurrency]; + } else if (transactionID) { + convertedAmount = convertedTransactions?.[transactionID]?.[selectedCurrency]; + } + return total - (convertedAmount ?? getEntrySource(transaction)); + }, 0); + } + footerData = { + count: Math.max(selectedCurrencyConvertedTotal.count - excludedExpenseCount, 0), + total: selectedCurrencyConvertedTotal.total - excludedConvertedTotal, + currency: selectedCurrency, + }; + } else { + const excludedTotal = hasExcludedExpenses || hasExcludedReports ? getTransactionTotal(excludedKeysForTotal.map((key) => excludedTransactions[key])) : 0; + footerData = { + count: metadataCount === undefined ? undefined : Math.max(metadataCount - excludedExpenseCount, 0), + total: metadataTotal === undefined ? undefined : metadataTotal - excludedTotal, + currency: effectiveDefaultCurrency ?? metadataCurrency, + }; } - return { - count: Math.max(selectedCurrencyConvertedTotal.count - excludedExpenseCount, 0), - total: selectedCurrencyConvertedTotal.total - excludedConvertedTotal, - currency: selectedCurrency, - }; } - - const excludedTotal = hasExcludedExpenses || hasExcludedReports ? getTransactionTotal(excludedKeysForTotal.map((key) => excludedTransactions[key])) : 0; - return { - count: metadataCount === undefined ? undefined : Math.max(metadataCount - excludedExpenseCount, 0), - total: metadataTotal === undefined ? undefined : metadataTotal - excludedTotal, - currency: effectiveDefaultCurrency ?? metadataCurrency, - }; - }, [ - areAllSelectedConverted, - areAllExcludedConverted, - canConvertExcludedReports, - convertedGroups, - convertedReports, - convertedTransactions, - effectiveDefaultCurrency, - excludedExpenseCount, - excludedReportIDs, - excludedTransactions, - excludedTransactionsKeys, - hasConversionFailed, - hasCustomFooterCurrency, - hasExcludedExpenses, - hasExcludedGroupUnderBreakdown, - hasExcludedReports, - isReportsSearch, - isSearchTotalFresh, - metadataCount, - metadataCurrency, - metadataTotal, - selectedCurrency, - selectedCurrencyConvertedTotal, - selectedExpenseCount, - selectedReportIDs, - selectedTransactions, - selectedTransactionsKeys, - shouldAllowFooterTotals, - shouldUseClientTotal, - footerTotalBreakdown, - ]); + } if (!shouldShowFooter) { return null; diff --git a/src/components/Search/SearchTableHeader.tsx b/src/components/Search/SearchTableHeader.tsx index b53ed1a71a76..e4db145179e4 100644 --- a/src/components/Search/SearchTableHeader.tsx +++ b/src/components/Search/SearchTableHeader.tsx @@ -11,7 +11,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; import type {ValueOf} from 'type-fest'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import type {SearchColumnType, SearchGroupBy, SearchSortBy, SortOrder} from './types'; @@ -600,20 +600,16 @@ function SearchTableHeader({ // Only load Profile icon when it's needed for EXPENSE_REPORT type or grouped transactions const icons = useMemoizedLazyExpensifyIcons(type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT || !!groupBy ? ['Profile', 'Bank', 'CreditCard'] : []) satisfies SearchHeaderIcons; - const shouldShowColumn = useCallback( - (columnName: SearchColumnType) => { - return columns.includes(columnName); - }, - [columns], - ); - - const columnConfig = useMemo(() => getSearchColumns(type, icons, groupBy, isExpenseReportView), [type, groupBy, icons, isExpenseReportView]); + const shouldShowColumn = (columnName: SearchColumnType) => { + return columns.includes(columnName); + }; - const orderedColumnConfig = useMemo(() => { - if (!columnConfig) { - return null; - } + const columnConfig = getSearchColumns(type, icons, groupBy, isExpenseReportView); + let orderedColumnConfig; + if (!columnConfig) { + orderedColumnConfig = null; + } else { const configMap = new Map(columnConfig.map((config) => [config.columnName, config])); // Users can customize column order via the Search Columns page. @@ -621,7 +617,6 @@ function SearchTableHeader({ // then appending any remaining columns (which will be filtered out by shouldShowColumn). const orderedConfig: SearchColumnConfig[] = []; const addedColumns = new Set(); - for (const col of columns) { const config = configMap.get(col); if (config) { @@ -629,15 +624,13 @@ function SearchTableHeader({ addedColumns.add(col); } } - for (const config of columnConfig) { if (!addedColumns.has(config.columnName)) { orderedConfig.push(config); } } - - return orderedConfig; - }, [columnConfig, columns]); + orderedColumnConfig = orderedConfig; + } if (displayNarrowVersion) { return; diff --git a/src/components/Search/hooks/useLiveFilteredReportActions.ts b/src/components/Search/hooks/useLiveFilteredReportActions.ts index 6b3fae867e8e..aa0d4ec98b01 100644 --- a/src/components/Search/hooks/useLiveFilteredReportActions.ts +++ b/src/components/Search/hooks/useLiveFilteredReportActions.ts @@ -3,7 +3,6 @@ import {selectFilteredReportActions, selectFilteredReportActionsForReports} from import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportAction} from '@src/types/onyx'; -import {useCallback, useMemo} from 'react'; // Use the original useOnyx hook to get the real-time data from Onyx and not from the snapshot // eslint-disable-next-line no-restricted-imports import {useOnyx as useOnyxWithoutSnapshots} from 'react-native-onyx'; @@ -14,12 +13,9 @@ import {useOnyx as useOnyxWithoutSnapshots} from 'react-native-onyx'; * group renders, so the selected output and its equality check stay small. */ function useLiveFilteredReportActions(reportIDs?: string[]): Record | undefined { - const reportActionsKeys = useMemo(() => [...new Set(reportIDs)].map((reportID) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`), [reportIDs]); - const selector = useCallback( - (reportActions: Parameters[0]) => - reportIDs ? selectFilteredReportActionsForReports(reportActions, reportActionsKeys) : selectFilteredReportActions(reportActions), - [reportIDs, reportActionsKeys], - ); + const reportActionsKeys = [...new Set(reportIDs)].map((reportID) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`); + const selector = (reportActions: Parameters[0]) => + reportIDs ? selectFilteredReportActionsForReports(reportActions, reportActionsKeys) : selectFilteredReportActions(reportActions); const [liveReportActions] = useOnyxWithoutSnapshots | undefined>(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { selector, }); diff --git a/src/components/Search/hooks/useSearchSnapshot.ts b/src/components/Search/hooks/useSearchSnapshot.ts index b0aece4059b0..09797da47273 100644 --- a/src/components/Search/hooks/useSearchSnapshot.ts +++ b/src/components/Search/hooks/useSearchSnapshot.ts @@ -23,8 +23,6 @@ import type SearchResults from '@src/types/onyx/SearchResults'; import type {OnyxCollection} from 'react-native-onyx'; -import {useMemo} from 'react'; - import useLiveFilteredReportActions from './useLiveFilteredReportActions'; import useOptimisticSearchTracking from './useOptimisticSearchTracking'; import useSearchColumnsToShow from './useSearchColumnsToShow'; @@ -92,6 +90,36 @@ const EMPTY_DATA: SearchListItem[] = []; const EMPTY_HASHES: string[] = []; const EMPTY_GROUP_ITEMS: TransactionGroupListItemType[] = []; +type SearchSectionData = { + baseFilteredData: SearchListItem[]; + filteredDataLength: number; + allDataLength: number; + hasDeletedTransaction: boolean; +}; + +function useSearchSections(options: Parameters[0] | undefined): SearchSectionData { + // This projection has no other hooks, so opt it into compilation explicitly. + 'use memo'; + + if (!options) { + return {baseFilteredData: EMPTY_DATA, filteredDataLength: 0, allDataLength: 0, hasDeletedTransaction: false}; + } + + const [filtered, allDataLength, hasDeletedTransaction] = getSections(options); + return {baseFilteredData: filtered as SearchListItem[], filteredDataLength: filtered.length, allDataLength, hasDeletedTransaction}; +} + +function useSortedSearchSections(shouldComputeSections: boolean, hash: number, sortArgs: Parameters): SearchListItem[] { + // The hook boundary keeps projection results immutable for the downstream optimistic-state hook. + 'use memo'; + + if (!shouldComputeSections) { + return EMPTY_DATA; + } + + return getSortedSections(...sortArgs).map((item) => (item.hash === hash ? item : {...item, hash})); +} + /** How many leading rows the cap keeps: `rowLimit` rows on screen, stretched down to the last ticked row. */ function getRowLimitEnd(rows: SearchListItem[], rowLimit: number, selectedTransactions: SelectedTransactions | undefined, isOffline: boolean): number { const isKeySelected = (key: string | undefined) => !!key && !!selectedTransactions?.[key]?.isSelected; @@ -193,178 +221,101 @@ function useSearchSnapshot({queryJSON, searchResults, transactions, reportAction !shouldDeferHeavySearchWork && searchResults !== undefined && isDataLoaded && !!searchDataWithOptimisticTransaction && !(validGroupBy && (isChat || isTask)); // Stage 1: base sections from the (optimistically augmented) snapshot. - // Memoized explicitly: React Compiler caches the inline callbacks of these projection stages but NOT - // the getSections/getSortedSections/getColumnsToShow call results themselves, so without useMemo each - // stage returns a fresh array every render. An unstable `data` reference drives - // useStableOptimisticSortedData's setState effect into an infinite render loop during the - // optimistic-create flow ("Maximum update depth exceeded"). - const {baseFilteredData, filteredDataLength, allDataLength, hasDeletedTransaction} = useMemo<{ - baseFilteredData: SearchListItem[]; - filteredDataLength: number; - allDataLength: number; - hasDeletedTransaction: boolean; - }>(() => { - if (!shouldComputeSections) { - return { - baseFilteredData: EMPTY_DATA, - filteredDataLength: 0, - allDataLength: 0, - hasDeletedTransaction: false, - }; - } - - const [filtered, allLength, hasDeletedTransactionFromSections] = getSections({ - dateFnsLocale, - type, - data: searchDataWithOptimisticTransaction, - currentAccountID: accountID, - currentUserEmail: email ?? '', - translate, - formatPhoneNumber, - bankAccountList, - rules, - groupBy: validGroupBy, - reportActions: exportReportActions, - currentSearch: currentSearchKey, - reportNameValuePairs: reportNameValuePairsForSections, - queryJSON, - isActionLoadingSet, - cardFeeds, - cardList: personalAndWorkspaceCards, - nonPersonalAndWorkspaceCardList: nonPersonalAndWorkspaceCards, - isOffline, - customCardNames, - conciergeReportID, - onyxPersonalDetailsList, - isAttendeesEnabledForMovingPolicy, - convertToDisplayString, - reportAttributesDerivedValue: reportAttributesForSections, - optimisticTransactionID, - }); - return { - baseFilteredData: filtered as SearchListItem[], - filteredDataLength: filtered.length, - allDataLength: allLength, - hasDeletedTransaction: hasDeletedTransactionFromSections, - }; - }, [ - shouldComputeSections, - searchDataWithOptimisticTransaction, - type, - accountID, - email, - translate, - formatPhoneNumber, - bankAccountList, - rules, - validGroupBy, - exportReportActions, - currentSearchKey, - reportNameValuePairsForSections, - queryJSON, - isActionLoadingSet, - cardFeeds, - personalAndWorkspaceCards, - nonPersonalAndWorkspaceCards, - isOffline, - customCardNames, - conciergeReportID, - onyxPersonalDetailsList, - isAttendeesEnabledForMovingPolicy, - convertToDisplayString, - reportAttributesForSections, - optimisticTransactionID, - dateFnsLocale, - ]); + const {baseFilteredData, filteredDataLength, allDataLength, hasDeletedTransaction} = useSearchSections( + shouldComputeSections + ? { + dateFnsLocale, + type, + data: searchDataWithOptimisticTransaction, + currentAccountID: accountID, + currentUserEmail: email ?? '', + translate, + formatPhoneNumber, + bankAccountList, + rules, + groupBy: validGroupBy, + reportActions: exportReportActions, + currentSearch: currentSearchKey, + reportNameValuePairs: reportNameValuePairsForSections, + queryJSON, + isActionLoadingSet, + cardFeeds, + cardList: personalAndWorkspaceCards, + nonPersonalAndWorkspaceCardList: nonPersonalAndWorkspaceCards, + isOffline, + customCardNames, + conciergeReportID, + onyxPersonalDetailsList, + isAttendeesEnabledForMovingPolicy, + convertToDisplayString, + reportAttributesDerivedValue: reportAttributesForSections, + optimisticTransactionID, + } + : undefined, + ); // Stage 2: for grouped views, fetch each group's sub-snapshot and enrich it with its transactions. // validGroupBy guarantees these are group containers (chat/task are guarded out above). // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- guarded by validGroupBy const groupItems = validGroupBy ? (baseFilteredData as TransactionGroupListItemType[]) : EMPTY_GROUP_ITEMS; - const groupByTransactionHashes = useMemo( - () => (validGroupBy ? groupItems.map((item) => hashToString(item.transactionsQueryJSON?.hash)).filter((hashValue): hashValue is string => !!hashValue) : EMPTY_HASHES), - [validGroupBy, groupItems], - ); + const groupByTransactionHashes = validGroupBy + ? groupItems.map((item) => hashToString(item.transactionsQueryJSON?.hash)).filter((hashValue): hashValue is string => !!hashValue) + : EMPTY_HASHES; const groupByTransactionSnapshots = useMultipleSnapshots(groupByTransactionHashes); - const filteredData = useMemo(() => { - if (!validGroupBy || isExpenseReportType) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- snapshot rows are a SearchData subset - return baseFilteredData as SearchData; - } - return groupItems.map((item) => { - const subSnapshot = groupByTransactionSnapshots[hashToString(item.transactionsQueryJSON?.hash) ?? '']; - if (!subSnapshot?.data) { - return item; - } - const [groupTransactions] = getSections({ - dateFnsLocale, - type: CONST.SEARCH.DATA_TYPES.EXPENSE, - data: subSnapshot.data, - currentAccountID: accountID, - currentUserEmail: email ?? '', - bankAccountList, - rules, - translate, - formatPhoneNumber, - isActionLoadingSet, - cardFeeds, - cardList: personalAndWorkspaceCards, - conciergeReportID, - onyxPersonalDetailsList, - convertToDisplayString, - reportActions: exportReportActions, - reportAttributesDerivedValue: undefined, - }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- group children are flat transactions - const typedGroupTransactions = groupTransactions as TransactionListItemType[]; - return { - ...item, - transactions: getSortedTransactionData(typedGroupTransactions, localeCompare, translate, CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.SORT_ORDER.DESC), - }; - }); - }, [ - validGroupBy, - isExpenseReportType, - baseFilteredData, - groupItems, - groupByTransactionSnapshots, - accountID, - email, - bankAccountList, - rules, - translate, - localeCompare, - formatPhoneNumber, - isActionLoadingSet, - cardFeeds, - personalAndWorkspaceCards, - conciergeReportID, - onyxPersonalDetailsList, - convertToDisplayString, - dateFnsLocale, - exportReportActions, - ]); + const filteredData: SearchData = + !validGroupBy || isExpenseReportType + ? // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- snapshot rows are a SearchData subset + (baseFilteredData as SearchData) + : groupItems.map((item) => { + const subSnapshot = groupByTransactionSnapshots[hashToString(item.transactionsQueryJSON?.hash) ?? '']; + if (!subSnapshot?.data) { + return item; + } + const [groupTransactions] = getSections({ + dateFnsLocale, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: subSnapshot.data, + currentAccountID: accountID, + currentUserEmail: email ?? '', + bankAccountList, + rules, + translate, + formatPhoneNumber, + isActionLoadingSet, + cardFeeds, + cardList: personalAndWorkspaceCards, + conciergeReportID, + onyxPersonalDetailsList, + convertToDisplayString, + reportActions: exportReportActions, + reportAttributesDerivedValue: undefined, + }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- group children are flat transactions + const typedGroupTransactions = groupTransactions as TransactionListItemType[]; + return { + ...item, + transactions: getSortedTransactionData(typedGroupTransactions, localeCompare, translate, CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.SORT_ORDER.DESC), + }; + }); // Stage 3: sort the (enriched) data. getSortedSections accepts the full section union; our // SearchListItem[] is a compatible subset of that input. - const chartData = useMemo(() => { - if (!shouldComputeSections) { - return EMPTY_DATA; - } - const sortInput = filteredData as Parameters[1]; - return getSortedSections(type, sortInput, localeCompare, translate, sortBy, sortOrder, validGroupBy, { + const sortInput = filteredData as Parameters[1]; + const chartData = useSortedSearchSections(shouldComputeSections, hash, [ + type, + sortInput, + localeCompare, + translate, + sortBy, + sortOrder, + validGroupBy, + { policyCategories, policyTags, fallbackPolicyID: policyForMovingExpensesID, - }).map((item) => { - if (item.hash === hash) { - return item; - } - - return {...item, hash}; - }); - }, [shouldComputeSections, type, filteredData, localeCompare, translate, sortBy, sortOrder, validGroupBy, policyCategories, policyTags, policyForMovingExpensesID, hash]); + }, + ]); // Keep the optimistic row visible across a snapshot-replacement gap for up to // OPTIMISTIC_ROLLBACK_GRACE_MS until the new snapshot picks it up or the grace expires. @@ -377,13 +328,15 @@ function useSearchSnapshot({queryJSON, searchResults, transactions, reportAction // Non-grouped views are always fully loaded; grouped views require every group's sub-snapshot to // report no further results. - const hasLoadedAllTransactions = !validGroupBy - ? true - : groupItems.every((item) => { - const subSnapshot = - item.transactionsQueryJSON?.hash || item.transactionsQueryJSON?.hash === 0 ? groupByTransactionSnapshots[String(item.transactionsQueryJSON.hash)] : undefined; - return item.transactions.length === 0 || !subSnapshot || !subSnapshot?.search?.hasMoreResults; - }); + let hasLoadedAllTransactions; + if (!validGroupBy) { + hasLoadedAllTransactions = true; + } else { + hasLoadedAllTransactions = groupItems.every((item) => { + const subSnapshot = item.transactionsQueryJSON?.hash || item.transactionsQueryJSON?.hash === 0 ? groupByTransactionSnapshots[String(item.transactionsQueryJSON.hash)] : undefined; + return item.transactions.length === 0 || !subSnapshot || !subSnapshot?.search?.hasMoreResults; + }); + } // slice after the sort so page 2 continues the order on screen const rowLimitEnd = visibleRowLimit === undefined ? stableSortedData.length : getRowLimitEnd(stableSortedData, visibleRowLimit, selectedTransactions, isOffline); diff --git a/src/components/Search/primitives/HorizontalTableScroll.tsx b/src/components/Search/primitives/HorizontalTableScroll.tsx index 42816a5ca6a5..9c040b696c48 100644 --- a/src/components/Search/primitives/HorizontalTableScroll.tsx +++ b/src/components/Search/primitives/HorizontalTableScroll.tsx @@ -12,7 +12,7 @@ import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {NativeScrollEvent, NativeSyntheticEvent, ScrollView as RNScrollView} from 'react-native'; -import React, {useCallback, useLayoutEffect, useRef} from 'react'; +import React, {useLayoutEffect, useRef} from 'react'; // Keep a ref to the horizontal scroll offset so we can restore it if users change the search query let savedHorizontalScrollOffset = 0; @@ -76,9 +76,9 @@ function HorizontalTableScroll({children, columns, type, isActionColumnWide, isH const horizontalScrollViewRef = useRef>(null); - const handleHorizontalScroll = useCallback((event: NativeSyntheticEvent) => { + const handleHorizontalScroll = (event: NativeSyntheticEvent) => { savedHorizontalScrollOffset = event.nativeEvent.contentOffset.x; - }, []); + }; // Restore horizontal scroll position synchronously before paint using useLayoutEffect to avoid a visible shift on the table useLayoutEffect(() => { diff --git a/src/components/Search/primitives/useRowLongPressMenu.tsx b/src/components/Search/primitives/useRowLongPressMenu.tsx index 772cbf7643d3..dc9332a0928f 100644 --- a/src/components/Search/primitives/useRowLongPressMenu.tsx +++ b/src/components/Search/primitives/useRowLongPressMenu.tsx @@ -13,7 +13,7 @@ import navigationRef from '@libs/Navigation/navigationRef'; import CONST from '@src/CONST'; import {useRoute} from '@react-navigation/native'; -import React, {useCallback, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; type UseRowLongPressMenuParams = { @@ -67,32 +67,29 @@ function useRowLongPressMenu({shouldPreventLongPressRow, isSmallScreenWidth, isM toggle(item, itemTransactions); }; - const handleLongPressRow = useCallback( - (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => { - const currentRoute = navigationRef.current?.getCurrentRoute(); - if (currentRoute && route.key !== currentRoute.key) { - return; - } - - if (shouldPreventLongPressRow || !isSmallScreenWidth || item?.isDisabled || item?.isDisabledCheckbox || item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - return; - } - - setLongPressedItem(item); - setLongPressedItemTransactions(itemTransactions); - setIsModalVisible(true); - }, - [route.key, shouldPreventLongPressRow, isSmallScreenWidth], - ); + const handleLongPressRow = (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => { + const currentRoute = navigationRef.current?.getCurrentRoute(); + if (currentRoute && route.key !== currentRoute.key) { + return; + } + + if (shouldPreventLongPressRow || !isSmallScreenWidth || item?.isDisabled || item?.isDisabledCheckbox || item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return; + } - const turnOnSelectionMode = useCallback(() => { + setLongPressedItem(item); + setLongPressedItemTransactions(itemTransactions); + setIsModalVisible(true); + }; + + const turnOnSelectionMode = () => { turnOnMobileSelectionMode(); setIsModalVisible(false); if (longPressedItem) { toggle(longPressedItem, longPressedItemTransactions); } - }, [longPressedItem, toggle, longPressedItemTransactions]); + }; const onLongPressRow = isMobileSelectionModeEnabled ? handleLongPressRowInMobileSelectionMode : handleLongPressRow; diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index dab269ee44fb..6c365faf9d72 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -12,7 +12,7 @@ import type {FlashListRef, ListRenderItem, ListRenderItemInfo} from '@shopify/fl import {useIsFocused} from '@react-navigation/native'; import {FlashList} from '@shopify/flash-list'; import {deepEqual} from 'fast-equals'; -import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; import {Keyboard, View} from 'react-native'; import type {DataDetailsType, ListItem, SelectionListProps} from './types'; @@ -114,55 +114,63 @@ function BaseSelectionListImpl({ const itemFocusTimeoutRef = useRef(null); const keyboardListenerRef = useRef | null>(null); - const initialFocusedIndex = useMemo(() => data.findIndex((i) => i.keyForList === initiallyFocusedItemKey), [data, initiallyFocusedItemKey]); + const initialFocusedIndex = data.findIndex((i) => i.keyForList === initiallyFocusedItemKey); const [itemsToHighlight, setItemsToHighlight] = useState | null>(null); - const isItemSelected = useCallback( - (item: ListItem) => item.isSelected ?? ((isSelected?.(item) ?? selectedItems.includes(item.keyForList)) && canSelectMultiple), - [isSelected, selectedItems, canSelectMultiple], - ); + const isItemSelected = (item: ListItem) => item.isSelected ?? ((isSelected?.(item) ?? selectedItems.includes(item.keyForList)) && canSelectMultiple); - const paddingBottomStyle = useMemo(() => !isKeyboardShown && safeAreaPaddingBottomStyle, [isKeyboardShown, safeAreaPaddingBottomStyle]); + const paddingBottomStyle = !isKeyboardShown && safeAreaPaddingBottomStyle; const hasFooter = !!footerContent || confirmButtonOptions?.showButton; - const dataDetails = useMemo>(() => { - const {disabledIndexes, disabledArrowKeyIndexes, selectedOptions, disabledSelectedIndexes} = data.reduce( - (acc: {disabledIndexes: number[]; disabledArrowKeyIndexes: number[]; selectedOptions: ListItem[]; disabledSelectedIndexes: number[]}, item: ListItem, idx: number) => { - const itemIsSelected = isItemSelected(item); - const isItemDisabled = isDisabled || (!!item?.isDisabled && !itemIsSelected); - const isEffectivelyDisabled = isItemDisabled || !!item?.isDisabledCheckbox; - - if (itemIsSelected && (canSelectMultiple || acc.selectedOptions.length === 0)) { - acc.selectedOptions.push(item); - } - - if (!isEffectivelyDisabled) { - return acc; - } - - acc.disabledIndexes.push(idx); - - if (isItemDisabled) { - acc.disabledArrowKeyIndexes.push(idx); - } - - if (itemIsSelected) { - acc.disabledSelectedIndexes.push(idx); - } - - return acc; + const {disabledIndexes, disabledArrowKeyIndexes, selectedOptions, disabledSelectedIndexes} = data.reduce( + ( + acc: { + disabledIndexes: number[]; + disabledArrowKeyIndexes: number[]; + selectedOptions: ListItem[]; + disabledSelectedIndexes: number[]; }, - {disabledIndexes: [], disabledArrowKeyIndexes: [], selectedOptions: [], disabledSelectedIndexes: []}, - ); - - const totalSelectable = data.length - disabledIndexes.length; - const selectableSelectedCount = selectedOptions.length - disabledSelectedIndexes.length; - const allSelected = selectableSelectedCount > 0 && selectableSelectedCount === totalSelectable; - const someSelected = selectableSelectedCount > 0 && selectableSelectedCount < totalSelectable; - - return {data, allSelected, someSelected, selectedOptions, disabledIndexes, disabledArrowKeyIndexes}; - }, [canSelectMultiple, data, isDisabled, isItemSelected]); + item: ListItem, + idx: number, + ) => { + const itemIsSelected = isItemSelected(item); + const isItemDisabled = isDisabled || (!!item?.isDisabled && !itemIsSelected); + const isEffectivelyDisabled = isItemDisabled || !!item?.isDisabledCheckbox; + if (itemIsSelected && (canSelectMultiple || acc.selectedOptions.length === 0)) { + acc.selectedOptions.push(item); + } + if (!isEffectivelyDisabled) { + return acc; + } + acc.disabledIndexes.push(idx); + if (isItemDisabled) { + acc.disabledArrowKeyIndexes.push(idx); + } + if (itemIsSelected) { + acc.disabledSelectedIndexes.push(idx); + } + return acc; + }, + { + disabledIndexes: [], + disabledArrowKeyIndexes: [], + selectedOptions: [], + disabledSelectedIndexes: [], + }, + ); + const totalSelectable = data.length - disabledIndexes.length; + const selectableSelectedCount = selectedOptions.length - disabledSelectedIndexes.length; + const allSelected = selectableSelectedCount > 0 && selectableSelectedCount === totalSelectable; + const someSelected = selectableSelectedCount > 0 && selectableSelectedCount < totalSelectable; + const dataDetails: DataDetailsType = { + data, + allSelected, + someSelected, + selectedOptions, + disabledIndexes, + disabledArrowKeyIndexes, + }; const {focusedIndex, setFocusedIndex, isKeyboardNavigating, setHasKeyBeenPressed} = useSelectionListKeyboardFocus({ initialFocusedIndex, @@ -183,59 +191,44 @@ function BaseSelectionListImpl({ // extraData helps FlashList detect when data changes significantly (e.g., during filtering) // Including data.length ensures FlashList resets its layout cache when the list size changes // This prevents "index out of bounds" errors when filtering reduces the list size - const extraData = useMemo(() => [data.length], [data.length]); + const extraData = [data.length]; const syncedSearchValue = searchValueForFocusSync ?? textInputOptions?.value; - const selectRow = useCallback( - (item: ListItem, indexToFocus?: number) => { - if (!isFocused) { - return; - } - if (canSelectMultiple) { - if (shouldShowTextInput && shouldClearInputOnSelect) { - textInputOptions?.onChangeText?.(''); - } else if (isSmallScreenWidth) { - if (!item.isDisabledCheckbox) { - onSelectionButtonPress?.(item); - } - return; + const selectRow = (item: ListItem, indexToFocus?: number) => { + if (!isFocused) { + return; + } + if (canSelectMultiple) { + if (shouldShowTextInput && shouldClearInputOnSelect) { + textInputOptions?.onChangeText?.(''); + } else if (isSmallScreenWidth) { + if (!item.isDisabledCheckbox) { + onSelectionButtonPress?.(item); } + return; } - if (shouldUpdateFocusedIndex && typeof indexToFocus === 'number') { - setFocusedIndex(indexToFocus); - } - onSelectRow(item); - - if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow) { - focusTextInput(); - } - }, - [ - isFocused, - canSelectMultiple, - shouldUpdateFocusedIndex, - onSelectRow, - shouldShowTextInput, - shouldClearInputOnSelect, - shouldPreventDefaultFocusOnSelectRow, - isSmallScreenWidth, - textInputOptions, - onSelectionButtonPress, - setFocusedIndex, - focusTextInput, - ], - ); + } + if (shouldUpdateFocusedIndex && typeof indexToFocus === 'number') { + setFocusedIndex(indexToFocus); + } + onSelectRow(item); - const focusedOption = useMemo(() => { - if (focusedIndex < 0 || focusedIndex >= data.length) { - return; + if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow) { + focusTextInput(); } + }; + + let focusedOption; + if (focusedIndex < 0 || focusedIndex >= data.length) { + focusedOption = undefined; + } else { const option = data.at(focusedIndex); if (!option || (option.isDisabled && !isItemSelected(option))) { - return; + focusedOption = undefined; + } else { + focusedOption = option; } - return option; - }, [data, focusedIndex, isItemSelected]); + } const selectFocusedOption = () => { if (!focusedOption || focusedOption.isInteractive === false) { @@ -350,87 +343,78 @@ function BaseSelectionListImpl({ // The function scrolls to the focused input to prevent keyboard occlusion. // It ensures the entire list item is visible, not just the input field. // Added specifically for SplitExpensePage - const scrollToFocusedInput = useCallback( - (item: ListItem) => { - if (!listRef.current) { + const scrollToFocusedInput = (item: ListItem) => { + if (!listRef.current) { + return; + } + + // Clear any existing timer and listener before starting new ones + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + if (keyboardListenerRef.current) { + keyboardListenerRef.current.remove(); + } + + const performScroll = () => { + const index = data.findIndex((dataItem) => dataItem.keyForList === item.keyForList); + if (index === -1) { return; } + // Use scrollToIndex with viewPosition 0.5 to center the item in the visible area + // This ensures the item is visible above the keyboard + listRef.current?.scrollToIndex({index, animated: true, viewPosition: 0.5}); + }; - // Clear any existing timer and listener before starting new ones + // Wait for keyboard to fully appear, then scroll + keyboardListenerRef.current = Keyboard.addListener('keyboardDidShow', () => { + keyboardListenerRef.current?.remove(); + keyboardListenerRef.current = null; + // Clear fallback timeout since keyboard event fired if (scrollTimeoutRef.current) { clearTimeout(scrollTimeoutRef.current); + scrollTimeoutRef.current = null; } - if (keyboardListenerRef.current) { - keyboardListenerRef.current.remove(); - } - - const performScroll = () => { - const index = data.findIndex((dataItem) => dataItem.keyForList === item.keyForList); - if (index === -1) { - return; - } - // Use scrollToIndex with viewPosition 0.5 to center the item in the visible area - // This ensures the item is visible above the keyboard - listRef.current?.scrollToIndex({index, animated: true, viewPosition: 0.5}); - }; - - // Wait for keyboard to fully appear, then scroll - keyboardListenerRef.current = Keyboard.addListener('keyboardDidShow', () => { - keyboardListenerRef.current?.remove(); - keyboardListenerRef.current = null; - // Clear fallback timeout since keyboard event fired - if (scrollTimeoutRef.current) { - clearTimeout(scrollTimeoutRef.current); - scrollTimeoutRef.current = null; - } - // Add small delay after keyboard is shown for layout to settle - scrollTimeoutRef.current = setTimeout(performScroll, CONST.ANIMATION_IN_TIMING); - }); - - // Fallback timeout in case keyboard event doesn't fire (e.g., keyboard already open) - scrollTimeoutRef.current = setTimeout(() => { - keyboardListenerRef.current?.remove(); - keyboardListenerRef.current = null; - performScroll(); - }, CONST.ANIMATED_TRANSITION); - }, - [data], - ); + // Add small delay after keyboard is shown for layout to settle + scrollTimeoutRef.current = setTimeout(performScroll, CONST.ANIMATION_IN_TIMING); + }); + + // Fallback timeout in case keyboard event doesn't fire (e.g., keyboard already open) + scrollTimeoutRef.current = setTimeout(() => { + keyboardListenerRef.current?.remove(); + keyboardListenerRef.current = null; + performScroll(); + }, CONST.ANIMATED_TRANSITION); + }; - const scrollAndHighlightItem = useCallback( - (items: string[]) => { - const newItemsToHighlight = new Set(items); + const scrollAndHighlightItem = (items: string[]) => { + const newItemsToHighlight = new Set(items); - if (deepEqual(itemsToHighlight, newItemsToHighlight)) { - return; - } + if (deepEqual(itemsToHighlight, newItemsToHighlight)) { + return; + } - const index = data.findIndex((option) => newItemsToHighlight.has(option.keyForList)); - scrollToIndex(index); - setItemsToHighlight(newItemsToHighlight); + const index = data.findIndex((option) => newItemsToHighlight.has(option.keyForList)); + scrollToIndex(index); + setItemsToHighlight(newItemsToHighlight); - if (itemFocusTimeoutRef.current) { - clearTimeout(itemFocusTimeoutRef.current); - } - itemFocusTimeoutRef.current = setTimeout(() => { - setItemsToHighlight(null); - }, ANIMATED_HIGHLIGHT_DURATION); - }, - [data, itemsToHighlight, scrollToIndex], - ); + if (itemFocusTimeoutRef.current) { + clearTimeout(itemFocusTimeoutRef.current); + } + itemFocusTimeoutRef.current = setTimeout(() => { + setItemsToHighlight(null); + }, ANIMATED_HIGHLIGHT_DURATION); + }; - const updateFocusedIndex = useCallback( - (newFocusedIndex: number, shouldScroll = false) => { - if (newFocusedIndex < 0 || newFocusedIndex >= data.length) { - return; - } - setFocusedIndex(newFocusedIndex); - if (shouldScroll) { - scrollToIndex(newFocusedIndex); - } - }, - [data.length, scrollToIndex, setFocusedIndex], - ); + const updateFocusedIndex = (newFocusedIndex: number, shouldScroll = false) => { + if (newFocusedIndex < 0 || newFocusedIndex >= data.length) { + return; + } + setFocusedIndex(newFocusedIndex); + if (shouldScroll) { + scrollToIndex(newFocusedIndex); + } + }; useSelectedItemFocusSync({ data, @@ -459,12 +443,12 @@ function BaseSelectionListImpl({ clearTimeout(itemFocusTimeoutRef.current); }, []); - const handleSelectAll = useCallback(() => { + const handleSelectAll = () => { onSelectAll?.(); if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow) { focusTextInput(); } - }, [onSelectAll, shouldShowTextInput, shouldPreventDefaultFocusOnSelectRow, focusTextInput]); + }; useImperativeHandle(ref, () => ({scrollAndHighlightItem, scrollToIndex, updateFocusedIndex, scrollToFocusedInput, focusTextInput}), [ focusTextInput, diff --git a/src/components/SelectionList/ListItem/SplitListItem.tsx b/src/components/SelectionList/ListItem/SplitListItem.tsx index a6c7cb9806bd..f082159686a3 100644 --- a/src/components/SelectionList/ListItem/SplitListItem.tsx +++ b/src/components/SelectionList/ListItem/SplitListItem.tsx @@ -19,7 +19,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; -import React, {useCallback, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import type {ListItemProps, SplitListItemType} from './types'; @@ -40,17 +40,14 @@ function SplitListItem({item, isFocused, showTo const formattedOriginalAmount = convertToDisplayStringWithoutCurrency(item.originalAmount, item.currency); - const onSplitExpenseValueChange = useCallback( - (value: string) => { - const numericValue = Number(value); - // Skip update if value is just "-" or produces NaN (intermediate input state) - if (Number.isNaN(numericValue)) { - return; - } - item.onSplitExpenseValueChange(item.transactionID, numericValue, item.mode); - }, - [item], - ); + const onSplitExpenseValueChange = (value: string) => { + const numericValue = Number(value); + // Skip update if value is just "-" or produces NaN (intermediate input state) + if (Number.isNaN(numericValue)) { + return; + } + item.onSplitExpenseValueChange(item.transactionID, numericValue, item.mode); + }; const {inputCallbackRef: autoFocusCallbackRef} = useAutoFocusInput(); @@ -66,9 +63,9 @@ function SplitListItem({item, isFocused, showTo const contentWidth = (formattedOriginalAmount.length + 1) * CONST.CHARACTER_WIDTH; const [percentageDraft, setPercentageDraft] = useState(); - const focusHandler = useCallback(() => { + const focusHandler = () => { item.onInputFocus?.(item); - }, [item]); + }; // Only connect the auto-focus ref to the selected item so useAutoFocusInput's useFocusEffect // cleanup can cancel any pending focus task when the screen starts closing, preventing diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index 87462bf2ee9d..3ef8b5d33bdd 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -27,7 +27,7 @@ import type {ValueOf} from 'type-fest'; import {useIsFocused} from '@react-navigation/native'; import {FlashList} from '@shopify/flash-list'; -import React, {useCallback, useImperativeHandle, useRef} from 'react'; +import React, {useImperativeHandle, useRef} from 'react'; import {View} from 'react-native'; import type {FlattenedItem, ListItem, SelectionListWithSectionsProps} from './types'; @@ -147,7 +147,7 @@ function BaseSelectionListWithSectionsImpl({ focusTextInput(); } - const getFocusedItem = useCallback((): ListItem | undefined => { + const getFocusedItem = (): ListItem | undefined => { if (focusedIndex < 0 || focusedIndex >= flattenedData.length) { return; } @@ -156,7 +156,7 @@ function BaseSelectionListWithSectionsImpl({ return; } return item as ListItem; - }, [flattenedData, focusedIndex]); + }; const selectRow = (item: ListItem, indexToFocus?: number) => { if (!isScreenFocused) { @@ -189,29 +189,23 @@ function BaseSelectionListWithSectionsImpl({ selectRow(focusedItem); }; - const clearInputAfterSelect = useCallback(() => { + const clearInputAfterSelect = () => { textInputOptions?.onChangeText?.(''); - }, [textInputOptions]); + }; - const updateAndScrollToFocusedIndex = useCallback( - (index: number, shouldScroll = true) => { - setFocusedIndex(index); - if (shouldScroll) { - scrollToIndex(index); - } - }, - [scrollToIndex, setFocusedIndex], - ); + const updateAndScrollToFocusedIndex = (index: number, shouldScroll = true) => { + setFocusedIndex(index); + if (shouldScroll) { + scrollToIndex(index); + } + }; /** * Handles isTextInputFocusedRef value when using external TextInput, so external TextInput does not lose focus when typing in it. */ - const updateExternalTextInputFocus = useCallback( - (isTextInputFocused: boolean) => { - isTextInputFocusedRef.current = isTextInputFocused; - }, - [isTextInputFocusedRef], - ); + const updateExternalTextInputFocus = (isTextInputFocused: boolean) => { + isTextInputFocusedRef.current = isTextInputFocused; + }; useImperativeHandle( ref, diff --git a/src/components/SelectionList/components/Footer.tsx b/src/components/SelectionList/components/Footer.tsx index 353353e1f2d2..143abb3fbc0d 100644 --- a/src/components/SelectionList/components/Footer.tsx +++ b/src/components/SelectionList/components/Footer.tsx @@ -60,4 +60,4 @@ function Footer({footerContent, confirmButtonOptions, ad Footer.displayName = 'Footer'; -export default React.memo(Footer) as typeof Footer; +export default Footer; diff --git a/src/components/SelectionList/components/ListHeader.tsx b/src/components/SelectionList/components/ListHeader.tsx index d39f7fcd1d29..fc752e3497b7 100644 --- a/src/components/SelectionList/components/ListHeader.tsx +++ b/src/components/SelectionList/components/ListHeader.tsx @@ -122,4 +122,4 @@ function ListHeader({ ListHeader.displayName = 'ListHeader'; -export default React.memo(ListHeader); +export default ListHeader; diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index 591820f50008..43f14030063f 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -115,12 +115,9 @@ function TextInput({ const focusTimeoutRef = useRef(null); const mergedRef = mergeRefs(ref, optionsRef); - const handleTextInputChange = useCallback( - (text: string) => { - onChangeText?.(text); - }, - [onChangeText], - ); + const handleTextInputChange = (text: string) => { + onChangeText?.(text); + }; useFocusEffect( useCallback(() => { @@ -140,13 +137,13 @@ function TextInput({ }, [shouldShowTextInput, disableAutoFocus, focusTextInput, isInLandscapeMode, isScreenReaderEnabled]), ); - const handleFocus = useCallback(() => { + const handleFocus = () => { onFocusChange(true); - }, [onFocusChange]); + }; - const handleBlur = useCallback(() => { + const handleBlur = () => { onFocusChange(false); - }, [onFocusChange]); + }; if (!shouldShowTextInput) { return null; diff --git a/src/components/SelectionList/hooks/useScrollToFocusedInput/index.native.ts b/src/components/SelectionList/hooks/useScrollToFocusedInput/index.native.ts index eea70bb1d143..abaf86522b37 100644 --- a/src/components/SelectionList/hooks/useScrollToFocusedInput/index.native.ts +++ b/src/components/SelectionList/hooks/useScrollToFocusedInput/index.native.ts @@ -5,7 +5,7 @@ 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'; +import {useEffect, useRef} from 'react'; import {KeyboardEvents} from 'react-native-keyboard-controller'; import type {UseScrollToFocusedInput} from './types'; @@ -40,7 +40,7 @@ const useScrollToFocusedInput: UseScrollToFocusedInput = (listRef, isKeyboardSho isKeyboardShownRef.current = isKeyboardShown; }, [isKeyboardShown]); - const cleanup = useCallback(() => { + const cleanup = () => { keyboardListenerRef.current?.remove(); keyboardListenerRef.current = null; if (!scrollTimeoutRef.current) { @@ -48,70 +48,67 @@ const useScrollToFocusedInput: UseScrollToFocusedInput = (listRef, isKeyboardSho } clearTimeout(scrollTimeoutRef.current); scrollTimeoutRef.current = null; - }, []); + }; useEffect(() => cleanup, [cleanup]); - const trackScrollOffset = useCallback((event: NativeSyntheticEvent) => { + const trackScrollOffset = (event: NativeSyntheticEvent) => { scrollOffsetRef.current = event.nativeEvent.contentOffset.y; - }, []); + }; - const scrollInputIntoView = useCallback( - (input: MeasurableInput) => { - if (!isMeasurable(input)) { + const scrollInputIntoView = (input: MeasurableInput) => { + if (!isMeasurable(input)) { + return; + } + + const performScroll = () => { + const container = containerRef.current; + const list = listRef.current; + if (!container || !list) { return; } - - const performScroll = () => { - const container = containerRef.current; - const list = listRef.current; - if (!container || !list) { - return; - } - // The list container grows to fit its content, so we can't treat its bottom as the visible area. - // Instead we use its top (just below the header) as a stable anchor and pull the focused input up - // toward it. The list viewport is bounded above any sticky footer (which itself sits above the - // keyboard), so anchoring to the top reliably brings the input into view, clamped by the available - // scroll range. - container.measureInWindow((containerX, containerY) => { - input.measureInWindow((inputX, inputY) => { - const target = containerY + EXTRA_SCROLL_PADDING; - const delta = inputY - target; - // The input is already at or above the target anchor, so there's nothing to do. - if (delta <= 0) { - return; - } - list.scrollToOffset({offset: scrollOffsetRef.current + delta, animated: true}); - }); + // The list container grows to fit its content, so we can't treat its bottom as the visible area. + // Instead we use its top (just below the header) as a stable anchor and pull the focused input up + // toward it. The list viewport is bounded above any sticky footer (which itself sits above the + // keyboard), so anchoring to the top reliably brings the input into view, clamped by the available + // scroll range. + container.measureInWindow((containerX, containerY) => { + input.measureInWindow((inputX, inputY) => { + const target = containerY + EXTRA_SCROLL_PADDING; + const delta = inputY - target; + // The input is already at or above the target anchor, so there's nothing to do. + if (delta <= 0) { + return; + } + list.scrollToOffset({offset: scrollOffsetRef.current + delta, animated: true}); }); - }; + }); + }; - cleanup(); + cleanup(); - // The keyboard is already up (e.g. moving focus between fields), so the layout is settled — scroll right away. - if (isKeyboardShownRef.current) { - scrollTimeoutRef.current = setTimeout(performScroll, CONST.ANIMATION_IN_TIMING); - return; - } + // The keyboard is already up (e.g. moving focus between fields), so the layout is settled — scroll right away. + if (isKeyboardShownRef.current) { + scrollTimeoutRef.current = setTimeout(performScroll, CONST.ANIMATION_IN_TIMING); + return; + } - // Otherwise wait for the keyboard to appear before measuring. We use react-native-keyboard-controller's - // `keyboardDidShow` (the same source as `withKeyboardState`) rather than RN's `Keyboard`, because RN's - // `keyboardDidShow` does not fire on Android 10 and below under `adjustResize`, which would leave the - // focused field hidden until the safety timeout below. - keyboardListenerRef.current = KeyboardEvents.addListener('keyboardDidShow', () => { - cleanup(); - // Give the layout a moment to settle after the keyboard has fully appeared. - scrollTimeoutRef.current = setTimeout(performScroll, CONST.ANIMATION_IN_TIMING); - }); + // Otherwise wait for the keyboard to appear before measuring. We use react-native-keyboard-controller's + // `keyboardDidShow` (the same source as `withKeyboardState`) rather than RN's `Keyboard`, because RN's + // `keyboardDidShow` does not fire on Android 10 and below under `adjustResize`, which would leave the + // focused field hidden until the safety timeout below. + keyboardListenerRef.current = KeyboardEvents.addListener('keyboardDidShow', () => { + cleanup(); + // Give the layout a moment to settle after the keyboard has fully appeared. + scrollTimeoutRef.current = setTimeout(performScroll, CONST.ANIMATION_IN_TIMING); + }); - // Safety net in case the keyboard event never fires; long enough that the listener wins under normal conditions. - scrollTimeoutRef.current = setTimeout(() => { - cleanup(); - performScroll(); - }, CONST.MAX_TRANSITION_DURATION_MS); - }, - [cleanup, listRef], - ); + // Safety net in case the keyboard event never fires; long enough that the listener wins under normal conditions. + scrollTimeoutRef.current = setTimeout(() => { + cleanup(); + performScroll(); + }, CONST.MAX_TRANSITION_DURATION_MS); + }; return {containerRef, trackScrollOffset, scrollInputIntoView}; }; diff --git a/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts b/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts index 8b7867a7302c..7496ab36328b 100644 --- a/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts +++ b/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts @@ -1,6 +1,6 @@ import type {ListItem} from '@components/SelectionList/ListItem/types'; -import {useEffect, useMemo} from 'react'; +import {useEffect} from 'react'; type UseSelectedItemFocusSyncParams = { /** Array of items to search in */ @@ -32,7 +32,7 @@ function useSelectedItemFocusSync({ searchValue, setFocusedIndex, }: UseSelectedItemFocusSyncParams) { - const selectedItemIndex = useMemo(() => (initiallyFocusedItemKey ? data.findIndex(isItemSelected) : -1), [data, initiallyFocusedItemKey, isItemSelected]); + const selectedItemIndex = initiallyFocusedItemKey ? data.findIndex(isItemSelected) : -1; useEffect(() => { if (selectedItemIndex === -1 || selectedItemIndex === focusedIndex || searchValue) { diff --git a/src/components/SelectionList/hooks/useWebSelectionListBehavior/index.web.ts b/src/components/SelectionList/hooks/useWebSelectionListBehavior/index.web.ts index 3d42b2c4fc8f..26488cbf372b 100644 --- a/src/components/SelectionList/hooks/useWebSelectionListBehavior/index.web.ts +++ b/src/components/SelectionList/hooks/useWebSelectionListBehavior/index.web.ts @@ -3,7 +3,7 @@ import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import CONST from '@src/CONST'; -import {useCallback, useEffect, useState} from 'react'; +import {useEffect, useState} from 'react'; import {Keyboard} from 'react-native'; import type {UseWebSelectionListBehaviorOptions, UseWebSelectionListBehaviorResult} from './types'; @@ -98,13 +98,13 @@ function useWebSelectionListBehavior({shouldTrackHoverStyle = false, shouldHideK }, [shouldTrackHoverStyle]); // In SearchPageBottomTab we use useAnimatedScrollHandler from reanimated(for performance reasons) and it returns object instead of function. In that case we cannot change it to a function call, that's why we have to choose between onScroll and defaultOnScroll. - const onScroll = useCallback(() => { + const onScroll = () => { // Only dismiss the keyboard whenever the user scrolls the screen and `shouldHideKeyboardOnScroll` is true if (!isScreenTouched || !shouldHideKeyboardOnScroll) { return; } Keyboard.dismiss(); - }, [isScreenTouched, shouldHideKeyboardOnScroll]); + }; return { shouldIgnoreFocus: isMobileChrome() && isScreenTouched, diff --git a/src/components/SignInButtons/GoogleSignIn/index.tsx b/src/components/SignInButtons/GoogleSignIn/index.tsx index eea921d0c396..f4c96b5cddf3 100644 --- a/src/components/SignInButtons/GoogleSignIn/index.tsx +++ b/src/components/SignInButtons/GoogleSignIn/index.tsx @@ -13,7 +13,7 @@ import type {StoredMarketingAttribution} from '@src/types/onyx/MarketingAttribut import type {OnyxEntry} from 'react-native-onyx'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; type GoogleSignInProps = { @@ -42,7 +42,7 @@ function GoogleSignIn({isDesktopFlow = false, onPointerDown}: GoogleSignInProps) const styles = useThemeStyles(); const [preferredLocale] = useOnyx(ONYXKEYS.NVP_PREFERRED_LOCALE); const [marketingAttribution] = useOnyx(ONYXKEYS.MARKETING_ATTRIBUTION); - const loadScript = useCallback(() => { + const loadScript = () => { const google = window.google; if (google) { google.accounts.id.initialize({ @@ -65,7 +65,7 @@ function GoogleSignIn({isDesktopFlow = false, onPointerDown}: GoogleSignInProps) width: '300px', }); } - }, [preferredLocale, marketingAttribution]); + }; React.useEffect(() => { const script = document.createElement('script'); diff --git a/src/components/SignInButtons/IconButton.tsx b/src/components/SignInButtons/IconButton.tsx index f5e615e312c5..104e951cd225 100644 --- a/src/components/SignInButtons/IconButton.tsx +++ b/src/components/SignInButtons/IconButton.tsx @@ -11,7 +11,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; import type {ValueOf} from 'type-fest'; -import React, {useMemo} from 'react'; +import React from 'react'; type IconButtonProps = { onPress?: () => void; @@ -23,26 +23,22 @@ function IconButton({onPress = () => {}, provider}: IconButtonProps) { const styles = useThemeStyles(); const icons = useMemoizedLazyExpensifyIcons(['AppleLogo', 'GoogleLogo']); - const providerData = useMemo( - () => - ({ - [CONST.SIGN_IN_METHOD.APPLE]: { - icon: icons.AppleLogo, - accessibilityLabel: 'common.signInWithApple' as const, - }, - [CONST.SIGN_IN_METHOD.GOOGLE]: { - icon: icons.GoogleLogo, - accessibilityLabel: 'common.signInWithGoogle' as const, - }, - }) satisfies Record< - ValueOf, - { - icon: IconAsset; - accessibilityLabel: TranslationPaths; - } - >, - [icons.AppleLogo, icons.GoogleLogo], - ); + const providerData = { + [CONST.SIGN_IN_METHOD.APPLE]: { + icon: icons.AppleLogo, + accessibilityLabel: 'common.signInWithApple' as const, + }, + [CONST.SIGN_IN_METHOD.GOOGLE]: { + icon: icons.GoogleLogo, + accessibilityLabel: 'common.signInWithGoogle' as const, + }, + } satisfies Record< + ValueOf, + { + icon: IconAsset; + accessibilityLabel: TranslationPaths; + } + >; return ( { - onLayout?.(event); - - if (fixedNumItems) { - return; - } - - const totalHeight = event.nativeEvent.layout.height; - const newNumItems = Math.ceil(totalHeight / totalItemHeight); - if (newNumItems !== numItems) { - setNumItems(newNumItems); - } - }, - [fixedNumItems, numItems, onLayout, totalItemHeight], - ); + const handleLayout = (event: LayoutChangeEvent) => { + onLayout?.(event); + + if (fixedNumItems) { + return; + } - const skeletonViewItems = useMemo(() => { - const items = []; - for (let i = 0; i < numItems; i++) { - const opacity = gradientOpacityEnabled ? 1 - i / (numItems - 1) : 1; - const loader = ( - + {renderSkeletonItem({ + itemIndex: i, + })} + + ); + if (itemContainerStyle) { + const isLastItem = i === numItems - 1; + items.push( + - {renderSkeletonItem({itemIndex: i})} - + {loader} + , ); - - if (itemContainerStyle) { - const isLastItem = i === numItems - 1; - items.push( - - {loader} - , - ); - } else { - items.push(loader); - } + } else { + items.push(loader); } - return items; - }, [numItems, shouldAnimate, theme, themeStyles, renderSkeletonItem, gradientOpacityEnabled, itemViewHeight, itemViewStyle, speed, itemContainerStyle]); + } + const skeletonViewItems = items; return ( { + const skeletonItem = () => { return ( <> ); - }, [pageWidth]); + }; return ( { - return ( - <> - - - - - - - - ); - }, - [pageWidth], - ); + const skeletonItem = (args: {itemIndex: number}) => { + return ( + <> + + + + + + + + ); + }; return ( { + const hide = () => { // hide can only be called once if (hideHasBeenCalled.current) { return; @@ -59,7 +59,7 @@ function SplashScreenHider({onHide, shouldHideSplash}: SplashScreenHiderProps): ), ); }); - }, [opacity, onHide, scale]); + }; useEffect(() => { if (!shouldHideSplash) { diff --git a/src/components/SplashScreenHider/index.tsx b/src/components/SplashScreenHider/index.tsx index 8318404dccd3..69e6031e3521 100644 --- a/src/components/SplashScreenHider/index.tsx +++ b/src/components/SplashScreenHider/index.tsx @@ -1,13 +1,13 @@ import BootSplash from '@libs/BootSplash'; -import {useCallback, useEffect} from 'react'; +import {useEffect} from 'react'; import type {SplashScreenHiderProps, SplashScreenHiderReturnType} from './types'; function SplashScreenHider({onHide, shouldHideSplash}: SplashScreenHiderProps): SplashScreenHiderReturnType { - const hide = useCallback(() => { + const hide = () => { BootSplash.hide().then(() => onHide()); - }, [onHide]); + }; useEffect(() => { if (!shouldHideSplash) { diff --git a/src/components/StatePicker/StateSelectorModal.tsx b/src/components/StatePicker/StateSelectorModal.tsx index 7dfe8c26ed9d..b1d829676b60 100644 --- a/src/components/StatePicker/StateSelectorModal.tsx +++ b/src/components/StatePicker/StateSelectorModal.tsx @@ -17,7 +17,7 @@ import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; import {CONST as COMMON_CONST} from 'expensify-common'; -import React, {useMemo} from 'react'; +import React from 'react'; type State = keyof typeof COMMON_CONST.STATES; @@ -42,35 +42,27 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, const initialSelectedValue = useInitialSelection(currentState || undefined, {isVisible}); const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; - const countryStates = useMemo( - () => - Object.keys(COMMON_CONST.STATES).map((state) => { - const stateName = translate(`allStates.${state as State}.stateName`); - const stateISO = translate(`allStates.${state as State}.stateISO`); - return { - value: stateISO, - keyForList: stateISO, - text: stateName, - isSelected: currentState === stateISO, - searchValue: StringUtils.sanitizeString(`${stateISO}${stateName}`), - }; - }), - - [translate, currentState], - ); + const countryStates = Object.keys(COMMON_CONST.STATES).map((state) => { + const stateName = translate(`allStates.${state as State}.stateName`); + const stateISO = translate(`allStates.${state as State}.stateISO`); + return { + value: stateISO, + keyForList: stateISO, + text: stateName, + isSelected: currentState === stateISO, + searchValue: StringUtils.sanitizeString(`${stateISO}${stateName}`), + }; + }); const orderedCountryStates = moveInitialSelectionToTop(countryStates, initialSelectedValues); const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countryStates : orderedCountryStates); - const textInputOptions = useMemo( - () => ({ - headerMessage: debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '', - value: searchValue, - label: translate('common.search'), - onChangeText: setSearchValue, - }), - [debouncedSearchValue, searchResults.length, searchValue, setSearchValue, translate], - ); + const textInputOptions = { + headerMessage: debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '', + value: searchValue, + label: translate('common.search'), + onChangeText: setSearchValue, + }; return ( ): FormInputErrors => { - const errors = getFieldRequiredErrors(values, stepFields, translate); - - const street = getStringFormValue(values, inputFieldsIDs.street); - const streetError = getInvalidAddressErrorTranslationPath(street); - if (street && streetError) { - // @ts-expect-error type mismatch to be fixed - errors[inputFieldsIDs.street] = translate(streetError); - } - - const zipCode = getStringFormValue(values, inputFieldsIDs.zipCode); - const selectedCountry = (inputFieldsIDs.country ? getStringFormValue(values, inputFieldsIDs.country) : defaultValues.country) as Country | ''; - const shouldValidateSelectedCountryZip = shouldDisplayCountrySelector && !!inputFieldsIDs.country; - - if (zipCode && shouldValidateSelectedCountryZip && !isValidZipCodeForCountry(zipCode, selectedCountry)) { - const zipCodeSamples = getCountryZipRegexDetails(selectedCountry)?.samples; - // @ts-expect-error type mismatch to be fixed - errors[inputFieldsIDs.zipCode] = translate('privatePersonalDetails.error.incorrectZipFormat', zipCodeSamples); - } else if (zipCode && shouldValidateZipCodeFormat && !isValidZipCode(zipCode)) { - // @ts-expect-error type mismatch to be fixed - errors[inputFieldsIDs.zipCode] = translate('bankAccount.error.zipCode'); - } - - return errors; - }, - [defaultValues.country, inputFieldsIDs.country, inputFieldsIDs.street, inputFieldsIDs.zipCode, shouldDisplayCountrySelector, shouldValidateZipCodeFormat, stepFields, translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, stepFields, translate); + + const street = getStringFormValue(values, inputFieldsIDs.street); + const streetError = getInvalidAddressErrorTranslationPath(street); + if (street && streetError) { + // @ts-expect-error type mismatch to be fixed + errors[inputFieldsIDs.street] = translate(streetError); + } + + const zipCode = getStringFormValue(values, inputFieldsIDs.zipCode); + const selectedCountry = (inputFieldsIDs.country ? getStringFormValue(values, inputFieldsIDs.country) : defaultValues.country) as Country | ''; + const shouldValidateSelectedCountryZip = shouldDisplayCountrySelector && !!inputFieldsIDs.country; + + if (zipCode && shouldValidateSelectedCountryZip && !isValidZipCodeForCountry(zipCode, selectedCountry)) { + const zipCodeSamples = getCountryZipRegexDetails(selectedCountry)?.samples; + // @ts-expect-error type mismatch to be fixed + errors[inputFieldsIDs.zipCode] = translate('privatePersonalDetails.error.incorrectZipFormat', zipCodeSamples); + } else if (zipCode && shouldValidateZipCodeFormat && !isValidZipCode(zipCode)) { + // @ts-expect-error type mismatch to be fixed + errors[inputFieldsIDs.zipCode] = translate('bankAccount.error.zipCode'); + } + + return errors; + }; return ( ({ const [uploadedBankStatement, setUploadedBankStatement] = useState(bankStatementDefaultValue ?? []); const isDocusignStepRequired = requiresDocusignStep(currency); - const stepFields = useMemo(() => { - const fields: Array> = [ - inputIDs.authorizedToBindClientToAgreement, - inputIDs.provideTruthfulInformation, - inputIDs.agreeToTermsAndConditions, - inputIDs.consentToPrivacyNotice, - ]; - if (bankStatementInputID) { - fields.push(bankStatementInputID); - } - return fields; - }, [inputIDs.authorizedToBindClientToAgreement, inputIDs.provideTruthfulInformation, inputIDs.agreeToTermsAndConditions, inputIDs.consentToPrivacyNotice, bankStatementInputID]); + const fields: Array> = [ + inputIDs.authorizedToBindClientToAgreement, + inputIDs.provideTruthfulInformation, + inputIDs.agreeToTermsAndConditions, + inputIDs.consentToPrivacyNotice, + ]; + if (bankStatementInputID) { + fields.push(bankStatementInputID); + } + const stepFields = fields; const handleSelectBankStatement = (files: FileObject[]) => { if (!bankStatementInputID) { @@ -148,30 +146,27 @@ function AgreementsFullStep({ onBackButtonPress(); }; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, stepFields, translate); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, stepFields, translate); - if (Object.hasOwn(errors, inputIDs.authorizedToBindClientToAgreement)) { - errors[inputIDs.authorizedToBindClientToAgreement] = translate('agreementsStep.error.authorized'); - } + if (Object.hasOwn(errors, inputIDs.authorizedToBindClientToAgreement)) { + errors[inputIDs.authorizedToBindClientToAgreement] = translate('agreementsStep.error.authorized'); + } - if (Object.hasOwn(errors, inputIDs.provideTruthfulInformation)) { - errors[inputIDs.provideTruthfulInformation] = translate('agreementsStep.error.certify'); - } + if (Object.hasOwn(errors, inputIDs.provideTruthfulInformation)) { + errors[inputIDs.provideTruthfulInformation] = translate('agreementsStep.error.certify'); + } - if (Object.hasOwn(errors, inputIDs.agreeToTermsAndConditions)) { - errors[inputIDs.agreeToTermsAndConditions] = translate('common.error.acceptTerms'); - } + if (Object.hasOwn(errors, inputIDs.agreeToTermsAndConditions)) { + errors[inputIDs.agreeToTermsAndConditions] = translate('common.error.acceptTerms'); + } - if (Object.hasOwn(errors, inputIDs.consentToPrivacyNotice)) { - errors[inputIDs.consentToPrivacyNotice] = translate('agreementsStep.error.consent'); - } + if (Object.hasOwn(errors, inputIDs.consentToPrivacyNotice)) { + errors[inputIDs.consentToPrivacyNotice] = translate('agreementsStep.error.consent'); + } - return errors; - }, - [inputIDs.agreeToTermsAndConditions, inputIDs.authorizedToBindClientToAgreement, inputIDs.consentToPrivacyNotice, inputIDs.provideTruthfulInformation, stepFields, translate], - ); + return errors; + }; return ( = SubPageProps & ForwardedFSClassProps & { @@ -64,25 +64,22 @@ function DateOfBirthStepImpl({ const minDate = subYears(new Date(), CONST.DATE_BIRTH.MAX_AGE); const maxDate = subYears(new Date(), CONST.DATE_BIRTH.MIN_AGE_FOR_PAYMENT); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, stepFields, translate); - - const valuesToValidate = (values as Record)[dobInputID] as string; - if (valuesToValidate) { - if (!isValidPastDate(valuesToValidate) || !meetsMaximumAgeRequirement(valuesToValidate)) { - // @ts-expect-error type mismatch to be fixed - errors[dobInputID] = translate('bankAccount.error.dob'); - } else if (!meetsMinimumAgeRequirement(valuesToValidate)) { - // @ts-expect-error type mismatch to be fixed - errors[dobInputID] = translate('bankAccount.error.age'); - } + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, stepFields, translate); + + const valuesToValidate = (values as Record)[dobInputID] as string; + if (valuesToValidate) { + if (!isValidPastDate(valuesToValidate) || !meetsMaximumAgeRequirement(valuesToValidate)) { + // @ts-expect-error type mismatch to be fixed + errors[dobInputID] = translate('bankAccount.error.dob'); + } else if (!meetsMinimumAgeRequirement(valuesToValidate)) { + // @ts-expect-error type mismatch to be fixed + errors[dobInputID] = translate('bankAccount.error.age'); } + } - return errors; - }, - [dobInputID, stepFields, translate], - ); + return errors; + }; return ( = { /** Default value for file upload input */ @@ -61,12 +61,9 @@ function DocusignFullStepImpl({defaultValue, formID, inputID, isLoading, onBackB const [uploadedFiles, setUploadedFiles] = useState(defaultValue); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - return getFieldRequiredErrors(values, [inputID], translate); - }, - [inputID, translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => { + return getFieldRequiredErrors(values, [inputID], translate); + }; const handleSelectFile = (files: FileObject[]) => { setDraftValues(formID, {[inputID]: [...uploadedFiles, ...files]}); diff --git a/src/components/SubStepForms/FullNameStep.tsx b/src/components/SubStepForms/FullNameStep.tsx index d7da3de78a1b..29cb16d27b99 100644 --- a/src/components/SubStepForms/FullNameStep.tsx +++ b/src/components/SubStepForms/FullNameStep.tsx @@ -17,7 +17,7 @@ import HelpLinks from '@pages/ReimbursementAccount/USD/Requestor/PersonalInfo/He import CONST from '@src/CONST'; import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; type FullNameStepProps = SubPageProps & @@ -82,47 +82,44 @@ function FullNameStepImpl({ const {translate} = useLocalize(); const styles = useThemeStyles(); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, stepFields, translate); - - const firstName = (values as Record)[firstNameInputID] as string; - if (!isRequiredFulfilled(firstName)) { - // @ts-expect-error type mismatch to be fixed - errors[firstNameInputID] = translate('common.error.fieldRequired'); - } else if (!isValidLegalName(firstName)) { - // @ts-expect-error type mismatch to be fixed - errors[firstNameInputID] = translate('privatePersonalDetails.error.hasInvalidCharacter'); - } else if (firstName.length > CONST.LEGAL_NAME.MAX_LENGTH) { - // @ts-expect-error type mismatch to be fixed - errors[firstNameInputID] = translate('common.error.characterLimitExceedCounter', firstName.length, CONST.LEGAL_NAME.MAX_LENGTH); - } - - if (doesContainReservedWord(firstName, CONST.DISPLAY_NAME.RESERVED_NAMES)) { - // @ts-expect-error type mismatch to be fixed - errors[firstNameInputID] = translate('personalDetails.error.containsReservedWord'); - } - - const lastName = (values as Record)[lastNameInputID] as string; - if (!isRequiredFulfilled(lastName)) { - // @ts-expect-error type mismatch to be fixed - errors[lastNameInputID] = translate('common.error.fieldRequired'); - } else if (!isValidLegalName(lastName)) { - // @ts-expect-error type mismatch to be fixed - errors[lastNameInputID] = translate('privatePersonalDetails.error.hasInvalidCharacter'); - } else if (lastName.length > CONST.LEGAL_NAME.MAX_LENGTH) { - // @ts-expect-error type mismatch to be fixed - errors[lastNameInputID] = translate('common.error.characterLimitExceedCounter', lastName.length, CONST.LEGAL_NAME.MAX_LENGTH); - } - - if (doesContainReservedWord(lastName, CONST.DISPLAY_NAME.RESERVED_NAMES)) { - // @ts-expect-error type mismatch to be fixed - errors[lastNameInputID] = translate('personalDetails.error.containsReservedWord'); - } - return errors; - }, - [firstNameInputID, lastNameInputID, stepFields, translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, stepFields, translate); + + const firstName = (values as Record)[firstNameInputID] as string; + if (!isRequiredFulfilled(firstName)) { + // @ts-expect-error type mismatch to be fixed + errors[firstNameInputID] = translate('common.error.fieldRequired'); + } else if (!isValidLegalName(firstName)) { + // @ts-expect-error type mismatch to be fixed + errors[firstNameInputID] = translate('privatePersonalDetails.error.hasInvalidCharacter'); + } else if (firstName.length > CONST.LEGAL_NAME.MAX_LENGTH) { + // @ts-expect-error type mismatch to be fixed + errors[firstNameInputID] = translate('common.error.characterLimitExceedCounter', firstName.length, CONST.LEGAL_NAME.MAX_LENGTH); + } + + if (doesContainReservedWord(firstName, CONST.DISPLAY_NAME.RESERVED_NAMES)) { + // @ts-expect-error type mismatch to be fixed + errors[firstNameInputID] = translate('personalDetails.error.containsReservedWord'); + } + + const lastName = (values as Record)[lastNameInputID] as string; + if (!isRequiredFulfilled(lastName)) { + // @ts-expect-error type mismatch to be fixed + errors[lastNameInputID] = translate('common.error.fieldRequired'); + } else if (!isValidLegalName(lastName)) { + // @ts-expect-error type mismatch to be fixed + errors[lastNameInputID] = translate('privatePersonalDetails.error.hasInvalidCharacter'); + } else if (lastName.length > CONST.LEGAL_NAME.MAX_LENGTH) { + // @ts-expect-error type mismatch to be fixed + errors[lastNameInputID] = translate('common.error.characterLimitExceedCounter', lastName.length, CONST.LEGAL_NAME.MAX_LENGTH); + } + + if (doesContainReservedWord(lastName, CONST.DISPLAY_NAME.RESERVED_NAMES)) { + // @ts-expect-error type mismatch to be fixed + errors[lastNameInputID] = translate('personalDetails.error.containsReservedWord'); + } + return errors; + }; return ( = { inputID: FormOnyxKeys; @@ -52,12 +52,9 @@ function PushRowFieldsStepImpl({formID, formTitle, pushRowFields, onSubmit, isEd const styles = useThemeStyles(); const pushRowFieldsIDs = pushRowFields.map((field) => field.inputID); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - return getFieldRequiredErrors(values, pushRowFieldsIDs, translate); - }, - [pushRowFieldsIDs, translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => { + return getFieldRequiredErrors(values, pushRowFieldsIDs, translate); + }; return ( = SubPageProps & { @@ -52,23 +52,18 @@ function RegistrationNumberStepImpl({formID, onSubmit, inputID, defaultValue, is const internalInputRef = useRef(null); useDelayedAutoFocus(internalInputRef, shouldDelayAutoFocus); - const helpLink = useMemo(() => { - return CONST.REGISTRATION_NUMBER_HELP_URL[country as keyof typeof CONST.REGISTRATION_NUMBER_HELP_URL] ?? CONST.REGISTRATION_NUMBER_HELP_URL.EU; - }, [country]); + const helpLink = CONST.REGISTRATION_NUMBER_HELP_URL[country as keyof typeof CONST.REGISTRATION_NUMBER_HELP_URL] ?? CONST.REGISTRATION_NUMBER_HELP_URL.EU; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, [inputID], translate); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, [inputID], translate); - const registrationNumber = (values as Record)[inputID as string] as string; - if (registrationNumber && !isValidRegistrationNumber(registrationNumber, country)) { - (errors as Record)[inputID as string] = translate('businessInfoStep.error.registrationNumber'); - } + const registrationNumber = (values as Record)[inputID as string] as string; + if (registrationNumber && !isValidRegistrationNumber(registrationNumber, country)) { + (errors as Record)[inputID as string] = translate('businessInfoStep.error.registrationNumber'); + } - return errors; - }, - [country, inputID, translate], - ); + return errors; + }; return ( ( - () => [ - { - label: translate('common.yes'), - value: 'true', - }, - { - label: translate('common.no'), - value: 'false', - }, - ], - [translate], - ); + const options: Choice[] = [ + { + label: translate('common.yes'), + value: 'true', + }, + { + label: translate('common.no'), + value: 'false', + }, + ]; return ( { - if (isLocked) { - return `${accessibilityLabel}, ${translate('common.locked')}`; - } - return accessibilityLabel; - }, [accessibilityLabel, isLocked, translate]); + const enhancedAccessibilityLabel = isLocked ? `${accessibilityLabel}, ${translate('common.locked')}` : accessibilityLabel; return ( ({ const routesLength = tabs.length; - const defaultAffectedAnimatedTabs = useMemo(() => Array.from({length: routesLength}, (_v, i) => i), [routesLength]); + const defaultAffectedAnimatedTabs = Array.from({length: routesLength}, (_v, i) => i); const [affectedAnimatedTabs, setAffectedAnimatedTabs] = useState(defaultAffectedAnimatedTabs); const {containerRef} = useTabSelectorState(); diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 9fcdea4ffda6..11dea4e86fd6 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -25,7 +25,7 @@ import type {FlashListRef} from '@shopify/flash-list'; import type {ReactElement} from 'react'; import type {LayoutChangeEvent} from 'react-native'; -import React, {useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useImperativeHandle, useLayoutEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {TableListMetadata} from './buildTableListData'; @@ -367,18 +367,14 @@ function TableContent React.isValidElement(child) && child.type === TableBulkActionBar); const isBulkActionBarVisible = hasBulkActionBarChild && !shouldUseNarrowLayout && selectedKeys.length > 0; - const tableListMetadata = useMemo( - () => - getTableListMetadata({ - listHeaderElement, - listHeaderComponent: listProps.ListHeaderComponent, - hasColumnHeaderElement, - hasRows, - isColumnHeaderHiddenInNarrowLayout, - canStickColumnHeaderInScroller, - }), - [listHeaderElement, listProps.ListHeaderComponent, hasColumnHeaderElement, hasRows, isColumnHeaderHiddenInNarrowLayout, canStickColumnHeaderInScroller], - ); + const tableListMetadata = getTableListMetadata({ + listHeaderElement, + listHeaderComponent: listProps.ListHeaderComponent, + hasColumnHeaderElement, + hasRows, + isColumnHeaderHiddenInNarrowLayout, + canStickColumnHeaderInScroller, + }); /** * Exposes table control methods through the ref. * Uses a Proxy to also forward FlashList methods (like scrollToIndex). diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index 4f2d9a97fc86..11228b302e0b 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -12,7 +12,7 @@ import type {ListRenderItemInfo, ViewToken} from '@shopify/flash-list'; import type {StyleProp, ViewProps, ViewStyle} from 'react-native'; import {FlashList} from '@shopify/flash-list'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {StyleSheet, View} from 'react-native'; import type {TableData} from '.'; @@ -257,38 +257,25 @@ function TableBodyList({contentContainerStyle, emptyMessage, onLayout, style, .. return () => cancelAnimationFrame(frame); }, [hasActivatedStickyHeader, hasRows, isListLoaded, shouldRenderColumnHeaderAsStickyRow]); - const handleChangeStickyIndex: NonNullable = useCallback( - (current, previous) => { - setActiveStickyHeaderIndex((activeIndex) => (activeIndex === current ? activeIndex : current)); - onChangeStickyIndex?.(current, previous); - }, - [onChangeStickyIndex], - ); + const handleChangeStickyIndex: NonNullable = (current, previous) => { + setActiveStickyHeaderIndex((activeIndex) => (activeIndex === current ? activeIndex : current)); + onChangeStickyIndex?.(current, previous); + }; - const handleViewableItemsChanged: NonNullable = useCallback( - (info) => onViewableItemsChanged?.(getDataViewabilityInfo(info, tableListMetadata)), - [onViewableItemsChanged, tableListMetadata], - ); + const handleViewableItemsChanged: NonNullable = (info) => onViewableItemsChanged?.(getDataViewabilityInfo(info, tableListMetadata)); - const viewabilityConfigCallbackPairsForList = useMemo( - () => - viewabilityConfigCallbackPairs?.map((pair) => ({ - ...pair, - onViewableItemsChanged: pair.onViewableItemsChanged ? (info: ViewabilityInfo) => pair.onViewableItemsChanged?.(getDataViewabilityInfo(info, tableListMetadata)) : null, - })), - [tableListMetadata, viewabilityConfigCallbackPairs], - ); + const viewabilityConfigCallbackPairsForList = viewabilityConfigCallbackPairs?.map((pair) => ({ + ...pair, + onViewableItemsChanged: pair.onViewableItemsChanged ? (info: ViewabilityInfo) => pair.onViewableItemsChanged?.(getDataViewabilityInfo(info, tableListMetadata)) : null, + })); - const overrideItemLayoutForList: NonNullable = useCallback( - (layout, item, index, maxColumns, extraData) => { - if (getSyntheticRowKind(index, tableListMetadata) !== 'data') { - return; - } + const overrideItemLayoutForList: NonNullable = (layout, item, index, maxColumns, extraData) => { + if (getSyntheticRowKind(index, tableListMetadata) !== 'data') { + return; + } - overrideItemLayout?.(layout, item, getDataIndex(index, tableListMetadata), maxColumns, extraData); - }, - [overrideItemLayout, tableListMetadata], - ); + overrideItemLayout?.(layout, item, getDataIndex(index, tableListMetadata), maxColumns, extraData); + }; const initialScrollIndexForList = initialScrollIndex == null ? initialScrollIndex : getListIndex(initialScrollIndex, tableListMetadata); diff --git a/src/components/Table/middlewares/highlight.ts b/src/components/Table/middlewares/highlight.ts index 27f190b5d0e2..35a8c6a865d0 100644 --- a/src/components/Table/middlewares/highlight.ts +++ b/src/components/Table/middlewares/highlight.ts @@ -2,7 +2,7 @@ import type {TableData, TableRow} from '@components/Table/types'; import durationHighlightItem from '@libs/Navigation/helpers/getDurationHighlightItem'; -import {useCallback, useEffect, useRef, useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; import type {Middleware, MiddlewareHookResult} from './types'; @@ -15,28 +15,25 @@ function useHighlighting(): MiddlewareHookResult(null); const [highlightedRowKeys, setHighlightedRowKeys] = useState | null>(null); - const clearHighlightedRows = useCallback(() => { + const clearHighlightedRows = () => { setHighlightedRowKeys(null); highlightTimeoutRef.current = null; - }, []); + }; - const highlightItems: HighlightingMethods['highlightItems'] = useCallback( - (itemKeys) => { - if (highlightTimeoutRef.current) { - clearTimeout(highlightTimeoutRef.current); - highlightTimeoutRef.current = null; - } + const highlightItems: HighlightingMethods['highlightItems'] = (itemKeys) => { + if (highlightTimeoutRef.current) { + clearTimeout(highlightTimeoutRef.current); + highlightTimeoutRef.current = null; + } - if (!itemKeys.length) { - setHighlightedRowKeys(null); - return; - } + if (!itemKeys.length) { + setHighlightedRowKeys(null); + return; + } - setHighlightedRowKeys(new Set(itemKeys)); - highlightTimeoutRef.current = setTimeout(clearHighlightedRows, durationHighlightItem); - }, - [clearHighlightedRows], - ); + setHighlightedRowKeys(new Set(itemKeys)); + highlightTimeoutRef.current = setTimeout(clearHighlightedRows, durationHighlightItem); + }; useEffect(() => { return () => { diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index 1be2f4bd7a19..225d47275021 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -10,7 +10,7 @@ import {applyShiftRangeBatchToKeySet} from '@libs/shiftRangeSelection'; import type {Dispatch, SetStateAction} from 'react'; import {useIsFocused} from '@react-navigation/native'; -import {useCallback, useEffect, useState} from 'react'; +import {useEffect, useState} from 'react'; import type {MiddlewareHookResult} from './types'; @@ -107,13 +107,13 @@ export default function useSelection({ }); // The shift anchor deliberately survives clears — a vanished anchor re-resolves and stale deselects are no-ops, so the next shift+click still ranges from the last click. - const clearSelection = useCallback(() => { + const clearSelection = () => { onRowSelectionChange?.([]); - }, [onRowSelectionChange]); + }; // Disable selection mode when the Android hardware back button is pressed. A table that is always in selection mode // has none to leave, so the press has to fall through to navigation instead. - const androidBackButtonDisableSelectionMode = useCallback(() => { + const androidBackButtonDisableSelectionMode = () => { if (!isSelectionModeEnabled || shouldAlwaysEnableSelection) { return false; } @@ -121,7 +121,7 @@ export default function useSelection({ clearSelection(); setSelectionModeEnabled(false); return true; - }, [isSelectionModeEnabled, shouldAlwaysEnableSelection, clearSelection, setSelectionModeEnabled]); + }; useAndroidBackButtonHandler(androidBackButtonDisableSelectionMode); diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx index 5466a52bb3eb..19bbd4d0bf95 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx @@ -19,7 +19,7 @@ import type {Unit} from '@src/types/onyx/Policy'; import type {ListRenderItemInfo} from '@shopify/flash-list'; -import React, {useMemo} from 'react'; +import React from 'react'; import type {DistanceRateTableItemData} from './WorkspaceDistanceRatesTableRow'; @@ -154,15 +154,12 @@ function WorkspaceDistanceRatesTable({ ] : undefined; - const statusLabels = useMemo( - () => ({ - [CONST.CUSTOM_UNITS.RATE_STATUS.ACTIVE]: translate('workspace.distanceRates.statusActive'), - [CONST.CUSTOM_UNITS.RATE_STATUS.FUTURE]: translate('workspace.distanceRates.statusFuture'), - [CONST.CUSTOM_UNITS.RATE_STATUS.EXPIRED]: translate('workspace.distanceRates.statusExpired'), - [CONST.CUSTOM_UNITS.RATE_STATUS.INACTIVE]: translate('workspace.distanceRates.statusInactive'), - }), - [translate], - ); + const statusLabels = { + [CONST.CUSTOM_UNITS.RATE_STATUS.ACTIVE]: translate('workspace.distanceRates.statusActive'), + [CONST.CUSTOM_UNITS.RATE_STATUS.FUTURE]: translate('workspace.distanceRates.statusFuture'), + [CONST.CUSTOM_UNITS.RATE_STATUS.EXPIRED]: translate('workspace.distanceRates.statusExpired'), + [CONST.CUSTOM_UNITS.RATE_STATUS.INACTIVE]: translate('workspace.distanceRates.statusInactive'), + }; const renderItem = ({item, index}: ListRenderItemInfo) => ( policyRecentlyUsedTags?.[tagListName] ?? [], [policyRecentlyUsedTags, tagListName]); + const policyRecentlyUsedTagsList = policyRecentlyUsedTags?.[tagListName] ?? []; const policyTagList = getTagList(policyTags, tagListIndex); const selectedOptions = getSelectedOptions(selectedTag); // Merge any orphaned tag names into the policy's tag map so they appear as selectable options. - const policyTagsWithAdditions = useMemo(() => { - const baseTags = policyTagList.tags ?? {}; - if (!additionalTagsToInclude?.length) { - return baseTags; - } + let policyTagsWithAdditions: PolicyTags; + const baseTags = policyTagList.tags ?? {}; + if (!additionalTagsToInclude?.length) { + policyTagsWithAdditions = baseTags; + } else { const extras: PolicyTags = {}; for (const name of additionalTagsToInclude) { if (!name || baseTags[name]) { continue; } - extras[name] = {name, enabled: true} as PolicyTag; + extras[name] = { + name, + enabled: true, + } as PolicyTag; } - return Object.keys(extras).length ? {...baseTags, ...extras} : baseTags; - }, [policyTagList.tags, additionalTagsToInclude]); + policyTagsWithAdditions = Object.keys(extras).length + ? { + ...baseTags, + ...extras, + } + : baseTags; + } const getEnabledTags = (): PolicyTags | Array => { if (!shouldShowDisabledAndSelectedOption && !hasDependentTags) { diff --git a/src/components/TestDrive/TestDriveDemo.tsx b/src/components/TestDrive/TestDriveDemo.tsx index 4d4af8c7558d..6dc91171ed03 100644 --- a/src/components/TestDrive/TestDriveDemo.tsx +++ b/src/components/TestDrive/TestDriveDemo.tsx @@ -28,7 +28,7 @@ import ROUTES from '@src/ROUTES'; import {delegateEmailSelector} from '@selectors/Account'; import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import TestDriveBanner from './TestDriveBanner'; @@ -116,7 +116,7 @@ function TestDriveDemo() { return () => handle.cancel(); }, []); - const closeModal = useCallback(() => { + const closeModal = () => { setIsVisible(false); TransitionTracker.runAfterTransitions({ callback: () => { @@ -132,7 +132,7 @@ function TestDriveDemo() { }, waitForUpcomingTransition: true, }); - }, [onboardingReport]); + }; return ( diff --git a/src/components/Text.tsx b/src/components/Text.tsx index 7dc28542305e..338558c70425 100644 --- a/src/components/Text.tsx +++ b/src/components/Text.tsx @@ -16,7 +16,7 @@ import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {ComponentRef, ForwardedRef} from 'react'; import type {TextProps as RNTextProps, TextStyle} from 'react-native'; -import React, {useContext, useMemo} from 'react'; +import React, {useContext} from 'react'; // eslint-disable-next-line no-restricted-imports import {Platform, Text as RNText, StyleSheet} from 'react-native'; @@ -73,17 +73,13 @@ function Text({ componentStyle.lineHeight = variables.fontSizeNormalHeight; } - const isOnlyCustomEmoji = useMemo(() => { - if (typeof children === 'string') { - return containsOnlyCustomEmoji(children.replace(CONST.UNICODE.LTR, '')); - } - if (Array.isArray(children)) { - return children.every((child) => { - return child === null || child === undefined || (typeof child === 'string' && containsOnlyCustomEmoji(child)); - }); - } - return false; - }, [children]); + const isOnlyCustomEmoji = + typeof children === 'string' + ? containsOnlyCustomEmoji(children.replace(CONST.UNICODE.LTR, '')) + : Array.isArray(children) && + children.every((child) => { + return child === null || child === undefined || (typeof child === 'string' && containsOnlyCustomEmoji(child)); + }); if (isOnlyCustomEmoji) { componentStyle.fontFamily = FontUtils.fontFamily.single.CUSTOM_EMOJI_FONT?.fontFamily; diff --git a/src/components/TextBlock.tsx b/src/components/TextBlock.tsx index 061798680337..64637669ad1f 100644 --- a/src/components/TextBlock.tsx +++ b/src/components/TextBlock.tsx @@ -4,7 +4,7 @@ import type {StyleProp, TextStyle} from 'react-native'; * TextBlock component splits a given text into individual words and displays * each word within a Text component. */ -import React, {memo, useMemo} from 'react'; +import React from 'react'; import Text from './Text'; @@ -19,7 +19,7 @@ type TextBlockProps = { }; function TextBlock({color, textStyles, text}: TextBlockProps) { - const words = useMemo(() => text.match(/(\S+\s*)/g) ?? [], [text]); + const words = text.match(/(\S+\s*)/g) ?? []; return ( <> @@ -36,4 +36,4 @@ function TextBlock({color, textStyles, text}: TextBlockProps) { ); } -export default memo(TextBlock); +export default TextBlock; diff --git a/src/components/TextInput/TextInputLabel/index.tsx b/src/components/TextInput/TextInputLabel/index.tsx index e0c62c150386..c1e146dd051e 100644 --- a/src/components/TextInput/TextInputLabel/index.tsx +++ b/src/components/TextInput/TextInputLabel/index.tsx @@ -46,4 +46,4 @@ function TextInputLabel({for: inputId = '', label, labelTranslateY, labelScale, TextInputLabel.displayName = 'TextInputLabel'; -export default React.memo(TextInputLabel); +export default TextInputLabel; diff --git a/src/components/TextLinkBlock.tsx b/src/components/TextLinkBlock.tsx index 1b522180fdde..c76886697a53 100644 --- a/src/components/TextLinkBlock.tsx +++ b/src/components/TextLinkBlock.tsx @@ -11,7 +11,7 @@ import type {StyleProp, TextStyle} from 'react-native'; * TextLinkBlock component splits a given text into individual words and displays * each word within a TextLink component so the link text wraps naturally. */ -import React, {memo, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import type {LinkProps, PressProps} from './TextLink'; @@ -32,7 +32,7 @@ type TextLinkBlockProps = (LinkProps | PressProps) & { }; function TextLinkBlock({text, style, prefixIcon, ...rest}: TextLinkBlockProps) { - const words = useMemo(() => text.match(/(\S+\s*)/g) ?? [], [text]); + const words = text.match(/(\S+\s*)/g) ?? []; const {environmentURL} = useEnvironment(); const styles = useThemeStyles(); @@ -72,4 +72,4 @@ function TextLinkBlock({text, style, prefixIcon, ...rest}: TextLinkBlockProps) { ); } -export default memo(TextLinkBlock); +export default TextLinkBlock; diff --git a/src/components/TextPicker/TextSelectorModal.tsx b/src/components/TextPicker/TextSelectorModal.tsx index 9e1ede5d10ec..c8e841622dca 100644 --- a/src/components/TextPicker/TextSelectorModal.tsx +++ b/src/components/TextPicker/TextSelectorModal.tsx @@ -58,36 +58,33 @@ function TextSelectorModal({ inputRef.current = ref; }; - const hide = useCallback(() => { + const hide = () => { setIsClosing(true); onClose(); if (shouldClearOnClose) { setValue(''); } - }, [onClose, shouldClearOnClose]); + }; - const validate = useCallback( - (values: FormOnyxValues) => { - let errors: FormInputErrors = {}; - const formValue = values[rest.inputID]; + const validate = (values: FormOnyxValues) => { + let errors: FormInputErrors = {}; + const formValue = values[rest.inputID]; - if (required) { - errors = getFieldRequiredErrors(values, [rest.inputID], translate); - } + if (required) { + errors = getFieldRequiredErrors(values, [rest.inputID], translate); + } - if (formValue.length > maxLength) { - errors[rest.inputID] = translate('common.error.characterLimitExceedCounter', formValue.length, maxLength); - } + if (formValue.length > maxLength) { + errors[rest.inputID] = translate('common.error.characterLimitExceedCounter', formValue.length, maxLength); + } - if (customValidate) { - const customErrors = customValidate(values); - errors = {...errors, ...customErrors}; - } + if (customValidate) { + const customErrors = customValidate(values); + errors = {...errors, ...customErrors}; + } - return errors; - }, - [maxLength, rest.inputID, required, translate, customValidate], - ); + return errors; + }; // In TextPicker, when the modal is hidden, it is not completely unmounted, so when it is shown again, the currentValue is not updated with the value prop. // Therefore, we need to update the currentValue with the value prop when the modal is shown. This is done once when the modal is shown again. @@ -125,19 +122,16 @@ function TextSelectorModal({ }, [isVisible, autoGrowHeight]), ); - const handleSubmit = useCallback( - (data: FormOnyxValues) => { - const submittedValue = data[rest.inputID] ?? ''; + const handleSubmit = (data: FormOnyxValues) => { + const submittedValue = data[rest.inputID] ?? ''; - if (required && !submittedValue.trim()) { - return; - } + if (required && !submittedValue.trim()) { + return; + } - Keyboard.dismiss(); - onValueSelected?.(submittedValue); - }, - [onValueSelected, rest.inputID, required], - ); + Keyboard.dismiss(); + onValueSelected?.(submittedValue); + }; return ( illustrations[themePreference], [themePreference]); + const themeIllustrations = illustrations[themePreference]; return {children}; } diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx index 009457ac3312..65689ccf108b 100644 --- a/src/components/ThemeProvider.tsx +++ b/src/components/ThemeProvider.tsx @@ -8,7 +8,7 @@ import themes from '@styles/theme'; import ThemeContext from '@styles/theme/context/ThemeContext'; import type {ThemePreferenceWithoutSystem} from '@styles/theme/types'; -import React, {useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; type ThemeProviderProps = React.PropsWithChildren & { theme?: ThemePreferenceWithoutSystem; @@ -22,7 +22,7 @@ function ThemeProvider({children, theme: staticThemePreference}: ThemeProviderPr setDebouncedTheme(themePreference); }, [setDebouncedTheme, themePreference]); - const theme = useMemo(() => themes[debouncedTheme], [debouncedTheme]); + const theme = themes[debouncedTheme]; useEffect(() => { /* diff --git a/src/components/ThreeDotsMenu/index.tsx b/src/components/ThreeDotsMenu/index.tsx index 955ba916baf0..e4fd505d231e 100644 --- a/src/components/ThreeDotsMenu/index.tsx +++ b/src/components/ThreeDotsMenu/index.tsx @@ -25,7 +25,7 @@ import KeyboardUtils from '@src/utils/keyboard'; import type {ComponentRef} from 'react'; -import React, {useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState} from 'react'; +import React, {useEffect, useImperativeHandle, useLayoutEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type ThreeDotsMenuProps from './types'; @@ -76,16 +76,16 @@ function ThreeDotsMenu({ setPopupMenuVisible(true); }; - const hidePopoverMenu = useCallback((selectedItem?: PopoverMenuItem) => { + const hidePopoverMenu = (selectedItem?: PopoverMenuItem) => { if (selectedItem?.shouldKeepModalOpen || selectedItem?.shouldCloseModalOnSelect === false) { return; } setPopupMenuVisible(false); - }, []); + }; const {calculatePopoverPosition} = usePopoverPosition(); - const calculateAndSetThreeDotsMenuPosition = useCallback(() => calculatePopoverPosition(buttonRef, anchorAlignment), [anchorAlignment, calculatePopoverPosition]); + const calculateAndSetThreeDotsMenuPosition = () => calculatePopoverPosition(buttonRef, anchorAlignment); const getMenuPosition = shouldSelfPosition ? calculateAndSetThreeDotsMenuPosition : getAnchorPosition; diff --git a/src/components/Tooltip/BaseTooltip/index.tsx b/src/components/Tooltip/BaseTooltip/index.tsx index 47bc502d395f..65a2498babb1 100644 --- a/src/components/Tooltip/BaseTooltip/index.tsx +++ b/src/components/Tooltip/BaseTooltip/index.tsx @@ -8,7 +8,7 @@ import type {HTMLAttributes} from 'react'; import type {LayoutRectangle} from 'react-native'; import {BoundsObserver} from '@react-ng/bounds-observer'; -import React, {memo, useCallback, useRef} from 'react'; +import React, {useRef} from 'react'; type MouseEvents = { onMouseEnter: (e: React.MouseEvent) => void | undefined; @@ -57,13 +57,13 @@ function Tooltip({children, shouldHandleScroll = false, isFocused = true, ref, . const target = useRef(null); const initialMousePosition = useRef({x: 0, y: 0}); - const updateTargetAndMousePosition = useCallback((e: React.MouseEvent) => { + const updateTargetAndMousePosition = (e: React.MouseEvent) => { if (!(e.currentTarget instanceof HTMLElement)) { return; } target.current = e.currentTarget; initialMousePosition.current = {x: e.clientX, y: e.clientY}; - }, []); + }; /** * Get the tooltip bounding rectangle @@ -79,16 +79,13 @@ function Tooltip({children, shouldHandleScroll = false, isFocused = true, ref, . return chooseBoundingBox(target.current, initialMousePosition.current.x, initialMousePosition.current.y); }; - const updateTargetPositionOnMouseEnter = useCallback( - (e: React.MouseEvent) => { - updateTargetAndMousePosition(e); - if (React.isValidElement(children)) { - const onMouseEnter = (children.props as MouseEvents).onMouseEnter; - onMouseEnter?.(e); - } - }, - [children, updateTargetAndMousePosition], - ); + const updateTargetPositionOnMouseEnter = (e: React.MouseEvent) => { + updateTargetAndMousePosition(e); + if (React.isValidElement(children)) { + const onMouseEnter = (children.props as MouseEvents).onMouseEnter; + onMouseEnter?.(e); + } + }; // Skip the tooltip and return the children if the device does not support hovering if (!deviceHasHoverSupport) { @@ -134,4 +131,4 @@ function Tooltip({children, shouldHandleScroll = false, isFocused = true, ref, . ); } -export default memo(Tooltip); +export default Tooltip; diff --git a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx index 2e7d4b889bce..9401b289cf60 100644 --- a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx +++ b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import type {HostInstance, LayoutRectangle, NativeSyntheticEvent} from 'react-native'; import {NavigationContext, useIsFocused} from '@react-navigation/native'; -import React, {memo, useCallback, useContext, useEffect, useEffectEvent, useLayoutEffect, useRef, useState} from 'react'; +import React, {useContext, useEffect, useEffectEvent, useLayoutEffect, useRef, useState} from 'react'; import {DeviceEventEmitter, Dimensions} from 'react-native'; import measureTooltipCoordinate, {getTooltipCoordinates} from './measureTooltipCoordinate'; @@ -50,7 +50,7 @@ function BaseEducationalTooltip({ const shouldSuppressTooltip = !isFocused && shouldHideOnNavigate; - const renderTooltip = useCallback(() => { + const renderTooltip = () => { if (!tooltipElementRef.current || !genericTooltipStateRef.current || shouldSuppressTooltip) { return; } @@ -98,7 +98,7 @@ function BaseEducationalTooltip({ showTooltip(); } }); - }, [contentHeaderHeight, insets.top, insets.bottom, insets.left, shouldHideOnScroll, shouldShowTooltip, shouldSuppressTooltip]); + }; useEffect(() => { if (!genericTooltipStateRef.current || !shouldRender) { @@ -117,21 +117,18 @@ function BaseEducationalTooltip({ } }, [isResizing, renderTooltip, shouldRender, uniqueID]); - const setTooltipPosition = useCallback( - (isScrolling: boolean) => { - if (!shouldHideOnScroll || !genericTooltipStateRef.current) { - return; - } + const setTooltipPosition = (isScrolling: boolean) => { + if (!shouldHideOnScroll || !genericTooltipStateRef.current) { + return; + } - const {hideTooltip} = genericTooltipStateRef.current; - if (isScrolling) { - hideTooltip(); - } else { - renderTooltip(); - } - }, - [renderTooltip, shouldHideOnScroll], - ); + const {hideTooltip} = genericTooltipStateRef.current; + if (isScrolling) { + hideTooltip(); + } else { + renderTooltip(); + } + }; useLayoutEffect(() => { if (!shouldRender || !shouldHideOnScroll) { @@ -237,4 +234,4 @@ function BaseEducationalTooltip({ ); } -export default memo(BaseEducationalTooltip); +export default BaseEducationalTooltip; diff --git a/src/components/Tooltip/GenericTooltip.tsx b/src/components/Tooltip/GenericTooltip.tsx index 03b0011e48f7..30b8ec359380 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, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {cancelAnimation, useSharedValue, withDelay, withTiming} from 'react-native-reanimated'; import type {GenericTooltipProps} from './types'; @@ -206,4 +206,4 @@ function GenericTooltip({ ); } -export default memo(GenericTooltip); +export default GenericTooltip; diff --git a/src/components/TransactionItemRow/DataCells/ChatBubbleCell.tsx b/src/components/TransactionItemRow/DataCells/ChatBubbleCell.tsx index 9a5a9046a8f1..c4b0b46f4c59 100644 --- a/src/components/TransactionItemRow/DataCells/ChatBubbleCell.tsx +++ b/src/components/TransactionItemRow/DataCells/ChatBubbleCell.tsx @@ -22,7 +22,7 @@ import type Transaction from '@src/types/onyx/Transaction'; import type {ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; const isReportUnread = ({lastReadTime = '', lastVisibleActionCreated = '', lastMentionedTime = ''}: Report): boolean => @@ -46,13 +46,10 @@ function ChatBubbleCell({transaction, containerStyles, isInSingleTransactionRepo const transactionReport = isInSingleTransactionReport ? parentReport : childReport; - const threadMessages = useMemo( - () => ({ - count: iouReportAction?.childVisibleActionCount ?? 0, - isUnread: isChatThread(transactionReport) && isReportUnread(transactionReport), - }), - [iouReportAction?.childVisibleActionCount, transactionReport], - ); + const threadMessages = { + count: iouReportAction?.childVisibleActionCount ?? 0, + isUnread: isChatThread(transactionReport) && isReportUnread(transactionReport), + }; const StyleUtils = useStyleUtils(); const icons = useMemoizedLazyExpensifyIcons(['ChatBubbleCounter']); diff --git a/src/components/TransactionItemRow/DataCells/MerchantCell.tsx b/src/components/TransactionItemRow/DataCells/MerchantCell.tsx index e24ff67ef2b1..6e93d4545d18 100644 --- a/src/components/TransactionItemRow/DataCells/MerchantCell.tsx +++ b/src/components/TransactionItemRow/DataCells/MerchantCell.tsx @@ -17,7 +17,7 @@ import CONST from '@src/CONST'; import type {ComponentRef} from 'react'; import type {TextInput as RNTextInput} from 'react-native'; -import React, {useMemo, useRef} from 'react'; +import React, {useRef} from 'react'; type MerchantOrDescriptionCellProps = { merchantOrDescription: string; @@ -31,12 +31,7 @@ function MerchantOrDescriptionCell({merchantOrDescription, shouldShowTooltip, sh const {translate} = useLocalize(); const inputRef = useRef | null>(null); - const text = useMemo(() => { - if (!isDescription) { - return merchantOrDescription; - } - return StringUtils.lineBreaksToSpaces(Parser.htmlToText(merchantOrDescription)); - }, [merchantOrDescription, isDescription]); + const text = !isDescription ? merchantOrDescription : StringUtils.lineBreaksToSpaces(Parser.htmlToText(merchantOrDescription)); // Normalize merchant values for save/equality checks (descriptions can keep whitespace) const getNormalizedValue = (value: string) => (isDescription ? value : value.trim()); diff --git a/src/components/TransactionItemRow/ReceiptPreview/index.tsx b/src/components/TransactionItemRow/ReceiptPreview/index.tsx index 917b48fdf976..3b1bf09d3603 100644 --- a/src/components/TransactionItemRow/ReceiptPreview/index.tsx +++ b/src/components/TransactionItemRow/ReceiptPreview/index.tsx @@ -20,7 +20,7 @@ import type {ReceiptSource} from '@src/types/onyx/Transaction'; import type {LayoutChangeEvent} from 'react-native'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import ReactDOM from 'react-dom'; import {StyleSheet, View} from 'react-native'; import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; @@ -79,25 +79,19 @@ function ReceiptPreview({source, hovered, isEReceipt = false, transactionItem, a setEReceiptScaleFactor(width / variables.eReceiptBGHWidth); }; - const updateImageAspectRatio = useCallback( - (width: number, height: number) => { - if (!source) { - return; - } + const updateImageAspectRatio = (width: number, height: number) => { + if (!source) { + return; + } - setImageAspectRatio(height ? width / height : 'auto'); - }, - [source], - ); + setImageAspectRatio(height ? width / height : 'auto'); + }; - const handleLoad = useCallback( - (e: ImageOnLoadEvent) => { - const {width, height} = e.nativeEvent; - updateImageAspectRatio(width, height); - setIsLoading(false); - }, - [updateImageAspectRatio], - ); + const handleLoad = (e: ImageOnLoadEvent) => { + const {width, height} = e.nativeEvent; + updateImageAspectRatio(width, height); + setIsLoading(false); + }; const handleError = () => { setIsLoading(false); diff --git a/src/components/TrialPaymentReminderModalManager.tsx b/src/components/TrialPaymentReminderModalManager.tsx index 84bb8794b27c..f99daed1089e 100644 --- a/src/components/TrialPaymentReminderModalManager.tsx +++ b/src/components/TrialPaymentReminderModalManager.tsx @@ -10,7 +10,7 @@ import navigateToSubscriptionPayment from '@pages/home/common/navigateToSubscrip import ONYXKEYS from '@src/ONYXKEYS'; -import React, {useCallback, useState} from 'react'; +import React, {useState} from 'react'; import TrialPaymentReminderModal from './TrialPaymentReminderModal'; @@ -37,17 +37,17 @@ function TrialPaymentReminderModalManager() { setIsModalOpen(false); } - const handleClose = useCallback(() => { + const handleClose = () => { setIsModalOpen(false); dismiss(); - }, [dismiss]); + }; - const handleAddPaymentCard = useCallback(() => { + const handleAddPaymentCard = () => { setIsModalOpen(false); dismiss(); // Adding a payment card is web-only; on native this routes to the subscription page instead. navigateToSubscriptionPayment(); - }, [dismiss]); + }; if (!currentVariation) { return null; diff --git a/src/components/UnitPicker.tsx b/src/components/UnitPicker.tsx index 248ed09af25f..34c9d30925a9 100644 --- a/src/components/UnitPicker.tsx +++ b/src/components/UnitPicker.tsx @@ -6,7 +6,7 @@ import CONST from '@src/CONST'; import type {Unit} from '@src/types/onyx/Policy'; import {Str} from 'expensify-common'; -import React, {useMemo} from 'react'; +import React from 'react'; import SelectionList from './SelectionList'; import SingleSelectListItem from './SelectionList/ListItem/SingleSelectListItem'; @@ -28,16 +28,12 @@ const units = [CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, CONST.CUSTOM_UNITS.D function UnitPicker({defaultValue, onOptionSelected}: UnitPickerProps) { const {translate} = useLocalize(); - const unitOptions = useMemo( - () => - units.map((unit) => ({ - value: unit as Unit, - text: Str.recapitalize(translate(getUnitTranslationKey(unit))), - keyForList: unit, - isSelected: defaultValue === unit, - })), - [defaultValue, translate], - ); + const unitOptions = units.map((unit) => ({ + value: unit as Unit, + text: Str.recapitalize(translate(getUnitTranslationKey(unit))), + keyForList: unit, + isSelected: defaultValue === unit, + })); return ( ( - - - {icon ? ( - - ) : ( - - )} - - {title} - {subtitle} + const renderTooltipContent = () => ( + + + {icon ? ( + + ) : ( + + )} - ), - - [ - styles.alignItemsCenter, - styles.ph2, - styles.pv2, - styles.emptyAvatar, - styles.actionAvatar, - styles.mt2, - styles.textMicroBold, - styles.textReactionSenders, - styles.textAlignCenter, - styles.textMicro, - styles.fontColorReactionLabel, - styles.breakWord, - icon, - userAvatar, - userAccountID, - userLogin, - title, - subtitle, - ], + {title} + {subtitle} + ); if (!icon && !userDisplayName && !userLogin) { diff --git a/src/components/ValidateCode/ValidateCodeModal.tsx b/src/components/ValidateCode/ValidateCodeModal.tsx index 7a86dee21770..fa50cdda3579 100644 --- a/src/components/ValidateCode/ValidateCodeModal.tsx +++ b/src/components/ValidateCode/ValidateCodeModal.tsx @@ -19,7 +19,7 @@ import variables from '@styles/variables'; import ONYXKEYS from '@src/ONYXKEYS'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; type ValidateCodeModalProps = { @@ -35,7 +35,7 @@ function ValidateCodeModal({code, accountID}: ValidateCodeModalProps) { const illustrations = useMemoizedLazyIllustrations(['ValidateCode']); const [session] = useOnyx(ONYXKEYS.SESSION); const [preferredLocale] = useOnyx(ONYXKEYS.NVP_PREFERRED_LOCALE); - const signInHere = useCallback(() => signInWithValidateCode(accountID, code, preferredLocale), [accountID, code, preferredLocale]); + const signInHere = () => signInWithValidateCode(accountID, code, preferredLocale); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const isValidCode = isValidValidateCode(code); diff --git a/src/components/ValidateCodeActionModal/ValidateCodeActionContent.tsx b/src/components/ValidateCodeActionModal/ValidateCodeActionContent.tsx index 423f0665bb49..5d175dbe07ee 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeActionContent.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeActionContent.tsx @@ -10,7 +10,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; import type {ValidateCodeActionContentProps} from './type'; @@ -62,10 +62,10 @@ function ValidateCodeActionContent({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [sendValidateCode, validateCodeActionMetadata]); - const hide = useCallback(() => { + const hide = () => { clearError(); onClose?.(); - }, [onClose, clearError]); + }; return ( (null); const isFirstCountdownRunRef = useRef(true); - const clearDefaultValidationCodeError = useCallback(() => { + const clearDefaultValidationCodeError = () => { // Clear "Failed to send validateCode" error if (isEmptyObject(defaultValidateCodeError)) { return; } clearValidateCodeActionError('actionVerified'); - }, [defaultValidateCodeError]); + }; useImperativeHandle(ref, () => ({ focus() { @@ -263,26 +263,23 @@ function BaseValidateCodeForm({ /** * Handle text input and clear formError upon text change */ - const onTextInput = useCallback( - (text: string) => { - setValidateCode(text); - setFormError({}); + const onTextInput = (text: string) => { + setValidateCode(text); + setFormError({}); - if (!isEmptyObject(validateError) || !isEmptyObject(latestValidateCodeError)) { - // Clear flow specific error - clearError(); + if (!isEmptyObject(validateError) || !isEmptyObject(latestValidateCodeError)) { + // Clear flow specific error + clearError(); - // Clear "incorrect validateCode code" error - clearValidateCodeActionError(validateCodeActionErrorField); - } - }, - [validateError, clearError, latestValidateCodeError, validateCodeActionErrorField], - ); + // Clear "incorrect validateCode code" error + clearValidateCodeActionError(validateCodeActionErrorField); + } + }; /** * Check that all the form fields are valid, then trigger the submit callback */ - const validateAndSubmitForm = useCallback(() => { + const validateAndSubmitForm = () => { // Clear flow specific error clearError(); @@ -303,23 +300,20 @@ function BaseValidateCodeForm({ setFormError({}); handleSubmitForm(validateCode); - }, [validateCode, handleSubmitForm, validateCodeActionErrorField, clearError, clearDefaultValidationCodeError]); + }; - const errorText = useMemo(() => { - if (!canShowError) { - return ''; - } - if (formError?.validateCode) { - return translate(formError?.validateCode); - } - return getLatestErrorMessage(account ?? {}); - }, [canShowError, formError?.validateCode, account, translate]); + let errorText; + if (!canShowError) { + errorText = ''; + } else { + errorText = formError?.validateCode ? translate(formError?.validateCode) : getLatestErrorMessage(account ?? {}); + } const shouldShowTimer = isCountdownRunning && !isOffline; - const handleCountdownFinish = useCallback(() => { + const handleCountdownFinish = () => { setIsCountdownRunning(false); - }, []); + }; // latestValidateCodeError only holds an error related to bad validateCode // while validateError holds flow-specific errors diff --git a/src/components/ValuePicker/ValueSelectionList.tsx b/src/components/ValuePicker/ValueSelectionList.tsx index 8954d74afef1..22433e7055c4 100644 --- a/src/components/ValuePicker/ValueSelectionList.tsx +++ b/src/components/ValuePicker/ValueSelectionList.tsx @@ -10,7 +10,7 @@ import tokenizedSearch from '@libs/tokenizedSearch'; import CONST from '@src/CONST'; -import React, {useMemo} from 'react'; +import React from 'react'; import type {ValueSelectionListProps} from './types'; @@ -27,22 +27,22 @@ function ValueSelectionList({ const {translate} = useLocalize(); const initialSelectedValue = useInitialSelection(selectedItem?.value ? selectedItem.value : undefined, isVisible === undefined ? {resetOnFocus: true} : {isVisible}); - const options = useMemo(() => { - const mappedOptions = items.map((item) => ({ - value: item.value ?? '', - alternateText: item.description, - alternateTextComponent: item.alternateTextComponent, - text: item.label ?? '', - keyForList: item.value ?? '', - isDisabled: item.isDisabled, - rightElement: item.rightElement, - shouldHideSelectionButton: item.shouldHideSelectionButton, - titleStyles: item.titleStyles, - })); - const orderedOptions = moveInitialSelectionToTop(mappedOptions, initialSelectedValue ? [initialSelectedValue] : []); - - return orderedOptions.map((item) => ({...item, isSelected: item.value === selectedItem?.value})); - }, [initialSelectedValue, items, selectedItem?.value]); + const mappedOptions = items.map((item) => ({ + value: item.value ?? '', + alternateText: item.description, + alternateTextComponent: item.alternateTextComponent, + text: item.label ?? '', + keyForList: item.value ?? '', + isDisabled: item.isDisabled, + rightElement: item.rightElement, + shouldHideSelectionButton: item.shouldHideSelectionButton, + titleStyles: item.titleStyles, + })); + const orderedOptions = moveInitialSelectionToTop(mappedOptions, initialSelectedValue ? [initialSelectedValue] : []); + const options = orderedOptions.map((item) => ({ + ...item, + isSelected: item.value === selectedItem?.value, + })); const [searchValue, setSearchValue, filteredOptions] = useSearchResults(options, (option, searchInput) => tokenizedSearch([option], searchInput, () => [option.text]).length > 0); diff --git a/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx b/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx index 3e1d2ec12f23..685ae04c8ca3 100644 --- a/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx +++ b/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx @@ -14,7 +14,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; import type {PanGestureEvent} from 'react-native-gesture-handler'; -import React, {memo, useCallback, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import {GestureDetector, usePanGesture} from 'react-native-gesture-handler'; import Animated, {useAnimatedStyle, useDerivedValue} from 'react-native-reanimated'; @@ -47,17 +47,14 @@ function VolumeButton({style, small = false}: VolumeButtonProps) { const [volumeIcon, setVolumeIcon] = useState({icon: getVolumeIcon(expensifyIcons, volume.get())}); const [isSliderBeingUsed, setIsSliderBeingUsed] = useState(false); - const onSliderLayout = useCallback((event: LayoutChangeEvent) => { + const onSliderLayout = (event: LayoutChangeEvent) => { setSliderHeight(event.nativeEvent.layout.height); - }, []); + }; - const changeVolumeOnPan = useCallback( - (event: PanGestureEvent) => { - const val = roundToTwoDecimalPlaces(1 - event.y / sliderHeight); - volume.set(clamp(val, 0, 1)); - }, - [sliderHeight, volume], - ); + const changeVolumeOnPan = (event: PanGestureEvent) => { + const val = roundToTwoDecimalPlaces(1 - event.y / sliderHeight); + volume.set(clamp(val, 0, 1)); + }; const pan = usePanGesture({ onBegin: (event) => { @@ -74,12 +71,9 @@ function VolumeButton({style, small = false}: VolumeButtonProps) { const progressBarStyle = useAnimatedStyle(() => ({height: `${volume.get() * 100}%`})); - const updateIcon = useCallback( - (vol: number) => { - setVolumeIcon({icon: getVolumeIcon(expensifyIcons, vol)}); - }, - [expensifyIcons], - ); + const updateIcon = (vol: number) => { + setVolumeIcon({icon: getVolumeIcon(expensifyIcons, vol)}); + }; useDerivedValue(() => { scheduleOnRN(updateVolume, volume.get()); @@ -120,4 +114,4 @@ function VolumeButton({style, small = false}: VolumeButtonProps) { ); } -export default memo(VolumeButton); +export default VolumeButton; diff --git a/src/components/VideoPlayer/VideoPlayerControls/index.tsx b/src/components/VideoPlayer/VideoPlayerControls/index.tsx index bc71e0f9995e..9d1aa123b68d 100644 --- a/src/components/VideoPlayer/VideoPlayerControls/index.tsx +++ b/src/components/VideoPlayer/VideoPlayerControls/index.tsx @@ -18,7 +18,7 @@ import type {GestureResponderEvent, LayoutChangeEvent, StyleProp, ViewStyle} fro import type {AnimatedStyle} from 'react-native-reanimated'; import type {ValueOf} from 'type-fest'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import Animated from 'react-native-reanimated'; @@ -115,23 +115,20 @@ function VideoPlayerControls({ setShouldShowTime(event.nativeEvent.layout.width > CONST.VIDEO_PLAYER.HIDE_TIME_TEXT_WIDTH); }; - const enterFullScreenMode = useCallback(() => { + const enterFullScreenMode = () => { updateCurrentURLAndReportID(url, report, reportID); videoViewRef.current?.enterFullscreen(); - }, [report, reportID, updateCurrentURLAndReportID, url, videoViewRef]); - - const seekPosition = useCallback( - (newPosition: number) => { - if (!videoPlayerRef.current) { - return; - } - // eslint-disable-next-line no-param-reassign - videoPlayerRef.current.currentTime = newPosition; - }, - [videoPlayerRef], - ); + }; + + const seekPosition = (newPosition: number) => { + if (!videoPlayerRef.current) { + return; + } + // eslint-disable-next-line no-param-reassign + videoPlayerRef.current.currentTime = newPosition; + }; - const durationFormatted = useMemo(() => convertSecondsToTime(duration), [duration]); + const durationFormatted = convertSecondsToTime(duration); return ( { - if (!reportID) { - return; - } - - if (currentlyPlayingURLRef.current && url !== currentlyPlayingURLRef.current) { - video.pause(); - } - - // If there's no URL (image case), pause the player by setting currentlyPlayingURL - // without triggering the resetPlayerData in useEffect below - if (!url) { - setCurrentlyPlayingURL(reportID); - currentlyPlayingURLRef.current = reportID; - return; - } - - const isReportAChatThread = isChatThread(report); - let reportIDtoSet; - if (isReportAChatThread) { - reportIDtoSet = findURLInReportOrAncestorAttachments(report, url) ?? NO_REPORT_ID; - } else { - reportIDtoSet = reportID; - } - - // Always set currentRouteReportID so that shareVideoPlayerElements can match. - // When the video is in a thread/child report but the focused route shows a parent report, - // the IDs won't match. We still need to set it so video controls work properly. - setCurrentRouteReportID(reportIDtoSet); - - setCurrentlyPlayingURL(url); - currentlyPlayingURLRef.current = url; - }, - [video], - ); + const updateCurrentURLAndReportID: PlaybackActionsContextValues['updateCurrentURLAndReportID'] = (url, report, reportID) => { + if (!reportID) { + return; + } + + if (currentlyPlayingURLRef.current && url !== currentlyPlayingURLRef.current) { + video.pause(); + } + + // If there's no URL (image case), pause the player by setting currentlyPlayingURL + // without triggering the resetPlayerData in useEffect below + if (!url) { + setCurrentlyPlayingURL(reportID); + currentlyPlayingURLRef.current = reportID; + return; + } + + const isReportAChatThread = isChatThread(report); + const reportIDtoSet = isReportAChatThread ? (findURLInReportOrAncestorAttachments(report, url) ?? NO_REPORT_ID) : reportID; + + // Always set currentRouteReportID so that shareVideoPlayerElements can match. + // When the video is in a thread/child report but the focused route shows a parent report, + // the IDs won't match. We still need to set it so video controls work properly. + setCurrentRouteReportID(reportIDtoSet); + + setCurrentlyPlayingURL(url); + currentlyPlayingURLRef.current = url; + }; - const updatePlayerStatus = useCallback((newStatus: VideoPlayerStatus) => { + const updatePlayerStatus = (newStatus: VideoPlayerStatus) => { playerStatus.current = newStatus; - }, []); + }; - const requestDonorReRegistration = useCallback(() => { + const requestDonorReRegistration = () => { // Reset currentRouteReportID so the reportID guard in shareVideoPlayerElements is bypassed, // allowing the real donor (e.g. chat player) to re-register even if its reportID no longer // matches the stale currentRouteReportID left behind by the non-shared (narrow) player. setCurrentRouteReportID(NO_REPORT_ID); setShareVersion((v) => v + 1); - }, []); - - const shareVideoPlayerElements: PlaybackActionsContextValues['shareVideoPlayerElements'] = useCallback( - ( - videoPlayerRef: VideoPlayer | null, - videoViewRef: VideoView | null, - parent: ComponentRef | HTMLDivElement | null, - child: ComponentRef | HTMLDivElement | null, - shouldNotAutoPlay: boolean, - {shouldUseSharedVideoElement, url, reportID}, - ) => { - // When currentRouteReportID is NO_REPORT_ID it means a forced re-registration was requested - // (e.g. after narrow→wide resize). In that case we skip the reportID check so any non-shared - // player whose URL matches can reclaim the context refs. - const hasReportIDMismatch = currentRouteReportID !== NO_REPORT_ID && reportID !== currentRouteReportID; - if (shouldUseSharedVideoElement || url !== currentlyPlayingURL || hasReportIDMismatch) { - return; - } + }; - video.updateRefs(videoPlayerRef, videoViewRef); - setOriginalParent(parent); - setSharedElement(child); - // Prevents autoplay when uploading the attachment - if (!shouldNotAutoPlay) { - video.play(); - } - }, - [currentRouteReportID, currentlyPlayingURL, video], - ); + const shareVideoPlayerElements: PlaybackActionsContextValues['shareVideoPlayerElements'] = ( + videoPlayerRef: VideoPlayer | null, + videoViewRef: VideoView | null, + parent: ComponentRef | HTMLDivElement | null, + child: ComponentRef | HTMLDivElement | null, + shouldNotAutoPlay: boolean, + {shouldUseSharedVideoElement, url, reportID}, + ) => { + // When currentRouteReportID is NO_REPORT_ID it means a forced re-registration was requested + // (e.g. after narrow→wide resize). In that case we skip the reportID check so any non-shared + // player whose URL matches can reclaim the context refs. + const hasReportIDMismatch = currentRouteReportID !== NO_REPORT_ID && reportID !== currentRouteReportID; + if (shouldUseSharedVideoElement || url !== currentlyPlayingURL || hasReportIDMismatch) { + return; + } + + video.updateRefs(videoPlayerRef, videoViewRef); + setOriginalParent(parent); + setSharedElement(child); + // Prevents autoplay when uploading the attachment + if (!shouldNotAutoPlay) { + video.play(); + } + }; useEffect(() => { Navigation.isNavigationReady().then(() => { diff --git a/src/components/VideoPlayerContexts/VolumeContext.tsx b/src/components/VideoPlayerContexts/VolumeContext.tsx index efa65dd8f1f9..303eb167f775 100644 --- a/src/components/VideoPlayerContexts/VolumeContext.tsx +++ b/src/components/VideoPlayerContexts/VolumeContext.tsx @@ -1,6 +1,6 @@ import type ChildrenProps from '@src/types/utils/ChildrenProps'; -import React, {useCallback, useContext, useEffect} from 'react'; +import React, {useContext, useEffect} from 'react'; import {useSharedValue} from 'react-native-reanimated'; import type {VolumeActionsContextType, VolumeStateContextType} from './types'; @@ -16,28 +16,25 @@ function VolumeContextProvider({children}: ChildrenProps) { // We need this field to remember the last value before clicking mute const lastNonZeroVolume = useSharedValue(1); - const updateVolume = useCallback( - (newVolume: number) => { - if (!currentVideoPlayerRef.current) { - return; - } - currentVideoPlayerRef.current.volume = newVolume; - currentVideoPlayerRef.current.muted = newVolume === 0; + const updateVolume = (newVolume: number) => { + if (!currentVideoPlayerRef.current) { + return; + } + currentVideoPlayerRef.current.volume = newVolume; + currentVideoPlayerRef.current.muted = newVolume === 0; - volume.set(newVolume); - }, - [currentVideoPlayerRef, volume], - ); + volume.set(newVolume); + }; // This function ensures mute and unmute functionality. Overwriting lastNonZeroValue // only in the case of mute guarantees that a pan gesture reducing the volume to zero won't cause // us to lose this value. As a result, unmute restores the last non-zero value. - const toggleMute = useCallback(() => { + const toggleMute = () => { if (volume.get() !== 0) { lastNonZeroVolume.set(volume.get()); } updateVolume(volume.get() === 0 ? lastNonZeroVolume.get() : 0); - }, [lastNonZeroVolume, updateVolume, volume]); + }; // We want to update the volume when currently playing video changes. // When originalParent changed we're sure that currentVideoPlayerRef is updated. So we can apply the new volume. diff --git a/src/components/ViolationMessages.tsx b/src/components/ViolationMessages.tsx index 159852f7f7b0..30aab9549bc4 100644 --- a/src/components/ViolationMessages.tsx +++ b/src/components/ViolationMessages.tsx @@ -11,7 +11,7 @@ import type {Unit} from '@src/types/onyx/Policy'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import Text from './Text'; @@ -50,48 +50,30 @@ export default function ViolationMessages({ const {convertToDisplayString} = useCurrencyListActions(); const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); - const filteredViolations = useMemo(() => filterReceiptViolations(violations), [violations]); + const filteredViolations = filterReceiptViolations(violations); - const violationMessages = useMemo( - () => - filteredViolations.map((violation) => { - const cardID = violation.data?.cardID; - const card = cardID ? cardList?.[cardID] : undefined; - return [ - violation.name, - ViolationsUtils.getViolationTranslation({ - dateFnsLocale, - violation, - translate, - convertToDisplayString, - canEdit, - companyCardPageURL, - connectionLink, - card, - isMarkAsCash, - routeDistanceMeters, - distanceUnit, - policyVendors, - transactionCurrency, - }), - ]; + const violationMessages = filteredViolations.map((violation) => { + const cardID = violation.data?.cardID; + const card = cardID ? cardList?.[cardID] : undefined; + return [ + violation.name, + ViolationsUtils.getViolationTranslation({ + dateFnsLocale, + violation, + translate, + convertToDisplayString, + canEdit, + companyCardPageURL, + connectionLink, + card, + isMarkAsCash, + routeDistanceMeters, + distanceUnit, + policyVendors, + transactionCurrency, }), - [ - canEdit, - translate, - convertToDisplayString, - filteredViolations, - companyCardPageURL, - connectionLink, - cardList, - isMarkAsCash, - routeDistanceMeters, - distanceUnit, - policyVendors, - transactionCurrency, - dateFnsLocale, - ], - ); + ]; + }); return ( diff --git a/src/components/WalletStatementModal/index.tsx b/src/components/WalletStatementModal/index.tsx index 8c8aead941df..0fb9818a2686 100644 --- a/src/components/WalletStatementModal/index.tsx +++ b/src/components/WalletStatementModal/index.tsx @@ -8,7 +8,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; import type {WalletStatementMessage, WalletStatementProps} from './types'; @@ -32,14 +32,11 @@ function WalletStatementModal({statementPageURL}: WalletStatementProps) { /** * Handles in-app navigation for iframe links */ - const navigate = useCallback( - (event: MessageEvent) => { - const {data} = event; - const {type, url} = data || {}; - handleWalletStatementNavigation(conciergeReportID, introSelected, session?.accountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails, type, url); - }, - [conciergeReportID, introSelected, session?.accountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails], - ); + const navigate = (event: MessageEvent) => { + const {data} = event; + const {type, url} = data || {}; + handleWalletStatementNavigation(conciergeReportID, introSelected, session?.accountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails, type, url); + }; useEffect(() => { navigateRef.current = navigate; diff --git a/src/components/WideRHPContextProvider/index.tsx b/src/components/WideRHPContextProvider/index.tsx index 2e1e3b473bc1..c7e2d3c91d7c 100644 --- a/src/components/WideRHPContextProvider/index.tsx +++ b/src/components/WideRHPContextProvider/index.tsx @@ -8,7 +8,7 @@ import type {NavigationRoute} from '@libs/Navigation/types'; import NAVIGATORS from '@src/NAVIGATORS'; import {findFocusedRoute} from '@react-navigation/native'; -import React, {createContext, useCallback, useContext, useEffect, useRef, useState} from 'react'; +import React, {createContext, useContext, useEffect, useRef, useState} from 'react'; // We use Animated for all functionality related to wide RHP to make it easier // to interact with react-navigation components (e.g., CardContainer, interpolator), which also use Animated. // eslint-disable-next-line no-restricted-imports @@ -124,12 +124,12 @@ function WideRHPContextProvider({children}: React.PropsWithChildren) { const {isWideRHPBelow, isSuperWideRHPBelow} = getIsRHPDisplayedBelow(focusedRoute?.key, allSuperWideRHPRouteKeys, allWideRHPRouteKeys); // Updates the Wide RHP visible keys table from the all keys table - const syncRHPKeys = useCallback(() => { + const syncRHPKeys = () => { const {visibleSuperWideRHPRouteKeys, visibleWideRHPRouteKeys} = getVisibleRHPKeys(allSuperWideRHPRouteKeys, allWideRHPRouteKeys); setWideRHPRouteKeys(visibleWideRHPRouteKeys); setSuperWideRHPRouteKeys(visibleSuperWideRHPRouteKeys); setExpandedRHPProgress(visibleSuperWideRHPRouteKeys, visibleWideRHPRouteKeys); - }, [allSuperWideRHPRouteKeys, allWideRHPRouteKeys]); + }; const clearWideRHPKeys = () => { setWideRHPRouteKeys([]); diff --git a/src/components/WorkspaceConfirmationForm.tsx b/src/components/WorkspaceConfirmationForm.tsx index ad20fe98465a..8da1684ed2b1 100644 --- a/src/components/WorkspaceConfirmationForm.tsx +++ b/src/components/WorkspaceConfirmationForm.tsx @@ -30,7 +30,7 @@ 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'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {FormInputErrors, FormOnyxValues} from './Form/types'; @@ -90,40 +90,37 @@ function WorkspaceConfirmationForm({ const [account] = useOnyx(ONYXKEYS.ACCOUNT); const isApprovedAccountant = !!account?.isApprovedAccountant; - const validate = useCallback( - (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; - const name = values.name.trim(); - - if (!isRequiredFulfilled(name)) { - errors.name = translate('workspace.editor.nameIsRequiredError'); - } else if ([...name].length > CONST.TITLE_CHARACTER_LIMIT) { - // Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16 - // code units. - addErrorMessage(errors, 'name', translate('common.error.characterLimitExceedCounter', [...name].length, CONST.TITLE_CHARACTER_LIMIT)); + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const name = values.name.trim(); + + if (!isRequiredFulfilled(name)) { + errors.name = translate('workspace.editor.nameIsRequiredError'); + } else if ([...name].length > CONST.TITLE_CHARACTER_LIMIT) { + // Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16 + // code units. + addErrorMessage(errors, 'name', translate('common.error.characterLimitExceedCounter', [...name].length, CONST.TITLE_CHARACTER_LIMIT)); + } + + if (!isRequiredFulfilled(values[INPUT_IDS.CURRENCY])) { + errors[INPUT_IDS.CURRENCY] = translate('common.error.fieldRequired'); + } + + // Only validate plan type and owner for approved accountants + if (isApprovedAccountant) { + if (!isRequiredFulfilled(values[INPUT_IDS.PLAN_TYPE])) { + errors[INPUT_IDS.PLAN_TYPE] = translate('common.error.fieldRequired'); } - if (!isRequiredFulfilled(values[INPUT_IDS.CURRENCY])) { - errors[INPUT_IDS.CURRENCY] = translate('common.error.fieldRequired'); + if (!isRequiredFulfilled(values[INPUT_IDS.OWNER])) { + errors[INPUT_IDS.OWNER] = translate('common.error.fieldRequired'); } + } - // Only validate plan type and owner for approved accountants - if (isApprovedAccountant) { - if (!isRequiredFulfilled(values[INPUT_IDS.PLAN_TYPE])) { - errors[INPUT_IDS.PLAN_TYPE] = translate('common.error.fieldRequired'); - } + return errors; + }; - if (!isRequiredFulfilled(values[INPUT_IDS.OWNER])) { - errors[INPUT_IDS.OWNER] = translate('common.error.fieldRequired'); - } - } - - return errors; - }, - [translate, isApprovedAccountant], - ); - - const policyID = useMemo(() => generatePolicyID(), []); + const policyID = generatePolicyID(); const [session, metadata] = useOnyx(ONYXKEYS.SESSION); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); diff --git a/src/components/withKeyboardState.tsx b/src/components/withKeyboardState.tsx index 0ad25072f949..2f25c4135a2f 100755 --- a/src/components/withKeyboardState.tsx +++ b/src/components/withKeyboardState.tsx @@ -6,7 +6,7 @@ import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {ReactElement, RefObject} from 'react'; -import React, {createContext, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {createContext, useEffect, useRef, useState} from 'react'; import {KeyboardEvents, useKeyboardHandler} from 'react-native-keyboard-controller'; import {scheduleOnRN} from 'react-native-worklets'; @@ -68,9 +68,9 @@ function KeyboardStateProvider({children}: ChildrenProps): ReactElement | null { }; }, [bottom]); - const setIsKeyboardAnimating = useCallback((isAnimating: boolean) => { + const setIsKeyboardAnimating = (isAnimating: boolean) => { isKeyboardAnimatingRef.current = isAnimating; - }, []); + }; useKeyboardHandler( { @@ -88,16 +88,13 @@ function KeyboardStateProvider({children}: ChildrenProps): ReactElement | null { [], ); - const contextValue = useMemo( - () => ({ - keyboardHeight, - keyboardActiveHeight, - isKeyboardShown: keyboardHeight !== 0, - isKeyboardAnimatingRef, - isKeyboardActive, - }), - [isKeyboardActive, keyboardActiveHeight, keyboardHeight], - ); + const contextValue = { + keyboardHeight, + keyboardActiveHeight, + isKeyboardShown: keyboardHeight !== 0, + isKeyboardAnimatingRef, + isKeyboardActive, + }; return {children}; } diff --git a/src/hooks/useAccountIDToNameMap.ts b/src/hooks/useAccountIDToNameMap.ts index 8b38c26468a3..7e5bd3f1b3dd 100644 --- a/src/hooks/useAccountIDToNameMap.ts +++ b/src/hooks/useAccountIDToNameMap.ts @@ -1,7 +1,5 @@ import type {PersonalDetailsList} from '@src/types/onyx'; -import {useMemo} from 'react'; - import {useAllPersonalDetails} from './usePersonalDetails'; /** Mapping from accountID to user name (login or displayName). */ @@ -36,13 +34,13 @@ function buildAccountIDToNameMap(personalDetailsList: PersonalDetailsList | unde /** Returns an accountID -> name (login or displayName) map built from PERSONAL_DETAILS_LIST. */ function useAccountIDToNameMap(): AccountIDToNameMap { const [personalDetailsList] = useAllPersonalDetails(); - return useMemo(() => buildAccountIDToNameMap(personalDetailsList), [personalDetailsList]); + return buildAccountIDToNameMap(personalDetailsList); } /** Same as useAccountIDToNameMap, but only maps the users mentioned in the given HTML string, for components rendered many times in a list. */ function useMentionedAccountIDToNameMap(htmlString: string | undefined): AccountIDToNameMap { const [personalDetailsList] = useAllPersonalDetails(); - return useMemo(() => buildAccountIDToNameMap(personalDetailsList, getMentionedAccountIDs(htmlString)), [personalDetailsList, htmlString]); + return buildAccountIDToNameMap(personalDetailsList, getMentionedAccountIDs(htmlString)); } export default useAccountIDToNameMap; diff --git a/src/hooks/useActiveAdminPolicies.ts b/src/hooks/useActiveAdminPolicies.ts index 46d5ef255b7b..50a32a8ba9e9 100644 --- a/src/hooks/useActiveAdminPolicies.ts +++ b/src/hooks/useActiveAdminPolicies.ts @@ -5,14 +5,13 @@ import getEmptyArray from '@src/types/utils/getEmptyArray'; import type {OnyxCollection} from 'react-native-onyx'; import {activeAdminPoliciesSelector} from '@selectors/Policy'; -import {useCallback} from 'react'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useOnyx from './useOnyx'; function useActiveAdminPolicies() { const {login} = useCurrentUserPersonalDetails(); - const selector = useCallback((policies: OnyxCollection) => activeAdminPoliciesSelector(policies, login ?? ''), [login]); + const selector = (policies: OnyxCollection) => activeAdminPoliciesSelector(policies, login ?? ''); const [activeAdminPolicies = getEmptyArray()] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector}); return activeAdminPolicies; diff --git a/src/hooks/useActiveRoute.ts b/src/hooks/useActiveRoute.ts index ee4a3552d12d..3079252ec80c 100644 --- a/src/hooks/useActiveRoute.ts +++ b/src/hooks/useActiveRoute.ts @@ -1,17 +1,17 @@ import Navigation from '@libs/Navigation/Navigation'; -import {useCallback, useRef} from 'react'; +import {useRef} from 'react'; function useActiveRoute() { const currentReportRHPActiveRoute = useRef(''); - const getReportRHPActiveRoute = useCallback(() => { + const getReportRHPActiveRoute = () => { if (!currentReportRHPActiveRoute.current) { currentReportRHPActiveRoute.current = Navigation.getReportRHPActiveRoute(); } return currentReportRHPActiveRoute.current; - }, []); + }; return {getReportRHPActiveRoute}; } diff --git a/src/hooks/useAgentZeroStatusIndicator.ts b/src/hooks/useAgentZeroStatusIndicator.ts index b2a1bc1afa9f..3ed9efdc4d12 100644 --- a/src/hooks/useAgentZeroStatusIndicator.ts +++ b/src/hooks/useAgentZeroStatusIndicator.ts @@ -12,7 +12,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {getNewestReportActionSelector} from '@selectors/ReportAction'; import {getAgentZeroProcessingLabel} from '@selectors/ReportNameValuePairs'; -import {useCallback, useEffect, useRef, useState, useSyncExternalStore} from 'react'; +import {useEffect, useRef, useState, useSyncExternalStore} from 'react'; import useLocalize from './useLocalize'; import useNetwork from './useNetwork'; @@ -59,7 +59,7 @@ function useAgentZeroStatusIndicator(reportID: string, agentAccountID: number = // Server-driven processing label for this agent, from report name-value pairs (e.g. "Looking // up categories..."). The selector narrows to this agent's slot so the hook only re-renders // when its own label changes. - const serverLabelSelector = useCallback((reportNameValuePairs: OnyxEntry) => getAgentZeroProcessingLabel(reportNameValuePairs, agentAccountID), [agentAccountID]); + const serverLabelSelector = (reportNameValuePairs: OnyxEntry) => getAgentZeroProcessingLabel(reportNameValuePairs, agentAccountID); const [serverLabel] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, {selector: serverLabelSelector}); // Track the newest report action so we can fetch missed actions and detect actual Concierge replies. @@ -120,19 +120,19 @@ function useAgentZeroStatusIndicator(reportID: string, agentAccountID: number = * react-compiler-compat ESLint processor (which would otherwise suppress the exhaustive-deps * false positive) only runs on .tsx/.jsx files, not .ts. */ - const clearSafetyTimer = useCallback(() => { + const clearSafetyTimer = () => { if (!safetyTimerRef.current) { return; } clearTimeout(safetyTimerRef.current); safetyTimerRef.current = null; - }, []); + }; /** * Hard-clear the indicator by resetting local state and clearing the Onyx NVP. * Called as a safety net after MAX_INDICATOR_DURATION_MS if no response has arrived. */ - const hardClearIndicator = useCallback(() => { + const hardClearIndicator = () => { // If offline, don't clear — the response may arrive when reconnected if (isOfflineRef.current) { return; @@ -143,7 +143,7 @@ function useAgentZeroStatusIndicator(reportID: string, agentAccountID: number = setDisplayedLabel(''); clearAgentZeroProcessingIndicator(reportID, agentAccountID); getNewerActions(reportID, newestReportActionRef.current?.reportActionID); - }, [clearSafetyTimer, reportID, agentAccountID]); + }; /** * (Re)arm the safety timer. Called when processing becomes active or the server label @@ -155,23 +155,20 @@ function useAgentZeroStatusIndicator(reportID: string, agentAccountID: number = * one-shot `getNewerActions` on HTTP reconnect. The safety timer is the backstop * for the long tail. */ - const startSafetyTimer = useCallback( - (safetyDurationMs: number = MAX_INDICATOR_DURATION_MS) => { - clearSafetyTimer(); + const startSafetyTimer = (safetyDurationMs: number = MAX_INDICATOR_DURATION_MS) => { + clearSafetyTimer(); - if (safetyDurationMs <= 0) { - // Entry is already past the safety window (e.g. remount after >2 min) — - // hard-clear immediately rather than rearming a timer that would fire at zero. - hardClearIndicator(); - return; - } + if (safetyDurationMs <= 0) { + // Entry is already past the safety window (e.g. remount after >2 min) — + // hard-clear immediately rather than rearming a timer that would fire at zero. + hardClearIndicator(); + return; + } - safetyTimerRef.current = setTimeout(() => { - hardClearIndicator(); - }, safetyDurationMs); - }, - [clearSafetyTimer, hardClearIndicator], - ); + safetyTimerRef.current = setTimeout(() => { + hardClearIndicator(); + }, safetyDurationMs); + }; // On reconnect, defensively clear any stale NVP, refetch missed actions, and rearm the // safety timer so the cap measures from reconnect rather than the original kickoff. diff --git a/src/hooks/useAllTransactions.ts b/src/hooks/useAllTransactions.ts index 45f6488b69cb..4af29c7986ad 100644 --- a/src/hooks/useAllTransactions.ts +++ b/src/hooks/useAllTransactions.ts @@ -5,8 +5,6 @@ import type {Transaction} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {useMemo} from 'react'; - import useOnyx from './useOnyx'; /** @@ -16,12 +14,11 @@ function useAllTransactions() { const {currentSearchResults} = useSearchResultsContext(); const [allTransactionsCollection] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); - const allTransactions = useMemo(() => { - const data = currentSearchResults?.data; - if (!data) { - return allTransactionsCollection; - } - + let allTransactions; + const data = currentSearchResults?.data; + if (!data) { + allTransactions = allTransactionsCollection; + } else { const filteredSearchTransactions = Object.keys(data) .filter((key): key is `${typeof ONYXKEYS.COLLECTION.TRANSACTION}${string}` => key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) .reduce( @@ -34,12 +31,11 @@ function useAllTransactions() { }, {} as Record>, ); - - return { + allTransactions = { ...filteredSearchTransactions, ...allTransactionsCollection, }; - }, [currentSearchResults?.data, allTransactionsCollection]); + } return allTransactions; } diff --git a/src/hooks/useAncestors/index.ts b/src/hooks/useAncestors/index.ts index e06dafe34423..f728d68ffa7f 100644 --- a/src/hooks/useAncestors/index.ts +++ b/src/hooks/useAncestors/index.ts @@ -8,8 +8,6 @@ import type {Report, ReportAction} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {useMemo} from 'react'; - /** * Fetches ancestor reports and their associated actions for a given report. * @@ -22,9 +20,7 @@ function useAncestors(report: OnyxEntry, shouldExcludeAncestorReportActi const [reportCollection] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [reportDraftCollection] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT); const [reportActionsCollection] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); - return useMemo(() => { - return getAncestors(report, reportCollection, reportDraftCollection, reportActionsCollection, shouldExcludeAncestorReportActionCallback); - }, [report, reportCollection, reportDraftCollection, reportActionsCollection, shouldExcludeAncestorReportActionCallback]); + return getAncestors(report, reportCollection, reportDraftCollection, reportActionsCollection, shouldExcludeAncestorReportActionCallback); } export default useAncestors; diff --git a/src/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts index e1830c9d580e..26bbe4de16f9 100644 --- a/src/hooks/useAutoCreateSubmitWorkspace.ts +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -13,8 +13,6 @@ import type {Policy} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; -import {useCallback, useMemo} from 'react'; - import useDelegateAccountID from './useDelegateAccountID'; import useOnboardingWorkspaceCreationState from './useOnboardingWorkspaceCreationState'; import useOnyx from './useOnyx'; @@ -46,116 +44,83 @@ function useAutoCreateSubmitWorkspace() { } = useOnboardingWorkspaceCreationState(); const delegateAccountID = useDelegateAccountID(); - const groupPolicySelector = useMemo( - () => (policies: OnyxCollection) => Object.values(policies ?? {}).some((policy) => isGroupPolicy(policy) && canEditWorkspaceSettings(policy)), - [], - ); + const groupPolicySelector = (policies: OnyxCollection) => Object.values(policies ?? {}).some((policy) => isGroupPolicy(policy) && canEditWorkspaceSettings(policy)); const [hasEditableGroupPolicy] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: groupPolicySelector}); - const existingSubmitPolicyIDSelector = useMemo( - // Pass the login so the per-employee role fallback in canEditWorkspaceSettings covers - // partially-loaded policies where the top-level `role` isn't populated yet. - () => (policies: OnyxCollection) => Object.values(policies ?? {}).find((policy) => isSubmitPolicy(policy) && canEditWorkspaceSettings(policy, currentUserEmail))?.id, - [currentUserEmail], - ); + const existingSubmitPolicyIDSelector = (policies: OnyxCollection) => + Object.values(policies ?? {}).find((policy) => isSubmitPolicy(policy) && canEditWorkspaceSettings(policy, currentUserEmail))?.id; const [existingSubmitPolicyID] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: existingSubmitPolicyIDSelector}); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); - const autoCreateSubmitWorkspace = useCallback( - // Callers that already finished onboarding (e.g. the Submit plan welcome modal) don't need to - // run guided setup again, so they can skip the CompleteGuidedSetup request by passing `false`. - async (firstName: string, lastName: string, shouldCompleteOnboarding = true) => { - const shouldCreateWorkspace = !isRestrictedPolicyCreation && !onboardingPolicyID && !hasEditableGroupPolicy; - const displayName = createDisplayName(currentUserEmail, {firstName, lastName}, formatPhoneNumber); + const autoCreateSubmitWorkspace = async (firstName: string, lastName: string, shouldCompleteOnboarding = true) => { + const shouldCreateWorkspace = !isRestrictedPolicyCreation && !onboardingPolicyID && !hasEditableGroupPolicy; + const displayName = createDisplayName(currentUserEmail, {firstName, lastName}, formatPhoneNumber); - const {adminsChatReportID: newAdminsChatReportID, policyID: newPolicyID} = shouldCreateWorkspace - ? createWorkspace({ - policyOwner: undefined, - makeMeAdmin: true, - policyName: generateDefaultWorkspaceName(currentUserEmail, displayName, lastWorkspaceNumber, translate), - policyID: generatePolicyID(), - engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, - currency: localCurrencyCode, - file: undefined, - shouldAddOnboardingTasks: false, - introSelected, - activePolicy, - conciergeChat, - currentUserAccountIDParam: currentUserAccountID, - currentUserEmailParam: currentUserEmail, - shouldAddGuideWelcomeMessage: false, - type: CONST.POLICY.TYPE.SUBMIT, - isSelfTourViewed, - hasActiveAdminPolicies, - delegateAccountID, - hasOwnedPaidPolicy, - }) - : {adminsChatReportID: onboardingAdminsChatReportID, policyID: onboardingPolicyID}; + const {adminsChatReportID: newAdminsChatReportID, policyID: newPolicyID} = shouldCreateWorkspace + ? createWorkspace({ + policyOwner: undefined, + makeMeAdmin: true, + policyName: generateDefaultWorkspaceName(currentUserEmail, displayName, lastWorkspaceNumber, translate), + policyID: generatePolicyID(), + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + currency: localCurrencyCode, + file: undefined, + shouldAddOnboardingTasks: false, + introSelected, + activePolicy, + conciergeChat, + currentUserAccountIDParam: currentUserAccountID, + currentUserEmailParam: currentUserEmail, + shouldAddGuideWelcomeMessage: false, + type: CONST.POLICY.TYPE.SUBMIT, + isSelfTourViewed, + hasActiveAdminPolicies, + delegateAccountID, + hasOwnedPaidPolicy, + }) + : {adminsChatReportID: onboardingAdminsChatReportID, policyID: onboardingPolicyID}; - if (shouldCompleteOnboarding) { - try { - await completeOnboarding({ - engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, - onboardingMessage: onboardingMessages[CONST.ONBOARDING_CHOICES.EMPLOYER], - firstName, - lastName, - adminsChatReportID: newAdminsChatReportID, - onboardingPolicyID: newPolicyID, - introSelected, - isSelfTourViewed, - conciergeChat, - // Creating a Submit workspace posts a Concierge welcome with suggested responses in its - // #admins room, so a Concierge DM checklist on top of that is a competing second onboarding - // experience. Without a new workspace there is no #admins welcome, so the checklist stays. - shouldSkipConciergeOnboarding: shouldCreateWorkspace, - currentUserAccountID, - delegateAccountID, - }); - } catch (error) { - // Swallow onboarding completion failures so a network error doesn't block workspace - // creation or the follow-up navigation; the optimistic Onyx data is already applied. - // Still log so the failure remains diagnosable. - Log.warn('[useAutoCreateSubmitWorkspace] Error completing onboarding', {error}); - } + if (shouldCompleteOnboarding) { + try { + await completeOnboarding({ + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + onboardingMessage: onboardingMessages[CONST.ONBOARDING_CHOICES.EMPLOYER], + firstName, + lastName, + adminsChatReportID: newAdminsChatReportID, + onboardingPolicyID: newPolicyID, + introSelected, + isSelfTourViewed, + conciergeChat, + // Creating a Submit workspace posts a Concierge welcome with suggested responses in its + // #admins room, so a Concierge DM checklist on top of that is a competing second onboarding + // experience. Without a new workspace there is no #admins welcome, so the checklist stays. + shouldSkipConciergeOnboarding: shouldCreateWorkspace, + currentUserAccountID, + delegateAccountID, + }); + } catch (error) { + // Swallow onboarding completion failures so a network error doesn't block workspace + // creation or the follow-up navigation; the optimistic Onyx data is already applied. + // Still log so the failure remains diagnosable. + Log.warn('[useAutoCreateSubmitWorkspace] Error completing onboarding', {error}); } + } - setOnboardingAdminsChatReportID(); - setOnboardingPolicyID(); + setOnboardingAdminsChatReportID(); + setOnboardingPolicyID(); - // Already-onboarded callers (the Submit plan welcome modal) can reach this point with no workspace - // created and no onboarding policy ID when an editable Submit workspace already exists. Navigate to - // that existing workspace instead of falling back to Home. Onboarding callers keep the current - // behavior since they complete onboarding with `newPolicyID` and should land accordingly. - let policyIDForNavigation = newPolicyID; - if (!policyIDForNavigation && !shouldCompleteOnboarding) { - policyIDForNavigation = existingSubmitPolicyID; - } + // Already-onboarded callers (the Submit plan welcome modal) can reach this point with no workspace + // created and no onboarding policy ID when an editable Submit workspace already exists. Navigate to + // that existing workspace instead of falling back to Home. Onboarding callers keep the current + // behavior since they complete onboarding with `newPolicyID` and should land accordingly. + let policyIDForNavigation = newPolicyID; + if (!policyIDForNavigation && !shouldCompleteOnboarding) { + policyIDForNavigation = existingSubmitPolicyID; + } - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyIDForNavigation, shouldUseNarrowLayout); - }, - [ - currentUserEmail, - currentUserAccountID, - lastWorkspaceNumber, - translate, - formatPhoneNumber, - isRestrictedPolicyCreation, - onboardingPolicyID, - hasEditableGroupPolicy, - existingSubmitPolicyID, - onboardingAdminsChatReportID, - localCurrencyCode, - introSelected, - activePolicy, - isSelfTourViewed, - onboardingMessages, - hasActiveAdminPolicies, - hasOwnedPaidPolicy, - shouldUseNarrowLayout, - conciergeChat, - delegateAccountID, - ], - ); + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyIDForNavigation, shouldUseNarrowLayout); + }; return autoCreateSubmitWorkspace; } diff --git a/src/hooks/useAutoFocusInput.ts b/src/hooks/useAutoFocusInput.ts index 4a3acee32f45..f9f0b18482d6 100644 --- a/src/hooks/useAutoFocusInput.ts +++ b/src/hooks/useAutoFocusInput.ts @@ -43,13 +43,13 @@ export default function useAutoFocusInput(isMultiline = false): UseAutoFocusInpu const transitionEndTimeoutRef = useRef(null); const isAutoFocusCancelledRef = useRef(false); - const clearTransitionEndTimeout = useCallback(() => { + const clearTransitionEndTimeout = () => { if (!transitionEndTimeoutRef.current) { return; } clearTimeout(transitionEndTimeoutRef.current); transitionEndTimeoutRef.current = null; - }, []); + }; const cancelAutoFocus = () => { isAutoFocusCancelledRef.current = true; diff --git a/src/hooks/useAvatarMenu.ts b/src/hooks/useAvatarMenu.ts index d5eab60a2d95..e9c4a8074c76 100644 --- a/src/hooks/useAvatarMenu.ts +++ b/src/hooks/useAvatarMenu.ts @@ -8,7 +8,7 @@ import AttachmentModalContext from '@pages/media/AttachmentModalScreen/Attachmen import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {FileObject} from '@src/types/utils/Attachment'; -import {useCallback, useContext} from 'react'; +import {useContext} from 'react'; import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; import useLocalize from './useLocalize'; @@ -40,51 +40,48 @@ function useAvatarMenu({shouldHideAvatarEdit, accountID, onImageRemoved, showAva /** * Create menu items list for avatar menu */ - const createMenuItems = useCallback( - (openPicker: OpenPicker): Array> => { - const menuItems: Array> = [ - { - icon: icons.Upload, - text: translate('avatarWithImagePicker.uploadPhoto'), - onSelected: () => { - openPicker({ - onPicked: (data) => showAvatarCropModal(data.at(0) ?? {}), - }); - }, - value: null, + const createMenuItems = (openPicker: OpenPicker): Array> => { + const menuItems: Array> = [ + { + icon: icons.Upload, + text: translate('avatarWithImagePicker.uploadPhoto'), + onSelected: () => { + openPicker({ + onPicked: (data) => showAvatarCropModal(data.at(0) ?? {}), + }); }, - ]; - // If current avatar is a default avatar and for avatar is selected in the form, only show upload option - if (shouldHideAvatarEdit) { - return menuItems; - } - if (!source) { - menuItems.push({ - icon: icons.Trashcan, - text: translate('avatarWithImagePicker.removePhoto'), - value: null, - onSelected: () => { - clearError(); - onImageRemoved(); - }, - }); - } + value: null, + }, + ]; + // If current avatar is a default avatar and for avatar is selected in the form, only show upload option + if (shouldHideAvatarEdit) { + return menuItems; + } + if (!source) { + menuItems.push({ + icon: icons.Trashcan, + text: translate('avatarWithImagePicker.removePhoto'), + value: null, + onSelected: () => { + clearError(); + onImageRemoved(); + }, + }); + } - return [ - ...menuItems, - { - value: null, - icon: icons.Eye, - text: translate('avatarWithImagePicker.viewPhoto'), - onSelected: () => { - attachmentContext.setCurrentAttachment({source, originalFileName}); - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.PROFILE_AVATAR.getRoute(accountID))); - }, + return [ + ...menuItems, + { + value: null, + icon: icons.Eye, + text: translate('avatarWithImagePicker.viewPhoto'), + onSelected: () => { + attachmentContext.setCurrentAttachment({source, originalFileName}); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.PROFILE_AVATAR.getRoute(accountID))); }, - ]; - }, - [icons.Upload, icons.Trashcan, icons.Eye, translate, shouldHideAvatarEdit, source, showAvatarCropModal, clearError, onImageRemoved, attachmentContext, originalFileName, accountID], - ); + }, + ]; + }; return {createMenuItems}; } diff --git a/src/hooks/useBlockDistanceRequest.ts b/src/hooks/useBlockDistanceRequest.ts index 33ad2df3d3f0..f76afe8fe556 100644 --- a/src/hooks/useBlockDistanceRequest.ts +++ b/src/hooks/useBlockDistanceRequest.ts @@ -13,8 +13,6 @@ import type PrivatePersonalDetails from '@src/types/onyx/PrivatePersonalDetails' import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import {useCallback} from 'react'; - import useConfirmModal from './useConfirmModal'; import {useMemoizedLazyIllustrations} from './useLazyAsset'; import useLocalize from './useLocalize'; @@ -98,76 +96,67 @@ function useBlockDistanceRequest({ const [policiesRequiringMapOrGPS] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createPoliciesRequiringMapOrGPSSelector(session?.email)}); const [hasHomeAddress] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, {selector: hasHomeAddressSelector}); - const getBlockReason = useCallback( - (policyIDToCheck: string | undefined): BlockDistanceRequestReason | undefined => { - if (!policyIDToCheck || !policiesRequiringMapOrGPS?.[policyIDToCheck] || isEditingExistingDistanceRequest) { - return; - } - - if (isManualDistanceRequest || isOdometerDistanceRequest) { - return 'mapOrGpsRequired'; - } - - if (isDistanceRequest && policiesRequiringMapOrGPS[policyIDToCheck].isCommuteMeasuredFromHome && !hasHomeAddress) { - return 'homeAddressRequired'; - } - }, - [hasHomeAddress, isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest, isEditingExistingDistanceRequest, policiesRequiringMapOrGPS], - ); - - const showBlockModal = useCallback( - (reason: BlockDistanceRequestReason, policyIDToCheck: string | undefined) => { - const baseModalProps = { - image: illustrations.HouseWithMap, - titleStyles: styles.textHeadline, - titleContainerStyles: styles.mb2, - promptStyles: [styles.textSupporting, styles.mb4], - shouldShowCancelButton: false, - shouldUseSuccessStyleForConfirm: true, - shouldFitImageToContainer: true, - imageStyles: [styles.mt5, styles.mh5], - imageWidth: styles.commuterExclusionStaticIllustration.width, - imageHeight: styles.commuterExclusionStaticIllustration.height, - shouldShowDismissIcon: false, - }; - if (reason === 'homeAddressRequired') { - showConfirmModal({ - ...baseModalProps, - title: translate('iou.homeAddressRequired.title'), - prompt: translate('iou.homeAddressRequired.prompt', {workspaceName: policyIDToCheck ? (policiesRequiringMapOrGPS?.[policyIDToCheck]?.name ?? '') : ''}), - confirmText: translate('iou.homeAddressRequired.cta'), - }).then(({action: modalAction}) => { - if (modalAction !== ModalActions.CONFIRM) { - return; - } - openPrivatePersonalDetailsPage(INPUT_IDS.ADDRESS_LINE_1); - }); - return; - } - + const getBlockReason = (policyIDToCheck: string | undefined): BlockDistanceRequestReason | undefined => { + if (!policyIDToCheck || !policiesRequiringMapOrGPS?.[policyIDToCheck] || isEditingExistingDistanceRequest) { + return; + } + + if (isManualDistanceRequest || isOdometerDistanceRequest) { + return 'mapOrGpsRequired'; + } + + if (isDistanceRequest && policiesRequiringMapOrGPS[policyIDToCheck].isCommuteMeasuredFromHome && !hasHomeAddress) { + return 'homeAddressRequired'; + } + }; + + const showBlockModal = (reason: BlockDistanceRequestReason, policyIDToCheck: string | undefined) => { + const baseModalProps = { + image: illustrations.HouseWithMap, + titleStyles: styles.textHeadline, + titleContainerStyles: styles.mb2, + promptStyles: [styles.textSupporting, styles.mb4], + shouldShowCancelButton: false, + shouldUseSuccessStyleForConfirm: true, + shouldFitImageToContainer: true, + imageStyles: [styles.mt5, styles.mh5], + imageWidth: styles.commuterExclusionStaticIllustration.width, + imageHeight: styles.commuterExclusionStaticIllustration.height, + shouldShowDismissIcon: false, + }; + if (reason === 'homeAddressRequired') { showConfirmModal({ ...baseModalProps, - title: translate('distance.error.mapOrGpsDistanceRequired.title'), - prompt: translate('distance.error.mapOrGpsDistanceRequired.description'), - confirmText: translate('common.buttonConfirm'), + title: translate('iou.homeAddressRequired.title'), + prompt: translate('iou.homeAddressRequired.prompt', {workspaceName: policyIDToCheck ? (policiesRequiringMapOrGPS?.[policyIDToCheck]?.name ?? '') : ''}), + confirmText: translate('iou.homeAddressRequired.cta'), + }).then(({action: modalAction}) => { + if (modalAction !== ModalActions.CONFIRM) { + return; + } + openPrivatePersonalDetailsPage(INPUT_IDS.ADDRESS_LINE_1); }); - }, - [illustrations.HouseWithMap, policiesRequiringMapOrGPS, showConfirmModal, styles, translate], - ); - - const blockDistanceRequestIfNeeded = useCallback( - (...policyIDsToCheck: [string?]) => { - const policyIDToCheck = policyIDsToCheck.length > 0 ? policyIDsToCheck[0] : policyID; - const reason = getBlockReason(policyIDToCheck); - if (!reason) { - return false; - } - - showBlockModal(reason, policyIDToCheck); - return true; - }, - [getBlockReason, policyID, showBlockModal], - ); + return; + } + + showConfirmModal({ + ...baseModalProps, + title: translate('distance.error.mapOrGpsDistanceRequired.title'), + prompt: translate('distance.error.mapOrGpsDistanceRequired.description'), + confirmText: translate('common.buttonConfirm'), + }); + }; + + const blockDistanceRequestIfNeeded = (...policyIDsToCheck: [string?]) => { + const policyIDToCheck = policyIDsToCheck.length > 0 ? policyIDsToCheck[0] : policyID; + const reason = getBlockReason(policyIDToCheck); + if (!reason) { + return false; + } + + showBlockModal(reason, policyIDToCheck); + return true; + }; return blockDistanceRequestIfNeeded; } diff --git a/src/hooks/useBottomSafeSafeAreaPaddingStyle.ts b/src/hooks/useBottomSafeSafeAreaPaddingStyle.ts index e591643f287d..c0cfefbac58d 100644 --- a/src/hooks/useBottomSafeSafeAreaPaddingStyle.ts +++ b/src/hooks/useBottomSafeSafeAreaPaddingStyle.ts @@ -4,7 +4,7 @@ import CONST from '@src/CONST'; import type {StyleProp, ViewStyle} from 'react-native'; -import {useContext, useMemo} from 'react'; +import {useContext} from 'react'; import {StyleSheet} from 'react-native'; import useNetwork from './useNetwork'; @@ -45,52 +45,41 @@ function useBottomSafeSafeAreaPaddingStyle({ const {isOffline} = useNetwork(); const {addSafeAreaPadding: isOfflineIndicatorSafeAreaPaddingEnabled} = useContext(ScreenWrapperOfflineIndicatorContext); - return useMemo>(() => { - let totalPaddingBottom: number | string = additionalPaddingBottom; + let totalPaddingBottom: number | string = additionalPaddingBottom; - // Add the safe area padding to the total padding if the flag is enabled - if (addBottomSafeAreaPadding) { - totalPaddingBottom += safeAreaPaddingBottom; - } + // Add the safe area padding to the total padding if the flag is enabled + if (addBottomSafeAreaPadding) { + totalPaddingBottom += safeAreaPaddingBottom; + } - if (addOfflineIndicatorBottomSafeAreaPadding && isOffline && isOfflineIndicatorSafeAreaPaddingEnabled) { - totalPaddingBottom += CONST.OFFLINE_INDICATOR_HEIGHT; - } + if (addOfflineIndicatorBottomSafeAreaPadding && isOffline && isOfflineIndicatorSafeAreaPaddingEnabled) { + totalPaddingBottom += CONST.OFFLINE_INDICATOR_HEIGHT; + } + + // If there is no bottom safe area or additional padding, return the style as is + if (totalPaddingBottom === 0) { + return style; + } - // If there is no bottom safe area or additional padding, return the style as is - if (totalPaddingBottom === 0) { + // If a style is provided, flatten the style and add the padding to it + if (style) { + const contentContainerStyleFlattened = StyleSheet.flatten(style); + const styleBottomSafeAreaPadding = contentContainerStyleFlattened?.[styleProperty]; + + if (typeof styleBottomSafeAreaPadding === 'number') { + totalPaddingBottom += styleBottomSafeAreaPadding; + } else if (typeof styleBottomSafeAreaPadding === 'string') { + totalPaddingBottom = `calc(${totalPaddingBottom}px + ${styleBottomSafeAreaPadding})`; + } else if (styleBottomSafeAreaPadding !== undefined) { return style; } - // If a style is provided, flatten the style and add the padding to it - if (style) { - const contentContainerStyleFlattened = StyleSheet.flatten(style); - const styleBottomSafeAreaPadding = contentContainerStyleFlattened?.[styleProperty]; - - if (typeof styleBottomSafeAreaPadding === 'number') { - totalPaddingBottom += styleBottomSafeAreaPadding; - } else if (typeof styleBottomSafeAreaPadding === 'string') { - totalPaddingBottom = `calc(${totalPaddingBottom}px + ${styleBottomSafeAreaPadding})`; - } else if (styleBottomSafeAreaPadding !== undefined) { - return style; - } - - // The user of this hook can decide which style property to use for applying the padding. - return [style, {[styleProperty]: totalPaddingBottom}]; - } + // The user of this hook can decide which style property to use for applying the padding. + return [style, {[styleProperty]: totalPaddingBottom}]; + } - // If no style is provided, return the padding as an object - return {paddingBottom: totalPaddingBottom}; - }, [ - additionalPaddingBottom, - addBottomSafeAreaPadding, - addOfflineIndicatorBottomSafeAreaPadding, - isOffline, - isOfflineIndicatorSafeAreaPaddingEnabled, - style, - safeAreaPaddingBottom, - styleProperty, - ]); + // If no style is provided, return the padding as an object + return {paddingBottom: totalPaddingBottom}; } export default useBottomSafeSafeAreaPaddingStyle; diff --git a/src/hooks/useCheckIfRouteHasRemainedUnchanged.ts b/src/hooks/useCheckIfRouteHasRemainedUnchanged.ts index 93deadba2300..84c929a9e792 100644 --- a/src/hooks/useCheckIfRouteHasRemainedUnchanged.ts +++ b/src/hooks/useCheckIfRouteHasRemainedUnchanged.ts @@ -3,7 +3,7 @@ import Navigation, {navigationRef} from '@navigation/Navigation'; import SCREENS from '@src/SCREENS'; import {useNavigation} from '@react-navigation/native'; -import {useCallback, useEffect, useRef} from 'react'; +import {useEffect, useRef} from 'react'; import useResponsiveLayout from './useResponsiveLayout'; @@ -23,7 +23,7 @@ function useCheckIfRouteHasRemainedUnchanged(videoUrl: string) { /** * Return true only when on the initially rendered route or the video is currently playing in attachment modal. */ - const hasRouteRemainedUnchanged = useCallback(() => { + const hasRouteRemainedUnchanged = () => { if (navigation.isFocused()) { return true; } @@ -48,7 +48,7 @@ function useCheckIfRouteHasRemainedUnchanged(videoUrl: string) { } return false; - }, [shouldUseNarrowLayout, isInNarrowPaneModal, videoUrl, navigation]); + }; // Initialize and check if starting with the attachment modal useEffect(() => { diff --git a/src/hooks/useCloseImportPage.tsx b/src/hooks/useCloseImportPage.tsx index 4ab16a6bc1aa..7f4406e63fbb 100644 --- a/src/hooks/useCloseImportPage.tsx +++ b/src/hooks/useCloseImportPage.tsx @@ -1,13 +1,13 @@ import {closeImportPage} from '@libs/actions/ImportSpreadsheet'; -import {useCallback, useEffect, useRef} from 'react'; +import {useEffect, useRef} from 'react'; function useCloseImportPage() { const isClosing = useRef(false); - const setIsClosing = useCallback((value: boolean) => { + const setIsClosing = (value: boolean) => { isClosing.current = value; - }, []); + }; useEffect(() => { return () => { diff --git a/src/hooks/useConciergeSidePanelReportActions.ts b/src/hooks/useConciergeSidePanelReportActions.ts index b755de2f01c9..42adbca81700 100644 --- a/src/hooks/useConciergeSidePanelReportActions.ts +++ b/src/hooks/useConciergeSidePanelReportActions.ts @@ -7,9 +7,10 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {useCallback, useLayoutEffect, useMemo, useState} from 'react'; +import {useLayoutEffect, useState} from 'react'; /** An OPEN task assigned to the current user. Canceled tasks stay OPEN optimistically, so deleted parents are excluded. */ + function isOpenChildTaskAction(action: OnyxTypes.ReportAction, currentUserAccountID: number): boolean { return ( action.childType === CONST.REPORT.TYPE.TASK && @@ -106,158 +107,136 @@ function useConciergeSidePanelReportActions({ // fit in a single page (hasOlderActions=false). On an existing account with // prior interactions the history spans multiple pages (hasOlderActions=true). // - const hadUserMessageAtSessionStart = useMemo(() => { - if (!isConciergeHiddenHistory || !sessionStartTime) { - return false; - } + let hadUserMessageAtSessionStart; + if (!isConciergeHiddenHistory || !sessionStartTime) { + hadUserMessageAtSessionStart = false; + } else { const hasUserMessageInLoadedSet = visibleReportActions.some( (action) => !isCreatedAction(action) && action.actorAccountID === currentUserAccountID && action.created < sessionStartTime, ); - return hasUserMessageInLoadedSet || hasOlderActions; - }, [isConciergeHiddenHistory, visibleReportActions, currentUserAccountID, sessionStartTime, hasOlderActions]); + hadUserMessageAtSessionStart = hasUserMessageInLoadedSet || hasOlderActions; + } - const hasPreviousMessages = useMemo(() => { - if (!isConciergeHiddenHistory || !hadUserMessageAtSessionStart || !sessionStartTime) { - return false; - } + let hasPreviousMessages; + if (!isConciergeHiddenHistory || !hadUserMessageAtSessionStart || !sessionStartTime) { + hasPreviousMessages = false; + } else { const hasPreSessionActionInLoadedSet = visibleReportActions.some((action) => !isCreatedAction(action) && action.created < sessionStartTime); - return hasPreSessionActionInLoadedSet || hasOlderActions; - }, [isConciergeHiddenHistory, visibleReportActions, sessionStartTime, hadUserMessageAtSessionStart, hasOlderActions]); + hasPreviousMessages = hasPreSessionActionInLoadedSet || hasOlderActions; + } // Main DM only: check if there are any messages (from any actor) after sessionStartTime. // When true, we have unread content to display and should not enter welcome mode. - const hasMessagesInSession = useMemo(() => { - if (!isConciergeMainDM || !isConciergeHiddenHistory || !sessionStartTime) { - return false; - } - return visibleReportActions.some((action) => !isCreatedAction(action) && action.created >= sessionStartTime); - }, [isConciergeMainDM, isConciergeHiddenHistory, visibleReportActions, sessionStartTime]); + const hasMessagesInSession = + !(!isConciergeMainDM || !isConciergeHiddenHistory || !sessionStartTime) && visibleReportActions.some((action) => !isCreatedAction(action) && action.created >= sessionStartTime); // Main DM only: the welcome state must stand down for a pinned task, or `filterActions` returns before it renders. - const hasOpenChildTask = useMemo(() => { - if (!isConciergeMainDM || !isConciergeHiddenHistory) { - return false; - } - return visibleReportActions.some((action) => isOpenChildTaskAction(action, currentUserAccountID)); - }, [isConciergeMainDM, isConciergeHiddenHistory, visibleReportActions, currentUserAccountID]); + const hasOpenChildTask = !(!isConciergeMainDM || !isConciergeHiddenHistory) && visibleReportActions.some((action) => isOpenChildTaskAction(action, currentUserAccountID)); // A re-stamp flips `hasUserSentMessage` too, so the sticky set is what keeps the welcome state from coming back. const hasSentInSession = hasUserSentMessage || sessionSentActionIDs.size > 0; const showConciergeSidePanelWelcome = isConciergeHiddenHistory && hadUserMessageAtSessionStart && !hasSentInSession && !showFullHistory && !hasMessagesInSession && !hasOpenChildTask; const showConciergeGreeting = isConciergeHiddenHistory && hadUserMessageAtSessionStart && !showFullHistory && (!isConciergeMainDM || !hadMessagesAtSessionStart); - const conciergeGreetingAction = useMemo(() => { - if (!showConciergeGreeting) { - return undefined; - } + let conciergeGreetingAction; + if (!showConciergeGreeting) { + conciergeGreetingAction = undefined; + } else { const created = report?.lastReadTime ?? DateUtils.getDBTime(); - return buildConciergeGreetingReportAction({reportID: report?.reportID, greetingText, created}); - }, [showConciergeGreeting, report?.reportID, report?.lastReadTime, greetingText]); + conciergeGreetingAction = buildConciergeGreetingReportAction({ + reportID: report?.reportID, + greetingText, + created, + }); + } // Side panel only: find the first user message in the current session. // Used to filter actions so that only messages from the first user message // onwards are shown (upstream side panel behavior). - const firstUserMessageCreated = useMemo(() => { - if (isConciergeMainDM || showConciergeSidePanelWelcome || !isConciergeHiddenHistory || !hasUserSentMessage || !sessionStartTime) { - return undefined; + const firstUserMessageCreated = + isConciergeMainDM || showConciergeSidePanelWelcome || !isConciergeHiddenHistory || !hasUserSentMessage || !sessionStartTime + ? undefined + : reportActions.reduce((earliest, action) => { + const isCurrentSessionUserMessage = + !isCreatedAction(action) && + action.actorAccountID === currentUserAccountID && + (isCurrentUserPendingAddAction(action, currentUserAccountID) || action.created >= sessionStartTime); + if (!isCurrentSessionUserMessage) { + return earliest; + } + return !earliest || action.created < earliest ? action.created : earliest; + }, undefined); + + const isCurrentSessionAction = (action: OnyxTypes.ReportAction): boolean => { + if (!sessionStartTime) { + return false; } - return reportActions.reduce((earliest, action) => { - const isCurrentSessionUserMessage = - !isCreatedAction(action) && - action.actorAccountID === currentUserAccountID && - (isCurrentUserPendingAddAction(action, currentUserAccountID) || action.created >= sessionStartTime); - if (!isCurrentSessionUserMessage) { - return earliest; - } - return !earliest || action.created < earliest ? action.created : earliest; - }, undefined); - }, [isConciergeMainDM, showConciergeSidePanelWelcome, isConciergeHiddenHistory, hasUserSentMessage, sessionStartTime, reportActions, currentUserAccountID]); - - const isCurrentSessionAction = useCallback( - (action: OnyxTypes.ReportAction): boolean => { - if (!sessionStartTime) { - return false; - } - // Already shown this session — keep it whatever the server stamped on it. - if (sessionSentActionIDs.has(action.reportActionID)) { - return true; - } - if (isConciergeMainDM) { - // Pin a still-open task so collapsing read history never buries something the user has to act on. - return ( - isCreatedAction(action) || - isCurrentUserPendingAddAction(action, currentUserAccountID) || - isOpenChildTaskAction(action, currentUserAccountID) || - action.created >= sessionStartTime - ); - } - if (!firstUserMessageCreated) { - return false; - } - // The firstUserMessageCreated floor only trims the user's OWN pre-question messages, so apply it to - // current-user actions alone. Concierge replies are server-stamped and already bounded by the - // server-anchored sessionStartTime; gating them on the question's `created` would hide a reply whenever - // that `created` was clamped forward onto an ahead client clock to stay monotonic across sends. - const isFromCurrentUser = action.actorAccountID === currentUserAccountID; + // Already shown this session — keep it whatever the server stamped on it. + if (sessionSentActionIDs.has(action.reportActionID)) { + return true; + } + if (isConciergeMainDM) { + // Pin a still-open task so collapsing read history never buries something the user has to act on. return ( isCreatedAction(action) || isCurrentUserPendingAddAction(action, currentUserAccountID) || - (action.created >= sessionStartTime && (!isFromCurrentUser || action.created >= firstUserMessageCreated)) + isOpenChildTaskAction(action, currentUserAccountID) || + action.created >= sessionStartTime ); - }, - [sessionStartTime, isConciergeMainDM, firstUserMessageCreated, currentUserAccountID, sessionSentActionIDs], - ); + } + if (!firstUserMessageCreated) { + return false; + } + // The firstUserMessageCreated floor only trims the user's OWN pre-question messages, so apply it to + // current-user actions alone. Concierge replies are server-stamped and already bounded by the + // server-anchored sessionStartTime; gating them on the question's `created` would hide a reply whenever + // that `created` was clamped forward onto an ahead client clock to stay monotonic across sends. + const isFromCurrentUser = action.actorAccountID === currentUserAccountID; + return ( + isCreatedAction(action) || + isCurrentUserPendingAddAction(action, currentUserAccountID) || + (action.created >= sessionStartTime && (!isFromCurrentUser || action.created >= firstUserMessageCreated)) + ); + }; - const filterActions = useCallback( - (actions: OnyxTypes.ReportAction[]): OnyxTypes.ReportAction[] => { - if (showConciergeSidePanelWelcome && conciergeGreetingAction) { + const filterActions = (actions: OnyxTypes.ReportAction[]): OnyxTypes.ReportAction[] => { + if (showConciergeSidePanelWelcome && conciergeGreetingAction) { + const createdAction = actions.find(isCreatedAction); + return createdAction ? [conciergeGreetingAction, createdAction] : [conciergeGreetingAction]; + } + if (!isConciergeHiddenHistory || showFullHistory) { + return actions; + } + if (!sessionStartTime) { + return actions.filter(isCreatedAction); + } + if (!hadUserMessageAtSessionStart) { + return actions; + } + const filtered = actions.filter(isCurrentSessionAction); + if (filtered.length === 0) { + // Side panel: nothing matched the current session yet (e.g. just after reopen, before the new + // message propagates). Show the greeting instead of `actions` to avoid flashing stale history. + if (!isConciergeMainDM && conciergeGreetingAction) { const createdAction = actions.find(isCreatedAction); return createdAction ? [conciergeGreetingAction, createdAction] : [conciergeGreetingAction]; } - if (!isConciergeHiddenHistory || showFullHistory) { - return actions; - } - if (!sessionStartTime) { - return actions.filter(isCreatedAction); - } - if (!hadUserMessageAtSessionStart) { - return actions; - } - const filtered = actions.filter(isCurrentSessionAction); - if (filtered.length === 0) { - // Side panel: nothing matched the current session yet (e.g. just after reopen, before the new - // message propagates). Show the greeting instead of `actions` to avoid flashing stale history. - if (!isConciergeMainDM && conciergeGreetingAction) { - const createdAction = actions.find(isCreatedAction); - return createdAction ? [conciergeGreetingAction, createdAction] : [conciergeGreetingAction]; - } - return actions; - } - if (conciergeGreetingAction) { - const createdIndex = filtered.findIndex(isCreatedAction); - filtered.splice(createdIndex === -1 ? filtered.length : createdIndex, 0, conciergeGreetingAction); - } - return filtered; - }, - [ - showConciergeSidePanelWelcome, - conciergeGreetingAction, - isConciergeHiddenHistory, - showFullHistory, - sessionStartTime, - isCurrentSessionAction, - hadUserMessageAtSessionStart, - isConciergeMainDM, - ], - ); + return actions; + } + if (conciergeGreetingAction) { + const createdIndex = filtered.findIndex(isCreatedAction); + filtered.splice(createdIndex === -1 ? filtered.length : createdIndex, 0, conciergeGreetingAction); + } + return filtered; + }; - const filteredVisibleActions = useMemo(() => filterActions(visibleReportActions), [filterActions, visibleReportActions]); - const filteredReportActions = useMemo(() => filterActions(reportActions), [filterActions, reportActions]); + const filteredVisibleActions = filterActions(visibleReportActions); + const filteredReportActions = filterActions(reportActions); - const handleShowPreviousMessages = useCallback(() => { + const handleShowPreviousMessages = () => { setShowFullHistory(true); loadOlderChats(true); - }, [setShowFullHistory, loadOlderChats]); + }; return { filteredVisibleActions, diff --git a/src/hooks/useConditionalCreateEmptyReportConfirmation.ts b/src/hooks/useConditionalCreateEmptyReportConfirmation.ts index fec504acae7f..ca92c4ac4a8e 100644 --- a/src/hooks/useConditionalCreateEmptyReportConfirmation.ts +++ b/src/hooks/useConditionalCreateEmptyReportConfirmation.ts @@ -1,5 +1,3 @@ -import {useCallback} from 'react'; - import useCreateEmptyReportConfirmation from './useCreateEmptyReportConfirmation'; import useShouldShowEmptyReportConfirmation from './useShouldShowEmptyReportConfirmation'; @@ -36,12 +34,9 @@ export default function useConditionalCreateEmptyReportConfirmation({ }: UseConditionalCreateEmptyReportConfirmationParams): UseConditionalCreateEmptyReportConfirmationResult { const shouldShowEmptyReportConfirmation = useShouldShowEmptyReportConfirmation(policyID, shouldBypassConfirmation); - const handleReportCreationConfirmed = useCallback( - (shouldDismissEmptyReportsConfirmation?: boolean) => { - onCreateReport(shouldDismissEmptyReportsConfirmation); - }, - [onCreateReport], - ); + const handleReportCreationConfirmed = (shouldDismissEmptyReportsConfirmation?: boolean) => { + onCreateReport(shouldDismissEmptyReportsConfirmation); + }; const {openCreateReportConfirmation} = useCreateEmptyReportConfirmation({ policyID, @@ -50,14 +45,14 @@ export default function useConditionalCreateEmptyReportConfirmation({ onCancel, }); - const handleCreateReport = useCallback(() => { + const handleCreateReport = () => { if (shouldShowEmptyReportConfirmation) { openCreateReportConfirmation(); return; } onCreateReport(false); - }, [shouldShowEmptyReportConfirmation, onCreateReport, openCreateReportConfirmation]); + }; return { handleCreateReport, diff --git a/src/hooks/useContactImport.ts b/src/hooks/useContactImport.ts index b3166303fa9d..64b1ac9ddd4a 100644 --- a/src/hooks/useContactImport.ts +++ b/src/hooks/useContactImport.ts @@ -12,7 +12,7 @@ import type {PersonalDetails} from '@src/types/onyx'; import type {PermissionStatus} from 'react-native-permissions'; -import {useCallback, useState} from 'react'; +import {useState} from 'react'; import {RESULTS} from 'react-native-permissions'; import useLocalize from './useLocalize'; @@ -41,13 +41,13 @@ function useContactImport(): UseContactImportResult { const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); const [loginList] = useOnyx(ONYXKEYS.LOGINS, {selector: expensifyLoginsSelector}); - const importAndSaveContacts = useCallback(() => { + const importAndSaveContacts = () => { contactImport().then(({contactList, permissionStatus}: ContactImportResult) => { setContactPermissionState(permissionStatus); const usersFromContact = getContactsExtended(contactList, localeCompare, formatPhoneNumber, countryCode, loginList); setContacts(usersFromContact); }); - }, [localeCompare, formatPhoneNumber, countryCode, loginList]); + }; // Opens the system contact picker and adds the picked contacts to the list, replacing any earlier copy of the same login const pickContact = () => { diff --git a/src/hooks/useContainerWidth.ts b/src/hooks/useContainerWidth.ts index ca2760f5c6d8..eff23606b269 100644 --- a/src/hooks/useContainerWidth.ts +++ b/src/hooks/useContainerWidth.ts @@ -1,21 +1,19 @@ import type {LayoutChangeEvent} from 'react-native'; -import {useCallback, useState} from 'react'; +import {useState} from 'react'; /** * Returns an onLayout handler and the measured container width (minus an optional offset). * Used by skeleton components that need the container width for SVG layout calculations. * The width updates automatically when the container resizes. */ + function useContainerWidth(offset = 0): {onLayout: (event: LayoutChangeEvent) => void; containerWidth: number} { const [containerWidth, setContainerWidth] = useState(0); - const onLayout = useCallback( - (event: LayoutChangeEvent) => { - setContainerWidth(event.nativeEvent.layout.width - offset); - }, - [offset], - ); + const onLayout = (event: LayoutChangeEvent) => { + setContainerWidth(event.nativeEvent.layout.width - offset); + }; return {onLayout, containerWidth}; } diff --git a/src/hooks/useCreateNewReport.tsx b/src/hooks/useCreateNewReport.tsx index 5106c7e11bbe..6692861a37ea 100644 --- a/src/hooks/useCreateNewReport.tsx +++ b/src/hooks/useCreateNewReport.tsx @@ -4,7 +4,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import {useCallback} from 'react'; import {useCurrencyListActions} from './useCurrencyList'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; @@ -24,24 +23,21 @@ function useCreateNewReport() { const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {getCurrencyDecimals} = useCurrencyListActions(); - return useCallback( - (policyID: string, shouldDismissEmptyReportsConfirmation = false) => { - const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; - return createNewReport( - currentUserPersonalDetails, - // A new report has no expenses yet, so it can't have violations. - false, - isASAPSubmitBetaEnabled, - policy, - isTrackIntentUser, - getCurrencyDecimals, - rules, - false, - shouldDismissEmptyReportsConfirmation, - ); - }, - [currentUserPersonalDetails, isASAPSubmitBetaEnabled, policies, isTrackIntentUser, getCurrencyDecimals, rules], - ); + return (policyID: string, shouldDismissEmptyReportsConfirmation = false) => { + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + return createNewReport( + currentUserPersonalDetails, + // A new report has no expenses yet, so it can't have violations. + false, + isASAPSubmitBetaEnabled, + policy, + isTrackIntentUser, + getCurrencyDecimals, + rules, + false, + shouldDismissEmptyReportsConfirmation, + ); + }; } export default useCreateNewReport; diff --git a/src/hooks/useCreateReport.tsx b/src/hooks/useCreateReport.tsx index a3124b1c6dd3..712e61537367 100644 --- a/src/hooks/useCreateReport.tsx +++ b/src/hooks/useCreateReport.tsx @@ -14,7 +14,6 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import {useCallback} from 'react'; // eslint-disable-next-line no-restricted-imports -- Need original useOnyx to avoid reading partial Search snapshot policy data. import {useOnyx as useOnyxWithoutSnapshots} from 'react-native-onyx'; @@ -90,7 +89,7 @@ export default function useCreateReport({ shouldHandleNavigationBack, }); - const createReport = useCallback(() => { + const createReport = () => { interceptAnonymousUser(() => { if (!arePoliciesLoaded) { return; @@ -147,23 +146,7 @@ export default function useCreateReport({ Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(workspaceIDForReportCreation)); }); - }, [ - arePoliciesLoaded, - shouldNavigateToUpgradePath, - activePolicy, - defaultChatEnabledPolicy, - defaultChatEnabledPolicyID, - ownerBillingGracePeriodEnd, - userBillingGracePeriodEnds, - amountOwed, - accountID, - isLockedToPreferredPolicy, - defaultChatEnabledPolicySelection?.hasMultipleChatEnabledPolicies, - onNavigateToWorkspaceSelection, - shouldShowEmptyReportConfirmation, - openCreateReportConfirmation, - onCreateReport, - ]); + }; return {createReport, isVisible}; } diff --git a/src/hooks/useCurrentReportID.tsx b/src/hooks/useCurrentReportID.tsx index 9bb657050914..cd74cdcfcf9c 100644 --- a/src/hooks/useCurrentReportID.tsx +++ b/src/hooks/useCurrentReportID.tsx @@ -4,7 +4,7 @@ import NAVIGATORS from '@src/NAVIGATORS'; import type {NavigationState, PartialState} from '@react-navigation/native'; -import React, {createContext, startTransition, useCallback, useContext, useMemo, useRef, useState} from 'react'; +import React, {createContext, startTransition, useContext, useRef, useState} from 'react'; type CurrentReportIDStateContextType = { currentReportID: string | undefined; @@ -62,57 +62,47 @@ function CurrentReportIDContextProvider(props: CurrentReportIDContextProviderPro * This function is used to update the currentReportID and currentRHPReportID * @param state root navigation state */ - const updateCurrentReportID = useCallback( - (state: NavigationState) => { - /* - * Make sure we don't make the reportID undefined when switching between the chat list and settings tab. - * This helps prevent unnecessary re-renders. - */ - const params = state?.routes?.[state.index]?.params; - if (params && 'screen' in params && typeof params.screen === 'string' && params.screen.indexOf('Settings_') !== -1) { - return; + const updateCurrentReportID = (state: NavigationState) => { + /* + * Make sure we don't make the reportID undefined when switching between the chat list and settings tab. + * This helps prevent unnecessary re-renders. + */ + const params = state?.routes?.[state.index]?.params; + if (params && 'screen' in params && typeof params.screen === 'string' && params.screen.indexOf('Settings_') !== -1) { + return; + } + + const reportID = Navigation.getTopmostReportId(state); + + if (pendingReportIDRef.current !== reportID) { + if (pendingReportIDRef.current || reportID) { + pendingReportIDRef.current = reportID; + props.onSetCurrentReportID?.(reportID); + // Mark the report ID update as a non-urgent transition so React can keep the + // UI responsive to user input while the (potentially expensive) report screen + // re-render is processed in the background. + startTransition(() => { + setCurrentReportID(reportID); + }); } + } - const reportID = Navigation.getTopmostReportId(state); - - if (pendingReportIDRef.current !== reportID) { - if (pendingReportIDRef.current || reportID) { - pendingReportIDRef.current = reportID; - props.onSetCurrentReportID?.(reportID); - // Mark the report ID update as a non-urgent transition so React can keep the - // UI responsive to user input while the (potentially expensive) report screen - // re-render is processed in the background. - startTransition(() => { - setCurrentReportID(reportID); - }); - } - } - - const focusedTopRoute = state.routes[state.index]; - const modalReportID = focusedTopRoute?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR && focusedTopRoute.state ? getFocusedRouteReportID(focusedTopRoute.state) : undefined; + const focusedTopRoute = state.routes[state.index]; + const modalReportID = focusedTopRoute?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR && focusedTopRoute.state ? getFocusedRouteReportID(focusedTopRoute.state) : undefined; - if (currentRHPReportID !== modalReportID && (currentRHPReportID || modalReportID)) { - setCurrentRHPReportID(modalReportID); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want to re-render when onSetCurrentReportID changes - [setCurrentReportID, setCurrentRHPReportID, currentRHPReportID], - ); + if (currentRHPReportID !== modalReportID && (currentRHPReportID || modalReportID)) { + setCurrentRHPReportID(modalReportID); + } + }; - const actionsContextValue = useMemo( - () => ({ - updateCurrentReportID, - }), - [updateCurrentReportID], - ); + const actionsContextValue: CurrentReportIDActionsContextType = { + updateCurrentReportID, + }; - const stateContextValue = useMemo( - () => ({ - currentReportID, - currentRHPReportID, - }), - [currentReportID, currentRHPReportID], - ); + const stateContextValue: CurrentReportIDStateContextType = { + currentReportID, + currentRHPReportID, + }; return ( diff --git a/src/hooks/useDefaultFundID.ts b/src/hooks/useDefaultFundID.ts index 24fd0fd95501..b4b5559ea3f4 100644 --- a/src/hooks/useDefaultFundID.ts +++ b/src/hooks/useDefaultFundID.ts @@ -12,8 +12,6 @@ import type {ExpensifyCardSettings} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; -import {useCallback} from 'react'; - import useOnyx from './useOnyx'; import useWorkspaceAccountID from './useWorkspaceAccountID'; @@ -27,32 +25,29 @@ function useDefaultFundID(policyID: string | undefined) { const [lastSelectedCardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${lastSelectedExpensifyCardFeed}`); const lastSelectedSettings = getCardSettings(lastSelectedCardSettings); - const getDomainFundID = useCallback( - (cardSettings: OnyxCollection) => { - // Compare the parsed fund ID rather than testing the raw key for a substring: a substring test - // also excludes unrelated funds whose ID merely contains these digits, and while the workspace - // fund is still unresolved it holds CONST.DEFAULT_NUMBER_ID, which would drop every fund whose - // ID contains a zero. - const eligibleEntries = Object.entries(cardSettings ?? {}).filter( - ([key, settings]) => !!settings && getFundIdFromSettingsKey(key) !== workspaceAccountID && settings.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - ); - - if (policyID) { - const preferredMatch = eligibleEntries.find(([, settings]) => getPreferredPolicyFromExpensifyCardSettings(settings)?.toUpperCase() === policyID.toUpperCase()); - if (preferredMatch) { - return getFundIdFromSettingsKey(preferredMatch[0]); - } + const getDomainFundID = (cardSettings: OnyxCollection) => { + // Compare the parsed fund ID rather than testing the raw key for a substring: a substring test + // also excludes unrelated funds whose ID merely contains these digits, and while the workspace + // fund is still unresolved it holds CONST.DEFAULT_NUMBER_ID, which would drop every fund whose + // ID contains a zero. + const eligibleEntries = Object.entries(cardSettings ?? {}).filter( + ([key, settings]) => !!settings && getFundIdFromSettingsKey(key) !== workspaceAccountID && settings.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + ); + + if (policyID) { + const preferredMatch = eligibleEntries.find(([, settings]) => getPreferredPolicyFromExpensifyCardSettings(settings)?.toUpperCase() === policyID.toUpperCase()); + if (preferredMatch) { + return getFundIdFromSettingsKey(preferredMatch[0]); + } - const linkedMatch = eligibleEntries.find(([, settings]) => isPolicyIDInLinkedExpensifyCardPolicyList(getLinkedPolicyIDsFromExpensifyCardSettings(settings), policyID)); - if (linkedMatch) { - return getFundIdFromSettingsKey(linkedMatch[0]); - } + const linkedMatch = eligibleEntries.find(([, settings]) => isPolicyIDInLinkedExpensifyCardPolicyList(getLinkedPolicyIDsFromExpensifyCardSettings(settings), policyID)); + if (linkedMatch) { + return getFundIdFromSettingsKey(linkedMatch[0]); } + } - return undefined; - }, - [policyID, workspaceAccountID], - ); + return undefined; + }; const [domainFundID] = useOnyx(ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS, { selector: getDomainFundID, diff --git a/src/hooks/useDefaultParticipants.ts b/src/hooks/useDefaultParticipants.ts index 0ba51790b3ab..0eb6a0a4a300 100644 --- a/src/hooks/useDefaultParticipants.ts +++ b/src/hooks/useDefaultParticipants.ts @@ -13,8 +13,6 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import {useCallback, useMemo} from 'react'; - import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useDefaultExpensePolicy from './useDefaultExpensePolicy'; import useOnyx from './useOnyx'; @@ -59,10 +57,7 @@ function useDefaultParticipants({sourceReport, transaction, iouType}: UseDefault const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); const {selfDMReport, isLoading: isLoadingSelfDMReport} = useResolvedSelfDMReport(); const [activePolicyID, activePolicyIDResult] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); - const hasExplicitPersonalDestinationSelector = useCallback( - (policies: OnyxCollection) => !!activePolicyID && !isGroupPolicy(policies?.[`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`]), - [activePolicyID], - ); + const hasExplicitPersonalDestinationSelector = (policies: OnyxCollection) => !!activePolicyID && !isGroupPolicy(policies?.[`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`]); const [hasExplicitPersonalDestination, policyCollectionResult] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasExplicitPersonalDestinationSelector}); const [amountOwed, amountOwedResult] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const [userBillingGracePeriodEnds, userBillingGracePeriodEndsResult] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); @@ -74,50 +69,23 @@ function useDefaultParticipants({sourceReport, transaction, iouType}: UseDefault isLoadingSelfDMReport || isLoadingOnyxValue(activePolicyIDResult, policyCollectionResult, amountOwedResult, userBillingGracePeriodEndsResult, ownerBillingGracePeriodEndResult); - const participants = useMemo(() => { - const reportParticipants = getMoneyRequestParticipantsFromReport(sourceReport, accountID).filter((participant) => participant.selected); - if (reportParticipants.length > 0) { - return reportParticipants; - } - - const isGlobalCreateFlow = transaction?.isFromGlobalCreate ?? transaction?.isFromFloatingActionButton ?? iouType === CONST.IOU.TYPE.CREATE; - if (!isGlobalCreateFlow || !iouType) { - return []; - } - - if (iouType === CONST.IOU.TYPE.TRACK) { - return getMoneyRequestParticipantsFromReport(selfDMReport, accountID).filter((participant) => participant.selected); + const reportParticipants = getMoneyRequestParticipantsFromReport(sourceReport, accountID).filter((participant) => participant.selected); + let participants = reportParticipants; + const isGlobalCreateFlow = transaction?.isFromGlobalCreate ?? transaction?.isFromFloatingActionButton ?? iouType === CONST.IOU.TYPE.CREATE; + if (participants.length === 0 && isGlobalCreateFlow && iouType) { + if (iouType === CONST.IOU.TYPE.TRACK || (hasExplicitPersonalDestination && !isRestrictedToPreferredPolicy)) { + participants = getMoneyRequestParticipantsFromReport(selfDMReport, accountID).filter((participant) => participant.selected); + } else { + const canUseDefaultPolicy = shouldUseDefaultExpensePolicy(iouType, defaultExpensePolicy, amountOwed, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, accountID); + if (canUseDefaultPolicy) { + const shouldAutoReport = !!defaultExpensePolicy?.autoReporting || !!personalPolicy?.autoReporting; + const defaultTargetReport = !shouldAutoReport ? selfDMReport : getPolicyExpenseChat(accountID, defaultExpensePolicy?.id); + participants = getMoneyRequestParticipantsFromReport(defaultTargetReport, accountID).filter((participant) => participant.selected); + } } + } - if (hasExplicitPersonalDestination && !isRestrictedToPreferredPolicy) { - return getMoneyRequestParticipantsFromReport(selfDMReport, accountID).filter((participant) => participant.selected); - } - - const canUseDefaultPolicy = shouldUseDefaultExpensePolicy(iouType, defaultExpensePolicy, amountOwed, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, accountID); - if (!canUseDefaultPolicy) { - return []; - } - - const shouldAutoReport = !!defaultExpensePolicy?.autoReporting || !!personalPolicy?.autoReporting; - const defaultTargetReport = !shouldAutoReport ? selfDMReport : getPolicyExpenseChat(accountID, defaultExpensePolicy?.id); - return getMoneyRequestParticipantsFromReport(defaultTargetReport, accountID).filter((participant) => participant.selected); - }, [ - sourceReport, - accountID, - transaction?.isFromGlobalCreate, - transaction?.isFromFloatingActionButton, - iouType, - hasExplicitPersonalDestination, - isRestrictedToPreferredPolicy, - defaultExpensePolicy, - amountOwed, - userBillingGracePeriodEnds, - ownerBillingGracePeriodEnd, - personalPolicy?.autoReporting, - selfDMReport, - ]); - - return useMemo(() => ({participants, isLoading}), [participants, isLoading]); + return {participants, isLoading}; } export default useDefaultParticipants; diff --git a/src/hooks/useDeleteSavedSearch.tsx b/src/hooks/useDeleteSavedSearch.tsx index 852922d6e5d1..f074edf1e92e 100644 --- a/src/hooks/useDeleteSavedSearch.tsx +++ b/src/hooks/useDeleteSavedSearch.tsx @@ -11,8 +11,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {lastExpensesSearchQuerySelector} from '@src/selectors/SearchFilters'; -import {useCallback} from 'react'; - import useConfirmModal from './useConfirmModal'; import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; @@ -23,28 +21,25 @@ export default function useDeleteSavedSearch() { const {showConfirmModal} = useConfirmModal(); const [lastExpensesSearchQuery] = useOnyx(ONYXKEYS.SEARCH_FILTERS, {selector: lastExpensesSearchQuerySelector}); - const handleDeleteSavedSearch = useCallback( - (savedSearchID: string) => { - showConfirmModal({ - title: translate('search.deleteSavedSearch'), - prompt: translate('search.deleteSavedSearchConfirm'), - confirmText: translate('common.delete'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }).then((result) => { - if (result.action !== ModalActions.CONFIRM) { - return; - } - deleteSavedSearch(savedSearchID); - - if (savedSearchID === searchKeyToSavedSearchID(currentSearchKey)) { - const query = lastExpensesSearchQuery ?? buildCannedSearchQuery(); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query, searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES})); - } - }); - }, - [showConfirmModal, translate, currentSearchKey, lastExpensesSearchQuery], - ); + const handleDeleteSavedSearch = (savedSearchID: string) => { + showConfirmModal({ + title: translate('search.deleteSavedSearch'), + prompt: translate('search.deleteSavedSearchConfirm'), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + deleteSavedSearch(savedSearchID); + + if (savedSearchID === searchKeyToSavedSearchID(currentSearchKey)) { + const query = lastExpensesSearchQuery ?? buildCannedSearchQuery(); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query, searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES})); + } + }); + }; return {showDeleteModal: handleDeleteSavedSearch}; } diff --git a/src/hooks/useDragAndDrop/index.ts b/src/hooks/useDragAndDrop/index.ts index cde4c1a0f308..1c01ca9e41b6 100644 --- a/src/hooks/useDragAndDrop/index.ts +++ b/src/hooks/useDragAndDrop/index.ts @@ -1,7 +1,7 @@ import {usePopoverActions} from '@components/PopoverProvider'; import {useIsFocused} from '@react-navigation/native'; -import {useCallback, useEffect, useRef, useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; import type UseDragAndDrop from './types'; @@ -50,74 +50,68 @@ const useDragAndDrop: UseDragAndDrop = ({ } }, [isFocused, isDisabled]); - const handleDragEvent = useCallback( - (event: DragEvent) => { - if (!shouldHandleDragEvent) { - return; - } + const handleDragEvent = (event: DragEvent) => { + if (!shouldHandleDragEvent) { + return; + } - const shouldAcceptThisDrop = shouldAllowDrop && shouldAcceptDrop(event); + const shouldAcceptThisDrop = shouldAllowDrop && shouldAcceptDrop(event); - if (shouldAcceptThisDrop && event.type === DRAG_ENTER_EVENT) { - closePopover(); - } + if (shouldAcceptThisDrop && event.type === DRAG_ENTER_EVENT) { + closePopover(); + } - const effect = shouldAcceptThisDrop ? COPY_DROP_EFFECT : NONE_DROP_EFFECT; + const effect = shouldAcceptThisDrop ? COPY_DROP_EFFECT : NONE_DROP_EFFECT; - if (event.dataTransfer) { - // eslint-disable-next-line no-param-reassign - event.dataTransfer.dropEffect = effect; - // eslint-disable-next-line no-param-reassign - event.dataTransfer.effectAllowed = effect; - } - }, - [shouldHandleDragEvent, shouldAllowDrop, shouldAcceptDrop, closePopover], - ); + if (event.dataTransfer) { + // eslint-disable-next-line no-param-reassign + event.dataTransfer.dropEffect = effect; + // eslint-disable-next-line no-param-reassign + event.dataTransfer.effectAllowed = effect; + } + }; /** * Handles all types of drag-N-drop events on the drop zone associated with composer */ - const dropZoneDragHandler = useCallback( - (event: DragEvent) => { - if (!isFocused || isDisabled || !shouldAcceptDrop(event)) { - return; - } + const dropZoneDragHandler = (event: DragEvent) => { + if (!isFocused || isDisabled || !shouldAcceptDrop(event)) { + return; + } - event.preventDefault(); - if (shouldStopPropagation) { - event.stopPropagation(); - } + event.preventDefault(); + if (shouldStopPropagation) { + event.stopPropagation(); + } - // Clear any existing debounce timeout - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - debounceTimeoutRef.current = null; - } + // Clear any existing debounce timeout + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + debounceTimeoutRef.current = null; + } - switch (event.type) { - case DRAG_OVER_EVENT: - case DRAG_ENTER_EVENT: - handleDragEvent(event); - if (!isDraggingOver) { - setIsDraggingOver(true); - } - break; - case DRAG_LEAVE_EVENT: - // Add small debounce to prevent rapid flickering - debounceTimeoutRef.current = setTimeout(() => { - setIsDraggingOver(false); - }, 50); - break; - case DROP_EVENT: + switch (event.type) { + case DRAG_OVER_EVENT: + case DRAG_ENTER_EVENT: + handleDragEvent(event); + if (!isDraggingOver) { + setIsDraggingOver(true); + } + break; + case DRAG_LEAVE_EVENT: + // Add small debounce to prevent rapid flickering + debounceTimeoutRef.current = setTimeout(() => { setIsDraggingOver(false); - onDrop(event); - break; - default: - break; - } - }, - [isFocused, isDisabled, shouldAcceptDrop, shouldStopPropagation, handleDragEvent, isDraggingOver, onDrop], - ); + }, 50); + break; + case DROP_EVENT: + setIsDraggingOver(false); + onDrop(event); + break; + default: + break; + } + }; useEffect(() => { if (!dropZone.current) { diff --git a/src/hooks/useDuplicateFeedDetection.ts b/src/hooks/useDuplicateFeedDetection.ts index ae417a89d096..4bd54688cb41 100644 --- a/src/hooks/useDuplicateFeedDetection.ts +++ b/src/hooks/useDuplicateFeedDetection.ts @@ -3,7 +3,7 @@ import Navigation from '@libs/Navigation/Navigation'; import ROUTES from '@src/ROUTES'; import type {CompanyCardFeedWithDomainID} from '@src/types/onyx'; -import {useCallback, useEffect, useRef} from 'react'; +import {useEffect, useRef} from 'react'; import useConfirmModal from './useConfirmModal'; import useLocalize from './useLocalize'; @@ -34,31 +34,28 @@ function useDuplicateFeedDetection({policyID, isPlaid}: UseDuplicateFeedDetectio translateRef.current = translate; }, [translate]); - const checkForDuplicateFeed = useCallback( - (newFeed: CompanyCardFeedWithDomainID | undefined) => { - if (newFeed) { - hasEverDetectedNewFeed.current = true; - } - - const isDuplicateFeed = !newFeed && isPlaid && !hasEverDetectedNewFeed.current; - if (isDuplicateFeed && !hasShownDuplicateModal.current) { - hasShownDuplicateModal.current = true; - Navigation.closeRHPFlow(); - Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID), {forceReplace: true}); - showConfirmModalRef.current({ - title: translateRef.current('workspace.companyCards.addNewCard.duplicateFeedModal.title'), - prompt: translateRef.current('workspace.companyCards.addNewCard.duplicateFeedModal.prompt'), - confirmText: translateRef.current('common.buttonConfirm'), - shouldShowCancelButton: false, - shouldHandleNavigationBack: false, - }); - return true; - } - - return false; - }, - [isPlaid, policyID], - ); + const checkForDuplicateFeed = (newFeed: CompanyCardFeedWithDomainID | undefined) => { + if (newFeed) { + hasEverDetectedNewFeed.current = true; + } + + const isDuplicateFeed = !newFeed && isPlaid && !hasEverDetectedNewFeed.current; + if (isDuplicateFeed && !hasShownDuplicateModal.current) { + hasShownDuplicateModal.current = true; + Navigation.closeRHPFlow(); + Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID), {forceReplace: true}); + showConfirmModalRef.current({ + title: translateRef.current('workspace.companyCards.addNewCard.duplicateFeedModal.title'), + prompt: translateRef.current('workspace.companyCards.addNewCard.duplicateFeedModal.prompt'), + confirmText: translateRef.current('common.buttonConfirm'), + shouldShowCancelButton: false, + shouldHandleNavigationBack: false, + }); + return true; + } + + return false; + }; return {checkForDuplicateFeed}; } diff --git a/src/hooks/useDuplicateTransactionsAndViolations.ts b/src/hooks/useDuplicateTransactionsAndViolations.ts index 83d5592c981d..3f1bce1156d5 100644 --- a/src/hooks/useDuplicateTransactionsAndViolations.ts +++ b/src/hooks/useDuplicateTransactionsAndViolations.ts @@ -4,7 +4,6 @@ import type {Transaction, TransactionViolation, TransactionViolations} from '@sr import type {OnyxCollection} from 'react-native-onyx'; -import {useMemo} from 'react'; // Use the original useOnyx hook to get the real-time data from Onyx and not from the snapshot // eslint-disable-next-line no-restricted-imports import {useOnyx as useOnyxWithoutSnapshots} from 'react-native-onyx'; @@ -121,29 +120,22 @@ type DuplicateTransactionsAndViolations = { * @returns - An object containing duplicate transactions and their violations. */ function useDuplicateTransactionsAndViolations(transactionIDs: string[]): DuplicateTransactionsAndViolations { - const violationsSelectorMemo = useMemo(() => { - return (allTransactionsViolations: OnyxCollection) => selectViolationsWithDuplicates(transactionIDs, allTransactionsViolations); - }, [transactionIDs]); + const violationsSelectorMemo = (allTransactionsViolations: OnyxCollection) => selectViolationsWithDuplicates(transactionIDs, allTransactionsViolations); const [duplicateTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, { selector: violationsSelectorMemo, }); - const transactionSelector = useMemo(() => { - return (allTransactions: OnyxCollection) => selectTransactionsWithDuplicates(transactionIDs, allTransactions, duplicateTransactionViolations); - }, [transactionIDs, duplicateTransactionViolations]); + const transactionSelector = (allTransactions: OnyxCollection) => selectTransactionsWithDuplicates(transactionIDs, allTransactions, duplicateTransactionViolations); const [duplicateTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { selector: transactionSelector, }); - return useMemo( - () => ({ - duplicateTransactions, - duplicateTransactionViolations, - }), - [duplicateTransactions, duplicateTransactionViolations], - ); + return { + duplicateTransactions, + duplicateTransactionViolations, + }; } /** @@ -154,29 +146,22 @@ function useDuplicateTransactionsAndViolations(transactionIDs: string[]): Duplic * @returns - An object containing duplicate transactions and their violations. */ function useLiveDuplicateTransactionsAndViolations(transactionIDs: string[]): DuplicateTransactionsAndViolations { - const violationsSelectorMemo = useMemo(() => { - return (allTransactionsViolations: OnyxCollection) => selectViolationsWithDuplicates(transactionIDs, allTransactionsViolations); - }, [transactionIDs]); + const violationsSelectorMemo = (allTransactionsViolations: OnyxCollection) => selectViolationsWithDuplicates(transactionIDs, allTransactionsViolations); const [duplicateTransactionViolations] = useOnyxWithoutSnapshots(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, { selector: violationsSelectorMemo, }); - const transactionSelector = useMemo(() => { - return (allTransactions: OnyxCollection) => selectTransactionsWithDuplicates(transactionIDs, allTransactions, duplicateTransactionViolations); - }, [transactionIDs, duplicateTransactionViolations]); + const transactionSelector = (allTransactions: OnyxCollection) => selectTransactionsWithDuplicates(transactionIDs, allTransactions, duplicateTransactionViolations); const [duplicateTransactions] = useOnyxWithoutSnapshots(ONYXKEYS.COLLECTION.TRANSACTION, { selector: transactionSelector, }); - return useMemo( - () => ({ - duplicateTransactions, - duplicateTransactionViolations, - }), - [duplicateTransactions, duplicateTransactionViolations], - ); + return { + duplicateTransactions, + duplicateTransactionViolations, + }; } export {useLiveDuplicateTransactionsAndViolations}; diff --git a/src/hooks/useEReceipt.tsx b/src/hooks/useEReceipt.tsx index 6f105bd43a07..692686b9f6d2 100644 --- a/src/hooks/useEReceipt.tsx +++ b/src/hooks/useEReceipt.tsx @@ -8,8 +8,6 @@ import {getTripEReceiptIcon} from '@libs/TripReservationUtils'; import CONST from '@src/CONST'; import type {Transaction} from '@src/types/onyx'; -import {useMemo} from 'react'; - import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; import useStyleUtils from './useStyleUtils'; @@ -36,7 +34,7 @@ export default function useEReceipt(transactionData: Transaction | TransactionLi const MCCIcon = transactionMCCGroup ? MCCIcons[`${transactionMCCGroup}`] : undefined; const tripIcon = getTripEReceiptIcon(expensifyIcons, transactionData); - const backgroundImage = useMemo(() => backgroundImages[colorCode], [colorCode]); + const backgroundImage = backgroundImages[colorCode]; return { primaryColor, diff --git a/src/hooks/useEndSubmitNavigationSpans.ts b/src/hooks/useEndSubmitNavigationSpans.ts index a2b21c1b0edc..217ae47a4591 100644 --- a/src/hooks/useEndSubmitNavigationSpans.ts +++ b/src/hooks/useEndSubmitNavigationSpans.ts @@ -2,7 +2,7 @@ import {endSubmitFollowUpActionSpan, getPendingSubmitFollowUpAction} from '@libs import CONST from '@src/CONST'; -import {useCallback, useRef} from 'react'; +import {useRef} from 'react'; type Options = { requireLayout?: boolean; @@ -23,41 +23,38 @@ function useEndSubmitNavigationSpans({requireLayout = true}: Options = {}): (was const hadFocusRef = useRef(false); const hadLayoutRef = useRef(false); - return useCallback( - (wasListEmpty: boolean, source: 'focus' | 'layout') => { - if (requireLayout) { - if (source === 'focus') { - hadFocusRef.current = true; - } else { - hadLayoutRef.current = true; - } - - const pendingForGate = getPendingSubmitFollowUpAction(); - const canEndOnFocusAlone = pendingForGate?.followUpAction !== CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT; - const hasBothSignals = hadFocusRef.current && hadLayoutRef.current; - const hasFocusOnly = hadFocusRef.current && canEndOnFocusAlone; - - if (!hasBothSignals && !hasFocusOnly) { - return; - } - - hadFocusRef.current = false; - hadLayoutRef.current = false; + return (wasListEmpty: boolean, source: 'focus' | 'layout') => { + if (requireLayout) { + if (source === 'focus') { + hadFocusRef.current = true; + } else { + hadLayoutRef.current = true; } - // Re-read after the gate check - the value is synchronous module state so it - // can't change between the two reads in the same tick, but reading once here - // keeps the logic self-contained for the actual end-span decision. - const pending = getPendingSubmitFollowUpAction(); - if (pending && pending.followUpAction !== CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT) { - endSubmitFollowUpActionSpan(pending.followUpAction, undefined, { - [CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true, - [CONST.TELEMETRY.ATTRIBUTE_WAS_LIST_EMPTY]: wasListEmpty, - }); + const pendingForGate = getPendingSubmitFollowUpAction(); + const canEndOnFocusAlone = pendingForGate?.followUpAction !== CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT; + const hasBothSignals = hadFocusRef.current && hadLayoutRef.current; + const hasFocusOnly = hadFocusRef.current && canEndOnFocusAlone; + + if (!hasBothSignals && !hasFocusOnly) { + return; } - }, - [requireLayout], - ); + + hadFocusRef.current = false; + hadLayoutRef.current = false; + } + + // Re-read after the gate check - the value is synchronous module state so it + // can't change between the two reads in the same tick, but reading once here + // keeps the logic self-contained for the actual end-span decision. + const pending = getPendingSubmitFollowUpAction(); + if (pending && pending.followUpAction !== CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT) { + endSubmitFollowUpActionSpan(pending.followUpAction, undefined, { + [CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true, + [CONST.TELEMETRY.ATTRIBUTE_WAS_LIST_EMPTY]: wasListEmpty, + }); + } + }; } export default useEndSubmitNavigationSpans; diff --git a/src/hooks/useEnterKeyHandler.ts b/src/hooks/useEnterKeyHandler.ts index b9d7733e08df..26f3b81b0948 100644 --- a/src/hooks/useEnterKeyHandler.ts +++ b/src/hooks/useEnterKeyHandler.ts @@ -2,8 +2,6 @@ import CONST from '@src/CONST'; import type {KeyboardEvent} from 'react'; -import {useCallback} from 'react'; - /** * Custom hook that returns a keyboard event handler which triggers a callback * when the Enter key is pressed. @@ -15,16 +13,13 @@ import {useCallback} from 'react'; * const handleKeyDown = useEnterKeyHandler(() => navigation.navigate('Home')); */ function useEnterKeyHandler(callback: () => void) { - return useCallback( - (event: KeyboardEvent) => { - if (event.key !== CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) { - return; - } - event.preventDefault(); - callback(); - }, - [callback], - ); + return (event: KeyboardEvent) => { + if (event.key !== CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) { + return; + } + event.preventDefault(); + callback(); + }; } export default useEnterKeyHandler; diff --git a/src/hooks/useExpensifyCardFeeds.ts b/src/hooks/useExpensifyCardFeeds.ts index 5ecd9d52f79e..935b8cb58436 100644 --- a/src/hooks/useExpensifyCardFeeds.ts +++ b/src/hooks/useExpensifyCardFeeds.ts @@ -5,28 +5,23 @@ import type {ExpensifyCardSettings} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; -import {useCallback} from 'react'; - import useOnyx from './useOnyx'; import useWorkspaceAccountID from './useWorkspaceAccountID'; function useExpensifyCardFeeds(policyID: string | undefined) { const workspaceAccountID = useWorkspaceAccountID(policyID); - const getAllExpensifyCardFeeds = useCallback( - (cardSettings: OnyxCollection) => { - const matchingEntries = Object.entries(cardSettings ?? {}).filter(([key, settings]) => { - const isDomainFeed = - !!(policyID && isPolicyIDInLinkedExpensifyCardPolicyList(getLinkedPolicyIDsFromExpensifyCardSettings(settings), policyID)) || - (!!policyID && getPreferredPolicyFromExpensifyCardSettings(settings)?.toUpperCase() === policyID.toUpperCase()); - const isWorkspaceFeed = key.includes(workspaceAccountID.toString()) && settings && Object.keys(settings).length > 1; - return isDomainFeed || isWorkspaceFeed; - }); - - return Object.fromEntries(matchingEntries); - }, - [policyID, workspaceAccountID], - ); + const getAllExpensifyCardFeeds = (cardSettings: OnyxCollection) => { + const matchingEntries = Object.entries(cardSettings ?? {}).filter(([key, settings]) => { + const isDomainFeed = + !!(policyID && isPolicyIDInLinkedExpensifyCardPolicyList(getLinkedPolicyIDsFromExpensifyCardSettings(settings), policyID)) || + (!!policyID && getPreferredPolicyFromExpensifyCardSettings(settings)?.toUpperCase() === policyID.toUpperCase()); + const isWorkspaceFeed = key.includes(workspaceAccountID.toString()) && settings && Object.keys(settings).length > 1; + return isDomainFeed || isWorkspaceFeed; + }); + + return Object.fromEntries(matchingEntries); + }; const [allExpensifyCardFeeds] = useOnyx(ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS, { selector: getAllExpensifyCardFeeds, diff --git a/src/hooks/useFeedKeysWithAssignedCards.ts b/src/hooks/useFeedKeysWithAssignedCards.ts index cdba9fd9993f..31b352be0891 100644 --- a/src/hooks/useFeedKeysWithAssignedCards.ts +++ b/src/hooks/useFeedKeysWithAssignedCards.ts @@ -4,14 +4,13 @@ import type {WorkspaceCardsList} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; import {buildFeedKeysWithAssignedCards} from '@selectors/Card'; -import {useCallback} from 'react'; import useOnyx from './useOnyx'; type FeedKeysWithAssignedCards = Record; function useFeedKeysWithAssignedCards(): FeedKeysWithAssignedCards | undefined { - const feedKeysWithCardsSelector = useCallback((allWorkspaceCards: OnyxCollection) => buildFeedKeysWithAssignedCards(allWorkspaceCards), []); + const feedKeysWithCardsSelector = (allWorkspaceCards: OnyxCollection) => buildFeedKeysWithAssignedCards(allWorkspaceCards); const [feedKeysWithCards] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, { selector: feedKeysWithCardsSelector, }); diff --git a/src/hooks/useFilterFormValues.tsx b/src/hooks/useFilterFormValues.tsx index e72d1abf66db..d81beadda4c1 100644 --- a/src/hooks/useFilterFormValues.tsx +++ b/src/hooks/useFilterFormValues.tsx @@ -13,8 +13,6 @@ import {getEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection} from 'react-native-onyx'; -import {useMemo} from 'react'; - import {useCurrencyListState} from './useCurrencyList'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useExportedToFilterOptions from './useExportedToFilterOptions'; @@ -67,8 +65,8 @@ const useFilterFormValues = (queryJSON?: SearchQueryJSON) => { const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); // Helps to avoid unnecessary recalculations when user open report details screen. React Compiler does not provide same result. - const taxRates = useMemo(() => getAllTaxRates(policies), [policies]); - const allCards = useMemo(() => mergeCardListWithWorkspaceFeeds(workspaceCardFeeds ?? CONST.EMPTY_OBJECT, userCardList), [workspaceCardFeeds, userCardList]); + const taxRates = getAllTaxRates(policies); + const allCards = mergeCardListWithWorkspaceFeeds(workspaceCardFeeds ?? CONST.EMPTY_OBJECT, userCardList); const {exportedToFilterOptions} = useExportedToFilterOptions(); const formValues = queryJSON diff --git a/src/hooks/useFilterSelectedTransactions.ts b/src/hooks/useFilterSelectedTransactions.ts index 48ef7b97935e..bb07850a399d 100644 --- a/src/hooks/useFilterSelectedTransactions.ts +++ b/src/hooks/useFilterSelectedTransactions.ts @@ -2,7 +2,7 @@ import {useSearchSelectionActions, useSearchSelectionContext} from '@components/ import type {Transaction} from '@src/types/onyx'; -import {useEffect, useMemo} from 'react'; +import {useEffect} from 'react'; /** * Hook that filters selected transaction IDs to only include transactions that exist in the provided list. @@ -11,12 +11,13 @@ import {useEffect, useMemo} from 'react'; * @param transactions - The current list of transactions * @param reportID - The report that owns the current transaction list */ + function useFilterSelectedTransactions(transactions: Transaction[], reportID?: string) { const {selectedTransactionIDs, currentSelectedTransactionReportID} = useSearchSelectionContext(); const {setSelectedTransactions} = useSearchSelectionActions(); - const transactionIDs = useMemo(() => transactions.map((transaction) => transaction.transactionID), [transactions]); - const filteredSelectedTransactionIDs = useMemo(() => selectedTransactionIDs.filter((id) => transactionIDs.includes(id)), [selectedTransactionIDs, transactionIDs]); + const transactionIDs = new Set(transactions.map((transaction) => transaction.transactionID)); + const filteredSelectedTransactionIDs = selectedTransactionIDs.filter((id) => transactionIDs.has(id)); useEffect(() => { if (filteredSelectedTransactionIDs.length === selectedTransactionIDs.length) { return; diff --git a/src/hooks/useFilteredOptions.ts b/src/hooks/useFilteredOptions.ts index 6ee22dad1f45..08b0df0cf3da 100644 --- a/src/hooks/useFilteredOptions.ts +++ b/src/hooks/useFilteredOptions.ts @@ -9,7 +9,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import {pendingDeleteMemberAccountIDsByReportIDSelector} from '@selectors/ReportMetaData'; -import {useCallback, useMemo, useState} from 'react'; +import {useState} from 'react'; import {useCurrencyListActions} from './useCurrencyList'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; @@ -122,69 +122,41 @@ function useFilteredOptions(config: UseFilteredOptionsConfig): UseFilteredOption const totalReports = allReports ? Object.keys(allReports).length : 0; // Kept referentially stable so consumers can pass it into memoized option builders without busting their caches. - const getReportByID = useCallback((reportID: string | undefined): OnyxEntry => allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`], [allReports]); + const getReportByID = (reportID: string | undefined): OnyxEntry => allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`]; // React Compiler can't prove referential stability for the destructured `config` param with default values, so explicit useMemo is required here. - const options: OptionList | null = useMemo( - () => - enabled && allReports && allPersonalDetails - ? createFilteredOptionList( - allPersonalDetails, - allReports, - reportAttributesDerived, - privateIsArchivedMap, - allPolicies, - { - currentUserAccountID, - currentUserLogin, - transactionThreadIDs, - lastActions, - dateFnsLocale, - convertToDisplayString, - conciergeReportID, - maxRecentReports: reportsLimit, - includeP2P, - isSearching, - deferContactsUntilSearch, - locale: preferredLocale, - pendingDeleteMemberAccountIDsByReportID, - formatPhoneNumber, - activePolicyID, - }, - rules, - undefined, - undefined, - isTrackIntentUser, - sortedActions, - ) - : null, - [ - enabled, - allReports, - allPersonalDetails, - reportAttributesDerived, - privateIsArchivedMap, - allPolicies, - conciergeReportID, - rules, - reportsLimit, - includeP2P, - isSearching, - deferContactsUntilSearch, - preferredLocale, - isTrackIntentUser, - currentUserLogin, - sortedActions, - transactionThreadIDs, - lastActions, - currentUserAccountID, - pendingDeleteMemberAccountIDsByReportID, - dateFnsLocale, - convertToDisplayString, - formatPhoneNumber, - activePolicyID, - ], - ); + const options: OptionList | null = + enabled && allReports && allPersonalDetails + ? createFilteredOptionList( + allPersonalDetails, + allReports, + reportAttributesDerived, + privateIsArchivedMap, + allPolicies, + { + currentUserAccountID, + currentUserLogin, + transactionThreadIDs, + lastActions, + dateFnsLocale, + convertToDisplayString, + conciergeReportID, + maxRecentReports: reportsLimit, + includeP2P, + isSearching, + deferContactsUntilSearch, + locale: preferredLocale, + pendingDeleteMemberAccountIDsByReportID, + formatPhoneNumber, + activePolicyID, + }, + rules, + undefined, + undefined, + isTrackIntentUser, + sortedActions, + ) + : null; // When isSearching is set to true, the createFilteredOptionList returns all reports const hasMore = !isSearching && options ? reportsLimit < totalReports : false; @@ -198,9 +170,9 @@ function useFilteredOptions(config: UseFilteredOptionsConfig): UseFilteredOption // Expand the window to cover every report in a single step. Used when the visible list is empty so the // option list is rebuilt once to surface any surviving row, instead of paginating batch-by-batch. - const loadAll = useCallback(() => { + const loadAll = () => { setReportsLimit((prev) => (prev < totalReports ? totalReports : prev)); - }, [totalReports]); + }; return { options, diff --git a/src/hooks/useGetReceiptPartnersIntegrationData.ts b/src/hooks/useGetReceiptPartnersIntegrationData.ts index a8ede44681dd..df36ae4c0353 100644 --- a/src/hooks/useGetReceiptPartnersIntegrationData.ts +++ b/src/hooks/useGetReceiptPartnersIntegrationData.ts @@ -1,7 +1,5 @@ import CONST from '@src/CONST'; -import {useCallback} from 'react'; - import useIsPolicyConnectedToUberReceiptPartner from './useIsPolicyConnectedToUberReceiptPartner'; import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; import useLocalize from './useLocalize'; @@ -15,24 +13,21 @@ export default function useGetReceiptPartnersIntegrationData(policyID?: string) const isUberConnected = useIsPolicyConnectedToUberReceiptPartner({policyID}); const shouldShowEnterCredentialsError = !!uber?.error; - const getReceiptPartnersIntegrationData = useCallback( - (receiptPartnerName: string) => { - switch (receiptPartnerName) { - case CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER: { - return { - title: CONST.POLICY.RECEIPT_PARTNERS.NAME_USER_FRIENDLY[CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER], - description: translate('workspace.receiptPartners.uber.subtitle', uber?.organizationName ?? ''), - icon: icons.Uber, - errorFields: uber?.errorFields, - errors: uber?.errors, - }; - } - default: - return undefined; + const getReceiptPartnersIntegrationData = (receiptPartnerName: string) => { + switch (receiptPartnerName) { + case CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER: { + return { + title: CONST.POLICY.RECEIPT_PARTNERS.NAME_USER_FRIENDLY[CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER], + description: translate('workspace.receiptPartners.uber.subtitle', uber?.organizationName ?? ''), + icon: icons.Uber, + errorFields: uber?.errorFields, + errors: uber?.errors, + }; } - }, - [translate, uber?.errorFields, uber?.errors, uber?.organizationName, icons], - ); + default: + return undefined; + } + }; return {getReceiptPartnersIntegrationData, shouldShowEnterCredentialsError, isUberConnected}; } diff --git a/src/hooks/useHasOutstandingChildTask.ts b/src/hooks/useHasOutstandingChildTask.ts index eebf60b508fe..c102e7c483e2 100644 --- a/src/hooks/useHasOutstandingChildTask.ts +++ b/src/hooks/useHasOutstandingChildTask.ts @@ -6,8 +6,6 @@ import type {Report} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {useMemo} from 'react'; - import useOnyx from './useOnyx'; /** @@ -18,28 +16,26 @@ import useOnyx from './useOnyx'; function useHasOutstandingChildTask(taskReport: OnyxEntry): boolean { const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${taskReport?.parentReportID}`); - return useMemo(() => { - if (!taskReport?.parentReportID || !reportActions) { + if (!taskReport?.parentReportID || !reportActions) { + return false; + } + + return Object.values(reportActions).some((reportAction) => { + if (String(reportAction.childReportID) === String(taskReport?.reportID)) { return false; } - return Object.values(reportActions).some((reportAction) => { - if (String(reportAction.childReportID) === String(taskReport?.reportID)) { - return false; - } - - if ( - reportAction.childType === CONST.REPORT.TYPE.TASK && - reportAction?.childStateNum === CONST.REPORT.STATE_NUM.OPEN && - reportAction?.childStatusNum === CONST.REPORT.STATUS_NUM.OPEN && - !getReportActionMessage(reportAction)?.isDeletedParentAction - ) { - return true; - } + if ( + reportAction.childType === CONST.REPORT.TYPE.TASK && + reportAction?.childStateNum === CONST.REPORT.STATE_NUM.OPEN && + reportAction?.childStatusNum === CONST.REPORT.STATUS_NUM.OPEN && + !getReportActionMessage(reportAction)?.isDeletedParentAction + ) { + return true; + } - return false; - }); - }, [taskReport?.parentReportID, taskReport?.reportID, reportActions]); + return false; + }); } export default useHasOutstandingChildTask; diff --git a/src/hooks/useHasPerDiemTransactions.ts b/src/hooks/useHasPerDiemTransactions.ts index 1f10b0fe0b35..6ae752a11fe4 100644 --- a/src/hooks/useHasPerDiemTransactions.ts +++ b/src/hooks/useHasPerDiemTransactions.ts @@ -5,8 +5,6 @@ import type {Transaction} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; -import {useCallback} from 'react'; - import useOnyx from './useOnyx'; const hasPerDiemTransactionsSelector = (transactions: OnyxCollection, transactionIDs: string[]) => { @@ -17,7 +15,7 @@ const hasPerDiemTransactionsSelector = (transactions: OnyxCollection) => hasPerDiemTransactionsSelector(transactions, transactionIDs), [transactionIDs]); + const selector = (transactions: OnyxCollection) => hasPerDiemTransactionsSelector(transactions, transactionIDs); const [hasPerDiemTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { selector, diff --git a/src/hooks/useImportPlaidAccounts.ts b/src/hooks/useImportPlaidAccounts.ts index 2f315a449b61..fb56e137e39a 100644 --- a/src/hooks/useImportPlaidAccounts.ts +++ b/src/hooks/useImportPlaidAccounts.ts @@ -4,8 +4,6 @@ import {importPlaidAccounts} from '@userActions/Plaid'; import ONYXKEYS from '@src/ONYXKEYS'; -import {useCallback} from 'react'; - import useOnyx from './useOnyx'; export default function useImportPlaidAccounts(policyID?: string) { @@ -18,10 +16,10 @@ export default function useImportPlaidAccounts(policyID?: string) { const plaidAccounts = addNewCard?.data?.plaidAccounts ?? assignCard?.cardToAssign?.plaidAccounts; const country = addNewCard?.data?.selectedCountry; - return useCallback(() => { + return () => { if (!policyID || !plaidToken || !plaidFeed || !plaidFeedName || !country || !plaidAccounts?.length) { return; } importPlaidAccounts(plaidToken, plaidFeed, plaidFeedName, country, getDomainNameForPolicy(policyID), JSON.stringify(plaidAccounts), ''); - }, [country, plaidAccounts, plaidFeed, plaidFeedName, plaidToken, policyID]); + }; } diff --git a/src/hooks/useIsPaidPolicyAdmin.ts b/src/hooks/useIsPaidPolicyAdmin.ts index f8c1dd8bd67c..627d28b709fa 100644 --- a/src/hooks/useIsPaidPolicyAdmin.ts +++ b/src/hooks/useIsPaidPolicyAdmin.ts @@ -5,8 +5,6 @@ import type {Policy} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; -import {useCallback} from 'react'; - import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useOnyx from './useOnyx'; @@ -16,12 +14,9 @@ import useOnyx from './useOnyx'; function useIsPaidPolicyAdmin() { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const isUserPaidPolicyAdminSelector = useCallback( - (policies: OnyxCollection) => { - return Object.values(policies ?? {}).some((policy) => isPaidGroupPolicy(policy) && isPolicyAdmin(policy, currentUserPersonalDetails.login)); - }, - [currentUserPersonalDetails.login], - ); + const isUserPaidPolicyAdminSelector = (policies: OnyxCollection) => { + return Object.values(policies ?? {}).some((policy) => isPaidGroupPolicy(policy) && isPolicyAdmin(policy, currentUserPersonalDetails.login)); + }; const [isCurrentUserPolicyAdmin = false] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { selector: isUserPaidPolicyAdminSelector, diff --git a/src/hooks/useIsReportReadyToDisplay.ts b/src/hooks/useIsReportReadyToDisplay.ts index 2c08d30a572e..279a14c47db1 100644 --- a/src/hooks/useIsReportReadyToDisplay.ts +++ b/src/hooks/useIsReportReadyToDisplay.ts @@ -4,22 +4,15 @@ import type {Report} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {useMemo} from 'react'; - function useIsReportReadyToDisplay(report: OnyxEntry, reportIDFromRoute: string | undefined, isReportArchived: boolean | undefined) { /** * When false the report is not ready to be fully displayed */ - const isCurrentReportLoadedFromOnyx = useMemo((): boolean => { - // This is necessary so that when we are retrieving the next report data from Onyx the ReportActionsList will remount completely - const isTransitioning = report && report?.reportID?.toString() !== reportIDFromRoute; - return reportIDFromRoute !== '' && !!report?.reportID && !isTransitioning; - }, [report, reportIDFromRoute]); + // This is necessary so that when we are retrieving the next report data from Onyx the ReportActionsList will remount completely + const isTransitioning = report && report?.reportID?.toString() !== reportIDFromRoute; + const isCurrentReportLoadedFromOnyx = reportIDFromRoute !== '' && !!report?.reportID && !isTransitioning; - const isEditingDisabled = useMemo( - () => !isCurrentReportLoadedFromOnyx || !canUserPerformWriteAction(report, isReportArchived), - [isCurrentReportLoadedFromOnyx, report, isReportArchived], - ); + const isEditingDisabled = !isCurrentReportLoadedFromOnyx || !canUserPerformWriteAction(report, isReportArchived); return { isCurrentReportLoadedFromOnyx, diff --git a/src/hooks/useIsScrollBarVisible/index.ts b/src/hooks/useIsScrollBarVisible/index.ts index ba535d1d8338..78a176642b77 100644 --- a/src/hooks/useIsScrollBarVisible/index.ts +++ b/src/hooks/useIsScrollBarVisible/index.ts @@ -1,15 +1,15 @@ -import {useCallback, useEffect, useState} from 'react'; +import {useEffect, useState} from 'react'; const useIsScrollBarVisible = (ref: React.RefObject, value: string) => { const [isScrollBarVisible, setIsScrollBarVisible] = useState(false); - const handleResize = useCallback(() => { + const handleResize = () => { if (!ref.current) { return; } const {scrollHeight, clientHeight} = ref.current; setIsScrollBarVisible(scrollHeight > clientHeight); - }, [ref]); + }; useEffect(() => { if (!ref.current || !('ResizeObserver' in (window || {}))) { diff --git a/src/hooks/useIsScrollLikelyLayoutTriggered.ts b/src/hooks/useIsScrollLikelyLayoutTriggered.ts index bf58df8d0dd2..a6a5a24e7c78 100644 --- a/src/hooks/useIsScrollLikelyLayoutTriggered.ts +++ b/src/hooks/useIsScrollLikelyLayoutTriggered.ts @@ -1,4 +1,4 @@ -import {useCallback, useRef} from 'react'; +import {useRef} from 'react'; import useDebounce from './useDebounce'; @@ -13,16 +13,12 @@ export default function useIsScrollLikelyLayoutTriggered() { * 1. There is a slight delay between onChangeText and onScroll * 2. Layout change will trigger onScroll multiple times */ - const debouncedLowerIsScrollLayoutTriggered = useDebounce( - useCallback(() => (isScrollLayoutTriggered.current = false), []), - 500, - {maxWait: 500}, - ); + const debouncedLowerIsScrollLayoutTriggered = useDebounce(() => (isScrollLayoutTriggered.current = false), 500, {maxWait: 500}); - const raiseIsScrollLayoutTriggered = useCallback(() => { + const raiseIsScrollLayoutTriggered = () => { isScrollLayoutTriggered.current = true; debouncedLowerIsScrollLayoutTriggered(); - }, [debouncedLowerIsScrollLayoutTriggered]); + }; return {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered}; } diff --git a/src/hooks/useIsVendorColumnAvailable.ts b/src/hooks/useIsVendorColumnAvailable.ts index c9669c0548bc..baf78b0608f1 100644 --- a/src/hooks/useIsVendorColumnAvailable.ts +++ b/src/hooks/useIsVendorColumnAvailable.ts @@ -6,8 +6,6 @@ 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'; @@ -19,7 +17,7 @@ import usePermissions from './usePermissions'; function useIsVendorColumnAvailable(): boolean { const {isBetaEnabled} = usePermissions(); const isVendorMatchingBetaEnabled = isBetaEnabled(CONST.BETAS.VENDOR_MATCHING); - const selector = useCallback((allPolicies: OnyxCollection) => hasVendorFeatureOnAnyPolicy(allPolicies, isVendorMatchingBetaEnabled), [isVendorMatchingBetaEnabled]); + const selector = (allPolicies: OnyxCollection) => hasVendorFeatureOnAnyPolicy(allPolicies, isVendorMatchingBetaEnabled); const [isVendorColumnAvailable = false] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector}); return isVendorColumnAvailable; } diff --git a/src/hooks/useLastWorkspaceNumber.ts b/src/hooks/useLastWorkspaceNumber.ts index 436bff3d0718..f0fbe21e7efb 100644 --- a/src/hooks/useLastWorkspaceNumber.ts +++ b/src/hooks/useLastWorkspaceNumber.ts @@ -6,8 +6,6 @@ import type {Policy} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; -import {useMemo} from 'react'; - import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; import usePersonalDetailByLogin from './usePersonalDetailByLogin'; @@ -19,10 +17,7 @@ function useLastWorkspaceNumber(email?: string) { const userDisplayName = usePersonalDetailByLogin(resolvedEmail, displayNameSelector); // Memoize so the POLICY-collection regex scan only re-runs when the resolved email or display name changes, not on // every render of consumers (e.g. the expense header renders this on every expense). - const selector = useMemo( - () => (policies: OnyxCollection) => lastWorkspaceNumberSelector(policies, resolvedEmail, userDisplayName, translate), - [resolvedEmail, translate, userDisplayName], - ); + const selector = (policies: OnyxCollection) => lastWorkspaceNumberSelector(policies, resolvedEmail, userDisplayName, translate); const [lastWorkspaceNumber] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector}); return lastWorkspaceNumber; } diff --git a/src/hooks/useLazyAsset.ts b/src/hooks/useLazyAsset.ts index d1f8525d67b6..2b72f3636433 100644 --- a/src/hooks/useLazyAsset.ts +++ b/src/hooks/useLazyAsset.ts @@ -9,13 +9,14 @@ import type IconAsset from '@src/types/utils/IconAsset'; import type {ReactElement} from 'react'; import type {SvgProps} from 'react-native-svg'; -import {cloneElement, isValidElement, useEffect, useMemo, useRef, useState} from 'react'; +import {cloneElement, isValidElement, useEffect, useRef, useState} from 'react'; /** * OXC can cache SVG assets as pre-rendered elements (including host `` nodes). * ImageSVG expects a component, so wrap those elements. Replacing them with PlaceholderIcon * blanks illustrations; unwrapping `.type` fails when type is the string `'svg'`. */ + const wrappedElementIconCache = new WeakMap(); function wrapElementAsIcon(element: ReactElement): IconAsset { @@ -142,8 +143,8 @@ function useMemoizedLazyAsset(importFn: () => {default: T} */ function useMemoizedLazyIllustrationsImpl(names: readonly IllustrationName[]): Record { const cachedChunk = getIllustrationsChunk(); - const namesKey = useMemo(() => names.join(','), [names]); - const namesList = useMemo(() => namesKey.split(',') as IllustrationName[], [namesKey]); + const namesKey = names.join(','); + const namesList = namesKey.split(','); // Try to get cached chunk synchronously to avoid Promise microtask delay const [assets, setAssets] = useState>(() => { @@ -163,6 +164,7 @@ function useMemoizedLazyIllustrationsImpl(names: readonly IllustrationName[]): R return; } + const requestedNames = namesKey.split(',') as IllustrationName[]; let isMounted = true; loadIllustrationsChunk() @@ -172,7 +174,7 @@ function useMemoizedLazyIllustrationsImpl(names: readonly IllustrationName[]): R } const loaded: Record = {}; - for (const name of namesList) { + for (const name of requestedNames) { loaded[name] = chunk.getIllustration(name) ?? PlaceholderIcon; } setAssets(loaded); @@ -183,7 +185,7 @@ function useMemoizedLazyIllustrationsImpl(names: readonly IllustrationName[]): R } const fallback: Record = {}; - for (const name of namesList) { + for (const name of requestedNames) { fallback[name] = PlaceholderIcon; } setAssets(fallback); @@ -192,7 +194,7 @@ function useMemoizedLazyIllustrationsImpl(names: readonly IllustrationName[]): R return () => { isMounted = false; }; - }, [namesList, cachedChunk]); + }, [namesKey, cachedChunk]); // Do not wrap this mapping in useMemo — OXC can fuse it into pre-rendered elements (#96158). const icons: Record = {}; @@ -219,14 +221,14 @@ function useMemoizedLazyIllustrations { const cachedChunk = getExpensifyIconsChunk(); - const namesKey = useMemo(() => names.join(','), [names]); - const namesList = useMemo(() => namesKey.split(',') as ExpensifyIconName[], [namesKey]); + const namesKey = names.join(','); + const namesList = namesKey.split(','); // Try to get cached chunk synchronously to avoid Promise microtask delay const [assets, setAssets] = useState>(() => { if (cachedChunk) { const loaded: Record = {}; - for (const name of namesList) { + for (const name of names) { loaded[name] = cachedChunk.getExpensifyIcon(name) ?? PlaceholderIcon; } return loaded; @@ -239,6 +241,7 @@ function useMemoizedLazyExpensifyIconsImpl(names: readonly ExpensifyIconName[]): return; } + const requestedNames = namesKey.split(',') as ExpensifyIconName[]; let isMounted = true; loadExpensifyIconsChunk() @@ -248,7 +251,7 @@ function useMemoizedLazyExpensifyIconsImpl(names: readonly ExpensifyIconName[]): } const loaded: Record = {}; - for (const name of namesList) { + for (const name of requestedNames) { loaded[name] = chunk.getExpensifyIcon(name) ?? PlaceholderIcon; } setAssets(loaded); @@ -259,7 +262,7 @@ function useMemoizedLazyExpensifyIconsImpl(names: readonly ExpensifyIconName[]): } const fallback: Record = {}; - for (const name of namesList) { + for (const name of requestedNames) { fallback[name] = PlaceholderIcon; } setAssets(fallback); @@ -268,7 +271,7 @@ function useMemoizedLazyExpensifyIconsImpl(names: readonly ExpensifyIconName[]): return () => { isMounted = false; }; - }, [namesList, cachedChunk]); + }, [namesKey, cachedChunk]); // Do not wrap this mapping in useMemo — OXC can fuse it into pre-rendered elements (#96158). const icons: Record = {}; diff --git a/src/hooks/useLocationBias.ts b/src/hooks/useLocationBias.ts index 2f55163d4594..74ce37a10c1e 100644 --- a/src/hooks/useLocationBias.ts +++ b/src/hooks/useLocationBias.ts @@ -3,55 +3,51 @@ import type {WaypointCollection} from '@src/types/onyx/Transaction'; import type {OnyxEntry} from 'react-native-onyx'; -import {useMemo} from 'react'; - /** * Construct the rectangular boundary based on user location and waypoints */ export default function useLocationBias(allWaypoints: WaypointCollection, userLocation?: OnyxEntry) { - return useMemo(() => { - const hasFilledWaypointCount = Object.values(allWaypoints).some((waypoint) => Object.keys(waypoint).length > 0); - // If there are no filled wayPoints and if user's current location cannot be retrieved, - // it is futile to arrive at a biased location. Let's return - if (!hasFilledWaypointCount && userLocation === undefined) { - return undefined; + const hasFilledWaypointCount = Object.values(allWaypoints).some((waypoint) => Object.keys(waypoint).length > 0); + // If there are no filled wayPoints and if user's current location cannot be retrieved, + // it is futile to arrive at a biased location. Let's return + if (!hasFilledWaypointCount && userLocation === undefined) { + return undefined; + } + + // Gather the longitudes and latitudes from filled waypoints. + const longitudes: number[] = Object.values(allWaypoints).reduce((accumulator: number[], waypoint) => { + if (waypoint?.lng) { + accumulator.push(waypoint.lng); } - - // Gather the longitudes and latitudes from filled waypoints. - const longitudes: number[] = Object.values(allWaypoints).reduce((accumulator: number[], waypoint) => { - if (waypoint?.lng) { - accumulator.push(waypoint.lng); - } - return accumulator; - }, []); - const latitudes: number[] = Object.values(allWaypoints).reduce((accumulator: number[], waypoint) => { - if (waypoint?.lat) { - accumulator.push(waypoint.lat); - } - return accumulator; - }, []); - - // When no filled waypoints are available but the current location of the user is available, - // let us consider the current user's location to construct a rectangular bound - if (!hasFilledWaypointCount && userLocation !== undefined) { - longitudes.push(userLocation?.longitude ?? 0); - latitudes.push(userLocation?.latitude ?? 0); + return accumulator; + }, []); + const latitudes: number[] = Object.values(allWaypoints).reduce((accumulator: number[], waypoint) => { + if (waypoint?.lat) { + accumulator.push(waypoint.lat); } - - // Extend the rectangular bound by 0.5 degree (roughly around 25-30 miles in US) - const minLat = Math.min(...latitudes) - 0.5; - const minLng = Math.min(...longitudes) - 0.5; - const maxLat = Math.max(...latitudes) + 0.5; - const maxLng = Math.max(...longitudes) + 0.5; - - // Ensuring coordinates do not go out of range. - const south = minLat > -90 ? minLat : -90; - const west = minLng > -180 ? minLng : -180; - const north = maxLat < 90 ? maxLat : 90; - const east = maxLng < 180 ? maxLng : 180; - - // Format: rectangle:south,west|north,east - const rectFormat = `rectangle:${south},${west}|${north},${east}`; - return rectFormat; - }, [userLocation, allWaypoints]); + return accumulator; + }, []); + + // When no filled waypoints are available but the current location of the user is available, + // let us consider the current user's location to construct a rectangular bound + if (!hasFilledWaypointCount && userLocation !== undefined) { + longitudes.push(userLocation?.longitude ?? 0); + latitudes.push(userLocation?.latitude ?? 0); + } + + // Extend the rectangular bound by 0.5 degree (roughly around 25-30 miles in US) + const minLat = Math.min(...latitudes) - 0.5; + const minLng = Math.min(...longitudes) - 0.5; + const maxLat = Math.max(...latitudes) + 0.5; + const maxLng = Math.max(...longitudes) + 0.5; + + // Ensuring coordinates do not go out of range. + const south = minLat > -90 ? minLat : -90; + const west = minLng > -180 ? minLng : -180; + const north = maxLat < 90 ? maxLat : 90; + const east = maxLng < 180 ? maxLng : 180; + + // Format: rectangle:south,west|north,east + const rectFormat = `rectangle:${south},${west}|${north},${east}`; + return rectFormat; } diff --git a/src/hooks/useMarkAsRead.ts b/src/hooks/useMarkAsRead.ts index 7fc9db45a894..d36062d0ab64 100644 --- a/src/hooks/useMarkAsRead.ts +++ b/src/hooks/useMarkAsRead.ts @@ -18,7 +18,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {useIsFocused, useRoute} from '@react-navigation/native'; -import {useCallback, useEffect, useEffectEvent, useRef, useState} from 'react'; +import {useEffect, useEffectEvent, useRef, useState} from 'react'; import {DeviceEventEmitter} from 'react-native'; import useAppFocusEvent from './useAppFocusEvent'; @@ -142,7 +142,7 @@ function useMarkAsRead({ // A visible browser window can regain OS focus without any visibility change, and nothing else re-runs the // read catch-up in that case, so bump a counter on app focus to re-run it. const [appFocusCount, setAppFocusCount] = useState(0); - useAppFocusEvent(useCallback(() => setAppFocusCount((count) => count + 1), [])); + useAppFocusEvent(() => setAppFocusCount((count) => count + 1)); const readActionSkippedRef = useRef(false); const userActiveSince = useRef(DateUtils.getDBTime()); diff --git a/src/hooks/useMarkdownStyle.ts b/src/hooks/useMarkdownStyle.ts index 8fe843ab8d4c..4b9ca42bb5f0 100644 --- a/src/hooks/useMarkdownStyle.ts +++ b/src/hooks/useMarkdownStyle.ts @@ -4,8 +4,6 @@ import variables from '@styles/variables'; import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; -import {useMemo} from 'react'; - import useTheme from './useTheme'; const defaultEmptyArray: Array = []; @@ -15,106 +13,100 @@ function useMarkdownStyle(hasMessageOnlyEmojis: boolean, excludeStyles: Array = useMemo( - () => ({ - color: theme.text, - backgroundColor: 'transparent', - marginLeft: 0, - paddingLeft: 0, - borderColor: 'transparent', - borderWidth: 0, - }), - [theme], - ); - - const markdownStyle = useMemo(() => { - const styling = { - syntax: { - color: theme.syntax, - }, - link: { - color: theme.link, - }, - h1: { - fontSize: fontScale.pageHeader, - }, - emoji: { - ...FontUtils.fontFamily.platform.CUSTOM_EMOJI_FONT, - fontSize: emojiFontSize, - lineHeight: lineHeightScale.pageHeader, - }, - blockquote: { - borderColor: theme.border, - borderWidth: 4, - marginLeft: 0, - paddingLeft: 6, - /** - * since blockquote has `inline-block` display -> padding-right is needed to prevent cursor overlapping - * with last character of the text node. - * As long as paddingRight > cursor.width, cursor will be displayed correctly. - */ - paddingRight: 1, - }, - code: { - fontFamily: FontUtils.fontFamily.platform.MONOSPACE.fontFamily, - fontSize: 13, // TODO: should be 15 if inside h1, see StyleUtils.getCodeFontSize - color: theme.text, - paddingHorizontal: 5, - borderColor: theme.border, - backgroundColor: theme.textBackground, - h1NestedFontSize: 15, - }, - pre: { - ...FontUtils.fontFamily.platform.MONOSPACE, - fontSize: 13, - color: theme.text, - paddingVertical: 8, - paddingHorizontal: 12, - borderColor: theme.border, - backgroundColor: theme.textBackground, - }, - mentionHere: { - color: theme.ourMentionText, - backgroundColor: theme.ourMentionBG, - borderRadius: variables.componentBorderRadiusSmall, - }, - mentionUser: { - color: theme.mentionText, - backgroundColor: theme.mentionBG, - borderRadius: variables.componentBorderRadiusSmall, - }, - mentionReport: { - color: theme.mentionText, - backgroundColor: theme.mentionBG, - }, - inlineImage: { - minWidth: variables.inlineImagePreviewMinSize, - minHeight: variables.inlineImagePreviewMinSize, - maxWidth: variables.inlineImagePreviewMaxSize, - maxHeight: variables.inlineImagePreviewMaxSize, - borderRadius: variables.componentBorderRadius, - marginTop: 4, - }, - loadingIndicator: { - primaryColor: theme.spinner, - secondaryColor: `${theme.spinner}33`, - }, - loadingIndicatorContainer: {}, - }; + const nonStylingDefaultValues: Record = { + color: theme.text, + backgroundColor: 'transparent', + marginLeft: 0, + paddingLeft: 0, + borderColor: 'transparent', + borderWidth: 0, + }; - if (excludeStyles.length) { - for (const key of excludeStyles) { - const style: Record = styling[key]; - if (style) { - for (const styleKey of Object.keys(style)) { - style[styleKey] = nonStylingDefaultValues[styleKey] ?? style[styleKey]; - } + const styling = { + syntax: { + color: theme.syntax, + }, + link: { + color: theme.link, + }, + h1: { + fontSize: fontScale.pageHeader, + }, + emoji: { + ...FontUtils.fontFamily.platform.CUSTOM_EMOJI_FONT, + fontSize: emojiFontSize, + lineHeight: lineHeightScale.pageHeader, + }, + blockquote: { + borderColor: theme.border, + borderWidth: 4, + marginLeft: 0, + paddingLeft: 6, + /** + * since blockquote has `inline-block` display -> padding-right is needed to prevent cursor overlapping + * with last character of the text node. + * As long as paddingRight > cursor.width, cursor will be displayed correctly. + */ + paddingRight: 1, + }, + code: { + fontFamily: FontUtils.fontFamily.platform.MONOSPACE.fontFamily, + fontSize: 13, + // TODO: should be 15 if inside h1, see StyleUtils.getCodeFontSize + color: theme.text, + paddingHorizontal: 5, + borderColor: theme.border, + backgroundColor: theme.textBackground, + h1NestedFontSize: 15, + }, + pre: { + ...FontUtils.fontFamily.platform.MONOSPACE, + fontSize: 13, + color: theme.text, + paddingVertical: 8, + paddingHorizontal: 12, + borderColor: theme.border, + backgroundColor: theme.textBackground, + }, + mentionHere: { + color: theme.ourMentionText, + backgroundColor: theme.ourMentionBG, + borderRadius: variables.componentBorderRadiusSmall, + }, + mentionUser: { + color: theme.mentionText, + backgroundColor: theme.mentionBG, + borderRadius: variables.componentBorderRadiusSmall, + }, + mentionReport: { + color: theme.mentionText, + backgroundColor: theme.mentionBG, + }, + inlineImage: { + minWidth: variables.inlineImagePreviewMinSize, + minHeight: variables.inlineImagePreviewMinSize, + maxWidth: variables.inlineImagePreviewMaxSize, + maxHeight: variables.inlineImagePreviewMaxSize, + borderRadius: variables.componentBorderRadius, + marginTop: 4, + }, + loadingIndicator: { + primaryColor: theme.spinner, + secondaryColor: `${theme.spinner}33`, + }, + loadingIndicatorContainer: {}, + }; + if (excludeStyles.length) { + for (const key of excludeStyles) { + const style: Record = styling[key]; + if (style) { + for (const styleKey of Object.keys(style)) { + style[styleKey] = nonStylingDefaultValues[styleKey] ?? style[styleKey]; } } } - - return styling; - }, [theme, emojiFontSize, excludeStyles, nonStylingDefaultValues]); + } + const markdownStyle = styling; return markdownStyle; } diff --git a/src/hooks/useMultipleSnapshots.ts b/src/hooks/useMultipleSnapshots.ts index 253ba3e70c2a..bae40b556067 100644 --- a/src/hooks/useMultipleSnapshots.ts +++ b/src/hooks/useMultipleSnapshots.ts @@ -4,8 +4,6 @@ import {getEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection} from 'react-native-onyx'; -import {useMemo} from 'react'; - import useOnyx from './useOnyx'; type SnapshotMap = Record; @@ -15,22 +13,20 @@ type SnapshotMap = Record; * Returns an object mapping each hash to its corresponding SearchResults. */ function useMultipleSnapshots(hashes: string[]): SnapshotMap { - const selector = useMemo(() => { - return (snapshots: OnyxCollection): SnapshotMap => { - if (!snapshots || hashes.length === 0) { - return {}; - } - - const result: SnapshotMap = {}; - for (const hash of hashes) { - const snapshot = snapshots[`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`]; - if (snapshot) { - result[hash] = snapshot; - } + const selector = (snapshots: OnyxCollection): SnapshotMap => { + if (!snapshots || hashes.length === 0) { + return {}; + } + + const result: SnapshotMap = {}; + for (const hash of hashes) { + const snapshot = snapshots[`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`]; + if (snapshot) { + result[hash] = snapshot; } - return result; - }; - }, [hashes]); + } + return result; + }; const [snapshotMap = getEmptyObject()] = useOnyx(ONYXKEYS.COLLECTION.SNAPSHOT, { selector, diff --git a/src/hooks/useNativeCamera.ts b/src/hooks/useNativeCamera.ts index c8c3ff8625ac..0e71f31b6e34 100644 --- a/src/hooks/useNativeCamera.ts +++ b/src/hooks/useNativeCamera.ts @@ -70,7 +70,7 @@ function useNativeCamera({onFocusStart, onFocusCleanup}: UseNativeCameraOptions) const [isAttachmentPickerActive, setIsAttachmentPickerActive] = useState(false); const camera = useRef(null); - const askForPermissions = useCallback(() => requestCameraPermission(translate, setCameraPermissionStatus), [translate]); + const askForPermissions = () => requestCameraPermission(translate, setCameraPermissionStatus); const {tapGesture, cameraFocusIndicatorAnimatedStyle} = useTapToFocusGesture(camera, device?.supportsFocusMetering ?? false); diff --git a/src/hooks/useOnboardingFlow.ts b/src/hooks/useOnboardingFlow.ts index c4e615c404b3..8bb5ffea0d95 100644 --- a/src/hooks/useOnboardingFlow.ts +++ b/src/hooks/useOnboardingFlow.ts @@ -18,7 +18,7 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {isSingleNewDotEntrySelector} from '@selectors/HybridApp'; import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector, wasInvitedToNewDotSelector} from '@selectors/Onboarding'; import {emailSelector} from '@selectors/Session'; -import {useCallback, useEffect} from 'react'; +import {useEffect} from 'react'; import useOnyx from './useOnyx'; import useRootNavigationState from './useRootNavigationState'; @@ -43,7 +43,7 @@ function useOnboardingFlowRouter() { // (set on cold launch and on warm url events for secure links) alongside the web URL and the active route. getCurrentUrl() // is empty on native, and getActiveRoute() serializes the secureKey query param on every platform via getPathFromState. const {initialURL} = useInitialURLState(); - const getIsVisitingSecureLink = useCallback(() => hasSecureLinkKey(getCurrentUrl()) || hasSecureLinkKey(Navigation.getActiveRoute()) || hasSecureLinkKey(initialURL), [initialURL]); + const getIsVisitingSecureLink = () => hasSecureLinkKey(getCurrentUrl()) || hasSecureLinkKey(Navigation.getActiveRoute()) || hasSecureLinkKey(initialURL); const isVisitingSecureLink = getIsVisitingSecureLink(); const [tryNewDot, tryNewDotMetadata] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, { selector: tryNewDotOnyxSelector, diff --git a/src/hooks/useOnboardingMessages/index.ts b/src/hooks/useOnboardingMessages/index.ts index 6016e66111de..96790dacaab4 100644 --- a/src/hooks/useOnboardingMessages/index.ts +++ b/src/hooks/useOnboardingMessages/index.ts @@ -2,10 +2,8 @@ import useLocalize from '@hooks/useLocalize'; import {getOnboardingMessages} from '@libs/actions/Welcome/OnboardingFlow'; -import {useMemo} from 'react'; - export default function useOnboardingMessages() { const {preferredLocale} = useLocalize(); - const onboardingMessages = useMemo(() => getOnboardingMessages(preferredLocale), [preferredLocale]); + const onboardingMessages = getOnboardingMessages(preferredLocale); return onboardingMessages; } diff --git a/src/hooks/useOutstandingBalanceGuard.tsx b/src/hooks/useOutstandingBalanceGuard.tsx index ff12abefe991..9ad4b02f7068 100644 --- a/src/hooks/useOutstandingBalanceGuard.tsx +++ b/src/hooks/useOutstandingBalanceGuard.tsx @@ -5,8 +5,6 @@ import Navigation from '@libs/Navigation/Navigation'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import {useCallback} from 'react'; - import useConfirmModal from './useConfirmModal'; import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; @@ -28,7 +26,7 @@ function useOutstandingBalanceGuard(ownedPaidPoliciesCount: number, onModalDismi const wouldBlockDeletion = (amountOwed ?? 0) > 0 && ownedPaidPoliciesCount === 1; - const shouldBlockDeletion = useCallback(() => { + const shouldBlockDeletion = () => { if (!wouldBlockDeletion) { return false; } @@ -47,7 +45,7 @@ function useOutstandingBalanceGuard(ownedPaidPoliciesCount: number, onModalDismi }); return true; - }, [onModalDismissed, showConfirmModal, translate, wouldBlockDeletion]); + }; return {shouldBlockDeletion, wouldBlockDeletion}; } diff --git a/src/hooks/useParentReportAction.ts b/src/hooks/useParentReportAction.ts index e3b1052f0e8b..d819a674483b 100644 --- a/src/hooks/useParentReportAction.ts +++ b/src/hooks/useParentReportAction.ts @@ -7,7 +7,6 @@ import type {ReportActionWithoutThreadReplyCounters} from '@selectors/ReportActi import type {OnyxEntry} from 'react-native-onyx'; import {getParentReportActionSelector, getParentReportActionWithoutThreadRepliesSelector} from '@selectors/ReportAction'; -import {useCallback} from 'react'; import useOnyx from './useOnyx'; @@ -21,13 +20,10 @@ type ParentReportFields = Pick): OnyxEntry; function useParentReportAction(report: OnyxEntry, options: UseParentReportActionOptions): ReportActionWithoutThreadReplyCounters | undefined; function useParentReportAction(report: OnyxEntry, {shouldIgnoreThreadReplies = false}: UseParentReportActionOptions = {}) { - const getParentReportAction = useCallback( - (parentReportActions: OnyxEntry) => - shouldIgnoreThreadReplies - ? getParentReportActionWithoutThreadRepliesSelector(parentReportActions, report?.parentReportActionID) - : getParentReportActionSelector(parentReportActions, report?.parentReportActionID), - [report?.parentReportActionID, shouldIgnoreThreadReplies], - ); + const getParentReportAction = (parentReportActions: OnyxEntry) => + shouldIgnoreThreadReplies + ? getParentReportActionWithoutThreadRepliesSelector(parentReportActions, report?.parentReportActionID) + : getParentReportActionSelector(parentReportActions, report?.parentReportActionID); const parentReportID = getNonEmptyStringOnyxID(report?.parentReportID); diff --git a/src/hooks/useParticipantsInvoiceReport.ts b/src/hooks/useParticipantsInvoiceReport.ts index 092aaf368877..0fd3436fc035 100644 --- a/src/hooks/useParticipantsInvoiceReport.ts +++ b/src/hooks/useParticipantsInvoiceReport.ts @@ -6,8 +6,6 @@ import type {InvoiceReceiverType} from '@src/types/onyx/Report'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import {useMemo} from 'react'; - import useOnyx from './useOnyx'; function getParticipantsInvoiceReport( @@ -44,10 +42,7 @@ function useParticipantsInvoiceReport(receiverID: string | number | undefined, r const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); - const invoiceReport = useMemo( - () => getParticipantsInvoiceReport(allReports, reportNameValuePairs, receiverID, receiverType, policyID), - [allReports, reportNameValuePairs, receiverID, receiverType, policyID], - ); + const invoiceReport = getParticipantsInvoiceReport(allReports, reportNameValuePairs, receiverID, receiverType, policyID); return invoiceReport; } diff --git a/src/hooks/usePayAndDowngrade.ts b/src/hooks/usePayAndDowngrade.ts index b2e121df6f11..518036aa4a72 100644 --- a/src/hooks/usePayAndDowngrade.ts +++ b/src/hooks/usePayAndDowngrade.ts @@ -4,7 +4,7 @@ import Navigation from '@libs/Navigation/Navigation'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import {useCallback, useEffect, useLayoutEffect, useRef} from 'react'; +import {useEffect, useLayoutEffect, useRef} from 'react'; import useOnyx from './useOnyx'; import useScreenBoundDynamicRoute from './useScreenBoundDynamicRoute'; @@ -15,9 +15,9 @@ function usePayAndDowngrade(continueAction: () => void) { const [shouldBillWhenDowngrading] = useOnyx(ONYXKEYS.SHOULD_BILL_WHEN_DOWNGRADING); const isDeletingPaidWorkspaceRef = useRef(false); - const setIsDeletingPaidWorkspace = useCallback((value: boolean) => { + const setIsDeletingPaidWorkspace = (value: boolean) => { isDeletingPaidWorkspaceRef.current = value; - }, []); + }; // Store continueAction in a ref to avoid stale closures in the useEffect below. // This ensures we always call the latest version of continueAction when the effect runs, diff --git a/src/hooks/usePaymentAnimations.ts b/src/hooks/usePaymentAnimations.ts index 548ed138eb04..71a52de57562 100644 --- a/src/hooks/usePaymentAnimations.ts +++ b/src/hooks/usePaymentAnimations.ts @@ -1,32 +1,32 @@ import HapticFeedback from '@libs/HapticFeedback'; -import {useCallback, useState} from 'react'; +import {useState} from 'react'; function usePaymentAnimations() { const [isPaidAnimationRunning, setIsPaidAnimationRunning] = useState(false); const [isApprovedAnimationRunning, setIsApprovedAnimationRunning] = useState(false); const [isSubmittingAnimationRunning, setIsSubmittingAnimationRunning] = useState(false); - const stopAnimation = useCallback(() => { + const stopAnimation = () => { setIsPaidAnimationRunning(false); setIsApprovedAnimationRunning(false); setIsSubmittingAnimationRunning(false); - }, []); + }; - const startAnimation = useCallback(() => { + const startAnimation = () => { setIsPaidAnimationRunning(true); HapticFeedback.longPress(); - }, []); + }; - const startApprovedAnimation = useCallback(() => { + const startApprovedAnimation = () => { setIsApprovedAnimationRunning(true); HapticFeedback.longPress(); - }, []); + }; - const startSubmittingAnimation = useCallback(() => { + const startSubmittingAnimation = () => { setIsSubmittingAnimationRunning(true); HapticFeedback.longPress(); - }, []); + }; return { isPaidAnimationRunning, diff --git a/src/hooks/usePaymentMethodState/index.ts b/src/hooks/usePaymentMethodState/index.ts index 0fcd5df93c09..deca1ab5c2a6 100644 --- a/src/hooks/usePaymentMethodState/index.ts +++ b/src/hooks/usePaymentMethodState/index.ts @@ -1,4 +1,4 @@ -import {useCallback, useState} from 'react'; +import {useState} from 'react'; import type {PaymentMethodState} from './types'; @@ -15,9 +15,9 @@ const initialState: PaymentMethodState = { function usePaymentMethodState() { const [paymentMethod, setPaymentMethod] = useState(initialState); - const resetSelectedPaymentMethodData = useCallback(() => { + const resetSelectedPaymentMethodData = () => { setPaymentMethod(initialState); - }, [setPaymentMethod]); + }; return { paymentMethod, diff --git a/src/hooks/usePersonalDetailsByLogin.ts b/src/hooks/usePersonalDetailsByLogin.ts index d793102cd94d..1ef52878b633 100644 --- a/src/hooks/usePersonalDetailsByLogin.ts +++ b/src/hooks/usePersonalDetailsByLogin.ts @@ -1,7 +1,5 @@ import type {PersonalDetails} from '@src/types/onyx'; -import {useMemo} from 'react'; - import {useAllPersonalDetails} from './usePersonalDetails'; /** @@ -11,18 +9,16 @@ import {useAllPersonalDetails} from './usePersonalDetails'; function usePersonalDetailsByLogin(): Record { const [personalDetails] = useAllPersonalDetails(); - return useMemo(() => { - if (!personalDetails) { - return {}; - } + if (!personalDetails) { + return {}; + } - return Object.values(personalDetails).reduce((acc: Record, detail) => { - if (detail?.login) { - acc[detail.login.toLowerCase()] = detail; - } - return acc; - }, {}); - }, [personalDetails]); + return Object.values(personalDetails).reduce((acc: Record, detail) => { + if (detail?.login) { + acc[detail.login.toLowerCase()] = detail; + } + return acc; + }, {}); } export default usePersonalDetailsByLogin; diff --git a/src/hooks/usePolicyData/index.ts b/src/hooks/usePolicyData/index.ts index afaa6efee241..c0766a68b321 100644 --- a/src/hooks/usePolicyData/index.ts +++ b/src/hooks/usePolicyData/index.ts @@ -10,8 +10,6 @@ import type {OnyxValueWithOfflineFeedback} from '@src/types/onyx/OnyxCommon'; import type {OnyxCollection} from 'react-native-onyx'; -import {useCallback, useMemo} from 'react'; - import type PolicyData from './types'; /** @@ -24,39 +22,34 @@ function usePolicyData(policyID?: string): PolicyData { const allReportsTransactionsAndViolations = useAllReportsTransactionsAndViolations(); // Stable selector for useOnyx to avoid defining the selector inline - const reportsSelectorCallback = useCallback( - (allReports: OnyxCollection) => { - if (!policyID || !allReports || !allReportsTransactionsAndViolations) { - return {}; - } + const reportsSelectorCallback = (allReports: OnyxCollection) => { + if (!policyID || !allReports || !allReportsTransactionsAndViolations) { + return {}; + } - // Filter reports to only include those that belong to the specified policy and have associated transactions - return Object.keys(allReportsTransactionsAndViolations).reduce>((acc, reportID) => { - const policyReport = allReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; - if (policyReport?.policyID === policyID) { - acc[reportID] = policyReport; - } - return acc; - }, {}); - }, - [policyID, allReportsTransactionsAndViolations], - ); + // Filter reports to only include those that belong to the specified policy and have associated transactions + return Object.keys(allReportsTransactionsAndViolations).reduce>((acc, reportID) => { + const policyReport = allReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + if (policyReport?.policyID === policyID) { + acc[reportID] = policyReport; + } + return acc; + }, {}); + }; const [tags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); const [categories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const [vendors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: reportsSelectorCallback}); - const transactionsAndViolations = useMemo(() => { - if (!reports || !allReportsTransactionsAndViolations) { - return {}; - } - return Object.keys(reports).reduce((acc, reportID) => { - if (allReportsTransactionsAndViolations[reportID]) { - acc[reportID] = allReportsTransactionsAndViolations[reportID]; - } - return acc; - }, {}); - }, [reports, allReportsTransactionsAndViolations]); + const transactionsAndViolations = + !reports || !allReportsTransactionsAndViolations + ? {} + : Object.keys(reports).reduce((acc, reportID) => { + if (allReportsTransactionsAndViolations[reportID]) { + acc[reportID] = allReportsTransactionsAndViolations[reportID]; + } + return acc; + }, {}); return { transactionsAndViolations, tags: tags ?? {}, diff --git a/src/hooks/usePreferredCurrency.ts b/src/hooks/usePreferredCurrency.ts index 0ad6a816f110..3894fd42b74b 100644 --- a/src/hooks/usePreferredCurrency.ts +++ b/src/hooks/usePreferredCurrency.ts @@ -3,8 +3,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {ValueOf} from 'type-fest'; -import {useMemo} from 'react'; - import useOnyx from './useOnyx'; import {usePersonalDetail} from './usePersonalDetails'; @@ -23,7 +21,7 @@ function usePreferredCurrency(): PreferredCurrency { const [currentUserPersonalDetail] = usePersonalDetail(session?.accountID); const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); - const paymentCardCurrency = useMemo(() => Object.values(fundList ?? {}).find((card) => card.accountData?.additionalData?.isBillingCard)?.accountData?.currency, [fundList]); + const paymentCardCurrency = Object.values(fundList ?? {}).find((card) => card.accountData?.additionalData?.isBillingCard)?.accountData?.currency; if (paymentCardCurrency) { return paymentCardCurrency; diff --git a/src/hooks/usePreferredEmojiSkinTone.ts b/src/hooks/usePreferredEmojiSkinTone.ts index 5a5140f52398..2c37286901ef 100644 --- a/src/hooks/usePreferredEmojiSkinTone.ts +++ b/src/hooks/usePreferredEmojiSkinTone.ts @@ -3,23 +3,18 @@ import {updatePreferredSkinTone as updatePreferredSkinToneAction} from '@userAct import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {useCallback} from 'react'; - import useOnyx from './useOnyx'; export default function usePreferredEmojiSkinTone() { const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE); - const updatePreferredSkinTone = useCallback( - (skinTone: number) => { - if (Number(preferredSkinTone) === Number(skinTone)) { - return; - } + const updatePreferredSkinTone = (skinTone: number) => { + if (Number(preferredSkinTone) === Number(skinTone)) { + return; + } - updatePreferredSkinToneAction(skinTone); - }, - [preferredSkinTone], - ); + updatePreferredSkinToneAction(skinTone); + }; return [preferredSkinTone, updatePreferredSkinTone] as const; } diff --git a/src/hooks/useReceiptScanDrop.tsx b/src/hooks/useReceiptScanDrop.tsx index 567da1466670..785c599148c7 100644 --- a/src/hooks/useReceiptScanDrop.tsx +++ b/src/hooks/useReceiptScanDrop.tsx @@ -17,7 +17,6 @@ import ROUTES from '@src/ROUTES'; import type {FileObject} from '@src/types/utils/Attachment'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; -import {useMemo} from 'react'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useFilesValidation from './useFilesValidation'; @@ -51,7 +50,7 @@ function useReceiptScanDrop() { }); // Memoize the new report ID to avoid re-generating it on every render and cause the hook to change, which leads to performance issues. - const newReportID = useMemo(() => generateReportID(), []); + const newReportID = generateReportID(); const saveFileAndInitMoneyRequest = (files: FileObject[]) => { if (!areBlobBackedFiles(files)) { diff --git a/src/hooks/useReimbursementAccountSubmitCallback.ts b/src/hooks/useReimbursementAccountSubmitCallback.ts index 65f90b71936a..21f43d9bc33d 100644 --- a/src/hooks/useReimbursementAccountSubmitCallback.ts +++ b/src/hooks/useReimbursementAccountSubmitCallback.ts @@ -1,6 +1,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; -import {useCallback, useEffect, useRef} from 'react'; +import {useEffect, useRef} from 'react'; import useOnyx from './useOnyx'; @@ -33,7 +33,7 @@ export default function useReimbursementAccountSubmitCallback(onSubmit?: () => v onSubmit?.(); }, [reimbursementAccount?.isLoading, reimbursementAccount?.errors, onSubmit]); - return useCallback(() => { + return () => { isSubmittingRef.current = true; - }, []); + }; } diff --git a/src/hooks/useReportActionsPagination.ts b/src/hooks/useReportActionsPagination.ts index 77a79341a71f..42d7cd6ac96c 100644 --- a/src/hooks/useReportActionsPagination.ts +++ b/src/hooks/useReportActionsPagination.ts @@ -10,7 +10,7 @@ import type {Report, ReportAction} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {useMemo, useState} from 'react'; +import {useState} from 'react'; import {useCurrencyListActions} from './useCurrencyList'; import useDelegateAccountID from './useDelegateAccountID'; @@ -63,7 +63,7 @@ function useReportActionsPagination(reportID: string | undefined, reportActionID // (https://github.com/Expensify/App/issues/93196) without changing regular inbox chat pagination. shouldSnapshotInitialLastReadTime: isConciergeChat, }); - const allReportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]); + const allReportActions = getFilteredReportActionsForReportView(unfilteredReportActions); const thread = useTransactionThread({reportID, report, allReportActions, isOffline}); @@ -73,37 +73,29 @@ function useReportActionsPagination(reportID: string | undefined, reportActionID const shouldAddCreatedAction = !isCreatedAction(lastAction) && (isMoneyRequestReport(report) || isInvoiceReport(report) || isReportTransactionThread || isConciergeChat); const [chatReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(report?.chatReportID)}`); - const reportPreviewAction = useMemo( - () => getReportPreviewReportAction(report?.chatReportID, report?.reportID, chatReportActions), - [report?.chatReportID, report?.reportID, chatReportActions], - ); + const reportPreviewAction = getReportPreviewReportAction(report?.chatReportID, report?.reportID, chatReportActions); // When we are offline before opening an IOU/Expense report, // the total of the report and sometimes the expense aren't displayed because these actions aren't returned until `OpenReport` API is complete. // We generate a fake created action here if it doesn't exist to display the total whenever possible because the total just depends on report data // and we also generate an expense action if the number of expenses in allReportActions is less than the total number of expenses // to display at least one expense action to match the total data. - const reportActionsToDisplay = useMemo( - () => - getReportActionsToDisplay( - allReportActions, - lastAction, - report, - reportPreviewAction, - thread.transactionThreadReport, - shouldAddCreatedAction, - getCurrencyDecimals, - delegateAccountID, - ), - [allReportActions, lastAction, report, reportPreviewAction, shouldAddCreatedAction, thread.transactionThreadReport, getCurrencyDecimals, delegateAccountID], + const reportActionsToDisplay = getReportActionsToDisplay( + allReportActions, + lastAction, + report, + reportPreviewAction, + thread.transactionThreadReport, + shouldAddCreatedAction, + getCurrencyDecimals, + delegateAccountID, ); - const reportActions = useMemo( - () => (reportActionsToDisplay ? getCombinedReportActions(reportActionsToDisplay, thread.transactionThreadReportID ?? null, thread.transactionThreadReportActions ?? []) : []), - [reportActionsToDisplay, thread.transactionThreadReportActions, thread.transactionThreadReportID], - ); + const reportActions = reportActionsToDisplay + ? getCombinedReportActions(reportActionsToDisplay, thread.transactionThreadReportID ?? null, thread.transactionThreadReportActions ?? []) + : []; - const allReportActionIDs = useMemo(() => allReportActions.map((action) => action.reportActionID), [allReportActions]); + const allReportActionIDs = allReportActions.map((action) => action.reportActionID); return { reportActions, diff --git a/src/hooks/useReportSubmitToPopover.tsx b/src/hooks/useReportSubmitToPopover.tsx index 17aa2d3eb73d..6c3ea8b8c0a6 100644 --- a/src/hooks/useReportSubmitToPopover.tsx +++ b/src/hooks/useReportSubmitToPopover.tsx @@ -14,7 +14,7 @@ import type {ComponentRef, RefObject} from 'react'; import {willAlertModalBecomeVisibleSelector} from '@selectors/Modal'; import {loginSelector} from '@selectors/PersonalDetails'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import useIsInLandscapeMode from './useIsInLandscapeMode'; @@ -72,17 +72,15 @@ function useReportSubmitToPopover({reportID, onSubmitSuccess, anchorAlignment = const {keyboardActiveHeight} = useKeyboardState(); const {top: topSafeAreaInset} = useSafeAreaInsets(); - const submitToPopoverContentHeight = useMemo(() => { - if (!isInLandscapeMode) { - // Portrait uses the dedicated fixed height for this popover (`POPOVER_REPORT_SUBMIT_TO_CONTENT_HEIGHT`), - // so the intent stays explicit rather than silently coupling to the generic dropdown max. - return popoverDimensions.portraitHeight; - } - + let submitToPopoverContentHeight; + if (!isInLandscapeMode) { + // Portrait uses the dedicated fixed height for this popover (`POPOVER_REPORT_SUBMIT_TO_CONTENT_HEIGHT`), + // so the intent stays explicit rather than silently coupling to the generic dropdown max. + submitToPopoverContentHeight = popoverDimensions.portraitHeight; + } else { const contentHeightLandscapeMode = calculateModalHeightInLandscapeMode(windowHeight, topSafeAreaInset, keyboardActiveHeight); - - return Math.min(popoverDimensions.height, contentHeightLandscapeMode); - }, [isInLandscapeMode, windowHeight, keyboardActiveHeight, topSafeAreaInset]); + submitToPopoverContentHeight = Math.min(popoverDimensions.height, contentHeightLandscapeMode); + } const anchorRef = useRef>(null); const oneShotOnSubmitSuccessRef = useRef<(() => void) | undefined>(undefined); const onSubmitWithManagerEmailRef = useRef(undefined); @@ -122,20 +120,20 @@ function useReportSubmitToPopover({reportID, onSubmitSuccess, anchorAlignment = const submitToContentKey = `${reportID}:${getSubmitToEmail(policy, report, ownerLogin, rules)}`; - const clearDismissGuard = useCallback(() => { + const clearDismissGuard = () => { setIsDismissGuardActive(false); ignoreNextSearchSubmitPressRef.current = false; - }, []); + }; - const consumeIgnoreNextSearchSubmitPress = useCallback(() => { + const consumeIgnoreNextSearchSubmitPress = () => { if (!ignoreNextSearchSubmitPressRef.current) { return false; } clearDismissGuard(); return true; - }, [clearDismissGuard]); + }; - const closeReportSubmitToPopover = useCallback(() => { + const closeReportSubmitToPopover = () => { canSubmitRef.current = false; onSubmitWithManagerEmailRef.current = undefined; setShouldResolveViolationsOnOpen(undefined); @@ -150,13 +148,13 @@ function useReportSubmitToPopover({reportID, onSubmitSuccess, anchorAlignment = ignoreNextSearchSubmitPressRef.current = false; }); setIsVisible(false); - }, []); + }; - const handleReportSubmitToPopoverModalHide = useCallback(() => { + const handleReportSubmitToPopoverModalHide = () => { clearDismissGuard(); - }, [clearDismissGuard]); + }; - const handleCombinedSubmitSuccess = useCallback(() => { + const handleCombinedSubmitSuccess = () => { if (!canSubmitRef.current) { return; } @@ -166,9 +164,9 @@ function useReportSubmitToPopover({reportID, onSubmitSuccess, anchorAlignment = setIsSearchSubmitFlow(false); oneShot?.(); onSubmitSuccess?.(); - }, [onSubmitSuccess]); + }; - const handleSearchSubmitWithManagerEmail = useCallback((managerEmail: string, managerAccountID?: number, shouldResolveAcknowledgedViolations?: boolean) => { + const handleSearchSubmitWithManagerEmail = (managerEmail: string, managerAccountID?: number, shouldResolveAcknowledgedViolations?: boolean) => { if (!canSubmitRef.current) { return; } @@ -180,31 +178,28 @@ function useReportSubmitToPopover({reportID, onSubmitSuccess, anchorAlignment = onSubmitWithManagerEmailRef.current = undefined; setIsSearchSubmitFlow(false); onSubmit(managerEmail, managerAccountID, shouldResolveAcknowledgedViolations); - }, []); - - const showReportSubmitToPopover = useCallback( - (options?: ReportSubmitToPopoverOpenOptions) => { - canSubmitRef.current = true; - clearDismissGuard(); - oneShotOnSubmitSuccessRef.current = options?.onSubmitSuccess; - onSubmitWithManagerEmailRef.current = options?.onSubmitWithManagerEmail; - setShouldResolveViolationsOnOpen(options?.shouldResolveAcknowledgedViolations); - setIsSearchSubmitFlow(!!options?.onSubmitWithManagerEmail); - const anchorToMeasure = getAnchorRef?.() ?? anchorRef; - calculatePopoverPosition(anchorToMeasure, anchorAlignment) - .then((pos) => { - setAnchorPosition({ - horizontal: pos.horizontal, - vertical: pos.vertical, - }); - setIsVisible(true); - }) - .catch(() => { - clearDismissGuard(); + }; + + const showReportSubmitToPopover = (options?: ReportSubmitToPopoverOpenOptions) => { + canSubmitRef.current = true; + clearDismissGuard(); + oneShotOnSubmitSuccessRef.current = options?.onSubmitSuccess; + onSubmitWithManagerEmailRef.current = options?.onSubmitWithManagerEmail; + setShouldResolveViolationsOnOpen(options?.shouldResolveAcknowledgedViolations); + setIsSearchSubmitFlow(!!options?.onSubmitWithManagerEmail); + const anchorToMeasure = getAnchorRef?.() ?? anchorRef; + calculatePopoverPosition(anchorToMeasure, anchorAlignment) + .then((pos) => { + setAnchorPosition({ + horizontal: pos.horizontal, + vertical: pos.vertical, }); - }, - [calculatePopoverPosition, anchorAlignment, getAnchorRef, clearDismissGuard], - ); + setIsVisible(true); + }) + .catch(() => { + clearDismissGuard(); + }); + }; useEffect(() => { if (!reportID || willAlertModalBecomeVisible || !pendingSearchSubmitOpenOptionsRef.current) { @@ -216,119 +211,81 @@ function useReportSubmitToPopover({reportID, onSubmitSuccess, anchorAlignment = showReportSubmitToPopover(pendingOptions); }, [reportID, willAlertModalBecomeVisible, showReportSubmitToPopover]); - const openReportSubmitToPopover = useCallback( - (options?: ReportSubmitToPopoverOpenOptions) => { - if (!reportID) { - return; - } + const openReportSubmitToPopover = (options?: ReportSubmitToPopoverOpenOptions) => { + if (!reportID) { + return; + } - if (willAlertModalBecomeVisibleRef.current) { - pendingSearchSubmitOpenOptionsRef.current = options; - return; - } + if (willAlertModalBecomeVisibleRef.current) { + pendingSearchSubmitOpenOptionsRef.current = options; + return; + } - showReportSubmitToPopover(options); - }, - [reportID, showReportSubmitToPopover], - ); + showReportSubmitToPopover(options); + }; - const popoverContainerStyle = useMemo(() => (isSmallScreenWidth ? styles.w100 : {width: CONST.POPOVER_DROPDOWN_WIDTH}), [isSmallScreenWidth, styles.w100]); + const popoverContainerStyle = isSmallScreenWidth ? styles.w100 : {width: CONST.POPOVER_DROPDOWN_WIDTH}; - const outerStyle = useMemo( - () => ({ - ...(isSmallScreenWidth ? StyleUtils.getOuterModalStyle(windowHeight, viewportOffsetTop) : {}), - ...popoverContainerStyle, - }), - [StyleUtils, isSmallScreenWidth, popoverContainerStyle, viewportOffsetTop, windowHeight], - ); + const outerStyle = { + ...(isSmallScreenWidth ? StyleUtils.getOuterModalStyle(windowHeight, viewportOffsetTop) : {}), + ...popoverContainerStyle, + }; // No height in portrait: the wrapper `View` below owns it. This container is the modal's own box, so a height set // here does not bound the content (the wrapper's `flex1` had nothing to resolve against, which let the content // overflow the fixed box and pushed the Confirm button out of view). - const innerContainerStyle = useMemo( - () => ({ - ...popoverContainerStyle, - ...(isInLandscapeMode ? styles.getPopoverMaxHeight(windowHeight, true) : {}), - }), - [popoverContainerStyle, isInLandscapeMode, windowHeight, styles], - ); - - const reportSubmitToPopover = useMemo(() => { - if (!shouldRenderSubmitToPopover) { - return null; - } + const innerContainerStyle = { + ...popoverContainerStyle, + ...(isInLandscapeMode ? styles.getPopoverMaxHeight(windowHeight, true) : {}), + }; - return ( - + - - - - - ); - }, [ - shouldRenderSubmitToPopover, - innerContainerStyle, - outerStyle, - isVisible, - closeReportSubmitToPopover, - handleReportSubmitToPopoverModalHide, - anchorPosition, - anchorAlignment, - StyleUtils, - submitToPopoverContentHeight, - styles.flexColumn, - styles.w100, - styles.pt4, - isSmallScreenWidth, - submitToContentKey, - report, - policy, - isLoadingReportData, - handleCombinedSubmitSuccess, - isSearchSubmitFlow, - handleSearchSubmitWithManagerEmail, - shouldResolveViolationsOnOpen, - ]); + + + + ); return { anchorRef, diff --git a/src/hooks/useReportTransactionViolations.ts b/src/hooks/useReportTransactionViolations.ts index 77d4b407dc6d..8f5dac80b8d8 100644 --- a/src/hooks/useReportTransactionViolations.ts +++ b/src/hooks/useReportTransactionViolations.ts @@ -2,8 +2,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {transactionViolationsByIDsSelector} from '@src/selectors/TransactionViolations'; import type {Transaction} from '@src/types/onyx'; -import {useMemo} from 'react'; - import useOnyx from './useOnyx'; /** @@ -11,7 +9,7 @@ import useOnyx from './useOnyx'; * in an unrelated report does not re-render the consumer. */ function useReportTransactionViolations(transactions: Transaction[]) { - const transactionIDs = useMemo(() => transactions.map((transaction) => transaction.transactionID), [transactions]); + const transactionIDs = transactions.map((transaction) => transaction.transactionID); return useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {selector: transactionViolationsByIDsSelector(transactionIDs)}); } diff --git a/src/hooks/useReportTransactionsCollection.ts b/src/hooks/useReportTransactionsCollection.ts index 0dd1121ac44b..3ef4cdd7236a 100644 --- a/src/hooks/useReportTransactionsCollection.ts +++ b/src/hooks/useReportTransactionsCollection.ts @@ -3,17 +3,12 @@ import type {Transaction} from '@src/types/onyx'; import type {ReportTransactionsAndViolationsDerivedValue} from '@src/types/onyx/DerivedValues'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; -import {useCallback} from 'react'; - import useOnyx from './useOnyx'; function useReportTransactionsCollection(reportID?: string): Record { - const transactionsSelector = useCallback( - (allReportsTransactionsAndViolations: ReportTransactionsAndViolationsDerivedValue | undefined) => { - return reportID ? allReportsTransactionsAndViolations?.[reportID]?.transactions : undefined; - }, - [reportID], - ); + const transactionsSelector = (allReportsTransactionsAndViolations: ReportTransactionsAndViolationsDerivedValue | undefined) => { + return reportID ? allReportsTransactionsAndViolations?.[reportID]?.transactions : undefined; + }; const [reportTransactions] = useOnyx(ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS, { selector: transactionsSelector, diff --git a/src/hooks/useReportWithTransactionsAndViolations.ts b/src/hooks/useReportWithTransactionsAndViolations.ts index 387b03cabf1e..2d3006ce8f2f 100644 --- a/src/hooks/useReportWithTransactionsAndViolations.ts +++ b/src/hooks/useReportWithTransactionsAndViolations.ts @@ -6,8 +6,6 @@ import type {Report, Transaction, TransactionViolation} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import {useMemo} from 'react'; - import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; @@ -22,10 +20,7 @@ function useReportWithTransactionsAndViolations(reportID?: string): [OnyxEntry Object.values(transactions).filter((transaction) => isOffline || transaction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), - [transactions, isOffline], - ); + const filteredTransactions = Object.values(transactions).filter((transaction) => isOffline || transaction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); return [report, filteredTransactions ?? DEFAULT_FILTERED_TRANSACTIONS, violations]; } diff --git a/src/hooks/useResponsiveLayout/index.native.ts b/src/hooks/useResponsiveLayout/index.native.ts index c2740a6fc8af..d5c039408c54 100644 --- a/src/hooks/useResponsiveLayout/index.native.ts +++ b/src/hooks/useResponsiveLayout/index.native.ts @@ -10,7 +10,7 @@ import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import {NavigationContainerRefContext, NavigationContext} from '@react-navigation/native'; -import {useContext, useMemo} from 'react'; +import {useContext} from 'react'; import type ResponsiveLayoutResult from './types'; @@ -56,7 +56,7 @@ export default function useResponsiveLayout(): ResponsiveLayoutResult { const navigator = useContext(NavigationContext); const currentNavigator = navigator ?? navigationContainerRef; - const isDisplayedInNarrowModalNavigator = useMemo(() => !!currentNavigator?.getParent?.(NAVIGATORS.RIGHT_MODAL_NAVIGATOR as unknown as undefined), [currentNavigator]); + const isDisplayedInNarrowModalNavigator = !!currentNavigator?.getParent?.(NAVIGATORS.RIGHT_MODAL_NAVIGATOR as unknown as undefined); // The component calling this hook is in a "narrow pane modal" if: const isInNarrowPaneModal = diff --git a/src/hooks/useResponsiveLayout/index.ts b/src/hooks/useResponsiveLayout/index.ts index 0486cd295b94..053f21d0670d 100644 --- a/src/hooks/useResponsiveLayout/index.ts +++ b/src/hooks/useResponsiveLayout/index.ts @@ -10,7 +10,7 @@ import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import {NavigationContainerRefContext, NavigationContext} from '@react-navigation/native'; -import {useContext, useMemo} from 'react'; +import {useContext} from 'react'; import {Dimensions} from 'react-native'; import type ResponsiveLayoutResult from './types'; @@ -60,7 +60,7 @@ export default function useResponsiveLayout(): ResponsiveLayoutResult { const navigator = useContext(NavigationContext); const currentNavigator = navigator ?? navigationContainerRef; - const isDisplayedInNarrowModalNavigator = useMemo(() => !!currentNavigator?.getParent?.(NAVIGATORS.RIGHT_MODAL_NAVIGATOR as unknown as undefined), [currentNavigator]); + const isDisplayedInNarrowModalNavigator = !!currentNavigator?.getParent?.(NAVIGATORS.RIGHT_MODAL_NAVIGATOR as unknown as undefined); // The component calling this hook is in a "narrow pane modal" if: const isInNarrowPaneModal = diff --git a/src/hooks/useReturnToOriginReport.ts b/src/hooks/useReturnToOriginReport.ts index 0c3db78752c4..0abaf31c38e3 100644 --- a/src/hooks/useReturnToOriginReport.ts +++ b/src/hooks/useReturnToOriginReport.ts @@ -4,7 +4,7 @@ import {setDisableDismissOnEscape} from '@userActions/Modal'; import ONYXKEYS from '@src/ONYXKEYS'; -import {useCallback, useState} from 'react'; +import {useState} from 'react'; import useOnyx from './useOnyx'; @@ -19,7 +19,7 @@ import useOnyx from './useOnyx'; function useReturnToOriginReport(explicitOriginReportID?: string) { const [originReportID] = useState(() => Navigation.getFocusedReportId() ?? Navigation.getTopmostReportId()); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - return useCallback(() => { + return () => { setDisableDismissOnEscape(false); const reportID = explicitOriginReportID ?? originReportID ?? conciergeReportID; if (!reportID) { @@ -28,7 +28,7 @@ function useReturnToOriginReport(explicitOriginReportID?: string) { } Navigation.dismissModalWithReport({reportID}); - }, [explicitOriginReportID, originReportID, conciergeReportID]); + }; } export default useReturnToOriginReport; diff --git a/src/hooks/useReviewDuplicatesNavigation.tsx b/src/hooks/useReviewDuplicatesNavigation.tsx index 3d0adfd7cc13..59cad128a356 100644 --- a/src/hooks/useReviewDuplicatesNavigation.tsx +++ b/src/hooks/useReviewDuplicatesNavigation.tsx @@ -5,7 +5,7 @@ import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; -import {useEffect, useMemo, useState} from 'react'; +import {useEffect, useState} from 'react'; type StepName = 'description' | 'merchant' | 'category' | 'billable' | 'tag' | 'taxCode' | 'reimbursable' | 'confirmation'; @@ -13,7 +13,7 @@ function useReviewDuplicatesNavigation(stepNames: string[], currentScreenName: S const [nextScreen, setNextScreen] = useState(); const [prevScreen, setPrevScreen] = useState(); const [currentScreenIndex, setCurrentScreenIndex] = useState(0); - const intersection = useMemo(() => CONST.REVIEW_DUPLICATES_ORDER.filter((element) => stepNames.includes(element)), [stepNames]); + const intersection = CONST.REVIEW_DUPLICATES_ORDER.filter((element) => stepNames.includes(element)); useEffect(() => { if (currentScreenName === 'confirmation') { diff --git a/src/hooks/useSafeAreaPaddings.ts b/src/hooks/useSafeAreaPaddings.ts index 3b64e03cd86b..beea828ed1f6 100644 --- a/src/hooks/useSafeAreaPaddings.ts +++ b/src/hooks/useSafeAreaPaddings.ts @@ -1,6 +1,6 @@ import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext'; -import {useContext, useMemo} from 'react'; +import {useContext} from 'react'; import useSafeAreaInsets from './useSafeAreaInsets'; import useStyleUtils from './useStyleUtils'; @@ -35,17 +35,14 @@ import useStyleUtils from './useStyleUtils'; function useSafeAreaPaddings(isUsingEdgeToEdgeBottomSafeAreaPadding = false) { const StyleUtils = useStyleUtils(); const insets = useSafeAreaInsets(); - const {paddingTop, paddingBottom, paddingLeft, paddingRight} = useMemo(() => StyleUtils.getPlatformSafeAreaPadding(insets), [StyleUtils, insets]); + const {paddingTop, paddingBottom, paddingLeft, paddingRight} = StyleUtils.getPlatformSafeAreaPadding(insets); const screenWrapperStatusContext = useContext(ScreenWrapperStatusContext); const isSafeAreaTopPaddingApplied = screenWrapperStatusContext?.isSafeAreaTopPaddingApplied ?? false; const isSafeAreaBottomPaddingApplied = screenWrapperStatusContext?.isSafeAreaBottomPaddingApplied ?? false; const adaptedPaddingBottom = isSafeAreaBottomPaddingApplied ? 0 : paddingBottom; - const safeAreaPaddingBottomStyle = useMemo( - () => ({paddingBottom: isUsingEdgeToEdgeBottomSafeAreaPadding ? paddingBottom : adaptedPaddingBottom}), - [adaptedPaddingBottom, isUsingEdgeToEdgeBottomSafeAreaPadding, paddingBottom], - ); + const safeAreaPaddingBottomStyle = {paddingBottom: isUsingEdgeToEdgeBottomSafeAreaPadding ? paddingBottom : adaptedPaddingBottom}; if (isUsingEdgeToEdgeBottomSafeAreaPadding) { return { diff --git a/src/hooks/useScrollEventEmitter.ts b/src/hooks/useScrollEventEmitter.ts index 91507dbc5e65..39e208686dd4 100644 --- a/src/hooks/useScrollEventEmitter.ts +++ b/src/hooks/useScrollEventEmitter.ts @@ -1,6 +1,6 @@ import CONST from '@src/CONST'; -import {useCallback, useEffect, useRef} from 'react'; +import {useEffect, useRef} from 'react'; import {DeviceEventEmitter} from 'react-native'; /** @@ -10,7 +10,7 @@ const useScrollEventEmitter = () => { const isScrollingRef = useRef(false); const timeoutRef = useRef(null); - const triggerScrollEvent = useCallback(() => { + const triggerScrollEvent = () => { const emitScrolling = (isScrolling: boolean) => { DeviceEventEmitter.emit(CONST.EVENTS.SCROLLING, isScrolling); }; @@ -30,7 +30,7 @@ const useScrollEventEmitter = () => { emitScrolling(false); isScrollingRef.current = false; }, 250); - }, []); + }; // Cleanup timeout on unmount useEffect(() => { diff --git a/src/hooks/useSearchSelector/index.native.ts b/src/hooks/useSearchSelector/index.native.ts index 912aa3a06e4f..eca3b802d319 100644 --- a/src/hooks/useSearchSelector/index.native.ts +++ b/src/hooks/useSearchSelector/index.native.ts @@ -2,7 +2,6 @@ import useContactImport from '@hooks/useContactImport'; import {canPickContacts} from '@libs/ContactPicker'; -import {useMemo} from 'react'; import {RESULTS} from 'react-native-permissions'; import type {ContactState, UseSearchSelectorConfig, UseSearchSelectorReturn} from './base'; @@ -22,7 +21,7 @@ function useSearchSelector(config: UseSearchSelectorConfig): UseSearchSelectorRe // Phone contacts logic const {contacts, contactPermissionState, importAndSaveContacts, pickContact, setContactPermissionState} = useContactImport(); - const memoizedContacts = useMemo(() => (contacts.length ? contacts : []), [contacts]); + const memoizedContacts = contacts.length ? contacts : []; const showImportContacts = enablePhoneContacts && !(contactPermissionState === RESULTS.GRANTED || contactPermissionState === RESULTS.LIMITED); // Use base hook with contact options diff --git a/src/hooks/useSearchShouldCalculateTotals.ts b/src/hooks/useSearchShouldCalculateTotals.ts index fc405b3ccf3d..23fd7db4ad24 100644 --- a/src/hooks/useSearchShouldCalculateTotals.ts +++ b/src/hooks/useSearchShouldCalculateTotals.ts @@ -4,8 +4,6 @@ import type {SearchKey} from '@libs/SearchKeyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {useMemo} from 'react'; - import useOnyx from './useOnyx'; function getSearchRequestOffsetForMissingAllMatchingCount(offset: number, serverOffset: number | undefined, isAllMatchingItemsCountMissing: boolean): number { @@ -18,22 +16,17 @@ function getSearchRequestOffsetForMissingAllMatchingCount(offset: number, server function useSearchShouldCalculateTotals(searchKey: SearchKey | undefined, enabled: boolean, areAllMatchingItemsSelected = false) { const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); - const shouldCalculateTotals = useMemo(() => { - // All-matching selections need the server-computed count/total, including for ad-hoc queries. - // The caller enables this only while the initial offset is active or the count is still missing, - // so later pagination requests can avoid recalculating totals. - if (areAllMatchingItemsSelected) { - return enabled; - } - - if (!enabled) { - return false; - } - - if (!Object.keys(savedSearches ?? {}).length && !searchKey) { - return false; - } - + let shouldCalculateTotals; + // All-matching selections need the server-computed count/total, including for ad-hoc queries. + // The caller enables this only while the initial offset is active or the count is still missing, + // so later pagination requests can avoid recalculating totals. + if (areAllMatchingItemsSelected) { + shouldCalculateTotals = enabled; + } else if (!enabled) { + shouldCalculateTotals = false; + } else if (!Object.keys(savedSearches ?? {}).length && !searchKey) { + shouldCalculateTotals = false; + } else { const eligibleSearchKeys: Partial = [ CONST.SEARCH.SEARCH_KEYS.SUBMIT, CONST.SEARCH.SEARCH_KEYS.APPROVE, @@ -49,13 +42,11 @@ function useSearchShouldCalculateTotals(searchKey: SearchKey | undefined, enable CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME, CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER, ]; - const isSuggestedSearchWithTotals = eligibleSearchKeys.includes(searchKey); const savedSearchID = searchKeyToSavedSearchID(searchKey); const isSavedSearch = savedSearchID !== undefined && savedSearches && !!savedSearches[savedSearchID]; - - return isSuggestedSearchWithTotals || isSavedSearch; - }, [enabled, savedSearches, searchKey, areAllMatchingItemsSelected]); + shouldCalculateTotals = isSuggestedSearchWithTotals || isSavedSearch; + } return shouldCalculateTotals ?? false; } diff --git a/src/hooks/useSearchTypeMenuSections.ts b/src/hooks/useSearchTypeMenuSections.ts index 491da3edc3ff..8d1cb9d21b84 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -9,7 +9,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {defaultExpensifyCardSelector} from '@selectors/Card'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; -import {useCallback, useEffect, useMemo, useState} from 'react'; +import {useEffect, useState} from 'react'; import useCardFeedsForDisplay from './useCardFeedsForDisplay'; import useCreateEmptyReportConfirmation from './useCreateEmptyReportConfirmation'; @@ -81,17 +81,14 @@ const useSearchTypeMenuSections = (isScreenFocused = true) => { null, ); - const handlePendingConfirm = useCallback( - (shouldDismissEmptyReportsConfirmation: boolean) => { - pendingReportCreation?.onConfirm(shouldDismissEmptyReportsConfirmation); - setPendingReportCreation(null); - }, - [pendingReportCreation, setPendingReportCreation], - ); + const handlePendingConfirm = (shouldDismissEmptyReportsConfirmation: boolean) => { + pendingReportCreation?.onConfirm(shouldDismissEmptyReportsConfirmation); + setPendingReportCreation(null); + }; - const handlePendingCancel = useCallback(() => { + const handlePendingCancel = () => { setPendingReportCreation(null); - }, [setPendingReportCreation]); + }; const {openCreateReportConfirmation} = useCreateEmptyReportConfirmation({ policyID: pendingReportCreation?.policyID, @@ -107,39 +104,21 @@ const useSearchTypeMenuSections = (isScreenFocused = true) => { openCreateReportConfirmation(); }, [pendingReportCreation, openCreateReportConfirmation]); - const typeMenuSections = useMemo( - () => - createTypeMenuSections({ - currentUserEmail: currentUserLoginAndAccountID?.email, - currentUserAccountID: currentUserLoginAndAccountID?.accountID, - cardFeedsByPolicy, - defaultCardFeed: defaultCardFeed ?? defaultExpensifyCard, - policies: allPolicies, - savedSearches, - isOffline, - defaultExpensifyCard, - activeExpensifyCardFeedID, - draftTransactionIDs, - isTrackIntentUser: isTrackIntentUser ?? false, - hasReportAwaitingApproval, - policyCategories: allPolicyCategories, - }), - [ - currentUserLoginAndAccountID?.email, - currentUserLoginAndAccountID?.accountID, - cardFeedsByPolicy, - defaultCardFeed, - defaultExpensifyCard, - activeExpensifyCardFeedID, - allPolicies, - savedSearches, - isOffline, - draftTransactionIDs, - isTrackIntentUser, - hasReportAwaitingApproval, - allPolicyCategories, - ], - ); + const typeMenuSections = createTypeMenuSections({ + currentUserEmail: currentUserLoginAndAccountID?.email, + currentUserAccountID: currentUserLoginAndAccountID?.accountID, + cardFeedsByPolicy, + defaultCardFeed: defaultCardFeed ?? defaultExpensifyCard, + policies: allPolicies, + savedSearches, + isOffline, + defaultExpensifyCard, + activeExpensifyCardFeedID, + draftTransactionIDs, + isTrackIntentUser: isTrackIntentUser ?? false, + hasReportAwaitingApproval, + policyCategories: allPolicyCategories, + }); return typeMenuSections; }; diff --git a/src/hooks/useSeedMyExpensesSearch.ts b/src/hooks/useSeedMyExpensesSearch.ts index 1147105ae967..2d15aa152f04 100644 --- a/src/hooks/useSeedMyExpensesSearch.ts +++ b/src/hooks/useSeedMyExpensesSearch.ts @@ -7,7 +7,7 @@ import type {Policy} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; -import {useEffect, useMemo, useRef} from 'react'; +import {useEffect, useRef} from 'react'; import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; @@ -20,7 +20,7 @@ function useSeedMyExpensesSearch() { const [savedSearches, savedSearchesMetadata] = useOnyx(ONYXKEYS.SAVED_SEARCHES); const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const [currentUserEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); - const isSubmitterAndApproverSelector = useMemo(() => createIsSubmitterAndApproverSelector(currentUserEmail), [currentUserEmail]); + const isSubmitterAndApproverSelector = createIsSubmitterAndApproverSelector(currentUserEmail); const [isSubmitterAndApproverUser] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: isSubmitterAndApproverSelector}); const hasSeededRef = useRef(false); diff --git a/src/hooks/useShortMentionsList.ts b/src/hooks/useShortMentionsList.ts index 1a4bd1d3ca88..d79fb43f634d 100644 --- a/src/hooks/useShortMentionsList.ts +++ b/src/hooks/useShortMentionsList.ts @@ -7,8 +7,6 @@ import type {PersonalDetailsList} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {useMemo} from 'react'; - import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useOnyx from './useOnyx'; import {useAllPersonalDetailsWithoutSnapshots} from './usePersonalDetails'; @@ -63,14 +61,11 @@ export default function useShortMentionsList() { const [availableLoginsList = emptyLoginsList] = useAllPersonalDetailsWithoutSnapshots(availableLoginsListSelector(currentUserLogin)); // We want to highlight both short and long version of current user login - const currentUserMentions = useMemo(() => { - if (!currentUserPersonalDetails.login) { - return []; - } - + const currentUserMentions: string[] = []; + if (currentUserPersonalDetails.login) { const [baseName] = currentUserPersonalDetails.login.split('@'); - return [baseName, currentUserPersonalDetails.login]; - }, [currentUserPersonalDetails.login]); + currentUserMentions.push(baseName, currentUserPersonalDetails.login); + } return {availableLoginsList, currentUserMentions}; } diff --git a/src/hooks/useShouldBlockCurrencyChange.ts b/src/hooks/useShouldBlockCurrencyChange.ts index 57c6d939aa47..ab491b1f92c7 100644 --- a/src/hooks/useShouldBlockCurrencyChange.ts +++ b/src/hooks/useShouldBlockCurrencyChange.ts @@ -6,8 +6,6 @@ import type {BankAccountList, Policy, ReimbursementAccount} from '@src/types/ony import type {OnyxEntry} from 'react-native-onyx'; -import {useCallback} from 'react'; - import useOnyx from './useOnyx'; const reimbursementAccountAchStateSelector = (reimbursementAccount: OnyxEntry) => reimbursementAccount?.achData?.state; @@ -17,10 +15,7 @@ const policyAchStateSelector = (policy: OnyxEntry) => policy?.achAccount function useShouldBlockCurrencyChange(policyID: string | undefined): boolean { const [policyAchState] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: policyAchStateSelector}); const [reimbursementAccountAchState] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {selector: reimbursementAccountAchStateSelector}); - const bankAccountListSelector = useCallback( - (bankAccountList: OnyxEntry) => !!policyID && doesPolicyHavePartiallySetupBankAccount(bankAccountList, policyID), - [policyID], - ); + const bankAccountListSelector = (bankAccountList: OnyxEntry) => !!policyID && doesPolicyHavePartiallySetupBankAccount(bankAccountList, policyID); const [hasPartiallySetupBankAccount = false] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, { selector: bankAccountListSelector, }); diff --git a/src/hooks/useShouldShowEmptyReportConfirmation.ts b/src/hooks/useShouldShowEmptyReportConfirmation.ts index 3b94b1084280..720d82ee8c1d 100644 --- a/src/hooks/useShouldShowEmptyReportConfirmation.ts +++ b/src/hooks/useShouldShowEmptyReportConfirmation.ts @@ -7,7 +7,6 @@ import type {Transaction} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; import {accountIDSelector} from '@selectors/Session'; -import {useCallback} from 'react'; import useOnyx from './useOnyx'; @@ -27,15 +26,12 @@ function reportIDsWithActiveTransactionsSelector(transactions: OnyxCollection) => { - if (skip) { - return {}; - } - return reportIDsWithActiveTransactionsSelector(transactions); - }, - [skip], - ); + const activeTransactionsSelector = (transactions: OnyxCollection) => { + if (skip) { + return {}; + } + return reportIDsWithActiveTransactionsSelector(transactions); + }; const [reportIDsWithActiveTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {selector: activeTransactionsSelector}); const [hasEmptyReport = false] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { diff --git a/src/hooks/useShowNotFoundPageInIOUStep.ts b/src/hooks/useShowNotFoundPageInIOUStep.ts index daa8b64caf0d..a8b27d9c246a 100644 --- a/src/hooks/useShowNotFoundPageInIOUStep.ts +++ b/src/hooks/useShowNotFoundPageInIOUStep.ts @@ -10,8 +10,6 @@ import type {OnyxInputOrEntry, Report, ReportAction, ReportActions, Transaction} import type {OnyxEntry} from 'react-native-onyx'; -import {useMemo} from 'react'; - import useOnyx from './useOnyx'; /** @@ -32,13 +30,11 @@ const useShowNotFoundPageInIOUStep = (action: IOUAction, iouType: IOUType, repor const [iouReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(transaction?.reportID)}`); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - const reportActionsReportID = useMemo(() => { - let actionsReportID; - if (isEditing) { - actionsReportID = iouType === CONST.IOU.TYPE.SPLIT ? report?.reportID : report?.parentReportID; - } - return actionsReportID; - }, [isEditing, iouType, report?.reportID, report?.parentReportID]); + let actionsReportID; + if (isEditing) { + actionsReportID = iouType === CONST.IOU.TYPE.SPLIT ? report?.reportID : report?.parentReportID; + } + const reportActionsReportID = actionsReportID; const [reportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportActionsReportID}`, { selector: (reportActions: OnyxEntry): OnyxEntry => { diff --git a/src/hooks/useSortedActiveAdminPolicies.ts b/src/hooks/useSortedActiveAdminPolicies.ts index 4a512a68a16e..461e6ec24b27 100644 --- a/src/hooks/useSortedActiveAdminPolicies.ts +++ b/src/hooks/useSortedActiveAdminPolicies.ts @@ -1,14 +1,12 @@ import {sortPoliciesByName} from '@libs/PolicyUtils'; -import {useMemo} from 'react'; - import useActiveAdminPolicies from './useActiveAdminPolicies'; import useLocalize from './useLocalize'; function useSortedActiveAdminPolicies() { const {localeCompare} = useLocalize(); const activeAdminPolicies = useActiveAdminPolicies(); - const sortedActiveAdminPolicies = useMemo(() => sortPoliciesByName(activeAdminPolicies, localeCompare), [activeAdminPolicies, localeCompare]); + const sortedActiveAdminPolicies = sortPoliciesByName(activeAdminPolicies, localeCompare); return sortedActiveAdminPolicies; } diff --git a/src/hooks/useSubmitToDestinationVisible.ts b/src/hooks/useSubmitToDestinationVisible.ts index 69d051ce3d70..1bd7e6d9023a 100644 --- a/src/hooks/useSubmitToDestinationVisible.ts +++ b/src/hooks/useSubmitToDestinationVisible.ts @@ -39,7 +39,7 @@ function useSubmitToDestinationVisible( const usesFocus = triggers.includes(CONST.TELEMETRY.SUBMIT_TO_DESTINATION_VISIBLE_TRIGGER.FOCUS); const usesLayout = triggers.includes(CONST.TELEMETRY.SUBMIT_TO_DESTINATION_VISIBLE_TRIGGER.LAYOUT); - const tryEnd = useCallback(() => { + const tryEnd = () => { if (hasEndedRef.current) { return; } @@ -52,11 +52,11 @@ function useSubmitToDestinationVisible( } hasEndedRef.current = true; endSubmitFollowUpActionSpan(pending.followUpAction, reportID); - }, [followUpActions, reportID]); + }; - const reset = useCallback(() => { + const reset = () => { hasEndedRef.current = false; - }, []); + }; useFocusEffect( useCallback(() => { diff --git a/src/hooks/useTackInputFocus/index.ts b/src/hooks/useTackInputFocus/index.ts index 90460be73058..dc897feda1e3 100644 --- a/src/hooks/useTackInputFocus/index.ts +++ b/src/hooks/useTackInputFocus/index.ts @@ -5,40 +5,35 @@ import isHTMLElement from '@libs/isHTMLElement'; import CONST from '@src/CONST'; -import {useCallback, useEffect} from 'react'; +import {useEffect} from 'react'; /** * Detects input or text area focus on browsers, to avoid scrolling on virtual viewports */ + export default function useTackInputFocus(enable = false): boolean { const [, isInputFocusDebounced, setIsInputFocus] = useDebouncedState(false); const {shouldHideSidePanel} = useSidePanelState(); - const handleFocusIn = useCallback( - (event: FocusEvent) => { - const targetElement = event.target; - if (!isHTMLElement(targetElement)) { - return; - } - if (targetElement.tagName === CONST.ELEMENT_NAME.INPUT || targetElement.tagName === CONST.ELEMENT_NAME.TEXTAREA) { - setIsInputFocus(true); - } - }, - [setIsInputFocus], - ); - - const handleFocusOut = useCallback( - (event: FocusEvent) => { - const targetElement = event.target; - if (!isHTMLElement(targetElement)) { - return; - } - if (targetElement.tagName === CONST.ELEMENT_NAME.INPUT || targetElement.tagName === CONST.ELEMENT_NAME.TEXTAREA) { - setIsInputFocus(false); - } - }, - [setIsInputFocus], - ); + const handleFocusIn = (event: FocusEvent) => { + const targetElement = event.target; + if (!isHTMLElement(targetElement)) { + return; + } + if (targetElement.tagName === CONST.ELEMENT_NAME.INPUT || targetElement.tagName === CONST.ELEMENT_NAME.TEXTAREA) { + setIsInputFocus(true); + } + }; + + const handleFocusOut = (event: FocusEvent) => { + const targetElement = event.target; + if (!isHTMLElement(targetElement)) { + return; + } + if (targetElement.tagName === CONST.ELEMENT_NAME.INPUT || targetElement.tagName === CONST.ELEMENT_NAME.TEXTAREA) { + setIsInputFocus(false); + } + }; useEffect(() => { if (!enable || !shouldHideSidePanel) { diff --git a/src/hooks/useTextInputAccessibility/index.tsx b/src/hooks/useTextInputAccessibility/index.tsx index aebed54f4f67..d814d3ecb88a 100644 --- a/src/hooks/useTextInputAccessibility/index.tsx +++ b/src/hooks/useTextInputAccessibility/index.tsx @@ -1,5 +1,3 @@ -import {useMemo} from 'react'; - import type TextInputAccessibilityResult from './type'; /** @@ -8,7 +6,7 @@ import type TextInputAccessibilityResult from './type'; */ // eslint-disable-next-line @typescript-eslint/no-unused-vars function useTextInputAccessibility(value: string | undefined, _accessibilityLabel: string): TextInputAccessibilityResult { - const accessibilityValue = useMemo(() => ({text: value ?? ''}), [value]); + const accessibilityValue = {text: value ?? ''}; return { accessibilityValue, accessibilityLabelledBy: undefined, diff --git a/src/hooks/useThumbnailDimensions.ts b/src/hooks/useThumbnailDimensions.ts index 8535acd815d8..8327098eeb0d 100644 --- a/src/hooks/useThumbnailDimensions.ts +++ b/src/hooks/useThumbnailDimensions.ts @@ -3,8 +3,6 @@ import CONST from '@src/CONST'; import type {StyleProp, ViewStyle} from 'react-native'; import type {DimensionValue} from 'react-native/Libraries/StyleSheet/StyleSheetTypes'; -import {useMemo} from 'react'; - import useIsInSidePanel from './useIsInSidePanel'; import useResponsiveLayout from './useResponsiveLayout'; @@ -21,16 +19,26 @@ export default function useThumbnailDimensions(width: number, height: number): T const isInSidePanel = useIsInSidePanel(); // The side panel is as narrow as a phone screen even on wide layouts, so it needs the small thumbnail size to avoid clipping const fixedDimension = shouldUseNarrowLayout || isInSidePanel ? CONST.THUMBNAIL_IMAGE.SMALL_SCREEN.SIZE : CONST.THUMBNAIL_IMAGE.WIDE_SCREEN.SIZE; - const thumbnailDimensionsStyles = useMemo(() => { - if (!width || !height) { - return {width: fixedDimension, aspectRatio: CONST.THUMBNAIL_IMAGE.NAN_ASPECT_RATIO}; - } + let thumbnailDimensionsStyles; + if (!width || !height) { + thumbnailDimensionsStyles = { + width: fixedDimension, + aspectRatio: CONST.THUMBNAIL_IMAGE.NAN_ASPECT_RATIO, + }; + } else { const aspectRatio = (height && width / height) || 1; if (width > height) { - return {width: fixedDimension, aspectRatio}; + thumbnailDimensionsStyles = { + width: fixedDimension, + aspectRatio, + }; + } else { + thumbnailDimensionsStyles = { + height: fixedDimension, + aspectRatio, + }; } - return {height: fixedDimension, aspectRatio}; - }, [width, height, fixedDimension]); + } return {thumbnailDimensionsStyles}; } diff --git a/src/hooks/useTransactionThreadReportIDs.ts b/src/hooks/useTransactionThreadReportIDs.ts index d77adbf3650a..2ef8510fb264 100644 --- a/src/hooks/useTransactionThreadReportIDs.ts +++ b/src/hooks/useTransactionThreadReportIDs.ts @@ -2,8 +2,6 @@ import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils import ONYXKEYS from '@src/ONYXKEYS'; -import {useMemo} from 'react'; - import useOnyx from './useOnyx'; /** @@ -15,29 +13,27 @@ function useTransactionThreadReportIDs(transactionIDList: string[] | undefined): const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); const transactionIDsKey = transactionIDList?.join('|') ?? ''; - return useMemo(() => { - const result: Record = {}; - const ids = transactionIDsKey ? transactionIDsKey.split('|') : []; + const result: Record = {}; + const ids = transactionIDsKey ? transactionIDsKey.split('|') : []; - for (const transactionID of ids) { - const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - if (!transaction?.reportID) { - continue; - } + for (const transactionID of ids) { + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (!transaction?.reportID) { + continue; + } - const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction.reportID}`]; - const iouAction = Object.values(reportActions ?? {}).find((action) => { - if (!isMoneyRequestAction(action)) { - return false; - } - return getOriginalMessage(action)?.IOUTransactionID === transactionID; - }); + const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction.reportID}`]; + const iouAction = Object.values(reportActions ?? {}).find((action) => { + if (!isMoneyRequestAction(action)) { + return false; + } + return getOriginalMessage(action)?.IOUTransactionID === transactionID; + }); - result[transactionID] = iouAction?.childReportID; - } + result[transactionID] = iouAction?.childReportID; + } - return result; - }, [transactionIDsKey, allTransactions, allReportActions]); + return result; } export default useTransactionThreadReportIDs; diff --git a/src/hooks/useTransactionViolations.ts b/src/hooks/useTransactionViolations.ts index 43035509481e..f26b52f633da 100644 --- a/src/hooks/useTransactionViolations.ts +++ b/src/hooks/useTransactionViolations.ts @@ -10,7 +10,6 @@ import getEmptyArray from '@src/types/utils/getEmptyArray'; import type {OnyxEntry} from 'react-native-onyx'; import {loginSelector} from '@selectors/PersonalDetails'; -import {useMemo} from 'react'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useDistanceRateOriginalPolicy from './useDistanceRateOriginalPolicy'; @@ -29,20 +28,18 @@ function useTransactionViolations(transactionID?: string, shouldShowRterForSettl const policy = policyOverride ?? distanceOriginalPolicy ?? reportPolicy; const currentUserDetails = useCurrentUserPersonalDetails(); - return useMemo(() => { - const syncedViolations = syncCustomUnitRateOutOfDateRangeViolation(transactionViolations, transaction, policy); - - return getVisibleTransactionViolations( - transaction, - syncedViolations, - currentUserDetails.email ?? '', - currentUserDetails.accountID, - iouReport, - ownerLogin, - policy, - shouldShowRterForSettledReport, - ); - }, [transaction, transactionViolations, iouReport, ownerLogin, policy, shouldShowRterForSettledReport, currentUserDetails.email, currentUserDetails.accountID]); + const syncedViolations = syncCustomUnitRateOutOfDateRangeViolation(transactionViolations, transaction, policy); + + return getVisibleTransactionViolations( + transaction, + syncedViolations, + currentUserDetails.email ?? '', + currentUserDetails.accountID, + iouReport, + ownerLogin, + policy, + shouldShowRterForSettledReport, + ); } export default useTransactionViolations; diff --git a/src/hooks/useTransactionsByID.ts b/src/hooks/useTransactionsByID.ts index 9f2c8a1c4461..4cf4862342ab 100644 --- a/src/hooks/useTransactionsByID.ts +++ b/src/hooks/useTransactionsByID.ts @@ -4,13 +4,11 @@ import getEmptyArray from '@src/types/utils/getEmptyArray'; import type {OnyxCollection} from 'react-native-onyx'; -import {useMemo} from 'react'; - import useOnyx from './useOnyx'; function useTransactionsByID(transactionIDs: string[] | undefined) { const transactionIDsKey = transactionIDs?.join('|') ?? ''; - const stableTransactionIDs = useMemo(() => (transactionIDsKey ? transactionIDsKey.split('|') : []), [transactionIDsKey]); + const stableTransactionIDs = transactionIDsKey ? transactionIDsKey.split('|') : []; const transactionsSelector = (transactions: OnyxCollection) => stableTransactionIDs.map((id) => transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`]).filter((transaction): transaction is Transaction => !!transaction); diff --git a/src/hooks/useTrialPaymentReminder.ts b/src/hooks/useTrialPaymentReminder.ts index 9d1ee57aca9d..be5eb052d6a3 100644 --- a/src/hooks/useTrialPaymentReminder.ts +++ b/src/hooks/useTrialPaymentReminder.ts @@ -13,7 +13,7 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {ValueOf} from 'type-fest'; -import {useCallback, useEffect, useMemo, useState} from 'react'; +import {useEffect, useState} from 'react'; import useOnyx from './useOnyx'; @@ -222,52 +222,21 @@ function useTrialPaymentReminder() { return () => clearInterval(interval); }, [firstDayFreeTrial, lastDayFreeTrial, billingFundID]); - const isEligibleToShow = useMemo(() => { - if (!hasLoadedApp) { - return false; - } - if (!isUserOnFreeTrial(firstDayFreeTrial, lastDayFreeTrial)) { - return false; - } - if (isLoadingOnyxValue(billingFundIDResult)) { - return false; - } - if (doesUserHavePaymentCardAdded(billingFundID)) { - return false; - } - if (readinessState !== READINESS_STATE.READY) { - return false; - } - if (!getOwnedPaidPolicies(policies, currentUserAccountID).some((policy) => !isPendingDeletePolicy(policy))) { - return false; - } - if (isLoadingOnyxValue(dismissedTimestampResult)) { - return false; - } - if (!currentVariation) { - return false; - } - if (dismissedTimestamp) { - const windowStart = getVariationWindowStart(currentVariation.id, firstDayFreeTrial, lastDayFreeTrial); - const dismissedMs = new Date(dismissedTimestamp).getTime(); - if (windowStart !== null && Number.isFinite(dismissedMs) && dismissedMs >= windowStart) { - return false; - } - } - return true; - }, [ - hasLoadedApp, - firstDayFreeTrial, - lastDayFreeTrial, - billingFundID, - billingFundIDResult, - readinessState, - currentVariation, - dismissedTimestamp, - dismissedTimestampResult, - policies, - currentUserAccountID, - ]); + let isEligibleToShow = + hasLoadedApp && + isUserOnFreeTrial(firstDayFreeTrial, lastDayFreeTrial) && + !isLoadingOnyxValue(billingFundIDResult) && + !doesUserHavePaymentCardAdded(billingFundID) && + readinessState === READINESS_STATE.READY && + getOwnedPaidPolicies(policies, currentUserAccountID).some((policy) => !isPendingDeletePolicy(policy)) && + !isLoadingOnyxValue(dismissedTimestampResult) && + !!currentVariation; + + if (isEligibleToShow && dismissedTimestamp && currentVariation) { + const windowStart = getVariationWindowStart(currentVariation.id, firstDayFreeTrial, lastDayFreeTrial); + const dismissedMs = new Date(dismissedTimestamp).getTime(); + isEligibleToShow = windowStart === null || !Number.isFinite(dismissedMs) || dismissedMs < windowStart; + } // Run the 1s countdown only while the countdown modal is actually eligible to show, so we don't tick every // second in the background when it isn't rendered. @@ -294,12 +263,12 @@ function useTrialPaymentReminder() { return () => clearInterval(interval); }, [isEligibleToShow, currentVariation?.variant, lastDayFreeTrial]); - const dismiss = useCallback(() => { + const dismiss = () => { if (!currentVariation) { return; } setNameValuePair(ONYXKEYS.NVP_DISMISSED_TRIAL_PAYMENT_REMINDER, new Date().toISOString(), dismissedTimestamp ?? ''); - }, [currentVariation, dismissedTimestamp]); + }; return {isEligibleToShow, currentVariation, countdownTime, dismiss}; } diff --git a/src/hooks/useUpdateFeedBrokenConnection.ts b/src/hooks/useUpdateFeedBrokenConnection.ts index 1c2aeaff6645..073c9c4f1c1b 100644 --- a/src/hooks/useUpdateFeedBrokenConnection.ts +++ b/src/hooks/useUpdateFeedBrokenConnection.ts @@ -5,8 +5,6 @@ import {updateWorkspaceCompanyCard} from '@userActions/CompanyCards'; import CONST from '@src/CONST'; import type {CompanyCardFeedWithDomainID} from '@src/types/onyx'; -import {useCallback} from 'react'; - import useCardFeedErrors from './useCardFeedErrors'; import useCardFeeds from './useCardFeeds'; import usePolicy from './usePolicy'; @@ -21,7 +19,7 @@ export default function useUpdateFeedBrokenConnection({policyID, feed}: {policyI const isFeedConnectionBroken = feed ? !!cardFeedErrors[feed]?.isFeedConnectionBroken : false; - const updateBrokenConnection = useCallback(() => { + const updateBrokenConnection = () => { if (!feed) { return; } @@ -32,7 +30,7 @@ export default function useUpdateFeedBrokenConnection({policyID, feed}: {policyI } updateWorkspaceCompanyCard(domainOrWorkspaceAccountID, brokenCardId, bankName, card.lastScrapeResult); } - }, [cardsWithBrokenFeedConnection, domainOrWorkspaceAccountID, feed]); + }; return {updateBrokenConnection, isFeedConnectionBroken}; } diff --git a/src/hooks/useViolations.ts b/src/hooks/useViolations.ts index cd175216cce4..fd7656945240 100644 --- a/src/hooks/useViolations.ts +++ b/src/hooks/useViolations.ts @@ -5,8 +5,6 @@ import type {TransactionViolation, ViolationName} from '@src/types/onyx'; import type {TupleToUnion} from 'type-fest'; -import {useCallback, useMemo} from 'react'; - /** * Names of Fields where violations can occur. */ @@ -80,71 +78,65 @@ type ViolationsMap = Map; * @param shouldShowOnlyViolations – Whether we should only show violations of type 'violation' */ function useViolations(violations: TransactionViolation[], shouldShowOnlyViolations: boolean) { - const violationsByField = useMemo((): ViolationsMap => { - const filteredViolations = violations.filter((violation) => { - if (shouldShowOnlyViolations) { - return isHardViolationOrRateDateWarning(violation); - } - return true; - }); - - const violationGroups = new Map(); - for (const violation of filteredViolations) { - const field = violationNameToField[violation.name]?.(violation); - const existingViolations = violationGroups.get(field) ?? []; - violationGroups.set(field, [...existingViolations, violation]); + const filteredViolations = violations.filter((violation) => { + if (shouldShowOnlyViolations) { + return isHardViolationOrRateDateWarning(violation); + } + return true; + }); + const violationGroups = new Map(); + for (const violation of filteredViolations) { + const field = violationNameToField[violation.name]?.(violation); + const existingViolations = violationGroups.get(field) ?? []; + violationGroups.set(field, [...existingViolations, violation]); + } + const violationsByField: ViolationsMap = violationGroups; + + const getViolationsForField = (field: ViolationField, data?: TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string) => { + const currentViolations = violationsByField.get(field) ?? []; + const firstViolation = currentViolations.at(0); + + // someTagLevelsRequired has special logic because data.errorIndexes is a bit unique in how it denotes the tag list that has the violation + // tagListIndex can be 0 so we compare with undefined + if (firstViolation?.name === CONST.VIOLATIONS.SOME_TAG_LEVELS_REQUIRED && data?.tagListIndex !== undefined && Array.isArray(firstViolation?.data?.errorIndexes)) { + return currentViolations + .filter((violation) => violation.data?.errorIndexes?.includes(data?.tagListIndex ?? -1)) + .map((violation) => ({ + ...violation, + data: { + ...violation.data, + tagName: data?.tagListName, + }, + })); } - return violationGroups ?? new Map(); - }, [violations, shouldShowOnlyViolations]); - - const getViolationsForField = useCallback( - (field: ViolationField, data?: TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string) => { - const currentViolations = violationsByField.get(field) ?? []; - const firstViolation = currentViolations.at(0); - - // someTagLevelsRequired has special logic because data.errorIndexes is a bit unique in how it denotes the tag list that has the violation - // tagListIndex can be 0 so we compare with undefined - if (firstViolation?.name === CONST.VIOLATIONS.SOME_TAG_LEVELS_REQUIRED && data?.tagListIndex !== undefined && Array.isArray(firstViolation?.data?.errorIndexes)) { - return currentViolations - .filter((violation) => violation.data?.errorIndexes?.includes(data?.tagListIndex ?? -1)) - .map((violation) => ({ - ...violation, - data: { - ...violation.data, - tagName: data?.tagListName, - }, - })); - } - - // missingTag has special logic for policies with dependent tags, because only one violation is returned for all tags - // when no tags are present, so the tag name isn't set in the violation data. That's why we add it here - if (policyHasDependentTags && firstViolation?.name === CONST.VIOLATIONS.MISSING_TAG && data?.tagListName) { - return [ - { - ...firstViolation, - data: { - ...firstViolation.data, - tagName: data?.tagListName, - }, + + // missingTag has special logic for policies with dependent tags, because only one violation is returned for all tags + // when no tags are present, so the tag name isn't set in the violation data. That's why we add it here + if (policyHasDependentTags && firstViolation?.name === CONST.VIOLATIONS.MISSING_TAG && data?.tagListName) { + return [ + { + ...firstViolation, + data: { + ...firstViolation.data, + tagName: data?.tagListName, }, - ]; - } - - // tagOutOfPolicy has special logic because we have to account for multi-level tags and use tagName to find the right tag to put the violation on - if (firstViolation?.name === CONST.VIOLATIONS.TAG_OUT_OF_POLICY && data?.tagListName !== undefined && firstViolation?.data?.tagName) { - return currentViolations.filter((violation) => violation.data?.tagName === data?.tagListName); - } - - // allTagLevelsRequired has special logic because it is returned when one but not all the tags are set, - // so we need to return the violation for the tag fields without a tag set - if (firstViolation?.name === CONST.VIOLATIONS.ALL_TAG_LEVELS_REQUIRED && tagValue) { - return currentViolations.filter((violation) => violation.data?.tagName === data?.tagListName); - } - - return currentViolations; - }, - [violationsByField], - ); + }, + ]; + } + + // tagOutOfPolicy has special logic because we have to account for multi-level tags and use tagName to find the right tag to put the violation on + if (firstViolation?.name === CONST.VIOLATIONS.TAG_OUT_OF_POLICY && data?.tagListName !== undefined && firstViolation?.data?.tagName) { + return currentViolations.filter((violation) => violation.data?.tagName === data?.tagListName); + } + + // allTagLevelsRequired has special logic because it is returned when one but not all the tags are set, + // so we need to return the violation for the tag fields without a tag set + if (firstViolation?.name === CONST.VIOLATIONS.ALL_TAG_LEVELS_REQUIRED && tagValue) { + return currentViolations.filter((violation) => violation.data?.tagName === data?.tagListName); + } + + return currentViolations; + }; return { getViolationsForField, diff --git a/src/hooks/useWorkspaceList.ts b/src/hooks/useWorkspaceList.ts index 3de8e86dc005..99c078c99b76 100644 --- a/src/hooks/useWorkspaceList.ts +++ b/src/hooks/useWorkspaceList.ts @@ -12,8 +12,6 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import {useMemo} from 'react'; - import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; type UseWorkspaceListParams = { @@ -42,11 +40,10 @@ function useWorkspaceList({ includeArchivedPolicy = false, }: UseWorkspaceListParams) { const icons = useMemoizedLazyExpensifyIcons(['FallbackWorkspaceAvatar']); - const usersWorkspaces = useMemo(() => { - if (!policies || isEmptyObject(policies)) { - return []; - } - + let usersWorkspaces: WorkspaceListItem[]; + if (!policies || isEmptyObject(policies)) { + usersWorkspaces = []; + } else { const result = []; for (const policy of Object.values(policies)) { if ( @@ -57,7 +54,6 @@ function useWorkspaceList({ ) { continue; } - result.push({ text: policy.name ?? '', policyID: policy.id, @@ -76,34 +72,28 @@ function useWorkspaceList({ isSelected: policy.id && selectedPolicyIDs ? selectedPolicyIDs.includes(policy.id) : false, }); } - return result; - }, [policies, shouldShowPendingDeletePolicy, currentUserLogin, additionalFilter, icons.FallbackWorkspaceAvatar, selectedPolicyIDs, includeArchivedPolicy]); + usersWorkspaces = result; + } - const filteredAndSortedUserWorkspaces = useMemo( - () => - tokenizedSearch(usersWorkspaces, searchTerm, (policy) => [policy.text]).sort((policy1, policy2) => { - if (shouldSortSelectedToTop) { - return sortWorkspacesBySelected( - {policyID: policy1.policyID, name: policy1.text}, - {policyID: policy2.policyID, name: policy2.text}, - policyIDsToSortToTop ?? selectedPolicyIDs, - localeCompare, - ); - } - return localeCompare(policy1.text, policy2.text); - }), - [searchTerm, usersWorkspaces, selectedPolicyIDs, policyIDsToSortToTop, localeCompare, shouldSortSelectedToTop], - ); + const filteredAndSortedUserWorkspaces: WorkspaceListItem[] = tokenizedSearch(usersWorkspaces, searchTerm, (policy) => [policy.text]).sort((policy1, policy2) => { + if (shouldSortSelectedToTop) { + return sortWorkspacesBySelected( + {policyID: policy1.policyID, name: policy1.text}, + {policyID: policy2.policyID, name: policy2.text}, + policyIDsToSortToTop ?? selectedPolicyIDs, + localeCompare, + ); + } + return localeCompare(policy1.text, policy2.text); + }); - const sections = useMemo(() => { - const options: Array> = [ - { - data: filteredAndSortedUserWorkspaces, - sectionIndex: 0, - }, - ]; - return options; - }, [filteredAndSortedUserWorkspaces]); + const options: Array> = [ + { + data: filteredAndSortedUserWorkspaces, + sectionIndex: 0, + }, + ]; + const sections = options; const shouldShowNoResultsFoundMessage = filteredAndSortedUserWorkspaces.length === 0 && usersWorkspaces.length; const shouldShowSearchInput = usersWorkspaces.length >= CONST.STANDARD_LIST_ITEM_LIMIT; diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index 2c4de7bd0247..5e32bd5a32ad 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -2,7 +2,7 @@ import Log from '@libs/Log'; import type {LayoutChangeEvent} from 'react-native'; -import {useCallback, useState, useSyncExternalStore} from 'react'; +import {useState, useSyncExternalStore} from 'react'; import {AccessibilityInfo, AppState} from 'react-native'; import isScreenReaderEnabled from './isScreenReaderEnabled'; @@ -253,15 +253,12 @@ const getHitSlopForSize = ({x, y}: HitSlop) => { const useAutoHitSlop = () => { const [frameSize, setFrameSize] = useState({x: 0, y: 0}); - const onLayout = useCallback( - (event: LayoutChangeEvent) => { - const {layout} = event.nativeEvent; - if (layout.width !== frameSize.x && layout.height !== frameSize.y) { - setFrameSize({x: layout.width, y: layout.height}); - } - }, - [frameSize.x, frameSize.y], - ); + const onLayout = (event: LayoutChangeEvent) => { + const {layout} = event.nativeEvent; + if (layout.width !== frameSize.x && layout.height !== frameSize.y) { + setFrameSize({x: layout.width, y: layout.height}); + } + }; return [getHitSlopForSize(frameSize), onLayout] as const; }; diff --git a/src/libs/ContactPermission/useContactPermissions.ts b/src/libs/ContactPermission/useContactPermissions.ts index bebff8b681ab..c43e900388d3 100644 --- a/src/libs/ContactPermission/useContactPermissions.ts +++ b/src/libs/ContactPermission/useContactPermissions.ts @@ -17,7 +17,7 @@ type UseContactPermissionsProps = { }; function useContactPermissions({importAndSaveContacts, setContacts, contactPermissionState, setContactPermissionState}: UseContactPermissionsProps): void { - const checkPermissionAndUpdateContacts = useCallback(() => { + const checkPermissionAndUpdateContacts = () => { return getContactPermission() .then((newStatus) => { const isNewStatusGranted = newStatus === RESULTS.GRANTED || newStatus === RESULTS.LIMITED; // Permission is enabled, or just became enabled @@ -34,18 +34,15 @@ function useContactPermissions({importAndSaveContacts, setContacts, contactPermi .catch((error) => { console.error('Failed to check contact permission:', error); }); - }, [contactPermissionState, importAndSaveContacts, setContacts, setContactPermissionState]); + }; - const handleAppStateChange = useCallback( - (nextAppState: AppStateStatus) => { - if (nextAppState !== 'active') { - return; - } + const handleAppStateChange = (nextAppState: AppStateStatus) => { + if (nextAppState !== 'active') { + return; + } - checkPermissionAndUpdateContacts(); - }, - [checkPermissionAndUpdateContacts], - ); + checkPermissionAndUpdateContacts(); + }; useAppState({onAppStateChange: handleAppStateChange}); diff --git a/src/libs/Navigation/AppNavigator/AppNavigator.native.tsx b/src/libs/Navigation/AppNavigator/AppNavigator.native.tsx index b77cb00575fd..87a0f2fb7a9c 100644 --- a/src/libs/Navigation/AppNavigator/AppNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/AppNavigator.native.tsx @@ -4,7 +4,7 @@ import CONFIG from '@src/CONFIG'; import ONYXKEYS from '@src/ONYXKEYS'; import type ReactComponentModule from '@src/types/utils/ReactComponentModule'; -import React, {memo, useMemo} from 'react'; +import React from 'react'; type AppNavigatorProps = { /** If we have an authToken this is true */ @@ -14,13 +14,7 @@ type AppNavigatorProps = { function AppNavigator({authenticated}: AppNavigatorProps) { const [hybridApp] = useOnyx(ONYXKEYS.HYBRID_APP); - const shouldShowAuthScreens = useMemo(() => { - if (!CONFIG.IS_HYBRID_APP) { - return authenticated; - } - - return authenticated && hybridApp?.readyToShowAuthScreens; - }, [hybridApp?.readyToShowAuthScreens, authenticated]); + const shouldShowAuthScreens = !CONFIG.IS_HYBRID_APP ? authenticated : authenticated && hybridApp?.readyToShowAuthScreens; if (shouldShowAuthScreens) { const AuthScreens = require('./AuthScreens').default; @@ -34,4 +28,4 @@ function AppNavigator({authenticated}: AppNavigatorProps) { return ; } -export default memo(AppNavigator); +export default AppNavigator; diff --git a/src/libs/Navigation/AppNavigator/AppNavigator.tsx b/src/libs/Navigation/AppNavigator/AppNavigator.tsx index cb9472f99212..a12faf885cbd 100644 --- a/src/libs/Navigation/AppNavigator/AppNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/AppNavigator.tsx @@ -4,7 +4,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import lazyRetry from '@src/utils/lazyRetry'; -import React, {lazy, memo, Suspense} from 'react'; +import React, {lazy, Suspense} from 'react'; const AuthScreens = lazy(() => lazyRetry(() => import(/* webpackChunkName: "authScreens.prefetch" */ './AuthScreens'), 'authScreens')); const PublicScreens = lazy(() => lazyRetry(() => import(/* webpackMode: "eager" */ './PublicScreens'), 'publicScreens')); @@ -34,4 +34,4 @@ function AppNavigator({authenticated}: AppNavigatorProps) { ); } -export default memo(AppNavigator); +export default AppNavigator; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index a99a0ee287c0..fb1e7afc2b36 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -62,7 +62,7 @@ import type ReactComponentModule from '@src/types/utils/ReactComponentModule'; import type {ParamListBase} from '@react-navigation/routers'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; import useModalStackScreenOptions from './useModalStackScreenOptions'; @@ -160,16 +160,13 @@ function createModalStackNavigator(screens: Scr // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); - const getScreenOptions = useCallback( - ({route: optionRoute}) => { - // Extend common options if they are defined for the screen. - if (OPTIONS_PER_SCREEN[optionRoute.name as Screen]) { - return {...screenOptions({route: optionRoute}), ...OPTIONS_PER_SCREEN[optionRoute.name as Screen]}; - } - return screenOptions({route: optionRoute}); - }, - [screenOptions], - ); + const getScreenOptions: typeof screenOptions = ({route: optionRoute}) => { + // Extend common options if they are defined for the screen. + if (OPTIONS_PER_SCREEN[optionRoute.name as Screen]) { + return {...screenOptions({route: optionRoute}), ...OPTIONS_PER_SCREEN[optionRoute.name as Screen]}; + } + return screenOptions({route: optionRoute}); + }; return ( // This container is necessary to hide card translation during transition. Without it the user would see un-clipped cards. diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts b/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts index 276bb98716b7..558dec0afc9f 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/useModalStackScreenOptions.ts @@ -19,7 +19,6 @@ import type {ParamListBase} from '@react-navigation/native'; import type {StackCardStyleInterpolator} from '@react-navigation/stack'; import {CardStyleInterpolators} from '@react-navigation/stack'; -import {useCallback} from 'react'; // Import Animated directly from 'react-native' as animations are used with navigation. // eslint-disable-next-line no-restricted-imports import {Animated} from 'react-native'; @@ -39,58 +38,55 @@ function useWideModalStackScreenOptions() { const {wideRHPRouteKeys, superWideRHPRouteKeys} = useWideRHPState(); const {sidePanelOffset} = useSidePanelState(); - return useCallback<({route}: {route: PlatformStackRouteProp}) => PlatformStackNavigationOptions>( - ({route}) => { - const baseInterpolator: StackCardStyleInterpolator = isSmallScreenWidth - ? CardStyleInterpolators.forHorizontalIOS - : (props) => modalCardStyleInterpolator({props, enter: {kind: 'slide-and-fade', distancePx: CONST.MODAL.RHP_ENTER_OFFSET_PX_WEB}}); + return ({route}: {route: PlatformStackRouteProp}): PlatformStackNavigationOptions => { + const baseInterpolator: StackCardStyleInterpolator = isSmallScreenWidth + ? CardStyleInterpolators.forHorizontalIOS + : (props) => modalCardStyleInterpolator({props, enter: {kind: 'slide-and-fade', distancePx: CONST.MODAL.RHP_ENTER_OFFSET_PX_WEB}}); - let cardStyleInterpolator: StackCardStyleInterpolator = baseInterpolator; + let cardStyleInterpolator: StackCardStyleInterpolator = baseInterpolator; - if (!isSmallScreenWidth) { - // Shrink the super wide sheet by the Side Panel width while it is open so the sheet's - // left edge stays put instead of being pushed off-screen, capped at what its panes can spare - // so the card never clips its own content. See https://github.com/Expensify/App/issues/99035 - const sidePanelShrink = getSidePanelRHPShrink(sidePanelOffset.current, windowWidth); + if (!isSmallScreenWidth) { + // Shrink the super wide sheet by the Side Panel width while it is open so the sheet's + // left edge stays put instead of being pushed off-screen, capped at what its panes can spare + // so the card never clips its own content. See https://github.com/Expensify/App/issues/99035 + const sidePanelShrink = getSidePanelRHPShrink(sidePanelOffset.current, windowWidth); - // Cards draw their own frame, so the container must not clip them. - if (superWideRHPRouteKeys.includes(route.key)) { - cardStyleInterpolator = enhanceCardStyleInterpolator(baseInterpolator, { - cardStyle: styles.getSuperWideRHPExtendedCardInterpolatorStyles(Animated.subtract(animatedSuperWideRHPWidth, sidePanelShrink)), - containerStyle: styles.overflowVisible, - }); - } else if (wideRHPRouteKeys.includes(route.key)) { - cardStyleInterpolator = enhanceCardStyleInterpolator(baseInterpolator, { - cardStyle: styles.wideRHPExtendedCardInterpolatorStyles, - containerStyle: styles.overflowVisible, - }); - // single RHPs displayed above the wide RHP need to be positioned - } else if (superWideRHPRouteKeys.length > 0 || wideRHPRouteKeys.length > 0) { - cardStyleInterpolator = enhanceCardStyleInterpolator(baseInterpolator, { - cardStyle: styles.singleRHPExtendedCardInterpolatorStyles, - containerStyle: styles.overflowVisible, - }); - } + // Cards draw their own frame, so the container must not clip them. + if (superWideRHPRouteKeys.includes(route.key)) { + cardStyleInterpolator = enhanceCardStyleInterpolator(baseInterpolator, { + cardStyle: styles.getSuperWideRHPExtendedCardInterpolatorStyles(Animated.subtract(animatedSuperWideRHPWidth, sidePanelShrink)), + containerStyle: styles.overflowVisible, + }); + } else if (wideRHPRouteKeys.includes(route.key)) { + cardStyleInterpolator = enhanceCardStyleInterpolator(baseInterpolator, { + cardStyle: styles.wideRHPExtendedCardInterpolatorStyles, + containerStyle: styles.overflowVisible, + }); + // single RHPs displayed above the wide RHP need to be positioned + } else if (superWideRHPRouteKeys.length > 0 || wideRHPRouteKeys.length > 0) { + cardStyleInterpolator = enhanceCardStyleInterpolator(baseInterpolator, { + cardStyle: styles.singleRHPExtendedCardInterpolatorStyles, + containerStyle: styles.overflowVisible, + }); } + } - return { - ...hideKeyboardOnSwipe, - headerShown: false, - animationTypeForReplace: 'pop', - native: { - contentStyle: styles.navigationScreenCardStyle, - }, - web: { - // The RHP has its own scrim (BaseOverlay), so stacked screens must not fade in react-navigation's dark backdrop too. - cardOverlayEnabled: false, - cardStyle: isSmallScreenWidth ? StyleUtils.getStyleWithEnvSafeAreaPadding(styles.navigationScreenCardStyle) : styles.navigationScreenCardStyle, - cardStyleInterpolator, - transitionSpec: isSmallScreenWidth ? undefined : RHP_WEB_TRANSITION_SPEC, - }, - }; - }, - [StyleUtils, isSmallScreenWidth, modalCardStyleInterpolator, sidePanelOffset, styles, superWideRHPRouteKeys, windowWidth, wideRHPRouteKeys], - ); + return { + ...hideKeyboardOnSwipe, + headerShown: false, + animationTypeForReplace: 'pop', + native: { + contentStyle: styles.navigationScreenCardStyle, + }, + web: { + // The RHP has its own scrim (BaseOverlay), so stacked screens must not fade in react-navigation's dark backdrop too. + cardOverlayEnabled: false, + cardStyle: isSmallScreenWidth ? StyleUtils.getStyleWithEnvSafeAreaPadding(styles.navigationScreenCardStyle) : styles.navigationScreenCardStyle, + cardStyleInterpolator, + transitionSpec: isSmallScreenWidth ? undefined : RHP_WEB_TRANSITION_SPEC, + }, + }; + }; } export default useWideModalStackScreenOptions; diff --git a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx index 3028242dbba8..3305282117e1 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx @@ -37,7 +37,7 @@ import type {ValueOf} from 'type-fest'; import {CardStyleInterpolators} from '@react-navigation/stack'; import {accountIDSelector, emailSelector} from '@selectors/Session'; -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import OnboardingModalNavigatorContentWrapper from './OnboardingModalNavigatorContentWrapper'; @@ -82,30 +82,28 @@ function OnboardingModalNavigator() { GoogleTagManager.publishEvent(CONST.ANALYTICS.EVENT.SIGN_UP.NAME, accountID, email); }, [accountID, email]); - const handleOuterClick = useCallback(() => { + const handleOuterClick = () => { OnboardingRefManager.handleOuterClick(); - }, []); + }; useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, handleOuterClick, {shouldBubble: true}); const customInterpolator = useModalCardStyleInterpolator(); - const defaultScreenOptions = useMemo(() => { - return { - headerShown: false, - animation: Animations.SLIDE_FROM_RIGHT, - animationTypeForReplace: 'pop', + const defaultScreenOptions: PlatformStackNavigationOptions = { + headerShown: false, + animation: Animations.SLIDE_FROM_RIGHT, + animationTypeForReplace: 'pop', + gestureDirection: 'horizontal', + web: { + // The .forHorizontalIOS interpolator from `@react-navigation` is misbehaving on Safari, so we override it with Expensify custom interpolator + cardStyleInterpolator: isMobileSafari() ? (props) => customInterpolator({props, enter: {kind: 'slide-from-width'}}) : CardStyleInterpolators.forHorizontalIOS, gestureDirection: 'horizontal', - web: { - // The .forHorizontalIOS interpolator from `@react-navigation` is misbehaving on Safari, so we override it with Expensify custom interpolator - cardStyleInterpolator: isMobileSafari() ? (props) => customInterpolator({props, enter: {kind: 'slide-from-width'}}) : CardStyleInterpolators.forHorizontalIOS, - gestureDirection: 'horizontal', - cardStyle: { - height: '100%', - }, - transitionSpec: shouldUseNarrowLayout ? undefined : RHP_WEB_TRANSITION_SPEC, + cardStyle: { + height: '100%', }, - }; - }, [customInterpolator, shouldUseNarrowLayout]); + transitionSpec: shouldUseNarrowLayout ? undefined : RHP_WEB_TRANSITION_SPEC, + }, + }; // If the account data is not loaded yet, we don't want to show the onboarding modal if (isLoadingOnyxValue(accountMetadata)) { diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 25a6a6592cca..5ca1aae4c8fa 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -43,7 +43,7 @@ import type {ComponentRef} from 'react'; import type {View} from 'react-native'; import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; // eslint-disable-next-line no-restricted-imports import {Animated, DeviceEventEmitter} from 'react-native'; @@ -197,7 +197,7 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { superWideRHPSidePanelOffset, ); - const overlayPositionLeft = useMemo(() => -1 * calculateSuperWideRHPWidth(windowWidth), [windowWidth]); + const overlayPositionLeft = -1 * calculateSuperWideRHPWidth(windowWidth); // Width of the focused card. The super wide animated width already gives up the Side Panel offset. let focusedRHPWidth: OverlayPositionValue = singleRHPWidth; @@ -219,25 +219,22 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { const frameStyle = getRHPFrameStyle({styles, animatedWidth, shouldUseNarrowLayout, shouldUseCenteredFrame}); - const screenListeners = useMemo( - () => ({ - blur: () => { - const rhpParams = navigation.getState().routes.find((innerRoute) => innerRoute.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR)?.params as - | NavigatorScreenParams - | undefined; - - if (rhpParams?.screen === SCREENS.RIGHT_MODAL.TRANSACTION_DUPLICATE || route.params?.screen !== SCREENS.RIGHT_MODAL.TRANSACTION_DUPLICATE) { - return; - } - // Delay clearing review duplicate data till the RHP is completely closed - // to avoid not found showing briefly in confirmation page when RHP is closing - TransitionTracker.runAfterTransitions({callback: () => abandonReviewDuplicateTransactions()}); - }, - }), - [navigation, route.params?.screen], - ); - - const handleOverlayPress = useCallback(() => { + const screenListeners = { + blur: () => { + const rhpParams = navigation.getState().routes.find((innerRoute) => innerRoute.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR)?.params as + | NavigatorScreenParams + | undefined; + + if (rhpParams?.screen === SCREENS.RIGHT_MODAL.TRANSACTION_DUPLICATE || route.params?.screen !== SCREENS.RIGHT_MODAL.TRANSACTION_DUPLICATE) { + return; + } + // Delay clearing review duplicate data till the RHP is completely closed + // to avoid not found showing briefly in confirmation page when RHP is closing + TransitionTracker.runAfterTransitions({callback: () => abandonReviewDuplicateTransactions()}); + }, + }; + + const handleOverlayPress = () => { if (isExecutingRef.current) { return; } @@ -255,7 +252,7 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { } else { isExecutingRef.current = false; } - }, [navigation]); + }; // Pressing the dimmed gap around the focused card goes back one level when a card is stacked below, and closes the RHP when it is not. const floatingCardGapProgress = shouldRenderTertiaryOverlay ? thirdOverlayProgress : undefined; @@ -263,7 +260,7 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { // The gap on the right stops before the Side Panel, which shares that edge. const floatingCardGapRightBandLeft = Animated.subtract(Animated.subtract(windowWidth, sidePanelOffset.current), variables.rhpFloatingCardMargin); - const clearWideRHPKeysAfterTabChanged = useCallback(() => { + const clearWideRHPKeysAfterTabChanged = () => { const isRhpOpened = navigationRef?.getRootState()?.routes?.some((rootStateRoute) => rootStateRoute.key === route.key); const isFullScreenTopmostRoute = isFullScreenName(navigationRef.getRootState()?.routes?.at(-1)?.name); const hasTabChanged = isRhpOpened && isFullScreenTopmostRoute; @@ -271,7 +268,7 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { return; } clearWideRHPKeys(); - }, [clearWideRHPKeys, route.key]); + }; useFocusEffect( useCallback(() => { diff --git a/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx index edaf758e3603..d09e0c4fc56a 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx @@ -20,7 +20,7 @@ import SCREENS from '@src/SCREENS'; import type {ComponentRef, MouseEvent} from 'react'; -import React, {useCallback, useRef} from 'react'; +import React, {useRef} from 'react'; import {View} from 'react-native'; import Overlay from './Overlay'; @@ -33,17 +33,17 @@ function TestToolsModalNavigator() { const outerViewRef = useRef>(null); const isAuthenticated = useIsAuthenticated(); - const handleOuterClick = useCallback(() => { + const handleOuterClick = () => { // Release focus from any focused element before closing the modal blurActiveElement(); requestAnimationFrame(() => { toggleTestToolsModal(); }); - }, []); + }; - const handleInnerClick = useCallback((e: MouseEvent) => { + const handleInnerClick = (e: MouseEvent) => { e.stopPropagation(); - }, []); + }; useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => toggleTestToolsModal(), {shouldBubble: false}); diff --git a/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts b/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts index 8607c027175f..e16534dd10e3 100644 --- a/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/useRHPScreenOptions.ts @@ -13,7 +13,6 @@ import CONST from '@src/CONST'; import type {StackCardInterpolationProps} from '@react-navigation/stack'; import {CardStyleInterpolators} from '@react-navigation/stack'; -import {useMemo} from 'react'; import RHP_WEB_TRANSITION_SPEC from './RHPTransitionSpec'; import useModalCardStyleInterpolator from './useModalCardStyleInterpolator'; @@ -48,27 +47,25 @@ const useRHPScreenOptions = (): PlatformStackNavigationOptions => { // A skinny RHP pushed over a centered wide card gets the same slide-and-fade as the centered cards instead of a full-width slide. const isWideRHPContext = !isSmallScreenWidth && (wideRHPRouteKeys.length > 0 || superWideRHPRouteKeys.length > 0); - return useMemo(() => { - return { - headerShown: false, - animation: Animations.SLIDE_FROM_RIGHT, + return { + headerShown: false, + animation: Animations.SLIDE_FROM_RIGHT, + gestureDirection: 'horizontal', + web: { + // Otherwise forHorizontalIOS misbehaves on Safari, so the custom interpolator replaces it there. + // eslint-disable-next-line no-nested-ternary + cardStyleInterpolator: isWideRHPContext + ? (props) => customInterpolator({props, enter: {kind: 'slide-and-fade', distancePx: CONST.MODAL.RHP_ENTER_OFFSET_PX_WEB}}) + : isSafari() + ? (props) => customInterpolator({props, enter: {kind: 'slide-from-width'}}) + : (props) => CardStyleInterpolators.forHorizontalIOS(shouldAdjustInterpolatorProps ? getModifiedCardStyleInterpolatorProps(props) : props), + presentation: Presentation.TRANSPARENT_MODAL, + cardOverlayEnabled: false, + cardStyle: styles.navigationScreenCardStyle, gestureDirection: 'horizontal', - web: { - // Otherwise forHorizontalIOS misbehaves on Safari, so the custom interpolator replaces it there. - // eslint-disable-next-line no-nested-ternary - cardStyleInterpolator: isWideRHPContext - ? (props) => customInterpolator({props, enter: {kind: 'slide-and-fade', distancePx: CONST.MODAL.RHP_ENTER_OFFSET_PX_WEB}}) - : isSafari() - ? (props) => customInterpolator({props, enter: {kind: 'slide-from-width'}}) - : (props) => CardStyleInterpolators.forHorizontalIOS(shouldAdjustInterpolatorProps ? getModifiedCardStyleInterpolatorProps(props) : props), - presentation: Presentation.TRANSPARENT_MODAL, - cardOverlayEnabled: false, - cardStyle: styles.navigationScreenCardStyle, - gestureDirection: 'horizontal', - transitionSpec: isSmallScreenWidth ? undefined : RHP_WEB_TRANSITION_SPEC, - }, - }; - }, [customInterpolator, shouldAdjustInterpolatorProps, isWideRHPContext, isSmallScreenWidth, styles.navigationScreenCardStyle]); + transitionSpec: isSmallScreenWidth ? undefined : RHP_WEB_TRANSITION_SPEC, + }, + }; }; export default useRHPScreenOptions; diff --git a/src/libs/Navigation/DebugTabNavigator.tsx b/src/libs/Navigation/DebugTabNavigator.tsx index f19cbe2bcf0f..6a1f785e98ef 100644 --- a/src/libs/Navigation/DebugTabNavigator.tsx +++ b/src/libs/Navigation/DebugTabNavigator.tsx @@ -15,7 +15,7 @@ import type {EventMapCore, NavigationProp, NavigationState, ParamListBase} from import {useNavigation, useRoute} from '@react-navigation/native'; import {createStackNavigator} from '@react-navigation/stack'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; type IconAndTitle = { @@ -44,7 +44,7 @@ const StackNavigator = createStackNavigator(); type DebugTabNavigatorRoute = { name: string; - component: () => React.ReactNode; + content: React.ReactNode; }; type DebugTabNavigatorRoutes = DebugTabNavigatorRoute[]; @@ -61,7 +61,7 @@ function DebugTabNavigator({id, routes}: DebugTabNavigatorProps) { const navigation = useNavigation>>(); const {translate} = useLocalize(); const [currentTab, setCurrentTab] = useState(routes.at(0)?.name); - const defaultAffectedAnimatedTabs = useMemo(() => Array.from({length: routes.length}, (v, i) => i), [routes.length]); + const defaultAffectedAnimatedTabs = Array.from({length: routes.length}, (v, i) => i); const [affectedAnimatedTabs, setAffectedAnimatedTabs] = useState(defaultAffectedAnimatedTabs); const routeData = useRoute(); @@ -146,8 +146,9 @@ function DebugTabNavigator({id, routes}: DebugTabNavigatorProps) { + > + {() => route.content} + ))} diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index 328acfc6efbd..a038d592e30f 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -31,7 +31,7 @@ import type {NavigationState} from '@react-navigation/native'; import {findFocusedRoute, NavigationContainer} from '@react-navigation/native'; import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding'; import * as Sentry from '@sentry/react-native'; -import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useContext, useEffect, useRef, useState} from 'react'; import AppNavigator from './AppNavigator'; import {cleanPreservedNavigatorStates, clearPreservedNavigatorStates} from './AppNavigator/createSplitNavigator/usePreserveNavigatorState'; @@ -188,24 +188,21 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N }); // https://reactnavigation.org/docs/themes - const navigationTheme = useMemo(() => { - const defaultNavigationTheme = getNavigationBaseTheme(theme.colorScheme); - - return { - ...defaultNavigationTheme, - colors: { - ...defaultNavigationTheme.colors, - /** - * We want to have a stack with variable size of screens in RHP (wide layout). - * The stack is the size of the biggest screen in RHP. Screens that should be smaller will reduce its size with margin. - * The stack has to be this size because it has a container with overflow: hidden. - * On wide layout, background: 'transparent' is used to make the bottom of the card stack transparent. - * On narrow layout, we use theme.appBG to match the standard app background. - */ - background: shouldUseNarrowLayout ? theme.appBG : 'transparent', - }, - }; - }, [shouldUseNarrowLayout, theme.appBG, theme.colorScheme]); + const defaultNavigationTheme = getNavigationBaseTheme(theme.colorScheme); + const navigationTheme = { + ...defaultNavigationTheme, + colors: { + ...defaultNavigationTheme.colors, + /** + * We want to have a stack with variable size of screens in RHP (wide layout). + * The stack is the size of the biggest screen in RHP. Screens that should be smaller will reduce its size with margin. + * The stack has to be this size because it has a container with overflow: hidden. + * On wide layout, background: 'transparent' is used to make the bottom of the card stack transparent. + * On narrow layout, we use theme.appBG to match the standard app background. + */ + background: shouldUseNarrowLayout ? theme.appBG : 'transparent', + }, + }; useEffect(() => { if (firstRenderRef.current) { @@ -283,7 +280,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N cleanPreservedNavigatorStates(state); }; - const onReadyWithSentry = useCallback(() => { + const onReadyWithSentry = () => { endSpan(CONST.TELEMETRY.SPAN_NAVIGATION_ROOT_READY); endSpan(CONST.TELEMETRY.SPAN_BOOTSPLASH.NAVIGATION); onReady(); @@ -296,7 +293,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N // from the restored root state so isTopMostReportId is correct on the first report screen // (e.g. the composer's keyboard avoiding view is enabled right away). updateCurrentReportID(navigationRef.getRootState()); - }, [onReady, updateCurrentReportID]); + }; // Re-establish on (re)mount — StrictMode's cleanup-then-remount otherwise leaves us listener-less; setup is idempotent. useEffect(() => { diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index e9167b115687..8058e3ea6385 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -27,7 +27,7 @@ import type {EventArg, NavigationProp, ParamListBase, ScreenListeners, TabNaviga import {createMaterialTopTabNavigator} from '@react-navigation/material-top-tabs'; import {TabActions, useRoute} from '@react-navigation/native'; -import React, {useCallback, useContext, useEffect, useRef, useState} from 'react'; +import React, {useContext, useEffect, useRef, useState} from 'react'; import {Keyboard, StyleSheet, View} from 'react-native'; import type {RegisterTabSwitchGuard, TabSwitchGuard} from './TabSwitchGuardContext'; @@ -140,6 +140,19 @@ const getTabNames = (children: React.ReactNode): string[] => { return result; }; +function LazyPlaceholder() { + const styles = useThemeStyles(); + return ( + + + + ); +} + +function renderLazyPlaceholder() { + return ; +} + // This takes all the same props as MaterialTopTabsNavigator: https://reactnavigation.org/docs/material-top-tab-navigator/#props, // except ID is now required, and it gets a `selectedTab` from Onyx // It also takes 2 more optional callbacks to manage the focus trap container elements of the tab bar and the active tab @@ -163,7 +176,6 @@ function createOnyxTabNavigator(names: ReadonlyAr shouldReapplyInterruptedTabPress = false, ...rest }: OnyxTabNavigatorProps) { - const styles = useThemeStyles(); const isFirstMountRef = useRef(true); // Mapping of tab name to focus trap container element const [focusTrapContainerElementMapping, setFocusTrapContainerElementMapping] = useState>({}); @@ -178,14 +190,6 @@ function createOnyxTabNavigator(names: ReadonlyAr const storedTabName = names.find((name) => name === selectedTab); const validInitialTab = storedTabName && tabNames.includes(storedTabName) ? storedTabName : defaultSelectedTab; - const LazyPlaceholder = useCallback(() => { - return ( - - - - ); - }, [styles.fullScreenLoading, styles.w100]); - // This callback is used to register the focus trap container element of each available tab screen const setTabFocusTrapContainerElement = (tabName: string, containerElement: HTMLElement | null) => { setFocusTrapContainerElementMapping((prevMapping) => { @@ -337,19 +341,16 @@ function createOnyxTabNavigator(names: ReadonlyAr * This is a TabBar wrapper component that includes the focus trap container element callback. * In `TabSelector.tsx` component, the callback prop to register focus trap container element is supported out of the box */ - const TabBarWithFocusTrapInclusion = useCallback( - (props: TabSelectorProps) => { - return ( - - ); - }, - [TabBar, onTabBarFocusTrapContainerElementChanged, shouldShowLabelWhenInactive, equalWidth], - ); + const renderTabBar = (props: TabSelectorProps) => { + return ( + + ); + }; // Drop a queued re-apply jump on unmount so it isn't dispatched into a navigator that no longer exists. useEffect(() => () => cancelQueuedTabSwitch(reapplyFrameRef.current), []); @@ -373,7 +374,7 @@ function createOnyxTabNavigator(names: ReadonlyAr initialRouteName={validInitialTab} backBehavior={backBehavior} keyboardDismissMode="none" - tabBar={TabBarWithFocusTrapInclusion} + tabBar={renderTabBar} onTabSelect={onTabSelect} screenListeners={({navigation}: {navigation: NavigationProp}) => { const callerListeners = screenListeners ?? {}; @@ -417,7 +418,7 @@ function createOnyxTabNavigator(names: ReadonlyAr ...defaultScreenOptions, swipeEnabled: false, lazy: lazyLoadEnabled, - lazyPlaceholder: LazyPlaceholder, + lazyPlaceholder: renderLazyPlaceholder, }} > {children} diff --git a/src/libs/Navigation/useNavigateTo3DSAuthorizationChallenge.ts b/src/libs/Navigation/useNavigateTo3DSAuthorizationChallenge.ts index b239bb4bc3c4..2de09bf77cf3 100644 --- a/src/libs/Navigation/useNavigateTo3DSAuthorizationChallenge.ts +++ b/src/libs/Navigation/useNavigateTo3DSAuthorizationChallenge.ts @@ -18,7 +18,7 @@ import type {ValueOf} from 'type-fest'; import {findFocusedRoute} from '@react-navigation/native'; import * as Sentry from '@sentry/react-native'; -import {useEffect, useMemo} from 'react'; +import {useEffect} from 'react'; import Navigation, {isMFAFlowScreen} from './Navigation'; @@ -76,11 +76,10 @@ function useNavigateTo3DSAuthorizationChallenge() { const {deviceVerificationType, doesDeviceSupportAuthenticationMethod} = useBiometrics(); - const transactionPending3DSReview = useMemo(() => { - if (!transactionsPending3DSReview || isLoadingOnyxValue(locallyProcessedReviewsResult)) { - return undefined; - } - + let transactionPending3DSReview: TransactionPending3DSReview | undefined; + if (!transactionsPending3DSReview || isLoadingOnyxValue(locallyProcessedReviewsResult)) { + transactionPending3DSReview = undefined; + } else { const pendingTransactionsAwaitingLocalProcessing = Object.values(transactionsPending3DSReview).filter((challenge) => { // We can't process a transaction without an ID, so don't bother showing it. if (!challenge?.transactionID) { @@ -102,8 +101,8 @@ function useNavigateTo3DSAuthorizationChallenge() { }); // Return only the next eligible transaction - return getFirstSortedTransactionPending3DSReview(pendingTransactionsAwaitingLocalProcessing); - }, [transactionsPending3DSReview, locallyProcessed3DSTransactionReviews, locallyProcessedReviewsResult]); + transactionPending3DSReview = getFirstSortedTransactionPending3DSReview(pendingTransactionsAwaitingLocalProcessing); + } useEffect(() => { // transactionPending3DSReview is undefined when there are no pending transactions diff --git a/src/libs/SettlementButtonUtils.ts b/src/libs/SettlementButtonUtils.ts index 2bb122114d86..5b3a0ea88016 100644 --- a/src/libs/SettlementButtonUtils.ts +++ b/src/libs/SettlementButtonUtils.ts @@ -4,38 +4,34 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import CONST from '@src/CONST'; -import {useMemo} from 'react'; - /** * Retrieves SettlementButton payment methods. */ const useSettlementButtonPaymentMethods = (hasActivatedWallet: boolean, translate: LocaleContextProps['translate']) => { const icons = useMemoizedLazyExpensifyIcons(['User', 'Building', 'CheckCircle']); - const paymentMethods = useMemo(() => { - return { - [CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT]: { - text: hasActivatedWallet ? translate('iou.settleWallet', '') : translate('iou.settlePersonal', ''), - icon: icons.User, - value: CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT, - key: CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT, - }, - [CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT]: { - text: translate('iou.settleBusiness', ''), - icon: icons.Building, - value: CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT, - shouldUpdateSelectedIndex: false, - key: CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT, - }, - [CONST.IOU.PAYMENT_TYPE.ELSEWHERE]: { - text: translate('iou.payElsewhere', ''), - icon: icons.CheckCircle, - value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, - shouldUpdateSelectedIndex: false, - key: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, - }, - }; - }, [hasActivatedWallet, translate, icons]); + const paymentMethods = { + [CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT]: { + text: hasActivatedWallet ? translate('iou.settleWallet', '') : translate('iou.settlePersonal', ''), + icon: icons.User, + value: CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT, + key: CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT, + }, + [CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT]: { + text: translate('iou.settleBusiness', ''), + icon: icons.Building, + value: CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT, + shouldUpdateSelectedIndex: false, + key: CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT, + }, + [CONST.IOU.PAYMENT_TYPE.ELSEWHERE]: { + text: translate('iou.payElsewhere', ''), + icon: icons.CheckCircle, + value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, + shouldUpdateSelectedIndex: false, + key: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, + }, + }; return paymentMethods; }; diff --git a/src/pages/AddExistingExpense.tsx b/src/pages/AddExistingExpense.tsx index f484c12c8b6d..9a42b4ed112f 100644 --- a/src/pages/AddExistingExpense.tsx +++ b/src/pages/AddExistingExpense.tsx @@ -42,7 +42,7 @@ import getEmptyArray from '@src/types/utils/getEmptyArray'; import type {OnyxCollection} from 'react-native-onyx'; -import {useCallback, useEffect, useState} from 'react'; +import {useEffect, useState} from 'react'; import {View} from 'react-native'; type AddExistingExpensePageType = PlatformStackScreenProps; @@ -74,11 +74,8 @@ function AddExistingExpense({route}: AddExistingExpensePageType) { const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const styles = useThemeStyles(); - const transactionsSelector = useCallback( - (allTransactions: OnyxCollection) => - getEligibleTransactionsToAdd({transactions: allTransactions, report, policy, cardList, currentUserAccountID, reportID, allOpenReports, openReportDrafts}), - [report, policy, cardList, currentUserAccountID, reportID, allOpenReports, openReportDrafts], - ); + const transactionsSelector = (allTransactions: OnyxCollection) => + getEligibleTransactionsToAdd({transactions: allTransactions, report, policy, cardList, currentUserAccountID, reportID, allOpenReports, openReportDrafts}); const [transactions = getEmptyArray()] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {selector: transactionsSelector}); const fetchMoreUnreportedTransactions = () => { diff --git a/src/pages/AddPersonalBankAccountPage/substeps/AddressStep.tsx b/src/pages/AddPersonalBankAccountPage/substeps/AddressStep.tsx index ec2cfe67e98b..7ea595823e6f 100644 --- a/src/pages/AddPersonalBankAccountPage/substeps/AddressStep.tsx +++ b/src/pages/AddPersonalBankAccountPage/substeps/AddressStep.tsx @@ -18,7 +18,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/HomeAddressForm'; import type {Address} from '@src/types/onyx/PrivatePersonalDetails'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; type AddressStepProps = SubPageProps & { @@ -40,26 +40,14 @@ function AddressStep({onNext, isEditing, shouldSaveDraft = false, shouldHideCoun const [bankAccountPersonalDetails] = useOnyx(ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_FORM_DRAFT); const [homeAddressFormDraft] = useOnyx(ONYXKEYS.FORMS.HOME_ADDRESS_FORM_DRAFT); - const address = useMemo(() => { - const normalizedAddress = normalizeCountryCode(getCurrentAddress(privatePersonalDetails)) as Address; - return { - street: bankAccountPersonalDetails?.addressStreet - ? `${bankAccountPersonalDetails?.addressStreet}\n${bankAccountPersonalDetails?.addressStreet2}` - : (normalizedAddress?.street ?? ''), - city: bankAccountPersonalDetails?.addressCity ?? normalizedAddress?.city ?? '', - state: bankAccountPersonalDetails?.addressState ?? normalizedAddress?.state ?? '', - zip: bankAccountPersonalDetails?.addressZipCode ?? normalizedAddress?.zip ?? '', - country: (bankAccountPersonalDetails?.country ?? normalizedAddress?.country ?? '') as Country | '', - }; - }, [ - bankAccountPersonalDetails?.addressCity, - bankAccountPersonalDetails?.addressState, - bankAccountPersonalDetails?.addressStreet, - bankAccountPersonalDetails?.addressStreet2, - bankAccountPersonalDetails?.addressZipCode, - bankAccountPersonalDetails?.country, - privatePersonalDetails, - ]); + const normalizedAddress = normalizeCountryCode(getCurrentAddress(privatePersonalDetails)) as Address; + const address = { + street: bankAccountPersonalDetails?.addressStreet ? `${bankAccountPersonalDetails?.addressStreet}\n${bankAccountPersonalDetails?.addressStreet2}` : (normalizedAddress?.street ?? ''), + city: bankAccountPersonalDetails?.addressCity ?? normalizedAddress?.city ?? '', + state: bankAccountPersonalDetails?.addressState ?? normalizedAddress?.state ?? '', + zip: bankAccountPersonalDetails?.addressZipCode ?? normalizedAddress?.zip ?? '', + country: (bankAccountPersonalDetails?.country ?? normalizedAddress?.country ?? '') as Country | '', + }; const draftCountry = shouldSaveDraft ? homeAddressFormDraft?.country : undefined; const draftState = shouldSaveDraft ? homeAddressFormDraft?.state : undefined; diff --git a/src/pages/AddressPage.tsx b/src/pages/AddressPage.tsx index 926157b82536..fd79d142989e 100644 --- a/src/pages/AddressPage.tsx +++ b/src/pages/AddressPage.tsx @@ -19,7 +19,7 @@ import type {Address} from '@src/types/onyx/PrivatePersonalDetails'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; type AddressPageProps = { @@ -64,36 +64,33 @@ function AddressPage({title, address, updateAddress, isLoadingApp = true, backTo // eslint-disable-next-line react-hooks/exhaustive-deps }, [address?.state, address?.country, address?.city, address?.zip]); - const handleAddressChange = useCallback( - (value: unknown, key: unknown) => { - const addressPart = value as string; - const addressPartKey = key as keyof Address; + const handleAddressChange = (value: unknown, key: unknown) => { + const addressPart = value as string; + const addressPartKey = key as keyof Address; - if (addressPartKey !== INPUT_IDS.COUNTRY && addressPartKey !== INPUT_IDS.STATE && addressPartKey !== INPUT_IDS.CITY && addressPartKey !== INPUT_IDS.ZIP_POST_CODE) { - return; - } - if (addressPartKey === INPUT_IDS.COUNTRY && addressPart !== currentCountry) { - setCurrentCountry(addressPart as Country | ''); - setState(''); - setCity(''); - setZipcode(''); - return; - } - if (addressPartKey === INPUT_IDS.STATE) { - setState(addressPart); - setCity(''); - setZipcode(''); - return; - } - if (addressPartKey === INPUT_IDS.CITY) { - setCity(addressPart); - setZipcode(''); - return; - } - setZipcode(addressPart); - }, - [currentCountry], - ); + if (addressPartKey !== INPUT_IDS.COUNTRY && addressPartKey !== INPUT_IDS.STATE && addressPartKey !== INPUT_IDS.CITY && addressPartKey !== INPUT_IDS.ZIP_POST_CODE) { + return; + } + if (addressPartKey === INPUT_IDS.COUNTRY && addressPart !== currentCountry) { + setCurrentCountry(addressPart as Country | ''); + setState(''); + setCity(''); + setZipcode(''); + return; + } + if (addressPartKey === INPUT_IDS.STATE) { + setState(addressPart); + setCity(''); + setZipcode(''); + return; + } + if (addressPartKey === INPUT_IDS.CITY) { + setCity(addressPart); + setZipcode(''); + return; + } + setZipcode(addressPart); + }; return ( - Object.entries(DETAILS_CONSTANT_FIELDS[formType as DebugForms].find((field) => field.fieldName === fieldName)?.options ?? {}) - .reduce((acc: Array<[string, string]>, [key, value]) => { - // Option has multiple constants, so we need to flatten these into separate options - if (isObject(value)) { - acc.push(...Object.entries(value)); - return acc; - } - acc.push([key, String(value)]); - return acc; - }, []) - .map( - ([key, value]) => - ({ - text: value, - keyForList: key, - isSelected: value === fieldValue, - searchText: value, - }) satisfies ListItem, - ) - .filter(({searchText}) => { - return tokenizedSearch([{searchText}], searchValue, (item) => [item.searchText]).length > 0; - }), - [fieldName, fieldValue, formType, searchValue], - ); - const selectedOptionKey = useMemo(() => sections.find((option) => option.searchText === fieldValue)?.keyForList, [sections, fieldValue]); + const sections: ListItem[] = Object.entries(DETAILS_CONSTANT_FIELDS[formType as DebugForms].find((field) => field.fieldName === fieldName)?.options ?? {}) + .reduce((acc: Array<[string, string]>, [key, value]) => { + // Option has multiple constants, so we need to flatten these into separate options + if (isObject(value)) { + acc.push(...Object.entries(value)); + return acc; + } + acc.push([key, String(value)]); + return acc; + }, []) + .map( + ([key, value]) => + ({ + text: value, + keyForList: key, + isSelected: value === fieldValue, + searchText: value, + }) satisfies ListItem, + ) + .filter(({searchText}) => { + return tokenizedSearch([{searchText}], searchValue, (item) => [item.searchText]).length > 0; + }); + const selectedOptionKey = sections.find((option) => option.searchText === fieldValue)?.keyForList; - const textInputOptions = useMemo( - () => ({ - value: searchValue, - label: translate('common.search'), - onChangeText: setSearchValue, - }), - [searchValue, translate, setSearchValue], - ); + const textInputOptions = { + value: searchValue, + label: translate('common.search'), + onChangeText: setSearchValue, + }; return ( - Object.entries(data ?? {}) - .filter(([, value]) => typeof value === 'boolean') - .sort((a, b) => localeCompare(a[0], b[0])) as Array<[string, boolean]>, - [data, localeCompare], - ); - const constantFields = useMemo( - () => - Object.entries(data ?? {}) - .filter((entry): entry is [string, string] => { - // Tag picker needs to be hidden when the policy has no tags available to pick - if (entry[0] === TRANSACTION_FORM_INPUT_IDS.TAG && !policyHasEnabledTags) { - return false; - } - return DETAILS_CONSTANT_FIELDS[formType].some(({fieldName}) => fieldName === entry[0]); - }) - .sort((a, b) => localeCompare(a[0], b[0])), - [data, formType, policyHasEnabledTags, localeCompare], - ); - const numberFields = useMemo( - () => - Object.entries(data ?? {}) - .filter((entry): entry is [string, number] => typeof entry[1] === 'number') - .sort((a, b) => localeCompare(a[0], b[0])), - [data, localeCompare], - ); - const textFields = useMemo( - () => - Object.entries(data ?? {}) - .filter( - (entry): entry is [string, string | ObjectType>] => - (typeof entry[1] === 'string' || typeof entry[1] === 'object') && - !DETAILS_CONSTANT_FIELDS[formType].some(({fieldName}) => fieldName === entry[0]) && - !DETAILS_DATETIME_FIELDS.includes(entry[0]), - ) - .map(([key, value]) => [key, DebugUtils.onyxDataToString(value)]) - .sort((a, b) => localeCompare(a.at(0) ?? '', b.at(0) ?? '')), - [data, formType, localeCompare], - ); - const dateTimeFields = useMemo(() => Object.entries(data ?? {}).filter((entry): entry is [string, string] => DETAILS_DATETIME_FIELDS.includes(entry[0])), [data]); - - const validator = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const newErrors: Record = {}; - for (const [key, value] of Object.entries(values)) { - try { - validate(key, DebugUtils.onyxDataToString(value)); - } catch (e) { - if (!(e instanceof Error)) { - newErrors[key] = String(e); - continue; - } - - const {message, cause} = e; - newErrors[key] = cause || message === 'debug.missingValue' ? translate(message as TranslationPaths, cause as never) : message; + const booleanFields = Object.entries(data ?? {}) + .filter(([, value]) => typeof value === 'boolean') + .sort((a, b) => localeCompare(a[0], b[0])) as Array<[string, boolean]>; + const constantFields = Object.entries(data ?? {}) + .filter((entry): entry is [string, string] => { + // Tag picker needs to be hidden when the policy has no tags available to pick + if (entry[0] === TRANSACTION_FORM_INPUT_IDS.TAG && !policyHasEnabledTags) { + return false; + } + return DETAILS_CONSTANT_FIELDS[formType].some(({fieldName}) => fieldName === entry[0]); + }) + .sort((a, b) => localeCompare(a[0], b[0])); + const numberFields = Object.entries(data ?? {}) + .filter((entry): entry is [string, number] => typeof entry[1] === 'number') + .sort((a, b) => localeCompare(a[0], b[0])); + const textFields = Object.entries(data ?? {}) + .filter( + (entry): entry is [string, string | ObjectType>] => + (typeof entry[1] === 'string' || typeof entry[1] === 'object') && + !DETAILS_CONSTANT_FIELDS[formType].some(({fieldName}) => fieldName === entry[0]) && + !DETAILS_DATETIME_FIELDS.includes(entry[0]), + ) + .map(([key, value]) => [key, DebugUtils.onyxDataToString(value)]) + .sort((a, b) => localeCompare(a.at(0) ?? '', b.at(0) ?? '')); + const dateTimeFields = Object.entries(data ?? {}).filter((entry): entry is [string, string] => DETAILS_DATETIME_FIELDS.includes(entry[0])); + + const validator = (values: FormOnyxValues): FormInputErrors => { + const newErrors: Record = {}; + for (const [key, value] of Object.entries(values)) { + try { + validate(key, DebugUtils.onyxDataToString(value)); + } catch (e) { + if (!(e instanceof Error)) { + newErrors[key] = String(e); + continue; } + + const {message, cause} = e; + newErrors[key] = cause || message === 'debug.missingValue' ? translate(message as TranslationPaths, cause as never) : message; } - return newErrors; - }, - [translate, validate], - ); + } + return newErrors; + }; useEffect(() => { Debug.resetDebugDetailsDraftForm(); }, []); - const handleSubmit = useCallback( - (values: FormOnyxValues) => { - const dataPreparedToSave = Object.entries(values).reduce((acc: Record, [key, value]) => { - if (typeof value === 'boolean') { - acc[key] = value; - } else { - acc[key] = DebugUtils.stringToOnyxData(value, typeof data?.[key as keyof typeof data] as OnyxDataType); - } - return acc; - }, {}); - - onSave(dataPreparedToSave); - }, - [data, onSave], - ); - - const isSubmitDisabled = useMemo( - () => - !Object.entries(formDraftData ?? {}).some(([key, value]) => { - const onyxData = data?.[key as keyof typeof data]; - if (typeof value === 'string') { - return !DebugUtils.compareStringWithOnyxData(value, onyxData); - } - return onyxData !== value; - }), - [formDraftData, data], - ); + const handleSubmit = (values: FormOnyxValues) => { + const dataPreparedToSave = Object.entries(values).reduce((acc: Record, [key, value]) => { + if (typeof value === 'boolean') { + acc[key] = value; + } else { + acc[key] = DebugUtils.stringToOnyxData(value, typeof data?.[key as keyof typeof data] as OnyxDataType); + } + return acc; + }, {}); + + onSave(dataPreparedToSave); + }; + + const isSubmitDisabled = !Object.entries(formDraftData ?? {}).some(([key, value]) => { + const onyxData = data?.[key as keyof typeof data]; + if (typeof value === 'string') { + return !DebugUtils.compareStringWithOnyxData(value, onyxData); + } + return onyxData !== value; + }); return ( diff --git a/src/pages/Debug/DebugJSON.tsx b/src/pages/Debug/DebugJSON.tsx index 58e78410437c..f0b94e6f195e 100644 --- a/src/pages/Debug/DebugJSON.tsx +++ b/src/pages/Debug/DebugJSON.tsx @@ -11,7 +11,7 @@ import useThrottledButtonState from '@hooks/useThrottledButtonState'; import Clipboard from '@libs/Clipboard'; import DebugUtils from '@libs/DebugUtils'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type DebugJSONProps = { @@ -24,7 +24,7 @@ function DebugJSON({data}: DebugJSONProps) { const {translate} = useLocalize(); const [isThrottledButtonActive, setThrottledButtonInactive] = useThrottledButtonState(); - const json = useMemo(() => DebugUtils.stringifyJSON(data), [data]); + const json = DebugUtils.stringifyJSON(data); const icons = useMemoizedLazyExpensifyIcons(['Copy']); return ( diff --git a/src/pages/Debug/DebugTagPicker.tsx b/src/pages/Debug/DebugTagPicker.tsx index 5a396ff00e54..edfc6425cbf0 100644 --- a/src/pages/Debug/DebugTagPicker.tsx +++ b/src/pages/Debug/DebugTagPicker.tsx @@ -17,7 +17,7 @@ import type {Policy} 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 DebugTagPickerProps = { @@ -36,27 +36,25 @@ function DebugTagPicker({policyID, tagName = '', onSubmit}: DebugTagPickerProps) const styles = useThemeStyles(); const {translate} = useLocalize(); const [newTagName, setNewTagName] = useState(tagName); - const selectedTags = useMemo(() => getTagArrayFromName(newTagName), [newTagName]); + const selectedTags = getTagArrayFromName(newTagName); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); - const policyTagLists = useMemo(() => getTagLists(policyTags), [policyTags]); + const policyTagLists = getTagLists(policyTags); const [hasMultipleTagLists] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: policyHasMultipleTagListsSelector}); - const updateTagName = useCallback( + const updateTagName = (index: number) => - ({text}: Partial) => { - const newTag = text === selectedTags.at(index) ? undefined : text; - const updatedTagName = insertTagIntoTransactionTagsString(newTagName, newTag ?? '', index, hasMultipleTagLists ?? false); - if (policyTagLists.length === 1) { - return onSubmit({text: updatedTagName, keyForList: updatedTagName}); - } - setNewTagName(updatedTagName); - }, - [newTagName, onSubmit, policyTagLists.length, selectedTags, hasMultipleTagLists], - ); + ({text}: Partial) => { + const newTag = text === selectedTags.at(index) ? undefined : text; + const updatedTagName = insertTagIntoTransactionTagsString(newTagName, newTag ?? '', index, hasMultipleTagLists ?? false); + if (policyTagLists.length === 1) { + return onSubmit({text: updatedTagName, keyForList: updatedTagName}); + } + setNewTagName(updatedTagName); + }; - const submitTag = useCallback(() => { + const submitTag = () => { onSubmit({text: newTagName, keyForList: newTagName}); - }, [newTagName, onSubmit]); + }; return ( diff --git a/src/pages/Debug/DynamicDebugDetailsConstantPickerPage.tsx b/src/pages/Debug/DynamicDebugDetailsConstantPickerPage.tsx index ba5beffc62d3..34666cc27fa8 100644 --- a/src/pages/Debug/DynamicDebugDetailsConstantPickerPage.tsx +++ b/src/pages/Debug/DynamicDebugDetailsConstantPickerPage.tsx @@ -17,7 +17,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import TRANSACTION_FORM_INPUT_IDS from '@src/types/form/DebugTransactionForm'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; import ConstantPicker from './ConstantPicker'; @@ -38,7 +38,7 @@ function DynamicDebugDetailsConstantPickerPage({ Navigation.goBack(appendParam(backPath, fieldName ?? '', value), {compareParams: false}); }; - const renderPicker = useCallback(() => { + const renderPicker = () => { if (([TRANSACTION_FORM_INPUT_IDS.CURRENCY, TRANSACTION_FORM_INPUT_IDS.MODIFIED_CURRENCY, TRANSACTION_FORM_INPUT_IDS.ORIGINAL_CURRENCY] as string[]).includes(fieldName)) { return ( ); - }, [fieldName, fieldValue, formType, onSubmit, policyID, translate]); + }; return ( diff --git a/src/pages/Debug/Report/DebugReportActions.tsx b/src/pages/Debug/Report/DebugReportActions.tsx index 31258c8dd3b3..fe0a343f7d41 100644 --- a/src/pages/Debug/Report/DebugReportActions.tsx +++ b/src/pages/Debug/Report/DebugReportActions.tsx @@ -26,7 +26,7 @@ import type {ReportAction, ReportActions} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; type DebugReportActionsProps = { reportID: string; @@ -52,70 +52,62 @@ function DebugReportActions({reportID}: DebugReportActionsProps) { const participantAccountIDs = getParticipantsAccountIDsForDisplay(report, undefined, undefined, undefined, true); const participantPersonalDetailList = Object.values(getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails)); - const getReportActionDebugText = useCallback( - (reportAction: ReportAction) => { - const reportActionMessage = getReportActionMessage(reportAction); - const originalMessage = getOriginalMessage(reportAction); - - if (!reportActionMessage) { - return ''; - } - - if (!!reportActionMessage.deleted || (originalMessage && 'deleted' in originalMessage && originalMessage.deleted)) { - return `[${translate('parentReportAction.deletedMessage')}]`; - } - - if (isCreatedAction(reportAction)) { - return formatReportLastMessageText( - SidebarUtils.getWelcomeMessage({ - report, - policy, - invoiceReceiverPolicy, - participantPersonalDetailList, - translate, - localeCompare, - conciergeReportID, - derivedReportName, - isReportArchived, - formatPhoneNumber, - }).messageText ?? translate('report.noActivityYet'), - ); - } - - if (reportActionMessage.html) { - return Parser.htmlToText(reportActionMessage.html.replaceAll(/\s*<\/mention-user>/gi, '')); - } - - return getReportActionMessageText(reportAction); - }, - [translate, report, policy, invoiceReceiverPolicy, participantPersonalDetailList, localeCompare, conciergeReportID, derivedReportName, isReportArchived, formatPhoneNumber], - ); - - const searchedReportActions = useMemo(() => { - return (sortedAllReportActions ?? []) - .filter((reportAction) => reportAction?.reportActionID) - .filter( - (reportAction) => - reportAction.reportActionID?.includes(debouncedSearchValue) || getReportActionDebugText(reportAction).toLowerCase().includes(debouncedSearchValue.toLowerCase()), - ) - .map((reportAction) => ({ - reportActionID: reportAction.reportActionID, - text: getReportActionDebugText(reportAction), - alternateText: `${reportAction.reportActionID} | ${datetimeToCalendarTime(reportAction.created, false, false)}`, - keyForList: reportAction.reportActionID, - titleStyles: styles.fontWeightNormal, - })); - }, [sortedAllReportActions, debouncedSearchValue, getReportActionDebugText, datetimeToCalendarTime, styles.fontWeightNormal]); - - const textInputOptions = useMemo( - () => ({ - value: searchValue, - label: translate('common.search'), - onChangeText: setSearchValue, - headerMessage: getHeaderMessageForNonUserList(translate, searchedReportActions.length > 0, debouncedSearchValue), - }), - [debouncedSearchValue, searchValue, searchedReportActions.length, setSearchValue, translate], - ); + const getReportActionDebugText = (reportAction: ReportAction) => { + const reportActionMessage = getReportActionMessage(reportAction); + const originalMessage = getOriginalMessage(reportAction); + + if (!reportActionMessage) { + return ''; + } + + if (!!reportActionMessage.deleted || (originalMessage && 'deleted' in originalMessage && originalMessage.deleted)) { + return `[${translate('parentReportAction.deletedMessage')}]`; + } + + if (isCreatedAction(reportAction)) { + return formatReportLastMessageText( + SidebarUtils.getWelcomeMessage({ + report, + policy, + invoiceReceiverPolicy, + participantPersonalDetailList, + translate, + localeCompare, + conciergeReportID, + derivedReportName, + isReportArchived, + formatPhoneNumber, + }).messageText ?? translate('report.noActivityYet'), + ); + } + + if (reportActionMessage.html) { + return Parser.htmlToText(reportActionMessage.html.replaceAll(/\s*<\/mention-user>/gi, '')); + } + + return getReportActionMessageText(reportAction); + }; + + const searchedReportActions = (sortedAllReportActions ?? []) + .filter((reportAction) => reportAction?.reportActionID) + .filter( + (reportAction) => + reportAction.reportActionID?.includes(debouncedSearchValue) || getReportActionDebugText(reportAction).toLowerCase().includes(debouncedSearchValue.toLowerCase()), + ) + .map((reportAction) => ({ + reportActionID: reportAction.reportActionID, + text: getReportActionDebugText(reportAction), + alternateText: `${reportAction.reportActionID} | ${datetimeToCalendarTime(reportAction.created, false, false)}`, + keyForList: reportAction.reportActionID, + titleStyles: styles.fontWeightNormal, + })); + + const textInputOptions = { + value: searchValue, + label: translate('common.search'), + onChangeText: setSearchValue, + headerMessage: getHeaderMessageForNonUserList(translate, searchedReportActions.length > 0, debouncedSearchValue), + }; return ( diff --git a/src/pages/Debug/Report/DebugReportPage.tsx b/src/pages/Debug/Report/DebugReportPage.tsx index c8056409e612..841cd9481ee3 100644 --- a/src/pages/Debug/Report/DebugReportPage.tsx +++ b/src/pages/Debug/Report/DebugReportPage.tsx @@ -42,7 +42,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import {pendingExpenseActionSelector} from '@selectors/ReportMetaData'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import DebugReportActions from './DebugReportActions'; @@ -97,11 +97,10 @@ function DebugReportPage({ const hasGuidesEmails = hasExpensifyGuidesEmails(Object.keys(report?.participants ?? {}).map(Number), guideAccountIDs); const derivedIsEmptyReport = useDerivedIsEmptyReport(reportID); - const metadata = useMemo(() => { - if (!report) { - return []; - } - + let metadata: Metadata[]; + if (!report) { + metadata = []; + } else { const shouldDisplayViolations = !!getViolatingReportIDForRBRInLHN(report, transactionViolations); const hasViolations = !!shouldDisplayViolations; const {reason: reasonGBR, reportAction: reportActionGBR} = @@ -138,8 +137,7 @@ function DebugReportPage({ derivedIsEmptyReport, transactionViolations, }); - - return [ + metadata = [ { title: translate('debug.visibleInLHN'), subtitle: translate(`debug.${!!reasonLHN}`), @@ -182,142 +180,90 @@ function DebugReportPage({ : undefined, }, ]; - }, [ - report, - transactionViolations, - currentUserLogin, - currentUserAccountID, - isReportArchived, - pendingExpenseActionMetadata, - chatReport, - reportActions, - transactions, - reportAttributes?.reportErrors, - isOffline, - isDefaultRoomsBetaEnabled, - priorityMode, - draftComment, - translate, - conciergeReportID, - hasGuidesEmails, - derivedIsEmptyReport, - ]); + } const icons = useMemoizedLazyExpensifyIcons(['Eye']); - const DebugDetailsTab = useCallback( - () => ( - { - Debug.setDebugData(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, data); - }} - onDelete={() => { - navigateToConciergeChatAndDeleteReport( - reportID, - conciergeReportID, - currentUserAccountID, - introSelected, - isSelfTourViewed, - reportOwnerPersonalDetail, - currentUserPersonalDetail, - conciergePersonalDetail, - true, - true, - ); - }} - validate={DebugUtils.validateReportDraftProperty} - > - - {metadata?.map(({title, subtitle, message, action}) => ( - - - {title} - {subtitle} - - {!!message && {message}} - {!!action && ( - - )} + const detailsTab = ( + { + Debug.setDebugData(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, data); + }} + onDelete={() => { + navigateToConciergeChatAndDeleteReport( + reportID, + conciergeReportID, + currentUserAccountID, + introSelected, + isSelfTourViewed, + reportOwnerPersonalDetail, + currentUserPersonalDetail, + conciergePersonalDetail, + true, + true, + ); + }} + validate={DebugUtils.validateReportDraftProperty} + > + + {metadata?.map(({title, subtitle, message, action}) => ( + + + {title} + {subtitle} - ))} + {!!message && {message}} + {!!action && ( + + )} + + ))} + + {!!transactionID && ( - {!!transactionID && ( - - )} - - - ), - [ - StyleUtils, - metadata, - report, - reportID, - styles.br4, - styles.flexColumn, - styles.flexRow, - styles.gap2, - styles.gap5, - styles.h4, - styles.justifyContentBetween, - styles.mb5, - styles.p5, - styles.ph5, - styles.textSupporting, - theme.cardBG, - transactionID, - translate, - icons.Eye, - currentUserAccountID, - conciergeReportID, - introSelected, - isSelfTourViewed, - reportOwnerPersonalDetail, - currentUserPersonalDetail, - conciergePersonalDetail, - ], + )} + + ); - const DebugJSONTab = useCallback(() => , [report]); + const jsonTab = ; - const DebugReportActionsTab = useCallback(() => , [reportID]); + const reportActionsTab = ; - const routes = useMemo( - () => [ - { - name: CONST.DEBUG.DETAILS, - component: DebugDetailsTab, - }, - { - name: CONST.DEBUG.JSON, - component: DebugJSONTab, - }, - { - name: CONST.DEBUG.REPORT_ACTIONS, - component: DebugReportActionsTab, - }, - ], - [DebugDetailsTab, DebugJSONTab, DebugReportActionsTab], - ); + const routes: DebugTabNavigatorRoutes = [ + { + name: CONST.DEBUG.DETAILS, + content: detailsTab, + }, + { + name: CONST.DEBUG.JSON, + content: jsonTab, + }, + { + name: CONST.DEBUG.REPORT_ACTIONS, + content: reportActionsTab, + }, + ]; if (!report) { return ; diff --git a/src/pages/Debug/ReportAction/DebugReportActionPage.tsx b/src/pages/Debug/ReportAction/DebugReportActionPage.tsx index bb41067e347b..2cedf47bf9c8 100644 --- a/src/pages/Debug/ReportAction/DebugReportActionPage.tsx +++ b/src/pages/Debug/ReportAction/DebugReportActionPage.tsx @@ -28,7 +28,7 @@ import type {ReportAction, ReportActions} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import DebugReportActionPreview from './DebugReportActionPreview'; @@ -48,61 +48,52 @@ function DebugReportActionPage({ }); const transactionID = getLinkedTransactionID(reportAction); - const DebugDetailsTab = useCallback( - () => ( - { - Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: data}); - }} - onDelete={() => { - Navigation.goBack(undefined, { - // We need to wait for navigation animations to finish before deleting an action, - // otherwise the user will see a not found page briefly. - afterTransition: () => { - Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: null}); - }, - }); - }} - validate={DebugUtils.validateReportActionDraftProperty} - > - {!!transactionID && ( - - - - )} - - ), - [reportAction, reportActionID, reportID, styles.mb5, styles.mh5, transactionID, translate], + const detailsTab = ( + { + Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: data}); + }} + onDelete={() => { + Navigation.goBack(undefined, { + // We need to wait for navigation animations to finish before deleting an action, + // otherwise the user will see a not found page briefly. + afterTransition: () => { + Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: null}); + }, + }); + }} + validate={DebugUtils.validateReportActionDraftProperty} + > + {!!transactionID && ( + + + + )} + ); - const DebugJSONTab = useCallback(() => , [reportAction]); + const jsonTab = ; - const DebugReportActionPreviewTab = useCallback( - () => ( - - ), - [reportAction, reportID], + const reportActionPreviewTab = ( + ); - const routes = useMemo( - () => [ - {name: CONST.DEBUG.DETAILS, component: DebugDetailsTab}, - {name: CONST.DEBUG.JSON, component: DebugJSONTab}, - {name: CONST.DEBUG.REPORT_ACTION_PREVIEW, component: DebugReportActionPreviewTab}, - ], - [DebugDetailsTab, DebugJSONTab, DebugReportActionPreviewTab], - ); + const routes: DebugTabNavigatorRoutes = [ + {name: CONST.DEBUG.DETAILS, content: detailsTab}, + {name: CONST.DEBUG.JSON, content: jsonTab}, + {name: CONST.DEBUG.REPORT_ACTION_PREVIEW, content: reportActionPreviewTab}, + ]; return ( getTagLists(policyTags), [policyTags]); + const policyTagLists = getTagLists(policyTags); const styles = useThemeStyles(); - const DebugDetailsTab = useCallback( - () => ( - { - Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, data); - }} - onDelete={() => { - Navigation.goBack(undefined, { - // We need to wait for navigation animations to finish before deleting a transaction, - // otherwise the user will see a not found page briefly. - afterTransition: () => { - Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, null); - }, - }); - }} - validate={DebugUtils.validateTransactionDraftProperty} - > - - - - - ), - [policyTagLists, report?.policyID, styles.mb5, styles.mh5, transaction, transactionID, translate], + const detailsTab = ( + { + Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, data); + }} + onDelete={() => { + Navigation.goBack(undefined, { + // We need to wait for navigation animations to finish before deleting a transaction, + // otherwise the user will see a not found page briefly. + afterTransition: () => { + Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, null); + }, + }); + }} + validate={DebugUtils.validateTransactionDraftProperty} + > + + + + ); - const DebugJSONTab = useCallback(() => , [transaction]); + const jsonTab = ; - const DebugTransactionViolationsTab = useCallback(() => , [transactionID]); + const transactionViolationsTab = ; - const routes = useMemo( - () => [ - {name: CONST.DEBUG.DETAILS, component: DebugDetailsTab}, - {name: CONST.DEBUG.JSON, component: DebugJSONTab}, - {name: CONST.DEBUG.TRANSACTION_VIOLATIONS, component: DebugTransactionViolationsTab}, - ], - [DebugDetailsTab, DebugJSONTab, DebugTransactionViolationsTab], - ); + const routes: DebugTabNavigatorRoutes = [ + {name: CONST.DEBUG.DETAILS, content: detailsTab}, + {name: CONST.DEBUG.JSON, content: jsonTab}, + {name: CONST.DEBUG.TRANSACTION_VIOLATIONS, content: transactionViolationsTab}, + ]; if (!transaction) { return ; diff --git a/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx b/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx index baa67d6797cc..4634c017363f 100644 --- a/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx +++ b/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx @@ -23,7 +23,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type {TransactionViolation} from '@src/types/onyx'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type DebugTransactionViolationPageProps = PlatformStackScreenProps; @@ -35,20 +35,17 @@ function DebugTransactionViolationPage({ }: DebugTransactionViolationPageProps) { const {translate} = useLocalize(); const transactionViolations = useTransactionViolations(transactionID); - const transactionViolation = useMemo(() => transactionViolations?.[Number(index)], [index, transactionViolations]); + const transactionViolation = transactionViolations?.[Number(index)]; const styles = useThemeStyles(); - const saveChanges = useCallback( - (data: Record) => { - const updatedTransactionViolations = [...(transactionViolations ?? [])]; - updatedTransactionViolations.splice(Number(index), 1, data as TransactionViolation); - Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, updatedTransactionViolations); - }, - [index, transactionID, transactionViolations], - ); + const saveChanges = (data: Record) => { + const updatedTransactionViolations = [...(transactionViolations ?? [])]; + updatedTransactionViolations.splice(Number(index), 1, data as TransactionViolation); + Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, updatedTransactionViolations); + }; - const deleteTransactionViolation = useCallback(() => { + const deleteTransactionViolation = () => { const updatedTransactionViolations = [...(transactionViolations ?? [])]; updatedTransactionViolations.splice(Number(index), 1); Navigation.goBack(undefined, { @@ -58,30 +55,24 @@ function DebugTransactionViolationPage({ Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, updatedTransactionViolations); }, }); - }, [index, transactionID, transactionViolations]); - - const DebugDetailsTab = useCallback( - () => ( - - ), - [deleteTransactionViolation, saveChanges, transactionViolation], + }; + + const detailsTab = ( + ); - const DebugJSONTab = useCallback(() => , [transactionViolation]); + const jsonTab = ; - const routes = useMemo( - () => [ - {name: CONST.DEBUG.DETAILS, component: DebugDetailsTab}, - {name: CONST.DEBUG.JSON, component: DebugJSONTab}, - ], - [DebugDetailsTab, DebugJSONTab], - ); + const routes: DebugTabNavigatorRoutes = [ + {name: CONST.DEBUG.DETAILS, content: detailsTab}, + {name: CONST.DEBUG.JSON, content: jsonTab}, + ]; if (!transactionViolation) { return ; diff --git a/src/pages/DynamicReportChangeApproverPage.tsx b/src/pages/DynamicReportChangeApproverPage.tsx index b4a902661175..1a890462e93c 100644 --- a/src/pages/DynamicReportChangeApproverPage.tsx +++ b/src/pages/DynamicReportChangeApproverPage.tsx @@ -29,7 +29,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {ValueOf} from 'type-fest'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import type {WithReportOrNotFoundProps} from './inbox/report/withReportOrNotFound'; @@ -66,7 +66,7 @@ function DynamicReportChangeApproverPage({report, policy, isLoadingReportData}: Navigation.goBack(backPath); }; - const changeApprover = useCallback(() => { + const changeApprover = () => { if (selectedApproverType === APPROVER_TYPE.ADD_APPROVER) { if (policy && !isControlPolicy(policy)) { Navigation.navigate( @@ -88,38 +88,33 @@ function DynamicReportChangeApproverPage({report, policy, isLoadingReportData}: assignReportToMe(report, currentUserDetails.accountID, currentUserDetails.email ?? '', policy, hasViolations, isASAPSubmitBetaEnabled, isTrackIntentUser, formatPhoneNumber, rules); Navigation.dismissToPreviousRHP(); - }, [selectedApproverType, report, currentUserDetails.accountID, currentUserDetails.email, policy, hasViolations, isASAPSubmitBetaEnabled, isTrackIntentUser, formatPhoneNumber, rules]); - - const approverTypes = useMemo(() => { - const data: Array> = [ - { - text: translate('iou.changeApprover.actions.addApprover'), - keyForList: APPROVER_TYPE.ADD_APPROVER, - alternateText: translate('iou.changeApprover.actions.addApproverSubtitle'), - isSelected: selectedApproverType === APPROVER_TYPE.ADD_APPROVER, - }, - ]; - - if (isAllowedToApproveExpenseReport(report, currentUserDetails.accountID, policy)) { - data.push({ - text: translate('iou.changeApprover.actions.bypassApprovers'), - keyForList: APPROVER_TYPE.BYPASS_APPROVER, - alternateText: translate('iou.changeApprover.actions.bypassApproversSubtitle'), - isSelected: selectedApproverType === APPROVER_TYPE.BYPASS_APPROVER, - }); - } - - if (isPolicyAdmin(policy) && !isPendingDeletePolicy(policy)) { - data.push({ - text: translate('iou.changeApprover.actions.reassignApprover'), - keyForList: APPROVER_TYPE.REASSIGN_APPROVER, - alternateText: translate('iou.changeApprover.actions.reassignApproverSubtitle'), - isSelected: selectedApproverType === APPROVER_TYPE.REASSIGN_APPROVER, - }); - } + }; - return data; - }, [translate, selectedApproverType, policy, report, currentUserDetails.accountID]); + const data: Array> = [ + { + text: translate('iou.changeApprover.actions.addApprover'), + keyForList: APPROVER_TYPE.ADD_APPROVER, + alternateText: translate('iou.changeApprover.actions.addApproverSubtitle'), + isSelected: selectedApproverType === APPROVER_TYPE.ADD_APPROVER, + }, + ]; + if (isAllowedToApproveExpenseReport(report, currentUserDetails.accountID, policy)) { + data.push({ + text: translate('iou.changeApprover.actions.bypassApprovers'), + keyForList: APPROVER_TYPE.BYPASS_APPROVER, + alternateText: translate('iou.changeApprover.actions.bypassApproversSubtitle'), + isSelected: selectedApproverType === APPROVER_TYPE.BYPASS_APPROVER, + }); + } + if (isPolicyAdmin(policy) && !isPendingDeletePolicy(policy)) { + data.push({ + text: translate('iou.changeApprover.actions.reassignApprover'), + keyForList: APPROVER_TYPE.REASSIGN_APPROVER, + alternateText: translate('iou.changeApprover.actions.reassignApproverSubtitle'), + isSelected: selectedApproverType === APPROVER_TYPE.REASSIGN_APPROVER, + }); + } + const approverTypes = data; const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || @@ -128,22 +123,16 @@ function DynamicReportChangeApproverPage({report, policy, isLoadingReportData}: !isMoneyRequestReport(report) || isMoneyRequestReportPendingDeletion(report); - const confirmButtonOptions = useMemo( - () => ({ - showButton: true, - text: translate('iou.changeApprover.title'), - onConfirm: changeApprover, - }), - [changeApprover, translate], - ); + const confirmButtonOptions = { + showButton: true, + text: translate('iou.changeApprover.title'), + onConfirm: changeApprover, + }; - const listHeader = useMemo( - () => ( - - - - ), - [environmentURL, policy?.id, styles.flexRow, styles.mb5, styles.ph5, styles.renderHTML, translate], + const listHeader = ( + + + ); if (shouldShowNotFoundView) { diff --git a/src/pages/DynamicRoomMembersPage.tsx b/src/pages/DynamicRoomMembersPage.tsx index 0dff3b062f9f..0c9b15d4461a 100644 --- a/src/pages/DynamicRoomMembersPage.tsx +++ b/src/pages/DynamicRoomMembersPage.tsx @@ -62,7 +62,7 @@ import type {PersonalDetails} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {WithReportOrNotFoundProps} from './inbox/report/withReportOrNotFound'; @@ -84,53 +84,44 @@ function DynamicRoomMembersPage({report, policy}: DynamicRoomMembersPageProps) { const [userSearchPhrase] = useOnyx(ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE); const [didLoadRoomMembers, setDidLoadRoomMembers] = useState(false); const personalDetails = usePersonalDetails(); - const isPolicyExpenseChat = useMemo(() => isPolicyExpenseChatUtils(report), [report]); + const isPolicyExpenseChat = isPolicyExpenseChatUtils(report); const tableRef = useRef>(null); - const navigateBackToReportDetails = useCallback(() => { + const navigateBackToReportDetails = () => { Navigation.goBack(backPath); - }, [backPath]); + }; const isReportArchived = useReportIsArchived(report.reportID); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); - const reportForSubtitle = useMemo(() => getReportForHeader(report, parentReport), [report, parentReport]); + const reportForSubtitle = getReportForHeader(report, parentReport); const derivedSubtitleReportName = useDerivedReportNameByReportID(reportForSubtitle?.reportID); const subtitleReportName = getReportName(reportForSubtitle, derivedSubtitleReportName); const accountIDToName = useMentionedAccountIDToNameMap(subtitleReportName); - const {chatParticipants: participants, personalDetailsParticipants} = useMemo( - () => getReportPersonalDetailsParticipants(report, personalDetails, reportMetadata, true), - [report, personalDetails, reportMetadata], - ); + const {chatParticipants: participants, personalDetailsParticipants} = getReportPersonalDetailsParticipants(report, personalDetails, reportMetadata, true); - const shouldIncludeMember = useCallback( - (participant?: PersonalDetails) => { - if (!participant) { - return false; - } - const isInParticipants = participants.includes(participant.accountID); - const pendingChatMember = reportMetadata?.pendingChatMembers?.find((member) => member.accountID === participant.accountID.toString()); + const shouldIncludeMember = (participant?: PersonalDetails) => { + if (!participant) { + return false; + } + const isInParticipants = participants.includes(participant.accountID); + const pendingChatMember = reportMetadata?.pendingChatMembers?.find((member) => member.accountID === participant.accountID.toString()); - const isPendingDelete = pendingChatMember?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + const isPendingDelete = pendingChatMember?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - return isInParticipants && !isPendingDelete; - }, - [participants, reportMetadata?.pendingChatMembers], - ); + return isInParticipants && !isPendingDelete; + }; const [selectedMembers, setSelectedMembers] = useFilteredSelection(personalDetailsParticipants, shouldIncludeMember); const firstSelectedMember = selectedMembers?.at(0); const [firstSelectedMemberDetails] = usePersonalDetail(firstSelectedMember); - const clearTableSelection = useCallback(() => { + const clearTableSelection = () => { setSelectedMembers((prevSelectedMembers) => (prevSelectedMembers.length > 0 ? [] : prevSelectedMembers)); - }, [setSelectedMembers]); + }; // The Table stores selection as string keys, while this page tracks accountIDs as numbers. - const onRowSelectionChange = useCallback( - (keys: string[]) => { - setSelectedMembers(keys.map(Number)); - }, - [setSelectedMembers], - ); + const onRowSelectionChange = (keys: string[]) => { + setSelectedMembers(keys.map(Number)); + }; useCleanupSelectedOptions(clearTableSelection); @@ -144,13 +135,13 @@ function DynamicRoomMembersPage({report, policy}: DynamicRoomMembersPageProps) { const canSelectMultiple = isSmallScreenWidth ? isMobileSelectionModeEnabled : true; const isLoading = !isPersonalDetailsReady(personalDetails) || !didLoadRoomMembers; - const getRoomMembers = useCallback(() => { + const getRoomMembers = () => { if (!report) { return; } openRoomMembersPage(report.reportID); setDidLoadRoomMembers(true); - }, [report]); + }; useEffect(() => { clearUserSearchPhrase(); @@ -158,27 +149,27 @@ function DynamicRoomMembersPage({report, policy}: DynamicRoomMembersPageProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const clearTableSearch = useCallback(() => { + const clearTableSearch = () => { tableRef.current?.updateSearchString(''); - }, []); + }; - const inviteUser = useCallback(() => { + const inviteUser = () => { if (!report) { return; } Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.ROOM_INVITE.path)); - }, [report]); + }; - const removeUsers = useCallback(() => { + const removeUsers = () => { if (report) { removeFromRoom(report, selectedMembers); } clearTableSearch(); clearTableSelection(); clearUserSearchPhrase(); - }, [clearTableSearch, clearTableSelection, report, selectedMembers]); + }; - const showRemoveMembersModal = useCallback(async () => { + const showRemoveMembersModal = async () => { const {action} = await showConfirmModal({ title: translate('workspace.people.removeMembersTitle', { count: selectedMembers.length, @@ -195,7 +186,7 @@ function DynamicRoomMembersPage({report, policy}: DynamicRoomMembersPageProps) { return; } removeUsers(); - }, [showConfirmModal, translate, selectedMembers.length, formatPhoneNumber, firstSelectedMemberDetails?.displayName, removeUsers]); + }; useSearchBackPress({ onClearSelection: clearTableSelection, @@ -205,65 +196,46 @@ function DynamicRoomMembersPage({report, policy}: DynamicRoomMembersPageProps) { }, }); - const openRoomMemberDetails = useCallback((accountID: number) => { + const openRoomMemberDetails = (accountID: number) => { Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.ROOM_MEMBER_DETAILS.getRoute(accountID))); - }, []); + }; - const members = useMemo(() => { - const result: RoomMemberRowData[] = []; - - for (const accountID of participants) { - const details = personalDetails?.[accountID]; - if (!details) { - continue; - } - const pendingChatMember = reportMetadata?.pendingChatMembers?.findLast((member) => member.accountID === accountID.toString()); - // Check the listed member's own role on the policy, not the viewer's, and fail closed when their login is - // missing. Kept in sync with the member details page through the shared helper. - const isProtectedByPolicyRole = isRoomMemberProtectedByPolicyRole(policy, details.login, accountID); - const isDisabled = pendingChatMember?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || details.isOptimisticPersonalDetail; - const isSelectionDisabled = - (isPolicyExpenseChat && isProtectedByPolicyRole) || - accountID === session?.accountID || - pendingChatMember?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || - details.accountID === report.ownerAccountID || - isThreadMemberProtectedByParentReport(parentReport, accountID); - - result.push({ - keyForList: String(accountID), - accountID, - login: details.login ?? '', - name: temporaryGetDisplayNameOrDefault({ - passedPersonalDetails: details, - translate, - formatPhoneNumber, - }), - email: formatPhoneNumber(details.login ?? ''), - disabled: isDisabled, - isSelectionDisabled, - pendingAction: pendingChatMember?.pendingAction, - errors: pendingChatMember?.errors, - action: () => openRoomMemberDetails(accountID), - dismissError: () => clearAddRoomMemberError(report.reportID, String(accountID)), - }); + const result: RoomMemberRowData[] = []; + for (const accountID of participants) { + const details = personalDetails?.[accountID]; + if (!details) { + continue; } - - return result.sort((value1, value2) => localeCompare(value1.name.toLowerCase(), value2.name.toLowerCase())); - }, [ - formatPhoneNumber, - isPolicyExpenseChat, - localeCompare, - openRoomMemberDetails, - parentReport, - participants, - personalDetails, - policy, - report.ownerAccountID, - report.reportID, - reportMetadata?.pendingChatMembers, - session?.accountID, - translate, - ]); + const pendingChatMember = reportMetadata?.pendingChatMembers?.findLast((member) => member.accountID === accountID.toString()); + // Check the listed member's own role on the policy, not the viewer's, and fail closed when their login is + // missing. Kept in sync with the member details page through the shared helper. + const isProtectedByPolicyRole = isRoomMemberProtectedByPolicyRole(policy, details.login, accountID); + const isDisabled = pendingChatMember?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || details.isOptimisticPersonalDetail; + const isSelectionDisabled = + (isPolicyExpenseChat && isProtectedByPolicyRole) || + accountID === session?.accountID || + pendingChatMember?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || + details.accountID === report.ownerAccountID || + isThreadMemberProtectedByParentReport(parentReport, accountID); + result.push({ + keyForList: String(accountID), + accountID, + login: details.login ?? '', + name: temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: details, + translate, + formatPhoneNumber, + }), + email: formatPhoneNumber(details.login ?? ''), + disabled: isDisabled, + isSelectionDisabled, + pendingAction: pendingChatMember?.pendingAction, + errors: pendingChatMember?.errors, + action: () => openRoomMemberDetails(accountID), + dismissError: () => clearAddRoomMemberError(report.reportID, String(accountID)), + }); + } + const members: RoomMemberRowData[] = result.sort((value1, value2) => localeCompare(value1.name.toLowerCase(), value2.name.toLowerCase())); // Restore the persisted Onyx search phrase into the table's internal search state when returning to this screen. useEffect(() => { @@ -293,53 +265,49 @@ function DynamicRoomMembersPage({report, policy}: DynamicRoomMembersPageProps) { const selectedKeys = selectedMembers.map(String); - const isPolicyEmployee = useMemo(() => isPolicyEmployeeUtils(report.policyID, policy), [report.policyID, policy]); - - const bulkActionsButtonOptions = useMemo(() => { - const options: Array> = [ - { - text: translate('workspace.people.removeMembersTitle', { - count: selectedMembers.length, - }), - value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.REMOVE, - icon: icons.RemoveMembers, - shouldSkipFocusRestore: true, - onSelected: showRemoveMembersModal, - }, - ]; - return options; - }, [icons.RemoveMembers, translate, selectedMembers.length, showRemoveMembersModal]); - - const headerButtons = useMemo(() => { - return ( - - {(isSmallScreenWidth ? canSelectMultiple : selectedMembers.length > 0) ? ( - - variant={CONST.BUTTON_VARIANT.SUCCESS} - shouldAlwaysShowDropdownMenu - pressOnEnter - customText={translate('workspace.common.selected', {count: selectedMembers.length})} - size={CONST.BUTTON_SIZE.MEDIUM} - onPress={() => null} - options={bulkActionsButtonOptions} - isSplitButton={false} - style={[shouldUseNarrowLayout && styles.flexGrow1, styles.mb5]} - isDisabled={!selectedMembers.length} - /> - ) : ( - - )} - - ); - }, [bulkActionsButtonOptions, inviteUser, isSmallScreenWidth, selectedMembers.length, styles, translate, canSelectMultiple, shouldUseNarrowLayout, icons.Plus]); + const isPolicyEmployee = isPolicyEmployeeUtils(report.policyID, policy); + + const options: Array> = [ + { + text: translate('workspace.people.removeMembersTitle', { + count: selectedMembers.length, + }), + value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.REMOVE, + icon: icons.RemoveMembers, + shouldSkipFocusRestore: true, + onSelected: showRemoveMembersModal, + }, + ]; + const bulkActionsButtonOptions = options; + + const headerButtons = ( + + {(isSmallScreenWidth ? canSelectMultiple : selectedMembers.length > 0) ? ( + + variant={CONST.BUTTON_VARIANT.SUCCESS} + shouldAlwaysShowDropdownMenu + pressOnEnter + customText={translate('workspace.common.selected', {count: selectedMembers.length})} + size={CONST.BUTTON_SIZE.MEDIUM} + onPress={() => null} + options={bulkActionsButtonOptions} + isSplitButton={false} + style={[shouldUseNarrowLayout && styles.flexGrow1, styles.mb5]} + isDisabled={!selectedMembers.length} + /> + ) : ( + + )} + + ); const selectionModeHeader = isMobileSelectionModeEnabled && isSmallScreenWidth; const tableHeaderComponent = {headerButtons}; diff --git a/src/pages/DynamicWorkspaceCurrencySelectionPage.tsx b/src/pages/DynamicWorkspaceCurrencySelectionPage.tsx index 9d8c2e3d3ef2..7c2d392acfd9 100644 --- a/src/pages/DynamicWorkspaceCurrencySelectionPage.tsx +++ b/src/pages/DynamicWorkspaceCurrencySelectionPage.tsx @@ -16,7 +16,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; function DynamicWorkspaceCurrencySelectionPage() { @@ -29,20 +29,17 @@ function DynamicWorkspaceCurrencySelectionPage() { const value = workspaceConfirmationFormDraft?.currency ?? currentUserPersonalDetails?.localCurrencyCode ?? CONST.CURRENCY.USD; - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); - - const onSelect = useCallback( - (option: CurrencyListItem) => { - setWorkspaceConfirmationCurrency(option.currencyCode); - // After selecting, don't restore focus to the currency menu item on the confirmation page — - // a focused button suppresses the form's submit-on-Enter, so the next Enter would re-open this - // page instead of creating the workspace. The header Back button keeps the default focus restore. - Navigation.goBack(backPath, {shouldSkipFocusRestore: true}); - }, - [backPath], - ); + }; + + const onSelect = (option: CurrencyListItem) => { + setWorkspaceConfirmationCurrency(option.currencyCode); + // After selecting, don't restore focus to the currency menu item on the confirmation page — + // a focused button suppresses the form's submit-on-Enter, so the next Enter would re-open this + // page instead of creating the workspace. The header Back button keeps the default focus restore. + Navigation.goBack(backPath, {shouldSkipFocusRestore: true}); + }; return ( diff --git a/src/pages/EditReportFieldDate.tsx b/src/pages/EditReportFieldDate.tsx index e239fbabe679..fef74bf4e84c 100644 --- a/src/pages/EditReportFieldDate.tsx +++ b/src/pages/EditReportFieldDate.tsx @@ -9,7 +9,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; type EditReportFieldDatePageProps = { @@ -33,16 +33,13 @@ function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, f const styles = useThemeStyles(); const {translate} = useLocalize(); - const validate = useCallback( - (value: FormOnyxValues) => { - const errors: FormInputErrors = {}; - if (isRequired && value[fieldKey].trim() === '') { - errors[fieldKey] = translate('common.error.fieldRequired'); - } - return errors; - }, - [fieldKey, isRequired, translate], - ); + const validate = (value: FormOnyxValues) => { + const errors: FormInputErrors = {}; + if (isRequired && value[fieldKey].trim() === '') { + errors[fieldKey] = translate('common.error.fieldRequired'); + } + return errors; + }; return ( ) => { - const errors: FormInputErrors = {}; - // Validate the same value we submit: handleSubmit squashes line breaks to spaces before saving, so - // normalize here too. Otherwise a formula like `{field:Client\nName}` would be validated as a reference - // to `Client\nName` but saved as `{field:Client Name}`, letting a circular reference slip past this check. - const inputValue = StringUtils.lineBreaksToSpaces(values[fieldKey]).trim(); - - if (isRequired && inputValue === '') { - errors[fieldKey] = translate('common.error.fieldRequired'); - } - - if (hasCircularReferences(inputValue, fieldName, fieldList)) { - errors[fieldKey] = translate('workspace.reportFields.circularReferenceError'); - } - - return errors; - }, - [fieldName, fieldKey, isRequired, translate, fieldList], - ); - - const handleSubmit = useCallback( - (values: FormOnyxValues) => { - // The input can grow to multiple lines while editing, but report titles and text fields are single-line - // values. Squash any line breaks the user entered down to a single line before saving. - onSubmit({ - ...values, - [fieldKey]: StringUtils.lineBreaksToSpaces(values[fieldKey]), - }); - }, - [fieldKey, onSubmit], - ); + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + // Validate the same value we submit: handleSubmit squashes line breaks to spaces before saving, so + // normalize here too. Otherwise a formula like `{field:Client\nName}` would be validated as a reference + // to `Client\nName` but saved as `{field:Client Name}`, letting a circular reference slip past this check. + const inputValue = StringUtils.lineBreaksToSpaces(values[fieldKey]).trim(); + + if (isRequired && inputValue === '') { + errors[fieldKey] = translate('common.error.fieldRequired'); + } + + if (hasCircularReferences(inputValue, fieldName, fieldList)) { + errors[fieldKey] = translate('workspace.reportFields.circularReferenceError'); + } + + return errors; + }; + + const handleSubmit = (values: FormOnyxValues) => { + // The input can grow to multiple lines while editing, but report titles and text fields are single-line + // values. Squash any line breaks the user entered down to a single line before saving. + onSubmit({ + ...values, + [fieldKey]: StringUtils.lineBreaksToSpaces(values[fieldKey]), + }); + }; return ( { + const goBack = () => { Navigation.goBack(); - }, []); + }; - const goToPreviousStep = useCallback(() => { + const goToPreviousStep = () => { updateCurrentStep(CONST.WALLET.STEP.ADDITIONAL_DETAILS); - }, []); + }; - const reportError = useCallback(() => { + const reportError = () => { Growl.error({bodyText: translate('onfidoStep.genericError'), duration: 10000}); - }, [translate]); - - const verifyIdentity = useCallback( - (data: OnfidoData) => { - verifyIdentityAction({ - onfidoData: JSON.stringify({ - ...data, - applicantID: walletOnfidoData?.applicantID, - }), - }); - }, - [walletOnfidoData?.applicantID], - ); + }; + + const verifyIdentity = (data: OnfidoData) => { + verifyIdentityAction({ + onfidoData: JSON.stringify({ + ...data, + applicantID: walletOnfidoData?.applicantID, + }), + }); + }; return ( <> diff --git a/src/pages/EnablePayments/Wallet/AddBankAccount/AddBankAccount.tsx b/src/pages/EnablePayments/Wallet/AddBankAccount/AddBankAccount.tsx index 004188bb3513..b860162a5bec 100644 --- a/src/pages/EnablePayments/Wallet/AddBankAccount/AddBankAccount.tsx +++ b/src/pages/EnablePayments/Wallet/AddBankAccount/AddBankAccount.tsx @@ -28,7 +28,7 @@ import type {EnablePaymentsSubPageType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import React, {useCallback, useContext, useMemo} from 'react'; +import React, {useContext} from 'react'; import {View} from 'react-native'; import SetupMethod from './SetupMethod'; @@ -60,7 +60,7 @@ function AddBankAccount() { const {isBankAccountAdded: isBankAccountAlreadyAdded} = useIsBankAccountAdded(); - const submit = useCallback(() => { + const submit = () => { // Re-submitting an already added bank account fails with a "bank account already exists" error, so skip the // API call and advance the wallet step instead; the URL correction in EnablePaymentsPage navigates forward. if (isBankAccountAlreadyAdded) { @@ -101,17 +101,7 @@ function AddBankAccount() { personalPolicyID, ); } - }, [ - isBankAccountAlreadyAdded, - personalBankAccountDraft?.plaidAccountID, - personalBankAccountDraft?.setupType, - plaidData?.bankAccounts, - plaidData?.plaidAccessToken, - personalPolicyID, - walletAdditionalDetailsDraft, - walletAdditionalDetails, - privatePersonalDetails, - ]); + }; const isSetupTypeChosen = personalBankAccountDraft?.setupType === CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID; @@ -120,16 +110,12 @@ function AddBankAccount() { const initialWalletAdditionalDetails = useInitialOnyxValue(ONYXKEYS.WALLET_ADDITIONAL_DETAILS); const initialWalletAdditionalDetailsDraft = useInitialOnyxValue(ONYXKEYS.FORMS.WALLET_ADDITIONAL_DETAILS_DRAFT); const initialPrivatePersonalDetails = useInitialOnyxValue(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); - const skipPages = useMemo( - () => - getSkippedBankAccountOwnerPages( - getBankAccountOwnerDetails({ - walletAdditionalDetailsDraft: initialWalletAdditionalDetailsDraft, - walletAdditionalDetails: initialWalletAdditionalDetails, - privatePersonalDetails: initialPrivatePersonalDetails, - }), - ), - [initialPrivatePersonalDetails, initialWalletAdditionalDetails, initialWalletAdditionalDetailsDraft], + const skipPages = getSkippedBankAccountOwnerPages( + getBankAccountOwnerDetails({ + walletAdditionalDetailsDraft: initialWalletAdditionalDetailsDraft, + walletAdditionalDetails: initialWalletAdditionalDetails, + privatePersonalDetails: initialPrivatePersonalDetails, + }), ); const {CurrentPage, isEditing, pageIndex, nextPage, prevPage, moveTo, isRedirecting} = useSubPage({ diff --git a/src/pages/EnablePayments/Wallet/AddBankAccount/substeps/PlaidStep.tsx b/src/pages/EnablePayments/Wallet/AddBankAccount/substeps/PlaidStep.tsx index 4fe13075ce87..fa2b72365c2e 100644 --- a/src/pages/EnablePayments/Wallet/AddBankAccount/substeps/PlaidStep.tsx +++ b/src/pages/EnablePayments/Wallet/AddBankAccount/substeps/PlaidStep.tsx @@ -13,7 +13,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/PersonalBankAccountForm'; import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useEffect} from 'react'; +import React, {useEffect} from 'react'; const BANK_INFO_STEP_KEYS = INPUT_IDS.BANK_INFO_STEP; @@ -25,7 +25,7 @@ function PlaidStep({onNext}: SubPageProps) { const [plaidData] = useOnyx(ONYXKEYS.PLAID_DATA); const selectedPlaidAccountID = personalBankAccountDraft?.[BANK_INFO_STEP_KEYS.PLAID_ACCOUNT_ID] ?? ''; - const handleNextPress = useCallback(() => { + const handleNextPress = () => { const selectedPlaidBankAccount = (plaidData?.bankAccounts ?? []).find((account) => account.plaidAccountID === personalBankAccountDraft?.[BANK_INFO_STEP_KEYS.PLAID_ACCOUNT_ID]); const bankAccountData = { @@ -40,7 +40,7 @@ function PlaidStep({onNext}: SubPageProps) { updateAddPersonalBankAccountDraft(bankAccountData); onNext(); - }, [onNext, personalBankAccountDraft, plaidData]); + }; const handleSelectPlaidAccount = (plaidAccountID: string) => { updateAddPersonalBankAccountDraft({plaidAccountID}); diff --git a/src/pages/EnablePayments/Wallet/PersonalInfo/PersonalInfo.tsx b/src/pages/EnablePayments/Wallet/PersonalInfo/PersonalInfo.tsx index eecfd7a529fa..4ce904a8780b 100644 --- a/src/pages/EnablePayments/Wallet/PersonalInfo/PersonalInfo.tsx +++ b/src/pages/EnablePayments/Wallet/PersonalInfo/PersonalInfo.tsx @@ -20,8 +20,6 @@ import type {EnablePaymentsSubPageType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import {useMemo} from 'react'; - import Address from './substeps/AddressStep'; import Confirmation from './substeps/ConfirmationStep'; import DateOfBirth from './substeps/DateOfBirthStep'; @@ -51,32 +49,25 @@ function PersonalInfoPage() { const {submitPersonalDetails} = useWalletPhoneValidateCode(); - const values = useMemo( - () => getPersonalInfoStepValues(walletAdditionalDetailsDraft, walletAdditionalDetails, privatePersonalDetails), - [privatePersonalDetails, walletAdditionalDetails, walletAdditionalDetailsDraft], - ); + const values = getPersonalInfoStepValues(walletAdditionalDetailsDraft, walletAdditionalDetails, privatePersonalDetails); // Snapshot the sources once Onyx has loaded. Filling a page writes the wallet draft immediately, and a live skip // list would make Back from the next page skip the page the user just completed. const initialWalletAdditionalDetails = useInitialOnyxValue(ONYXKEYS.WALLET_ADDITIONAL_DETAILS); const initialWalletAdditionalDetailsDraft = useInitialOnyxValue(ONYXKEYS.FORMS.WALLET_ADDITIONAL_DETAILS_DRAFT); const initialPrivatePersonalDetails = useInitialOnyxValue(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); - const skipPages = useMemo( - () => - getSkippedBankAccountOwnerPages( - getBankAccountOwnerDetails({ - walletAdditionalDetailsDraft: initialWalletAdditionalDetailsDraft, - walletAdditionalDetails: initialWalletAdditionalDetails, - privatePersonalDetails: initialPrivatePersonalDetails, - }), - ), - [initialPrivatePersonalDetails, initialWalletAdditionalDetails, initialWalletAdditionalDetailsDraft], + const skipPages = getSkippedBankAccountOwnerPages( + getBankAccountOwnerDetails({ + walletAdditionalDetailsDraft: initialWalletAdditionalDetailsDraft, + walletAdditionalDetails: initialWalletAdditionalDetails, + privatePersonalDetails: initialPrivatePersonalDetails, + }), ); const submit = () => { submitPersonalDetails(getWalletPersonalDetailsParams(values)); }; - const startFrom = useMemo(() => getInitialSubstepForPersonalInfo(values), [values]); + const startFrom = getInitialSubstepForPersonalInfo(values); const {CurrentPage, isEditing, pageIndex, nextPage, prevPage, moveTo, isRedirecting} = useSubPage({ pages: formPages, diff --git a/src/pages/EnablePayments/Wallet/PersonalInfo/substeps/AddressStep.tsx b/src/pages/EnablePayments/Wallet/PersonalInfo/substeps/AddressStep.tsx index 2bd5e2096498..91b97b5be2ff 100644 --- a/src/pages/EnablePayments/Wallet/PersonalInfo/substeps/AddressStep.tsx +++ b/src/pages/EnablePayments/Wallet/PersonalInfo/substeps/AddressStep.tsx @@ -11,7 +11,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/WalletAdditionalDetailsForm'; -import React, {useMemo} from 'react'; +import React from 'react'; const PERSONAL_INFO_STEP_KEY = INPUT_IDS.PERSONAL_INFO_STEP; @@ -31,15 +31,16 @@ function AddressStep({onNext, onMove, isEditing}: SubPageProps) { const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); // Draft is applied by the form itself, so defaults stay on saved wallet details and the profile. - const defaultValues = useMemo(() => { - const owner = getBankAccountOwnerDetails({walletAdditionalDetails, privatePersonalDetails}); - return { - street: owner.formStreet, - city: owner.addressCity, - state: owner.addressState, - zipCode: owner.addressZipCode, - }; - }, [privatePersonalDetails, walletAdditionalDetails]); + const owner = getBankAccountOwnerDetails({ + walletAdditionalDetails, + privatePersonalDetails, + }); + const defaultValues = { + street: owner.formStreet, + city: owner.addressCity, + state: owner.addressState, + zipCode: owner.addressZipCode, + }; const handleSubmit = useWalletAdditionalDetailsStepFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/EnablePayments/Wallet/PersonalInfo/substeps/ConfirmationStep.tsx b/src/pages/EnablePayments/Wallet/PersonalInfo/substeps/ConfirmationStep.tsx index b0a6e315d1bf..1cdcdf64558d 100644 --- a/src/pages/EnablePayments/Wallet/PersonalInfo/substeps/ConfirmationStep.tsx +++ b/src/pages/EnablePayments/Wallet/PersonalInfo/substeps/ConfirmationStep.tsx @@ -13,7 +13,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/WalletAdditionalDetailsForm'; -import React, {useMemo} from 'react'; +import React from 'react'; const PERSONAL_INFO_STEP_KEYS = INPUT_IDS.PERSONAL_INFO_STEP; const PERSONAL_INFO_STEP_INDEXES = CONST.WALLET.SUBSTEP_INDEXES.PERSONAL_INFO; @@ -27,10 +27,7 @@ function ConfirmationStep({onNext, onMove, isEditing}: SubPageProps) { const isLoading = walletAdditionalDetails?.isLoading ?? false; const error = getLatestErrorMessage(walletAdditionalDetails ?? {}); - const values = useMemo( - () => getPersonalInfoStepValues(walletAdditionalDetailsDraft, walletAdditionalDetails, privatePersonalDetails), - [privatePersonalDetails, walletAdditionalDetails, walletAdditionalDetailsDraft], - ); + const values = getPersonalInfoStepValues(walletAdditionalDetailsDraft, walletAdditionalDetails, privatePersonalDetails); // Also check the shown value: errorCode is cleared optimistically on submit while a full 9-digit SSN is still displayed. const shouldAskForFullSSN = walletAdditionalDetails?.errorCode === CONST.WALLET.ERROR.SSN || isValidSSNFullNine(values[PERSONAL_INFO_STEP_KEYS.SSN_LAST_4]); const shouldShowSSNRowError = shouldAskForFullSSN && values[PERSONAL_INFO_STEP_KEYS.SSN_LAST_4].length < CONST.BANK_ACCOUNT.MAX_LENGTH.FULL_SSN; diff --git a/src/pages/EnablePayments/Wallet/PersonalInfo/substeps/LegalNameStep.tsx b/src/pages/EnablePayments/Wallet/PersonalInfo/substeps/LegalNameStep.tsx index 26055aa9dbd0..bb27e7b2652a 100644 --- a/src/pages/EnablePayments/Wallet/PersonalInfo/substeps/LegalNameStep.tsx +++ b/src/pages/EnablePayments/Wallet/PersonalInfo/substeps/LegalNameStep.tsx @@ -11,7 +11,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/WalletAdditionalDetailsForm'; -import React, {useMemo} from 'react'; +import React from 'react'; const PERSONAL_INFO_STEP_KEY = INPUT_IDS.PERSONAL_INFO_STEP; const STEP_FIELDS = [PERSONAL_INFO_STEP_KEY.FIRST_NAME, PERSONAL_INFO_STEP_KEY.LAST_NAME]; @@ -22,13 +22,14 @@ function LegalNameStep({onNext, onMove, isEditing}: SubPageProps) { const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); // Draft is applied by the form itself, so defaults stay on saved wallet details and the profile. - const defaultValues = useMemo(() => { - const owner = getBankAccountOwnerDetails({walletAdditionalDetails, privatePersonalDetails}); - return { - firstName: owner.legalFirstName, - lastName: owner.legalLastName, - }; - }, [privatePersonalDetails, walletAdditionalDetails]); + const owner = getBankAccountOwnerDetails({ + walletAdditionalDetails, + privatePersonalDetails, + }); + const defaultValues = { + firstName: owner.legalFirstName, + lastName: owner.legalLastName, + }; const handleSubmit = useWalletAdditionalDetailsStepFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/EnablePayments/Wallet/PersonalInfo/substeps/PhoneNumberStep.tsx b/src/pages/EnablePayments/Wallet/PersonalInfo/substeps/PhoneNumberStep.tsx index 984c23727a55..2b806e99f67f 100644 --- a/src/pages/EnablePayments/Wallet/PersonalInfo/substeps/PhoneNumberStep.tsx +++ b/src/pages/EnablePayments/Wallet/PersonalInfo/substeps/PhoneNumberStep.tsx @@ -13,7 +13,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/WalletAdditionalDetailsForm'; -import React, {useCallback} from 'react'; +import React from 'react'; const PERSONAL_INFO_STEP_KEY = INPUT_IDS.PERSONAL_INFO_STEP; const STEP_FIELDS = [PERSONAL_INFO_STEP_KEY.PHONE_NUMBER]; @@ -25,23 +25,20 @@ function PhoneNumberStep({onNext, onMove, isEditing}: SubPageProps) { const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); const defaultPhoneNumber = walletAdditionalDetails?.[PERSONAL_INFO_STEP_KEY.PHONE_NUMBER] ?? ''; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); - if (values.phoneNumber) { - const phoneNumberWithCountryCode = appendCountryCode(values.phoneNumber, countryCode); - const e164FormattedPhoneNumber = formatE164PhoneNumber(values.phoneNumber, countryCode); + if (values.phoneNumber) { + const phoneNumberWithCountryCode = appendCountryCode(values.phoneNumber, countryCode); + const e164FormattedPhoneNumber = formatE164PhoneNumber(values.phoneNumber, countryCode); - if (!isValidPhoneNumber(phoneNumberWithCountryCode) || !isValidUSPhone(e164FormattedPhoneNumber)) { - errors.phoneNumber = translate('common.error.phoneNumber'); - } + if (!isValidPhoneNumber(phoneNumberWithCountryCode) || !isValidUSPhone(e164FormattedPhoneNumber)) { + errors.phoneNumber = translate('common.error.phoneNumber'); } + } - return errors; - }, - [countryCode, translate], - ); + return errors; + }; const handleSubmit = useWalletAdditionalDetailsStepFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/EnablePayments/Wallet/PersonalInfo/substeps/SocialSecurityNumberStep.tsx b/src/pages/EnablePayments/Wallet/PersonalInfo/substeps/SocialSecurityNumberStep.tsx index b6d52be85d0c..b9cc5d106213 100644 --- a/src/pages/EnablePayments/Wallet/PersonalInfo/substeps/SocialSecurityNumberStep.tsx +++ b/src/pages/EnablePayments/Wallet/PersonalInfo/substeps/SocialSecurityNumberStep.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/WalletAdditionalDetailsForm'; -import React, {useCallback} from 'react'; +import React from 'react'; const PERSONAL_INFO_STEP_KEY = INPUT_IDS.PERSONAL_INFO_STEP; const STEP_FIELDS = [PERSONAL_INFO_STEP_KEY.SSN_LAST_4]; @@ -28,22 +28,19 @@ function SocialSecurityNumberStep({onNext, onMove, isEditing}: SubPageProps) { // Also check the shown value: errorCode is cleared optimistically on submit while a full 9-digit SSN is still displayed. const shouldAskForFullSSN = walletAdditionalDetails?.errorCode === CONST.WALLET.ERROR.SSN || isValidSSNFullNine(ssnValue); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); - if (shouldAskForFullSSN) { - if (values.ssn && !isValidSSNFullNine(values.ssn)) { - errors.ssn = translate('additionalDetailsStep.ssnFull9Error'); - } - } else if (values.ssn && !isValidSSNLastFour(values.ssn)) { - errors.ssn = translate('bankAccount.error.ssnLast4'); + if (shouldAskForFullSSN) { + if (values.ssn && !isValidSSNFullNine(values.ssn)) { + errors.ssn = translate('additionalDetailsStep.ssnFull9Error'); } + } else if (values.ssn && !isValidSSNLastFour(values.ssn)) { + errors.ssn = translate('bankAccount.error.ssnLast4'); + } - return errors; - }, - [translate, shouldAskForFullSSN], - ); + return errors; + }; const handleSubmit = useWalletAdditionalDetailsStepFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/EnablePayments/Wallet/VerifyIdentity/VerifyIdentity.tsx b/src/pages/EnablePayments/Wallet/VerifyIdentity/VerifyIdentity.tsx index 78468ee4a3d5..a00e8c5f1435 100644 --- a/src/pages/EnablePayments/Wallet/VerifyIdentity/VerifyIdentity.tsx +++ b/src/pages/EnablePayments/Wallet/VerifyIdentity/VerifyIdentity.tsx @@ -24,7 +24,7 @@ import {updateCurrentStep} from '@userActions/Wallet'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; const ONFIDO_ERROR_DISPLAY_DURATION = 10000; @@ -35,18 +35,15 @@ function VerifyIdentity() { const illustrations = useMemoizedLazyIllustrations(['ToddBehindCloud']); const [walletOnfidoData] = useOnyx(ONYXKEYS.RAM_ONLY_WALLET_ONFIDO); - const handleOnfidoSuccess = useCallback( - (onfidoData: OnfidoData) => { - verifyIdentity({ - onfidoData: JSON.stringify({ - ...onfidoData, - applicantID: walletOnfidoData?.applicantID, - }), - }); - updateAddPersonalBankAccountDraft({isOnfidoSetupComplete: true}); - }, - [walletOnfidoData?.applicantID], - ); + const handleOnfidoSuccess = (onfidoData: OnfidoData) => { + verifyIdentity({ + onfidoData: JSON.stringify({ + ...onfidoData, + applicantID: walletOnfidoData?.applicantID, + }), + }); + updateAddPersonalBankAccountDraft({isOnfidoSetupComplete: true}); + }; const onfidoError = getLatestErrorMessage(walletOnfidoData) ?? ''; diff --git a/src/pages/MissingPersonalDetails/MissingPersonalDetailsContent.tsx b/src/pages/MissingPersonalDetails/MissingPersonalDetailsContent.tsx index 6a02bda6deb4..81da6e9c03f7 100644 --- a/src/pages/MissingPersonalDetails/MissingPersonalDetailsContent.tsx +++ b/src/pages/MissingPersonalDetails/MissingPersonalDetailsContent.tsx @@ -26,7 +26,7 @@ import type {CardList, PrivatePersonalDetails} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import type {CustomSubPageProps} from './types'; @@ -70,7 +70,7 @@ function MissingPersonalDetailsContent({privatePersonalDetails, draftValues, hea const {isOffline} = useNetwork(); const {executeScenario} = useMultifactorAuthentication(); const {translate} = useLocalize(); - const targetCardSelector = useCallback((cardList: OnyxEntry) => (cardID ? cardList?.[cardID] : undefined), [cardID]); + const targetCardSelector = (cardList: OnyxEntry) => (cardID ? cardList?.[cardID] : undefined); const [targetCard] = useOnyx(ONYXKEYS.CARD_LIST, {selector: targetCardSelector}); const isVirtualCard = !!targetCard?.nameValuePairs?.isVirtual; const shouldCollectPIN = !!cardID && isUkEuExpensifyCard(targetCard) && !isVirtualCard; @@ -79,21 +79,14 @@ function MissingPersonalDetailsContent({privatePersonalDetails, draftValues, hea const {setIsConfirmStep} = usePINActions(); // Build form pages dynamically based on whether this is a UK/EU card - const formPages = useMemo(() => { - if (shouldCollectPIN) { - return [...baseFormPages, PINPage, confirmPage]; - } - return [...baseFormPages, confirmPage]; - }, [shouldCollectPIN]); + const formPages = shouldCollectPIN ? [...baseFormPages, PINPage, confirmPage] : [...baseFormPages, confirmPage]; const stepIndexList = shouldCollectPIN ? CONST.MISSING_PERSONAL_DETAILS.STEP_INDEX_LIST_WITH_PIN : CONST.MISSING_PERSONAL_DETAILS.STEP_INDEX_LIST; - const values = useMemo(() => normalizeCountryCode(getSubPageValues(privatePersonalDetails, draftValues)) as PersonalDetailsForm, [privatePersonalDetails, draftValues]); + const values = normalizeCountryCode(getSubPageValues(privatePersonalDetails, draftValues)) as PersonalDetailsForm; - const startFrom = useMemo(() => { - const initialPage = getInitialSubPage(values, shouldCollectPIN, PIN); - return findPageIndex(formPages, initialPage); - }, [formPages, values, shouldCollectPIN, PIN]); + const initialPage = getInitialSubPage(values, shouldCollectPIN, PIN); + const startFrom = findPageIndex(formPages, initialPage); const handleFinishStep = () => { if (shouldCollectPIN) { diff --git a/src/pages/MissingPersonalDetails/MissingPersonalDetailsValidateCodePage.tsx b/src/pages/MissingPersonalDetails/MissingPersonalDetailsValidateCodePage.tsx index 9abe847d04b9..2c26aba3e2df 100644 --- a/src/pages/MissingPersonalDetails/MissingPersonalDetailsValidateCodePage.tsx +++ b/src/pages/MissingPersonalDetails/MissingPersonalDetailsValidateCodePage.tsx @@ -37,7 +37,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxEntry} from 'react-native-onyx'; import {CONST as COMMON_CONST} from 'expensify-common'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {getSubPageValues} from './utils'; @@ -54,7 +54,7 @@ function MissingPersonalDetailsValidateCodePage({ const [draftValues] = useOnyx(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM_DRAFT); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); - const targetCardSelector = useCallback((cardList: OnyxEntry) => (cardID ? cardList?.[cardID] : undefined), [cardID]); + const targetCardSelector = (cardList: OnyxEntry) => (cardID ? cardList?.[cardID] : undefined); const [targetCard] = useOnyx(ONYXKEYS.CARD_LIST, {selector: targetCardSelector}); const isVirtualCard = !!targetCard?.nameValuePairs?.isVirtual; @@ -94,27 +94,24 @@ function MissingPersonalDetailsValidateCodePage({ clearPersonalDetailsErrors(); }; - const values = useMemo(() => normalizeCountryCode(getSubPageValues(privatePersonalDetails, draftValues)) as PersonalDetailsForm, [privatePersonalDetails, draftValues]); - - const handleSubmitForm = useCallback( - (validateCode: string) => { - if (isVirtualCard) { - setPersonalDetailsAndRevealExpensifyCard(buildSetPersonalDetailsAndShipExpensifyCardsParams(values, countryCode), Number(cardID), validateCode) - .then((details) => { - setRevealedVirtualCardDetails(cardID, details); - clearDraftValues(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM); - Navigation.closeRHPFlow(); - Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAIN_CARD.getRoute(cardID)); - }) - .catch((error: TranslationPaths) => { - setRevealCardError(getMicroSecondOnyxErrorWithTranslationKey(error)); - }); - return; - } - updatePersonalDetailsAndShipExpensifyCards(values, validateCode, countryCode); - }, - [countryCode, values, isVirtualCard, cardID], - ); + const values = normalizeCountryCode(getSubPageValues(privatePersonalDetails, draftValues)) as PersonalDetailsForm; + + const handleSubmitForm = (validateCode: string) => { + if (isVirtualCard) { + setPersonalDetailsAndRevealExpensifyCard(buildSetPersonalDetailsAndShipExpensifyCardsParams(values, countryCode), Number(cardID), validateCode) + .then((details) => { + setRevealedVirtualCardDetails(cardID, details); + clearDraftValues(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM); + Navigation.closeRHPFlow(); + Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAIN_CARD.getRoute(cardID)); + }) + .catch((error: TranslationPaths) => { + setRevealCardError(getMicroSecondOnyxErrorWithTranslationKey(error)); + }); + return; + } + updatePersonalDetailsAndShipExpensifyCards(values, validateCode, countryCode); + }; // The validate code generated here must carry the reasonCode of the command handleSubmitForm will // call: virtual cards verify via SetPersonalDetailsAndRevealExpensifyCard (reveal_card_details) diff --git a/src/pages/MissingPersonalDetails/PINContext.tsx b/src/pages/MissingPersonalDetails/PINContext.tsx index cb0673ac1c27..e1e346eb9f9f 100644 --- a/src/pages/MissingPersonalDetails/PINContext.tsx +++ b/src/pages/MissingPersonalDetails/PINContext.tsx @@ -1,6 +1,6 @@ import type {ReactNode} from 'react'; -import React, {createContext, useCallback, useContext, useEffect, useState} from 'react'; +import React, {createContext, useContext, useEffect, useState} from 'react'; import type {PINActionsContextType, PINStateContextType} from './types.context'; @@ -23,19 +23,19 @@ function PINContextProvider({children}: PINContextProviderProps) { const [isConfirmStep, setIsConfirmStepInternal] = useState(false); const [isPINHidden, setIsPINHidden] = useState(true); - const setIsConfirmStep = useCallback((value: boolean) => { + const setIsConfirmStep = (value: boolean) => { setIsConfirmStepInternal(value); setIsPINHidden(true); - }, []); + }; - const clearPIN = useCallback(() => { + const clearPIN = () => { setPIN(''); setIsConfirmStep(false); - }, [setIsConfirmStep]); + }; - const togglePINVisibility = useCallback(() => { + const togglePINVisibility = () => { setIsPINHidden((prev) => !prev); - }, []); + }; useEffect(() => { return () => { diff --git a/src/pages/MissingPersonalDetails/index.tsx b/src/pages/MissingPersonalDetails/index.tsx index ea16a03328ea..51ff6537ee45 100644 --- a/src/pages/MissingPersonalDetails/index.tsx +++ b/src/pages/MissingPersonalDetails/index.tsx @@ -10,7 +10,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import React, {useCallback} from 'react'; +import React from 'react'; import MissingPersonalDetailsContent from './MissingPersonalDetailsContent'; @@ -24,9 +24,9 @@ function MissingPersonalDetails({route: {params: {cardID = ''} = {}}}) { const isLoading = isLoadingOnyxValue(privatePersonalDetailsMetadata, draftValuesMetadata); - const handleComplete = useCallback(() => { + const handleComplete = () => { Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MISSING_PERSONAL_DETAILS_CONFIRM_VALIDATE_CODE.getRoute(cardID), basePath), {forceReplace: true}); - }, [cardID, basePath]); + }; if (isLoading) { return ; diff --git a/src/pages/MissingPersonalDetails/subPages/Confirmation.tsx b/src/pages/MissingPersonalDetails/subPages/Confirmation.tsx index 75e25141d02b..9f19d1a76c6d 100644 --- a/src/pages/MissingPersonalDetails/subPages/Confirmation.tsx +++ b/src/pages/MissingPersonalDetails/subPages/Confirmation.tsx @@ -8,7 +8,7 @@ import type {CustomSubPageProps} from '@pages/MissingPersonalDetails/types'; import CONST from '@src/CONST'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; -import React, {useMemo} from 'react'; +import React from 'react'; function Confirmation({personalDetailsValues: values, onNext, onMove, isEditing, shouldCollectPIN = false}: CustomSubPageProps) { const {translate} = useLocalize(); @@ -21,63 +21,57 @@ function Confirmation({personalDetailsValues: values, onNext, onMove, isEditing, const phoneNumberIndex = pageIndexes.PHONE_NUMBER; const pinIndex = CONST.MISSING_PERSONAL_DETAILS_INDEXES.MAPPING_WITH_PIN.PIN; - const summaryItems = useMemo(() => { - const baseItems = [ - { - id: 'legal-name', - description: translate('personalInfoStep.legalName'), - title: `${values[INPUT_IDS.LEGAL_FIRST_NAME]} ${values[INPUT_IDS.LEGAL_LAST_NAME]}`, - shouldShowRightIcon: true, - onPress: () => { - onMove(legalNameIndex); - }, + const baseItems = [ + { + id: 'legal-name', + description: translate('personalInfoStep.legalName'), + title: `${values[INPUT_IDS.LEGAL_FIRST_NAME]} ${values[INPUT_IDS.LEGAL_LAST_NAME]}`, + shouldShowRightIcon: true, + onPress: () => { + onMove(legalNameIndex); }, - { - id: 'date-of-birth', - description: translate('common.dob'), - title: values[INPUT_IDS.DATE_OF_BIRTH], - shouldShowRightIcon: true, - onPress: () => { - onMove(dateOfBirthIndex); - }, + }, + { + id: 'date-of-birth', + description: translate('common.dob'), + title: values[INPUT_IDS.DATE_OF_BIRTH], + shouldShowRightIcon: true, + onPress: () => { + onMove(dateOfBirthIndex); }, - { - id: 'address', - description: translate('personalInfoStep.address'), - title: `${values[INPUT_IDS.ADDRESS_LINE_1]}, ${values[INPUT_IDS.ADDRESS_LINE_2] ? `${values[INPUT_IDS.ADDRESS_LINE_2]}, ` : ''}${values[INPUT_IDS.CITY]}, ${ - values[INPUT_IDS.STATE] - }, ${values[INPUT_IDS.ZIP_POST_CODE].toUpperCase()}, ${values[INPUT_IDS.COUNTRY]}`, - shouldShowRightIcon: true, - onPress: () => { - onMove(addressIndex); - }, + }, + { + id: 'address', + description: translate('personalInfoStep.address'), + title: `${values[INPUT_IDS.ADDRESS_LINE_1]}, ${values[INPUT_IDS.ADDRESS_LINE_2] ? `${values[INPUT_IDS.ADDRESS_LINE_2]}, ` : ''}${values[INPUT_IDS.CITY]}, ${values[INPUT_IDS.STATE]}, ${values[INPUT_IDS.ZIP_POST_CODE].toUpperCase()}, ${values[INPUT_IDS.COUNTRY]}`, + shouldShowRightIcon: true, + onPress: () => { + onMove(addressIndex); }, - { - id: 'phone-number', - description: translate('common.phoneNumber'), - title: values[INPUT_IDS.PHONE_NUMBER], - shouldShowRightIcon: true, - onPress: () => { - onMove(phoneNumberIndex); - }, + }, + { + id: 'phone-number', + description: translate('common.phoneNumber'), + title: values[INPUT_IDS.PHONE_NUMBER], + shouldShowRightIcon: true, + onPress: () => { + onMove(phoneNumberIndex); }, - ]; - - if (shouldCollectPIN) { - baseItems.push({ - id: 'pin', - description: translate('cardPage.physicalCardPin'), - title: '••••', - shouldShowRightIcon: true, - onPress: () => { - setIsConfirmStep(false); - onMove(pinIndex); - }, - }); - } - - return baseItems; - }, [translate, values, onMove, legalNameIndex, dateOfBirthIndex, addressIndex, phoneNumberIndex, pinIndex, shouldCollectPIN, setIsConfirmStep]); + }, + ]; + if (shouldCollectPIN) { + baseItems.push({ + id: 'pin', + description: translate('cardPage.physicalCardPin'), + title: '••••', + shouldShowRightIcon: true, + onPress: () => { + setIsConfirmStep(false); + onMove(pinIndex); + }, + }); + } + const summaryItems = baseItems; return ( { - setError(''); - if (isConfirmStep) { - setConfirmPIN(value); - } else { - setEnteredPIN(value); - } - }, - [isConfirmStep], - ); + const handlePINChange = (value: string) => { + setError(''); + if (isConfirmStep) { + setConfirmPIN(value); + } else { + setEnteredPIN(value); + } + }; - const validatePIN = useCallback((): string => { + const validatePIN = (): string => { const PINToValidate = isConfirmStep ? confirmPIN : enteredPIN; if (PINToValidate.length !== CONST.EXPENSIFY_CARD.PIN.LENGTH) { @@ -59,9 +56,9 @@ function PIN({onNext}: CustomSubPageProps) { return translate('cardPage.invalidPin'); } return ''; - }, [isConfirmStep, confirmPIN, enteredPIN, translate]); + }; - const handleSubmit = useCallback(() => { + const handleSubmit = () => { const validationError = validatePIN(); if (validationError) { setError(validationError); @@ -78,7 +75,7 @@ function PIN({onNext}: CustomSubPageProps) { // PIN verified, save it and proceed setPIN(confirmPIN); onNext(); - }, [validatePIN, isConfirmStep, setPIN, confirmPIN, onNext, setIsConfirmStep]); + }; const currentPIN = isConfirmStep ? confirmPIN : enteredPIN; const title = isConfirmStep ? translate('cardPage.confirmYourPin') : translate('cardPage.setYourPin'); diff --git a/src/pages/MissingPersonalDetails/subPages/PhoneNumber.tsx b/src/pages/MissingPersonalDetails/subPages/PhoneNumber.tsx index 6a39e80b13f0..a05c00c40e34 100644 --- a/src/pages/MissingPersonalDetails/subPages/PhoneNumber.tsx +++ b/src/pages/MissingPersonalDetails/subPages/PhoneNumber.tsx @@ -14,7 +14,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; -import React, {useCallback} from 'react'; +import React from 'react'; const STEP_FIELDS = [INPUT_IDS.PHONE_NUMBER]; @@ -22,25 +22,22 @@ function PhoneNumberStep({isEditing, onNext, onMove, personalDetailsValues}: Cus const {translate} = useLocalize(); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; - const phoneNumber = values[INPUT_IDS.PHONE_NUMBER]; - const phoneNumberWithCountryCode = appendCountryCode(phoneNumber, countryCode); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + const phoneNumber = values[INPUT_IDS.PHONE_NUMBER]; + const phoneNumberWithCountryCode = appendCountryCode(phoneNumber, countryCode); - if (!isRequiredFulfilled(phoneNumber)) { - errors[INPUT_IDS.PHONE_NUMBER] = translate('common.error.fieldRequired'); - return errors; - } + if (!isRequiredFulfilled(phoneNumber)) { + errors[INPUT_IDS.PHONE_NUMBER] = translate('common.error.fieldRequired'); + return errors; + } - if (!isValidPhoneNumber(phoneNumberWithCountryCode)) { - errors[INPUT_IDS.PHONE_NUMBER] = translate('common.error.phoneNumber'); - } + if (!isValidPhoneNumber(phoneNumberWithCountryCode)) { + errors[INPUT_IDS.PHONE_NUMBER] = translate('common.error.phoneNumber'); + } - return errors; - }, - [translate, countryCode], - ); + return errors; + }; const handleSubmit = usePersonalDetailsFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx b/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx index 3d24c07a381b..206d3e9c15de 100644 --- a/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx +++ b/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx @@ -25,7 +25,7 @@ import type SCREENS from '@src/SCREENS'; import type {SeverityLevel} from '@sentry/react-native'; import * as Sentry from '@sentry/react-native'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import MultifactorAuthenticationAuthorizeTransactionActions from './AuthorizeTransactionActions'; @@ -63,7 +63,7 @@ function MultifactorAuthenticationScenarioAuthorizeTransactionPage({route}: Mult const [isConfirmModalVisible, setConfirmModalVisibility] = useState(false); const allowNavigatingAwayRef = useRef(false); - const showConfirmModal = useCallback(() => { + const showConfirmModal = () => { // FullPageOfflineBlockingView doesn't wrap HeaderWithBackButton, so we handle navigation manually when offline. // Offline mode isn't supported in MFA; navigate users away immediately without showing the confirmation modal. if (isOffline) { @@ -73,7 +73,7 @@ function MultifactorAuthenticationScenarioAuthorizeTransactionPage({route}: Mult return; } setConfirmModalVisibility(true); - }, [isOffline, transactionID]); + }; const hideConfirmModal = () => { setConfirmModalVisibility(false); diff --git a/src/pages/MultifactorAuthentication/RevokePage.tsx b/src/pages/MultifactorAuthentication/RevokePage.tsx index efad8d1dcc84..e8ad98a7989a 100644 --- a/src/pages/MultifactorAuthentication/RevokePage.tsx +++ b/src/pages/MultifactorAuthentication/RevokePage.tsx @@ -21,7 +21,7 @@ import CONST from '@src/CONST'; import type {ValueOf} from 'type-fest'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import RevokeRow from './RevokeRow'; @@ -91,44 +91,35 @@ function MultifactorAuthenticationRevokePage() { Navigation.goBack(); }; - const executeRevoke = useCallback( - async (params: Parameters[0], setLoading: (loading: boolean) => void) => { - setLoading(true); - setErrorMessage(undefined); - const result = await revokeMultifactorAuthenticationCredentials(params); - setLoading(false); - if (result.httpStatusCode !== 200) { - setErrorMessage(translate('multifactorAuthentication.revoke.error')); - } - }, - [translate], - ); + const executeRevoke = async (params: Parameters[0], setLoading: (loading: boolean) => void) => { + setLoading(true); + setErrorMessage(undefined); + const result = await revokeMultifactorAuthenticationCredentials(params); + setLoading(false); + if (result.httpStatusCode !== 200) { + setErrorMessage(translate('multifactorAuthentication.revoke.error')); + } + }; // Since localCredentialID is loaded asynchronously, a callback that closed over it directly could see a stale // undefined and make revokeThisDevice silently no-op, or make revokeOtherDevices send empty params and // accidentally revoke ALL credentials. These take the key as an argument so the caller supplies the latest value. - const revokeThisDevice = useCallback( - async (keyID: string) => { - await executeRevoke({onlyKeyID: keyID}, setIsThisDeviceLoading); - }, - [executeRevoke], - ); + const revokeThisDevice = async (keyID: string) => { + await executeRevoke({onlyKeyID: keyID}, setIsThisDeviceLoading); + }; - const revokeOtherDevices = useCallback( - async (currentDeviceKeyID: string) => { - const params = {exceptKeyID: currentDeviceKeyID}; - await executeRevoke(params, setIsOtherDevicesLoading); - }, - [executeRevoke], - ); + const revokeOtherDevices = async (currentDeviceKeyID: string) => { + const params = {exceptKeyID: currentDeviceKeyID}; + await executeRevoke(params, setIsOtherDevicesLoading); + }; - const revokeAll = useCallback(async () => { + const revokeAll = async () => { const setLoading = (loading: boolean) => { setIsThisDeviceLoading(loading); setIsOtherDevicesLoading(loading); }; await executeRevoke({}, setLoading); - }, [executeRevoke]); + }; // isConfirmLoading keeps the modal open in a loading state after the promise resolves, so every path has to call closeModal() to dismiss it. const handleRevokeConfirm = async (mode: ConfirmMode) => { diff --git a/src/pages/NewChatSelectorPage.tsx b/src/pages/NewChatSelectorPage.tsx index 05051e62e0d7..ae870797b95d 100755 --- a/src/pages/NewChatSelectorPage.tsx +++ b/src/pages/NewChatSelectorPage.tsx @@ -14,7 +14,7 @@ import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Nav import CONST from '@src/CONST'; import KeyboardUtils from '@src/utils/keyboard'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import type {WorkspaceNewRoomPageRef} from './workspace/WorkspaceNewRoomPage'; @@ -32,13 +32,11 @@ function NewChatSelectorPage() { const roomPageInputRef = useRef(null); // Theoretically, the focus trap container element can be null (due to component unmount/remount), so we filter out the null elements - const containerElements = useMemo(() => { - return [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element); - }, [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement]); + const containerElements = [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element); - const onTabFocusTrapContainerElementChanged = useCallback((activeTabElement?: HTMLElement | null) => { + const onTabFocusTrapContainerElementChanged = (activeTabElement?: HTMLElement | null) => { setActiveTabContainerElement(activeTabElement ?? null); - }, []); + }; // We're focusing the input using internal onPageSelected to fix input focus inconsistencies. // More info: https://github.com/Expensify/App/issues/59388 diff --git a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx index 26bae713e0ca..6fe0d6b6728a 100644 --- a/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx +++ b/src/pages/OnboardingAccounting/BaseOnboardingAccounting.tsx @@ -43,7 +43,7 @@ import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView} from 'react-native'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {BaseOnboardingAccountingProps} from './types'; @@ -155,14 +155,14 @@ function BaseOnboardingAccounting({shouldUseNativeStyles}: BaseOnboardingAccount setError(''); }; - const handleContentSizeChange = useCallback(() => { + const handleContentSizeChange = () => { if (!shouldScrollToOtherInput) { return; } scrollViewRef.current?.scrollToEnd({animated: false}); setShouldScrollToOtherInput(false); - }, [shouldScrollToOtherInput]); + }; function renderOption(optionKey: AccountingOptionKey) { const isOtherOption = optionKey === 'other'; diff --git a/src/pages/OnboardingEmployees/BaseOnboardingEmployees.tsx b/src/pages/OnboardingEmployees/BaseOnboardingEmployees.tsx index 88aa7a655167..a91181fa3846 100644 --- a/src/pages/OnboardingEmployees/BaseOnboardingEmployees.tsx +++ b/src/pages/OnboardingEmployees/BaseOnboardingEmployees.tsx @@ -25,7 +25,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; import type {BaseOnboardingEmployeesProps} from './types'; @@ -50,29 +50,17 @@ function BaseOnboardingEmployees({shouldUseNativeStyles, route}: BaseOnboardingE const [loginList] = useOnyx(ONYXKEYS.LOGINS, {selector: expensifyLoginsSelector}); const [session] = useOnyx(ONYXKEYS.SESSION); - const onboardingFlowContext = useMemo( - () => ({ - signupQualifier: onboardingValues?.signupQualifier, - isFromPublicDomain: account?.isFromPublicDomain, - hasAccessibleDomainPolicies: account?.hasAccessibleDomainPolicies, - purposeSelected: purposeSelected ?? undefined, - isMergeAccountStepSkipped: onboardingValues?.isMergeAccountStepSkipped, - isAccountValidated: isCurrentUserValidated(loginList, session?.email), - hasJoinablePolicies: Object.keys(joinablePolicies ?? {}).length > 0, - }), - [ - account?.hasAccessibleDomainPolicies, - account?.isFromPublicDomain, - joinablePolicies, - loginList, - onboardingValues?.isMergeAccountStepSkipped, - onboardingValues?.signupQualifier, - purposeSelected, - session?.email, - ], - ); + const onboardingFlowContext = { + signupQualifier: onboardingValues?.signupQualifier, + isFromPublicDomain: account?.isFromPublicDomain, + hasAccessibleDomainPolicies: account?.hasAccessibleDomainPolicies, + purposeSelected: purposeSelected ?? undefined, + isMergeAccountStepSkipped: onboardingValues?.isMergeAccountStepSkipped, + isAccountValidated: isCurrentUserValidated(loginList, session?.email), + hasJoinablePolicies: Object.keys(joinablePolicies ?? {}).length > 0, + }; - const handleBackButtonPress = useCallback(() => { + const handleBackButtonPress = () => { const previousRoute = getPreviousOnboardingRoute(SCREENS.ONBOARDING.EMPLOYEES, onboardingFlowContext, route.params?.backTo); if (previousRoute) { @@ -81,40 +69,34 @@ function BaseOnboardingEmployees({shouldUseNativeStyles, route}: BaseOnboardingE } Navigation.goBack(ROUTES.ONBOARDING_PURPOSE.getRoute()); - }, [onboardingFlowContext, route.params?.backTo]); - - const companySizeOptions: OnboardingListItem[] = useMemo(() => { - const isSmb = onboardingValues?.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.SMB; - const isVsb = onboardingValues?.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.VSB; - return Object.values(CONST.ONBOARDING_COMPANY_SIZE) - .filter((size) => { - // Always hide deprecated options. - if ( - size === CONST.ONBOARDING_COMPANY_SIZE.LEGACY_MICRO_MEDIUM || - size === CONST.ONBOARDING_COMPANY_SIZE.LEGACY_MICRO || - size === CONST.ONBOARDING_COMPANY_SIZE.LEGACY_SMALL - ) { - return false; - } - // For VSB-qualified users (1-9 from landing page), only show 1-4 and 5-9. - if (isVsb) { - return size === CONST.ONBOARDING_COMPANY_SIZE.MICRO_SMALL || size === CONST.ONBOARDING_COMPANY_SIZE.MICRO_MEDIUM; - } - // For SMB-qualified users, hide 1-4 and 5-9 since they already indicated 10+. - if (isSmb) { - return size !== CONST.ONBOARDING_COMPANY_SIZE.MICRO_SMALL && size !== CONST.ONBOARDING_COMPANY_SIZE.MICRO_MEDIUM; - } - return true; - }) - .map((companySize): OnboardingListItem => { - return { - text: translate(`onboarding.employees.${companySize}`), - keyForList: companySize, - isSelected: companySize === selectedCompanySize, - itemStyle: onboardingIsMediumOrLargerScreenWidth ? [styles.pl8, styles.pr8] : [], - }; - }); - }, [translate, selectedCompanySize, onboardingValues?.signupQualifier, onboardingIsMediumOrLargerScreenWidth, styles.pl8, styles.pr8]); + }; + + const isSmb = onboardingValues?.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.SMB; + const isVsb = onboardingValues?.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.VSB; + const companySizeOptions: OnboardingListItem[] = Object.values(CONST.ONBOARDING_COMPANY_SIZE) + .filter((size) => { + // Always hide deprecated options. + if (size === CONST.ONBOARDING_COMPANY_SIZE.LEGACY_MICRO_MEDIUM || size === CONST.ONBOARDING_COMPANY_SIZE.LEGACY_MICRO || size === CONST.ONBOARDING_COMPANY_SIZE.LEGACY_SMALL) { + return false; + } + // For VSB-qualified users (1-9 from landing page), only show 1-4 and 5-9. + if (isVsb) { + return size === CONST.ONBOARDING_COMPANY_SIZE.MICRO_SMALL || size === CONST.ONBOARDING_COMPANY_SIZE.MICRO_MEDIUM; + } + // For SMB-qualified users, hide 1-4 and 5-9 since they already indicated 10+. + if (isSmb) { + return size !== CONST.ONBOARDING_COMPANY_SIZE.MICRO_SMALL && size !== CONST.ONBOARDING_COMPANY_SIZE.MICRO_MEDIUM; + } + return true; + }) + .map((companySize): OnboardingListItem => { + return { + text: translate(`onboarding.employees.${companySize}`), + keyForList: companySize, + isSelected: companySize === selectedCompanySize, + itemStyle: onboardingIsMediumOrLargerScreenWidth ? [styles.pl8, styles.pr8] : [], + }; + }); const submitCompanySize = () => { if (!selectedCompanySize) { diff --git a/src/pages/OnboardingInterestedFeatures/BaseOnboardingInterestedFeatures.tsx b/src/pages/OnboardingInterestedFeatures/BaseOnboardingInterestedFeatures.tsx index 6a2dd31c3122..b17f47c9479a 100644 --- a/src/pages/OnboardingInterestedFeatures/BaseOnboardingInterestedFeatures.tsx +++ b/src/pages/OnboardingInterestedFeatures/BaseOnboardingInterestedFeatures.tsx @@ -36,7 +36,7 @@ import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import type {BaseOnboardingInterestedFeaturesProps, Feature, SectionObject} from './types'; @@ -58,113 +58,96 @@ function BaseOnboardingInterestedFeatures({shouldUseNativeStyles}: BaseOnboardin const {isOffline} = useNetwork(); const {completeOnboardingFlow, isLoading} = useCompleteOnboarding(); - const features: Feature[] = useMemo(() => { - return ONBOARDING_FEATURES.map((feature) => { - switch (feature.id) { - case CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED: - return { - ...feature, - title: translate('workspace.moreFeatures.categories.title'), - subtitle: translate('workspace.moreFeatures.categories.subtitle'), - icon: illustrations.FolderOpen, - }; - case CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED: - return { - ...feature, - title: translate('workspace.moreFeatures.connections.title'), - subtitle: translate('workspace.moreFeatures.connections.subtitle'), - icon: illustrations.Accounting, - }; - case CONST.POLICY.MORE_FEATURES.ARE_COMPANY_CARDS_ENABLED: - return { - ...feature, - title: translate('workspace.moreFeatures.companyCards.title'), - subtitle: translate('workspace.moreFeatures.companyCards.subtitle'), - icon: illustrations.CompanyCard, - }; - case CONST.POLICY.MORE_FEATURES.ARE_WORKFLOWS_ENABLED: - return { - ...feature, - title: translate('workspace.moreFeatures.workflows.title'), - subtitle: translate('workspace.moreFeatures.workflows.subtitle'), - icon: illustrations.Workflows, - }; - case CONST.POLICY.MORE_FEATURES.IS_TRAVEL_ENABLED: - return { - ...feature, - title: translate('workspace.moreFeatures.travel.title'), - subtitle: translate('workspace.moreFeatures.travel.subtitle'), - icon: illustrations.Luggage, - }; - case CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED: - return { - ...feature, - title: translate('workspace.moreFeatures.rules.title'), - subtitle: translate('workspace.moreFeatures.rules.subtitle'), - icon: illustrations.Rules, - }; - case CONST.POLICY.MORE_FEATURES.ARE_DISTANCE_RATES_ENABLED: - return { - ...feature, - title: translate('workspace.moreFeatures.distanceRates.title'), - subtitle: translate('workspace.moreFeatures.distanceRates.subtitle'), - icon: illustrations.Car, - }; - case CONST.POLICY.MORE_FEATURES.ARE_EXPENSIFY_CARDS_ENABLED: - return { - ...feature, - title: translate('workspace.moreFeatures.expensifyCard.title'), - subtitle: translate('workspace.moreFeatures.expensifyCard.subtitle'), - icon: illustrations.HandCard, - }; - case CONST.POLICY.MORE_FEATURES.ARE_TAGS_ENABLED: - return { - ...feature, - title: translate('workspace.moreFeatures.tags.title'), - subtitle: translate('workspace.moreFeatures.tags.subtitle'), - icon: illustrations.Tag, - }; - case CONST.POLICY.MORE_FEATURES.ARE_PER_DIEM_RATES_ENABLED: - return { - ...feature, - title: translate('workspace.moreFeatures.perDiem.title'), - subtitle: translate('workspace.moreFeatures.perDiem.subtitle'), - icon: illustrations.PerDiem, - }; - case CONST.POLICY.MORE_FEATURES.IS_TIME_TRACKING_ENABLED: - return { - ...feature, - title: translate('workspace.moreFeatures.timeTracking.title'), - subtitle: translate('workspace.moreFeatures.timeTracking.subtitle'), - icon: illustrations.Clock, - }; - default: - return {...feature, title: feature.id, icon: illustrations.FolderOpen}; - } - }); - }, [ - illustrations.FolderOpen, - illustrations.Accounting, - illustrations.CompanyCard, - illustrations.Workflows, - illustrations.Rules, - illustrations.Car, - illustrations.HandCard, - illustrations.Tag, - illustrations.PerDiem, - illustrations.Luggage, - illustrations.Clock, - translate, - ]); + const features: Feature[] = ONBOARDING_FEATURES.map((feature) => { + switch (feature.id) { + case CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED: + return { + ...feature, + title: translate('workspace.moreFeatures.categories.title'), + subtitle: translate('workspace.moreFeatures.categories.subtitle'), + icon: illustrations.FolderOpen, + }; + case CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED: + return { + ...feature, + title: translate('workspace.moreFeatures.connections.title'), + subtitle: translate('workspace.moreFeatures.connections.subtitle'), + icon: illustrations.Accounting, + }; + case CONST.POLICY.MORE_FEATURES.ARE_COMPANY_CARDS_ENABLED: + return { + ...feature, + title: translate('workspace.moreFeatures.companyCards.title'), + subtitle: translate('workspace.moreFeatures.companyCards.subtitle'), + icon: illustrations.CompanyCard, + }; + case CONST.POLICY.MORE_FEATURES.ARE_WORKFLOWS_ENABLED: + return { + ...feature, + title: translate('workspace.moreFeatures.workflows.title'), + subtitle: translate('workspace.moreFeatures.workflows.subtitle'), + icon: illustrations.Workflows, + }; + case CONST.POLICY.MORE_FEATURES.IS_TRAVEL_ENABLED: + return { + ...feature, + title: translate('workspace.moreFeatures.travel.title'), + subtitle: translate('workspace.moreFeatures.travel.subtitle'), + icon: illustrations.Luggage, + }; + case CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED: + return { + ...feature, + title: translate('workspace.moreFeatures.rules.title'), + subtitle: translate('workspace.moreFeatures.rules.subtitle'), + icon: illustrations.Rules, + }; + case CONST.POLICY.MORE_FEATURES.ARE_DISTANCE_RATES_ENABLED: + return { + ...feature, + title: translate('workspace.moreFeatures.distanceRates.title'), + subtitle: translate('workspace.moreFeatures.distanceRates.subtitle'), + icon: illustrations.Car, + }; + case CONST.POLICY.MORE_FEATURES.ARE_EXPENSIFY_CARDS_ENABLED: + return { + ...feature, + title: translate('workspace.moreFeatures.expensifyCard.title'), + subtitle: translate('workspace.moreFeatures.expensifyCard.subtitle'), + icon: illustrations.HandCard, + }; + case CONST.POLICY.MORE_FEATURES.ARE_TAGS_ENABLED: + return { + ...feature, + title: translate('workspace.moreFeatures.tags.title'), + subtitle: translate('workspace.moreFeatures.tags.subtitle'), + icon: illustrations.Tag, + }; + case CONST.POLICY.MORE_FEATURES.ARE_PER_DIEM_RATES_ENABLED: + return { + ...feature, + title: translate('workspace.moreFeatures.perDiem.title'), + subtitle: translate('workspace.moreFeatures.perDiem.subtitle'), + icon: illustrations.PerDiem, + }; + case CONST.POLICY.MORE_FEATURES.IS_TIME_TRACKING_ENABLED: + return { + ...feature, + title: translate('workspace.moreFeatures.timeTracking.title'), + subtitle: translate('workspace.moreFeatures.timeTracking.subtitle'), + icon: illustrations.Clock, + }; + default: + return {...feature, title: feature.id, icon: illustrations.FolderOpen}; + } + }); - const defaultSelectedFeatures = useMemo(() => features.filter((feature) => !!feature.enabledByDefault).map((feature) => feature.id), [features]); - const persistedSelectedFeatures = useMemo(() => onboardingInterestedFeaturesMap?.filter((feature) => feature.enabled).map((feature) => feature.id), [onboardingInterestedFeaturesMap]); + const defaultSelectedFeatures = features.filter((feature) => !!feature.enabledByDefault).map((feature) => feature.id); + const persistedSelectedFeatures = onboardingInterestedFeaturesMap?.filter((feature) => feature.enabled).map((feature) => feature.id); const [selectedFeatureIDs, setSelectedFeatureIDs] = useState>(); - const selectedFeatures = useMemo(() => { - return Array.from(selectedFeatureIDs ?? new Set(persistedSelectedFeatures ?? defaultSelectedFeatures)); - }, [defaultSelectedFeatures, persistedSelectedFeatures, selectedFeatureIDs]); - const isAccountingEnabled = selectedFeatures.includes(CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED); + const selectedFeatures = selectedFeatureIDs ?? new Set(persistedSelectedFeatures ?? defaultSelectedFeatures); + const isAccountingEnabled = selectedFeatures.has(CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED); // Set onboardingPolicyID and onboardingAdminsChatReportID if a workspace is created by the backend for OD signup useEffect(() => { @@ -175,10 +158,10 @@ function BaseOnboardingInterestedFeatures({shouldUseNativeStyles}: BaseOnboardin setOnboardingPolicyID(groupPolicy.id); }, [groupPolicy, onboardingPolicyID]); - const submitInterestedFeatures = useCallback(async () => { + const submitInterestedFeatures = async () => { const featuresMap: OnboardingFeatureMapItem[] = features.map((feature) => ({ id: feature.id, - enabled: selectedFeatures.includes(feature.id), + enabled: selectedFeatures.has(feature.id), enabledByDefault: feature.enabledByDefault, requiresUpdate: feature.requiresUpdate, })); @@ -194,7 +177,7 @@ function BaseOnboardingInterestedFeatures({shouldUseNativeStyles}: BaseOnboardin setOnboardingUserReportedIntegration(null); await completeOnboardingFlow({featuresMap}); - }, [completeOnboardingFlow, features, isAccountingEnabled, selectedFeatures]); + }; // Create items for enabled features, title-only per the mocks const enabledFeatures: Feature[] = features @@ -223,75 +206,66 @@ function BaseOnboardingInterestedFeatures({shouldUseNativeStyles}: BaseOnboardin }, ]; - const handleFeatureSelect = useCallback( - (featureId: string) => { - setSelectedFeatureIDs((prev) => { - const newSet = new Set(prev ?? persistedSelectedFeatures ?? defaultSelectedFeatures); - if (newSet.has(featureId)) { - newSet.delete(featureId); - } else { - newSet.add(featureId); - } - return newSet; - }); - }, - [defaultSelectedFeatures, persistedSelectedFeatures], - ); + const handleFeatureSelect = (featureId: string) => { + setSelectedFeatureIDs((prev) => { + const newSet = new Set(prev ?? persistedSelectedFeatures ?? defaultSelectedFeatures); + if (newSet.has(featureId)) { + newSet.delete(featureId); + } else { + newSet.add(featureId); + } + return newSet; + }); + }; - const renderItem = useCallback( - (item: Feature) => { - const isSelected = selectedFeatures.includes(item.id); - return ( - { + const isSelected = selectedFeatures.has(item.id); + return ( + { + handleFeatureSelect(item.id); + }} + accessibilityLabel={item.title} + accessible={false} + // Keep the selected fill on hover. + hoverStyle={isSelected ? undefined : styles.hoveredComponentBG} + style={[ + styles.onboardingTile, + isSmallScreenWidth && !isInLandscapeMode ? styles.onboardingTileNarrow : styles.onboardingInterestedFeaturesItemWide, + isSelected && styles.onboardingTileSelected, + ]} + sentryLabel={CONST.SENTRY_LABEL.ONBOARDING.INTERESTED_FEATURES_ITEM} + > + { handleFeatureSelect(item.id); }} - accessibilityLabel={item.title} - accessible={false} - // Keep the selected fill on hover. - hoverStyle={isSelected ? undefined : styles.hoveredComponentBG} - style={[ - styles.onboardingTile, - isSmallScreenWidth && !isInLandscapeMode ? styles.onboardingTileNarrow : styles.onboardingInterestedFeaturesItemWide, - isSelected && styles.onboardingTileSelected, - ]} - sentryLabel={CONST.SENTRY_LABEL.ONBOARDING.INTERESTED_FEATURES_ITEM} - > - { - handleFeatureSelect(item.id); - }} - wrapperStyle={styles.onboardingTileSelectionButton} - /> - - {item.title} - {!!item.subtitle && {item.subtitle}} - - ); - }, - [styles, isSmallScreenWidth, isInLandscapeMode, selectedFeatures, handleFeatureSelect], - ); + wrapperStyle={styles.onboardingTileSelectionButton} + /> + + {item.title} + {!!item.subtitle && {item.subtitle}} + + ); + }; - const renderSection = useCallback( - (section: SectionObject) => ( -
          {translate(section.titleTranslationKey as TranslationPaths)}} - subtitleMuted - > - {section.items.map(renderItem)} -
          - ), - [styles, renderItem, translate], + const renderSection = (section: SectionObject) => ( +
          {translate(section.titleTranslationKey as TranslationPaths)}} + subtitleMuted + > + {section.items.map(renderItem)} +
          ); return ( diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx index 1ad4a1e99c57..e740bee13401 100644 --- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx +++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx @@ -40,7 +40,7 @@ import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/DisplayNameForm'; import {hasSeenTourSelector} from '@selectors/Onboarding'; -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import type {BaseOnboardingPersonalDetailsProps} from './types'; @@ -91,132 +91,92 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat setOnboardingErrorMessage(null); }, []); - const completeOnboarding = useCallback( - async (firstName: string, lastName: string) => { - if (!onboardingPurposeSelected || isLoading) { - return; - } + const completeOnboarding = async (firstName: string, lastName: string) => { + if (!onboardingPurposeSelected || isLoading) { + return; + } - setIsLoading(true); - try { - const joinWorkspaceMessage = getJoinWorkspaceMessage({joinWorkspaceMessages, onboardingPolicyID, onboardingPolicy, workEmail, isValidated}); - await completeOnboardingReport({ - engagementChoice: onboardingPurposeSelected, - onboardingMessage: onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE ? joinWorkspaceMessage : onboardingMessages[onboardingPurposeSelected], - firstName, - lastName, - adminsChatReportID: onboardingAdminsChatReportID, - ...(onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE ? {} : {onboardingPolicyID}), - introSelected, - isSelfTourViewed, - conciergeChat, - companyDomain: getEmailDomain(workEmail), - workEmail, - currentUserAccountID, - delegateAccountID, - }); - - setOnboardingAdminsChatReportID(); - setOnboardingPolicyID(); - - navigateAfterOnboardingWithMicrotaskQueue( - isSmallScreenWidth, - isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), - conciergeChatReportID, - reportNameValuePairs, - isAnonymousUser, - onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE && !onboardingPolicy ? undefined : onboardingPolicyID, - mergedAccountConciergeReportID, - false, - ); - setIsLoading(false); - } catch (error) { - Log.warn('[BaseOnboardingPersonalDetails] Error completing onboarding', {error}); - setIsLoading(false); - } - }, - [ - isLoading, - onboardingPurposeSelected, - onboardingAdminsChatReportID, - onboardingMessages, - joinWorkspaceMessages, - isValidated, - workEmail, - onboardingPolicyID, - onboardingPolicy, - isBetaEnabled, - reportNameValuePairs, - isAnonymousUser, - isSmallScreenWidth, - mergedAccountConciergeReportID, - conciergeChatReportID, - introSelected, - isSelfTourViewed, - conciergeChat, - currentUserAccountID, - delegateAccountID, - ], - ); + setIsLoading(true); + try { + const joinWorkspaceMessage = getJoinWorkspaceMessage({joinWorkspaceMessages, onboardingPolicyID, onboardingPolicy, workEmail, isValidated}); + await completeOnboardingReport({ + engagementChoice: onboardingPurposeSelected, + onboardingMessage: onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE ? joinWorkspaceMessage : onboardingMessages[onboardingPurposeSelected], + firstName, + lastName, + adminsChatReportID: onboardingAdminsChatReportID, + ...(onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE ? {} : {onboardingPolicyID}), + introSelected, + isSelfTourViewed, + conciergeChat, + companyDomain: getEmailDomain(workEmail), + workEmail, + currentUserAccountID, + delegateAccountID, + }); + + setOnboardingAdminsChatReportID(); + setOnboardingPolicyID(); + + navigateAfterOnboardingWithMicrotaskQueue( + isSmallScreenWidth, + isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), + conciergeChatReportID, + reportNameValuePairs, + isAnonymousUser, + onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE && !onboardingPolicy ? undefined : onboardingPolicyID, + mergedAccountConciergeReportID, + false, + ); + setIsLoading(false); + } catch (error) { + Log.warn('[BaseOnboardingPersonalDetails] Error completing onboarding', {error}); + setIsLoading(false); + } + }; - const handleSubmit = useCallback( - (values: FormOnyxValues<'onboardingPersonalDetailsForm'>) => { - if (isLoading) { - return; - } + const handleSubmit = (values: FormOnyxValues<'onboardingPersonalDetailsForm'>) => { + if (isLoading) { + return; + } - const firstName = values.firstName.trim(); - const lastName = values.lastName.trim(); - - setDisplayName(firstName, lastName, formatPhoneNumber, currentUserPersonalDetails); - clearPersonalDetailsDraft(); - setPersonalDetails(firstName, lastName); - - if (onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.EMPLOYER) { - if (isPrivateDomainAndHasAccessiblePolicies && isValidated) { - Navigation.navigate(ROUTES.ONBOARDING_WORKSPACES.getRoute(route.params?.backTo)); - return; - } - setIsLoading(true); - updateDisplayName(firstName, lastName, formatPhoneNumber, currentUserPersonalDetails); - autoCreateSubmitWorkspace(firstName, lastName).finally(() => { - setIsLoading(false); - }); - return; - } + const firstName = values.firstName.trim(); + const lastName = values.lastName.trim(); - if (isPrivateDomainAndHasAccessiblePolicies && (!onboardingPurposeSelected || isVsb || isSmb)) { - const nextRoute = isValidated ? ROUTES.ONBOARDING_WORKSPACES : ROUTES.ONBOARDING_PRIVATE_DOMAIN; - Navigation.navigate(nextRoute.getRoute(route.params?.backTo)); - return; - } + setDisplayName(firstName, lastName, formatPhoneNumber, currentUserPersonalDetails); + clearPersonalDetailsDraft(); + setPersonalDetails(firstName, lastName); - if (isTrackOnboardingChoice(onboardingPurposeSelected)) { - setIsLoading(true); - updateDisplayName(firstName, lastName, formatPhoneNumber, currentUserPersonalDetails); - autoCreateTrackWorkspace(firstName, lastName, onboardingPurposeSelected).finally(() => { - setIsLoading(false); - }); + if (onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.EMPLOYER) { + if (isPrivateDomainAndHasAccessiblePolicies && isValidated) { + Navigation.navigate(ROUTES.ONBOARDING_WORKSPACES.getRoute(route.params?.backTo)); return; } + setIsLoading(true); + updateDisplayName(firstName, lastName, formatPhoneNumber, currentUserPersonalDetails); + autoCreateSubmitWorkspace(firstName, lastName).finally(() => { + setIsLoading(false); + }); + return; + } - completeOnboarding(firstName, lastName); - }, - [ - isLoading, - formatPhoneNumber, - currentUserPersonalDetails, - isPrivateDomainAndHasAccessiblePolicies, - onboardingPurposeSelected, - isVsb, - isSmb, - completeOnboarding, - isValidated, - route.params?.backTo, - autoCreateTrackWorkspace, - autoCreateSubmitWorkspace, - ], - ); + if (isPrivateDomainAndHasAccessiblePolicies && (!onboardingPurposeSelected || isVsb || isSmb)) { + const nextRoute = isValidated ? ROUTES.ONBOARDING_WORKSPACES : ROUTES.ONBOARDING_PRIVATE_DOMAIN; + Navigation.navigate(nextRoute.getRoute(route.params?.backTo)); + return; + } + + if (isTrackOnboardingChoice(onboardingPurposeSelected)) { + setIsLoading(true); + updateDisplayName(firstName, lastName, formatPhoneNumber, currentUserPersonalDetails); + autoCreateTrackWorkspace(firstName, lastName, onboardingPurposeSelected).finally(() => { + setIsLoading(false); + }); + return; + } + + completeOnboarding(firstName, lastName); + }; const validate = (values: FormOnyxValues<'onboardingPersonalDetailsForm'>) => { if (!shouldValidateOnChange) { diff --git a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx index 423eea2b87e9..32e1ffa98a78 100644 --- a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx +++ b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx @@ -31,7 +31,7 @@ import type {Route} from '@src/ROUTES'; import {isUserValidatedSelector} from '@selectors/Account'; import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding'; import {CONST as COMMON_CONST, PUBLIC_DOMAINS_SET} from 'expensify-common'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {BaseOnboardingPrivateDomainProps} from './types'; @@ -95,14 +95,14 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard delegateAccountID, }); - const sendValidateCode = useCallback(() => { + const sendValidateCode = () => { if (!email) { return; } resendValidateCode({reasonCode: COMMON_CONST.VALIDATE_CODE_REASONS.VALIDATE_ACCOUNT}, email); - }, [email]); + }; - const handleBackButtonPress = useCallback(() => { + const handleBackButtonPress = () => { const backTo = route.params?.backTo as Route | undefined; // When this screen was opened directly from the intent list (backTo is the purpose route), go straight // back there instead of running the work-email restore path, which would insert a work-email screen. @@ -112,38 +112,32 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard } Navigation.goBack(backTo ?? ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()); - }, [route.params?.backTo, onboardingValues]); + }; - const navigateToNextOnboardingStep = useCallback( - (backTo: string | undefined, options?: {forceReplace?: boolean}) => { - if (isVsb || isSmb) { - Navigation.navigate(ROUTES.ONBOARDING_EMPLOYEES.getRoute(backTo), options); - return; - } - Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(backTo), options); - }, - [isVsb, isSmb], - ); + const navigateToNextOnboardingStep = (backTo: string | undefined, options?: {forceReplace?: boolean}) => { + if (isVsb || isSmb) { + Navigation.navigate(ROUTES.ONBOARDING_EMPLOYEES.getRoute(backTo), options); + return; + } + Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(backTo), options); + }; // Reaching this screen from the join-workspace intent means there is no further onboarding step to route // back into: skipping or finding no joinable workspaces should complete onboarding (collecting a name first // if needed), or simply close when this screen was reopened from a Concierge task after onboarding finished. - const continueAfterPrivateDomain = useCallback( - (backTo: string | undefined, options?: {forceReplace?: boolean}) => { - if (isJoiningCompanyWorkspace) { - if (isConciergeTaskFlow) { - returnToOriginReport(); - return; - } - continueJoinWorkspaceOnboarding(options); + const continueAfterPrivateDomain = (backTo: string | undefined, options?: {forceReplace?: boolean}) => { + if (isJoiningCompanyWorkspace) { + if (isConciergeTaskFlow) { + returnToOriginReport(); return; } - navigateToNextOnboardingStep(backTo, options); - }, - [continueJoinWorkspaceOnboarding, isJoiningCompanyWorkspace, isConciergeTaskFlow, navigateToNextOnboardingStep, returnToOriginReport], - ); + continueJoinWorkspaceOnboarding(options); + return; + } + navigateToNextOnboardingStep(backTo, options); + }; - const handleConciergeTaskExit = useCallback(() => { + const handleConciergeTaskExit = () => { if (!isConciergeTaskFlow) { return; } @@ -160,15 +154,15 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard return; } returnToOriginReport(); - }, [conciergeChat, delegateAccountID, domain, email, isConciergeTaskFlow, returnToOriginReport, validateEmailTaskReport?.reportID]); + }; - const handleSkipButtonPress = useCallback(() => { + const handleSkipButtonPress = () => { if (isConciergeTaskFlow) { handleConciergeTaskExit(); return; } continueAfterPrivateDomain(route.params?.backTo); - }, [continueAfterPrivateDomain, handleConciergeTaskExit, isConciergeTaskFlow, route.params?.backTo]); + }; // Only users whose current primary login is both validated and public-domain are blocked from this screen, since // the "people on YOUR domain" copy would otherwise reference gmail.com. The account flag can lag a primary-login diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 9e1478b18f5b..11057faff10e 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -41,7 +41,7 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {useIsFocused} from '@react-navigation/native'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import {PUBLIC_DOMAINS_SET} from 'expensify-common'; -import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef} from 'react'; +import React, {useEffect, useImperativeHandle, useRef} from 'react'; import {View} from 'react-native'; import {ScrollView} from 'react-native-gesture-handler'; @@ -62,17 +62,14 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro const {translate} = useLocalize(); const illustrations = useMemoizedLazyIllustrations(['Abacus', 'Binoculars', 'BriefcaseHandshake', 'CalculatorMoney', 'ReceiptUpload', 'PiggyBank']); - const menuIcons = useMemo( - () => ({ - [CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE]: illustrations.BriefcaseHandshake, - [CONST.ONBOARDING_CHOICES.EMPLOYER]: illustrations.ReceiptUpload, - [CONST.ONBOARDING_CHOICES.MANAGE_TEAM]: illustrations.Abacus, - [CONST.ONBOARDING_CHOICES.TRACK_BUSINESS]: illustrations.CalculatorMoney, - [CONST.ONBOARDING_CHOICES.TRACK_PERSONAL]: illustrations.PiggyBank, - [CONST.ONBOARDING_CHOICES.LOOKING_AROUND]: illustrations.Binoculars, - }), - [illustrations.Abacus, illustrations.Binoculars, illustrations.BriefcaseHandshake, illustrations.CalculatorMoney, illustrations.ReceiptUpload, illustrations.PiggyBank], - ); + const menuIcons = { + [CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE]: illustrations.BriefcaseHandshake, + [CONST.ONBOARDING_CHOICES.EMPLOYER]: illustrations.ReceiptUpload, + [CONST.ONBOARDING_CHOICES.MANAGE_TEAM]: illustrations.Abacus, + [CONST.ONBOARDING_CHOICES.TRACK_BUSINESS]: illustrations.CalculatorMoney, + [CONST.ONBOARDING_CHOICES.TRACK_PERSONAL]: illustrations.PiggyBank, + [CONST.ONBOARDING_CHOICES.LOOKING_AROUND]: illustrations.Binoculars, + }; const {onboardingIsMediumOrLargerScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const delegateAccountID = useDelegateAccountID(); @@ -219,9 +216,9 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro isJoinWorkspaceNavigationPending.current = false; }, [isFocused]); - const handleOuterClick = useCallback(() => { + const handleOuterClick = () => { setOnboardingErrorMessage('onboarding.errorSelection'); - }, []); + }; const onboardingLocalRef = useRef(null); useImperativeHandle(isFocused ? OnboardingRefManager.ref : onboardingLocalRef, () => ({handleOuterClick}), [handleOuterClick]); diff --git a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx index fba19ce07e7a..a0c0b524de1b 100644 --- a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx +++ b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx @@ -57,7 +57,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {useIsFocused} from '@react-navigation/native'; import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding'; import {PUBLIC_DOMAINS_SET, Str} from 'expensify-common'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import type {BaseOnboardingWorkEmailProps} from './types'; @@ -243,38 +243,35 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW onboardingValues?.isMergingAccountBlocked, ]); - const submitWorkEmail = useCallback( - (values: FormOnyxValues) => { - const submittedWorkEmail = values[INPUT_IDS.ONBOARDING_WORK_EMAIL].trim(); - const isCurrentUnvalidatedWorkEmail = - isConciergeTaskFlow && - !isCurrentPrimaryValidated && - !!sessionEmail && - submittedWorkEmail.toLowerCase() === sessionEmail.toLowerCase() && - !PUBLIC_DOMAINS_SET.has(submittedWorkEmail.split('@').at(1)?.toLowerCase() ?? ''); - - // The work email was already added in an earlier visit, so let the user resume its validation without - // sending AddWorkEmail for the current primary login again. - if (isCurrentUnvalidatedWorkEmail) { - Navigation.navigate(ROUTES.ONBOARDING_PRIVATE_DOMAIN.getRoute(undefined, true, originReportID), {forceReplace: true}); - return; - } + const submitWorkEmail = (values: FormOnyxValues) => { + const submittedWorkEmail = values[INPUT_IDS.ONBOARDING_WORK_EMAIL].trim(); + const isCurrentUnvalidatedWorkEmail = + isConciergeTaskFlow && + !isCurrentPrimaryValidated && + !!sessionEmail && + submittedWorkEmail.toLowerCase() === sessionEmail.toLowerCase() && + !PUBLIC_DOMAINS_SET.has(submittedWorkEmail.split('@').at(1)?.toLowerCase() ?? ''); + + // The work email was already added in an earlier visit, so let the user resume its validation without + // sending AddWorkEmail for the current primary login again. + if (isCurrentUnvalidatedWorkEmail) { + Navigation.navigate(ROUTES.ONBOARDING_PRIVATE_DOMAIN.getRoute(undefined, true, originReportID), {forceReplace: true}); + return; + } - // AddWorkEmail already made this the account's login, and resending it would merge the account into itself. - if (sessionEmail && submittedWorkEmail.toLowerCase() === sessionEmail.toLowerCase()) { - setHasSubmittedWorkEmail(true); - setOnboardingShouldValidate(false); - return; - } + // AddWorkEmail already made this the account's login, and resending it would merge the account into itself. + if (sessionEmail && submittedWorkEmail.toLowerCase() === sessionEmail.toLowerCase()) { + setHasSubmittedWorkEmail(true); + setOnboardingShouldValidate(false); + return; + } - // The form's onSubmit must stay synchronous, so chain off the Onyx write instead of awaiting it. - resetOnboardingWorkEmailValidation(onboardingValues).then(() => { - setHasSubmittedWorkEmail(true); - AddWorkEmail(submittedWorkEmail, addWorkEmailTaskReport); - }); - }, - [addWorkEmailTaskReport, isConciergeTaskFlow, isCurrentPrimaryValidated, onboardingValues, originReportID, sessionEmail], - ); + // The form's onSubmit must stay synchronous, so chain off the Onyx write instead of awaiting it. + resetOnboardingWorkEmailValidation(onboardingValues).then(() => { + setHasSubmittedWorkEmail(true); + AddWorkEmail(submittedWorkEmail, addWorkEmailTaskReport); + }); + }; const isBackButtonMessage = onboardingErrorMessageTranslationKey === 'onboarding.purpose.errorBackButton'; @@ -290,12 +287,12 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW addWorkEmailFormError(translate(workEmailErrorTranslationKey)); }, [workEmailErrorTranslationKey, translate]); - const clearOnboardingErrorMessage = useCallback(() => { + const clearOnboardingErrorMessage = () => { if (!onboardingErrorMessageTranslationKey) { return; } setOnboardingErrorMessage(null); - }, [onboardingErrorMessageTranslationKey]); + }; const clearBackButtonMessage = () => { if (!isBackButtonMessage) { @@ -304,16 +301,16 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW setOnboardingErrorMessage(null); }; - const handleRegularJoinWorkspaceErrorConfirm = useCallback(() => { + const handleRegularJoinWorkspaceErrorConfirm = () => { setOnboardingErrorMessage(null); setOnboardingMergingAccountBlocked(false); Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(), {forceReplace: true}); - }, []); + }; - const handleConciergeTaskErrorConfirm = useCallback(() => { + const handleConciergeTaskErrorConfirm = () => { clearOnboardingMergeAccountBlocked(); returnToOriginReport(); - }, [returnToOriginReport]); + }; // The Concierge task flow returns to the task thread, the regular join flow restarts at the purpose step, // and every other flow leaves the blocked view without a confirm action. @@ -349,7 +346,7 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW // Android's system back runs the same logic as the header back button. useAndroidBackButtonHandler(handleAndroidBackPress); - const shouldRenderOfflineFeedback = useCallback((errorTranslation: string) => { + const shouldRenderOfflineFeedback = (errorTranslation: string) => { if ( errorTranslation !== 'onboarding.workEmail2FAError' && errorTranslation !== 'onboarding.mergeBlockScreen.workAccountClosedSubtitle' && @@ -358,7 +355,7 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW return true; } return false; - }, []); + }; const validate = (values: FormOnyxValues) => { if (!shouldValidateOnChange) { @@ -390,24 +387,21 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles, route}: BaseOnboardingW return errors; }; - const section: Item[] = useMemo( - () => [ - { - icon: illustrations.EnvelopeReceipt, - titleTranslationKey: 'onboarding.workEmail.explanationModal.descriptionOne', - shouldRenderEmail: true, - }, - { - icon: illustrations.Profile, - titleTranslationKey: 'onboarding.workEmail.explanationModal.descriptionTwo', - }, - { - icon: illustrations.Gears, - titleTranslationKey: 'onboarding.workEmail.explanationModal.descriptionThree', - }, - ], - [illustrations.EnvelopeReceipt, illustrations.Profile, illustrations.Gears], - ); + const section: Item[] = [ + { + icon: illustrations.EnvelopeReceipt, + titleTranslationKey: 'onboarding.workEmail.explanationModal.descriptionOne', + shouldRenderEmail: true, + }, + { + icon: illustrations.Profile, + titleTranslationKey: 'onboarding.workEmail.explanationModal.descriptionTwo', + }, + { + icon: illustrations.Gears, + titleTranslationKey: 'onboarding.workEmail.explanationModal.descriptionThree', + }, + ]; return ( { + const handleConciergeTaskExit = () => { shouldContinueAfterAccountMerge.current = false; setOnboardingErrorMessage(null); if (!isConciergeTaskFlow) { @@ -258,16 +258,7 @@ function BaseOnboardingWorkEmailValidation({shouldUseNativeStyles, route}: BaseO return; } returnToOriginReport(); - }, [ - account?.delegatedAccess?.delegate, - conciergeChat, - delegateAccountID, - isConciergeTaskFlow, - onboardingValues?.isMergingAccountBlocked, - returnToOriginReport, - validateEmailTaskReport, - workEmail, - ]); + }; return ( - lodashDebounce((text: string) => { - savePrivateNotesDraft(report.reportID, text); - }, 1000), - [report.reportID], - ); + const debouncedSavePrivateNote = lodashDebounce((text: string) => { + savePrivateNotesDraft(report.reportID, text); + }, 1000); // To focus on the input field when the page loads const privateNotesInput = useRef(null); @@ -119,7 +115,7 @@ function PrivateNotesEditPageInternal({route, report, accountID, privateNoteDraf } }; - const validate = useCallback((): Errors => { + const validate = (): Errors => { const errors: Errors = {}; const privateNoteLength = privateNote.trim().length; if (privateNoteLength > CONST.MAX_COMMENT_LENGTH) { @@ -127,7 +123,7 @@ function PrivateNotesEditPageInternal({route, report, accountID, privateNoteDraf } return errors; - }, [privateNote, translate]); + }; return ( ({reportID: item.reportID, accountID: Number(item.accountID), type: CONST.ATTACHMENT_TYPE.NOTE}), []); + const getAttachmentValue = (item: NoteListItem) => ({reportID: item.reportID, accountID: Number(item.accountID), type: CONST.ATTACHMENT_TYPE.NOTE}); /** * Gets the menu item for each workspace @@ -70,22 +70,20 @@ function PrivateNotesListPage({report, accountID: sessionAccountID}: PrivateNote /** * Returns a list of private notes on the given chat report */ - const privateNotes = useMemo(() => { - const privateNoteBrickRoadIndicator = (accountID: number) => (report.privateNotes?.[accountID].errors ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined); - return Object.keys(report.privateNotes ?? {}).map((privateNoteAccountID: string) => { - const accountID = Number(privateNoteAccountID); - const privateNote = report.privateNotes?.[accountID]; - return { - reportID: report.reportID, - accountID: privateNoteAccountID, - title: Number(sessionAccountID) === accountID ? translate('privateNotes.myNote') : (privateNoteAuthors?.[privateNoteAccountID]?.login ?? ''), - action: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.PRIVATE_NOTES_EDIT.getRoute(accountID))), - brickRoadIndicator: privateNoteBrickRoadIndicator(accountID), - note: privateNote?.note ?? '', - disabled: Number(sessionAccountID) !== accountID, - }; - }); - }, [report, privateNoteAuthors, sessionAccountID, translate]); + const privateNoteBrickRoadIndicator = (accountID: number) => (report.privateNotes?.[accountID].errors ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined); + const privateNotes = Object.keys(report.privateNotes ?? {}).map((privateNoteAccountID: string) => { + const accountID = Number(privateNoteAccountID); + const privateNote = report.privateNotes?.[accountID]; + return { + reportID: report.reportID, + accountID: privateNoteAccountID, + title: Number(sessionAccountID) === accountID ? translate('privateNotes.myNote') : (privateNoteAuthors?.[privateNoteAccountID]?.login ?? ''), + action: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.PRIVATE_NOTES_EDIT.getRoute(accountID))), + brickRoadIndicator: privateNoteBrickRoadIndicator(accountID), + note: privateNote?.note ?? '', + disabled: Number(sessionAccountID) !== accountID, + }; + }); return ( diff --git a/src/pages/ReimbursementAccount/EnterSignerInfo/index.tsx b/src/pages/ReimbursementAccount/EnterSignerInfo/index.tsx index d2c2cf569ea1..1f6b6752a415 100644 --- a/src/pages/ReimbursementAccount/EnterSignerInfo/index.tsx +++ b/src/pages/ReimbursementAccount/EnterSignerInfo/index.tsx @@ -19,7 +19,7 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {emailSelector} from '@selectors/Session'; -import React, {useCallback, useEffect} from 'react'; +import React, {useEffect} from 'react'; import Address from './subSteps/Address'; import Confirmation from './subSteps/Confirmation'; @@ -54,7 +54,7 @@ function EnterSignerInfo({route}: EnterSignerInfoProps) { const [enterSignerInfoForm] = useOnyx(ONYXKEYS.FORMS.ENTER_SINGER_INFO_FORM); const [enterSignerInfoFormDraft] = useOnyx(ONYXKEYS.FORMS.ENTER_SINGER_INFO_FORM_DRAFT); - const submit = useCallback(() => { + const submit = () => { const {signerDetails, signerFiles} = getSignerDetailsAndSignerFiles(enterSignerInfoFormDraft, signerEmail); saveCorpayOnboardingDirectorInformation({ @@ -62,7 +62,7 @@ function EnterSignerInfo({route}: EnterSignerInfoProps) { ...signerFiles, bankAccountID, }); - }, [bankAccountID, enterSignerInfoFormDraft, signerEmail]); + }; const buildRoute = (pageName: string, action?: 'edit') => ROUTES.BANK_ACCOUNT_ENTER_SIGNER_INFO.getRoute(policyID, route.params.bankAccountID, route.params.isCompleted === 'true', pageName, action); @@ -96,7 +96,7 @@ function EnterSignerInfo({route}: EnterSignerInfoProps) { return clearErrors(ONYXKEYS.FORMS.ENTER_SINGER_INFO_FORM); }, []); - const handleBackButtonPress = useCallback(() => { + const handleBackButtonPress = () => { clearErrors(ONYXKEYS.FORMS.ENTER_SINGER_INFO_FORM); if (isEditing) { moveTo(confirmationIndex, false); @@ -108,7 +108,7 @@ function EnterSignerInfo({route}: EnterSignerInfoProps) { } else { Navigation.goBack(); } - }, [isEditing, moveTo, pageIndex, prevPage]); + }; if (isRedirecting) { return ; diff --git a/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/Address.tsx b/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/Address.tsx index 0d39cfdc3861..169137302ce7 100644 --- a/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/Address.tsx +++ b/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/Address.tsx @@ -10,7 +10,7 @@ import type {Country} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/EnterSignerInfoForm'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; function Address({onNext, isEditing, onMove}: SubPageProps) { const {translate} = useLocalize(); @@ -40,14 +40,8 @@ function Address({onNext, isEditing, onMove}: SubPageProps) { ); const [shouldValidateZipCodeFormat, setShouldValidateZipCodeFormat] = useState(defaultValues.country === CONST.COUNTRY.US); - const stepFieldsWithState = useMemo( - () => [inputKeys.street, inputKeys.city, inputKeys.state, inputKeys.zipCode, inputKeys.country], - [inputKeys.country, inputKeys.city, inputKeys.state, inputKeys.street, inputKeys.zipCode], - ); - const stepFieldsWithoutState = useMemo( - () => [inputKeys.street, inputKeys.city, inputKeys.zipCode, inputKeys.country], - [inputKeys.country, inputKeys.city, inputKeys.street, inputKeys.zipCode], - ); + const stepFieldsWithState = [inputKeys.street, inputKeys.city, inputKeys.state, inputKeys.zipCode, inputKeys.country]; + const stepFieldsWithoutState = [inputKeys.street, inputKeys.city, inputKeys.zipCode, inputKeys.country]; const stepFields = shouldDisplayStateSelector ? stepFieldsWithState : stepFieldsWithoutState; diff --git a/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/JobTitle.tsx b/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/JobTitle.tsx index 13f3222b1f17..7d232b56705b 100644 --- a/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/JobTitle.tsx +++ b/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/JobTitle.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/EnterSignerInfoForm'; -import React, {useCallback} from 'react'; +import React from 'react'; function JobTitle({onNext, onMove, isEditing}: SubPageProps) { const {translate} = useLocalize(); @@ -21,12 +21,9 @@ function JobTitle({onNext, onMove, isEditing}: SubPageProps) { const inputID = INPUT_IDS.SIGNER_JOB_TITLE; const defaultValue = String(enterSignerInfoFormDraft?.[inputID] ?? ''); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - return getFieldRequiredErrors(values, [inputID], translate); - }, - [inputID, translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => { + return getFieldRequiredErrors(values, [inputID], translate); + }; const handleSubmit = useEnterSignerInfoStepFormSubmit({ fieldIds: [inputID], diff --git a/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/Name.tsx b/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/Name.tsx index ec53c897cee5..3c3d09d76354 100644 --- a/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/Name.tsx +++ b/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/Name.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/EnterSignerInfoForm'; -import React, {useCallback} from 'react'; +import React from 'react'; function Name({onNext, onMove, isEditing}: SubPageProps) { const {translate} = useLocalize(); @@ -21,18 +21,15 @@ function Name({onNext, onMove, isEditing}: SubPageProps) { const inputID = INPUT_IDS.SIGNER_FULL_NAME; const defaultValue = String(enterSignerInfoFormDraft?.[inputID] ?? ''); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, [inputID], translate); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, [inputID], translate); - if (values[inputID] && !isValidLegalName(String(values[inputID]))) { - errors[inputID] = translate('bankAccount.error.fullName'); - } + if (values[inputID] && !isValidLegalName(String(values[inputID]))) { + errors[inputID] = translate('bankAccount.error.fullName'); + } - return errors; - }, - [inputID, translate], - ); + return errors; + }; const handleSubmit = useEnterSignerInfoStepFormSubmit({ fieldIds: [inputID], diff --git a/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/UploadDocuments.tsx b/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/UploadDocuments.tsx index 85881089274e..751c5c9288d3 100644 --- a/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/UploadDocuments.tsx +++ b/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/UploadDocuments.tsx @@ -27,7 +27,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/EnterSignerInfoForm'; import type {FileObject} from '@src/types/utils/Attachment'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; type UploadDocumentsProps = SubPageProps & {policyID: string}; @@ -68,17 +68,11 @@ function UploadDocuments({onNext, isEditing, policyID}: UploadDocumentsProps) { getEnvironmentURL().then(setEnvironmentUrl); }, []); - const STEP_FIELDS = useMemo( - (): Array> => [copyOfIDInputID, addressProofInputID, directorsProofInputID, codiceFiscaleInputID], - [copyOfIDInputID, addressProofInputID, directorsProofInputID, codiceFiscaleInputID], - ); + const STEP_FIELDS: Array> = [copyOfIDInputID, addressProofInputID, directorsProofInputID, codiceFiscaleInputID]; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - return getFieldRequiredErrors(values, STEP_FIELDS, translate); - }, - [STEP_FIELDS, translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => { + return getFieldRequiredErrors(values, STEP_FIELDS, translate); + }; const handleSubmit = useEnterSignerInfoStepFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/ReimbursementAccount/NonUSD/Agreements/index.tsx b/src/pages/ReimbursementAccount/NonUSD/Agreements/index.tsx index bbdf5dea7aaa..3c97bbf5d023 100644 --- a/src/pages/ReimbursementAccount/NonUSD/Agreements/index.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/Agreements/index.tsx @@ -13,7 +13,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; -import React, {useEffect, useMemo, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; const INPUT_KEYS = { provideTruthfulInformation: INPUT_IDS.ADDITIONAL_DATA.CORPAY.PROVIDE_TRUTHFUL_INFORMATION, @@ -25,7 +25,7 @@ const INPUT_KEYS = { function Agreements({onBackButtonPress, onSubmit, stepNames, currency}: NonUSDPageProps) { const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); - const agreementsStepValues = useMemo(() => getSubStepValues(INPUT_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]); + const agreementsStepValues = getSubStepValues(INPUT_KEYS, reimbursementAccountDraft, reimbursementAccount); const bankAccountID = reimbursementAccount?.achData?.bankAccountID ?? CONST.DEFAULT_NUMBER_ID; const isDocusignStepRequired = requiresDocusignStep(currency); const isSubmittingRef = useRef(false); diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx b/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx index 70ae7b764350..e77db1eb9dd3 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx @@ -23,7 +23,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {ReimbursementAccountForm} from '@src/types/form/ReimbursementAccountForm'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import type BankInfoSubStepProps from './types'; @@ -49,8 +49,8 @@ function BankInfo({onBackButtonPress, onSubmit, policyID, stepNames, backTo}: No const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const {country, currency} = getCurrencyForNonUSDBankAccount(policy, reimbursementAccountDraft, reimbursementAccount); const inputKeys = getInputKeysForBankInfoStep(corpayFields); - const values = useMemo(() => getBankInfoStepValues(inputKeys, reimbursementAccountDraft, reimbursementAccount), [inputKeys, reimbursementAccount, reimbursementAccountDraft]); - const startFrom = useMemo(() => getInitialSubStepForBankInfoStep(values, corpayFields), [corpayFields, values]); + const values = getBankInfoStepValues(inputKeys, reimbursementAccountDraft, reimbursementAccount); + const startFrom = getInitialSubStepForBankInfoStep(values, corpayFields); const isSubmittingRef = useRef(false); const submit = () => { @@ -99,10 +99,7 @@ function BankInfo({onBackButtonPress, onSubmit, policyID, stepNames, backTo}: No getCorpayBankAccountFields(country, currency); }, [corpayFields?.bankCurrency, corpayFields?.bankCountry, country, currency]); - const buildRoute = useCallback( - (pageName: string, action?: 'edit') => ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.BANK_INFO, subPage: pageName, action, backTo}), - [policyID, backTo], - ); + const buildRoute = (pageName: string, action?: 'edit') => ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.BANK_INFO, subPage: pageName, action, backTo}); const {CurrentPage, isEditing, currentPageName, pageIndex, nextPage, prevPage, moveTo, isRedirecting} = useSubPage({ pages, diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/subSteps/AccountHolderDetails.tsx b/src/pages/ReimbursementAccount/NonUSD/BankInfo/subSteps/AccountHolderDetails.tsx index f4ccf1533046..2ab943706d62 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BankInfo/subSteps/AccountHolderDetails.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/subSteps/AccountHolderDetails.tsx @@ -24,7 +24,7 @@ import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import type {CorpayFormField} from '@src/types/onyx'; import {SafeString} from 'expensify-common'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; const {ACCOUNT_HOLDER_COUNTRY} = INPUT_IDS.ADDITIONAL_DATA.CORPAY; @@ -41,9 +41,7 @@ function AccountHolderDetails({onNext, isEditing, corpayFields}: BankInfoSubStep const {translate} = useLocalize(); const styles = useThemeStyles(); - const accountHolderDetailsFields = useMemo(() => { - return corpayFields?.formFields?.filter((field) => field.id.includes(CONST.NON_USD_BANK_ACCOUNT.BANK_INFO_STEP_ACCOUNT_HOLDER_KEY_PREFIX)); - }, [corpayFields]); + const accountHolderDetailsFields = corpayFields?.formFields?.filter((field) => field.id.includes(CONST.NON_USD_BANK_ACCOUNT.BANK_INFO_STEP_ACCOUNT_HOLDER_KEY_PREFIX)); const fieldIds = accountHolderDetailsFields?.map((field) => field.id); const subStepKeys = accountHolderDetailsFields?.reduce( @@ -56,10 +54,7 @@ function AccountHolderDetails({onNext, isEditing, corpayFields}: BankInfoSubStep const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); - const defaultValues = useMemo( - () => getBankInfoStepValues(subStepKeys ?? {}, reimbursementAccountDraft, reimbursementAccount), - [subStepKeys, reimbursementAccount, reimbursementAccountDraft], - ); + const defaultValues = getBankInfoStepValues(subStepKeys ?? {}, reimbursementAccountDraft, reimbursementAccount); const defaultBankAccountCountry = reimbursementAccount?.achData?.[COUNTRY] ?? reimbursementAccountDraft?.[COUNTRY] ?? ''; const handleSubmit = useReimbursementAccountStepFormSubmit({ @@ -68,91 +63,86 @@ function AccountHolderDetails({onNext, isEditing, corpayFields}: BankInfoSubStep shouldSaveDraft: true, }); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; - - if (accountHolderDetailsFields) { - for (const field of accountHolderDetailsFields) { - const fieldID = field.id as keyof FormOnyxValues; + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; - if (field.isRequired && !values[fieldID]) { - errors[fieldID] = translate('common.error.fieldRequired'); - } + if (accountHolderDetailsFields) { + for (const field of accountHolderDetailsFields) { + const fieldID = field.id as keyof FormOnyxValues; - for (const rule of field.validationRules) { - if (!rule.regEx) { - continue; - } + if (field.isRequired && !values[fieldID]) { + errors[fieldID] = translate('common.error.fieldRequired'); + } - if (new RegExp(rule.regEx).test(values[fieldID] ? SafeString(values[fieldID]) : '')) { - continue; - } + for (const rule of field.validationRules) { + if (!rule.regEx) { + continue; + } - errors[fieldID] = rule.errorMessage; + if (new RegExp(rule.regEx).test(values[fieldID] ? SafeString(values[fieldID]) : '')) { + continue; } + + errors[fieldID] = rule.errorMessage; } } + } - return errors; - }, - [accountHolderDetailsFields, translate], - ); + return errors; + }; - const inputs = useMemo(() => { - return accountHolderDetailsFields?.map((field) => { - if (field.valueSet !== undefined) { - return getInputForValueSet(field, SafeString(defaultValues[field.id as keyof typeof defaultValues]), isEditing, styles); - } - - if (field.id === ACCOUNT_HOLDER_COUNTRY) { - return ( - - - - ); - } + const inputs = accountHolderDetailsFields?.map((field) => { + if (field.valueSet !== undefined) { + return getInputForValueSet(field, SafeString(defaultValues[field.id as keyof typeof defaultValues]), isEditing, styles); + } - const inputComponent = getInputComponent(field); + if (field.id === ACCOUNT_HOLDER_COUNTRY) { return ( ); - }); - }, [accountHolderDetailsFields, styles, defaultValues, isEditing, defaultBankAccountCountry, translate]); + } + + const inputComponent = getInputComponent(field); + return ( + + + + ); + }); return ( getBankInfoStepValues(inputKeys, reimbursementAccountDraft, reimbursementAccount), [inputKeys, reimbursementAccount, reimbursementAccountDraft]); + const values = getBankInfoStepValues(inputKeys, reimbursementAccountDraft, reimbursementAccount); - const summaryItems = useMemo( - () => - (formFields ?? []).map((field) => { - let title = SafeString(values[field.id as keyof typeof values]); + const summaryItems = (formFields ?? []).map((field) => { + let title = SafeString(values[field.id as keyof typeof values]); - if (field.id === ACCOUNT_HOLDER_COUNTRY) { - title = CONST.ALL_COUNTRIES[title as keyof typeof CONST.ALL_COUNTRIES]; - } + if (field.id === ACCOUNT_HOLDER_COUNTRY) { + title = CONST.ALL_COUNTRIES[title as keyof typeof CONST.ALL_COUNTRIES]; + } - return { - id: field.id, - description: field.label, - title, - shouldShowRightIcon: true, - onPress: () => { - if (!field.id.includes(CONST.NON_USD_BANK_ACCOUNT.BANK_INFO_STEP_ACCOUNT_HOLDER_KEY_PREFIX)) { - onMove(0); - } else { - onMove(1); - } - }, - }; - }), - [formFields, onMove, values], - ); + return { + id: field.id, + description: field.label, + title, + shouldShowRightIcon: true, + onPress: () => { + if (!field.id.includes(CONST.NON_USD_BANK_ACCOUNT.BANK_INFO_STEP_ACCOUNT_HOLDER_KEY_PREFIX)) { + onMove(0); + } else { + onMove(1); + } + }, + }; + }); if (corpayFields?.isLoading) { return ( diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormPages.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormPages.tsx index a471ba97ad73..b41435bed358 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormPages.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormPages.tsx @@ -18,7 +18,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {SafeString} from 'expensify-common'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import Address from './BeneficialOwnerDetailsFormSubSteps/Address'; import Confirmation from './BeneficialOwnerDetailsFormSubSteps/Confirmation'; @@ -90,29 +90,24 @@ function BeneficialOwnerDetailsFormPages({stepNames, policyID, onFinished, backT }), ); - const skipPages = useMemo(() => { - const pagesToSkip: string[] = []; - if (beneficialOwnerNationality !== CONST.COUNTRY.US) { - pagesToSkip.push(SUB_PAGE_NAMES.SSN); - } - const documentsStatus = getNeededDocumentsStatusForBeneficialOwner(currency, countryStepCountryValue, beneficialOwnerAddressCountry); - if (!documentsStatus.isProofOfOwnershipNeeded && !documentsStatus.isCopyOfIDNeeded && !documentsStatus.isProofOfAddressNeeded && !documentsStatus.isCodiceFiscaleNeeded) { - pagesToSkip.push(SUB_PAGE_NAMES.DOCUMENTS); - } - return pagesToSkip; - }, [beneficialOwnerNationality, beneficialOwnerAddressCountry, countryStepCountryValue, currency]); - - const buildRoute = useCallback( - (pageName: string, action?: 'edit') => - ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({ - policyID, - page: PAGE_NAME.BENEFICIAL_OWNER_INFO, - subPage: pageName, - action, - backTo, - }), - [policyID, backTo], - ); + const pagesToSkip: string[] = []; + if (beneficialOwnerNationality !== CONST.COUNTRY.US) { + pagesToSkip.push(SUB_PAGE_NAMES.SSN); + } + const documentsStatus = getNeededDocumentsStatusForBeneficialOwner(currency, countryStepCountryValue, beneficialOwnerAddressCountry); + if (!documentsStatus.isProofOfOwnershipNeeded && !documentsStatus.isCopyOfIDNeeded && !documentsStatus.isProofOfAddressNeeded && !documentsStatus.isCodiceFiscaleNeeded) { + pagesToSkip.push(SUB_PAGE_NAMES.DOCUMENTS); + } + const skipPages = pagesToSkip; + + const buildRoute = (pageName: string, action?: 'edit') => + ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({ + policyID, + page: PAGE_NAME.BENEFICIAL_OWNER_INFO, + subPage: pageName, + action, + backTo, + }); const {CurrentPage, isEditing, currentPageName, pageIndex, prevPage, nextPage, moveTo, isRedirecting} = useSubPage({ pages, @@ -122,7 +117,7 @@ function BeneficialOwnerDetailsFormPages({stepNames, policyID, onFinished, backT skipPages, }); - const handleBackButtonPress = useCallback(() => { + const handleBackButtonPress = () => { clearErrors(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM); if (isEditing) { Navigation.goBack(buildRoute(SUB_PAGE_NAMES.CONFIRMATION)); @@ -146,7 +141,7 @@ function BeneficialOwnerDetailsFormPages({stepNames, policyID, onFinished, backT } else { prevPage(); } - }, [buildRoute, isEditing, isEditingCreatedOwner, pageIndex, prevPage, skipPages]); + }; if (isRedirecting) { return ; diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Address.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Address.tsx index 7d6501091298..2e9988cdb831 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Address.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Address.tsx @@ -10,7 +10,7 @@ import type {Country} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {SafeString} from 'expensify-common'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; type NameProps = SubPageProps & {isUserEnteringHisOwnData: boolean; ownerBeingModifiedID: string}; @@ -45,14 +45,8 @@ function Address({onNext, isEditing, onMove, isUserEnteringHisOwnData, ownerBein defaultValues.country === CONST.COUNTRY.US || defaultValues.country === CONST.COUNTRY.CA || defaultValues.country === '', ); - const stepFieldsWithState = useMemo( - () => [inputKeys.street, inputKeys.city, inputKeys.state, inputKeys.zipCode, countryInputKey], - [countryInputKey, inputKeys.city, inputKeys.state, inputKeys.street, inputKeys.zipCode], - ); - const stepFieldsWithoutState = useMemo( - () => [inputKeys.street, inputKeys.city, inputKeys.zipCode, countryInputKey], - [countryInputKey, inputKeys.city, inputKeys.street, inputKeys.zipCode], - ); + const stepFieldsWithState = [inputKeys.street, inputKeys.city, inputKeys.state, inputKeys.zipCode, countryInputKey]; + const stepFieldsWithoutState = [inputKeys.street, inputKeys.city, inputKeys.zipCode, countryInputKey]; const stepFields = shouldDisplayStateSelector ? stepFieldsWithState : stepFieldsWithoutState; diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Confirmation.tsx index 998ad0dd2c54..0e40bb793350 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Confirmation.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Confirmation.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {SafeString} from 'expensify-common'; -import React, {useMemo} from 'react'; +import React from 'react'; type ConfirmationProps = SubPageProps & {ownerBeingModifiedID: string}; @@ -22,7 +22,7 @@ function Confirmation({onNext, onMove, isEditing, ownerBeingModifiedID}: Confirm const {translate} = useLocalize(); const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); - const values = useMemo(() => getValuesForBeneficialOwner(ownerBeingModifiedID, reimbursementAccountDraft), [ownerBeingModifiedID, reimbursementAccountDraft]); + const values = getValuesForBeneficialOwner(ownerBeingModifiedID, reimbursementAccountDraft); const beneficialOwnerNationalityInputID = `${PREFIX}_${ownerBeingModifiedID}_${NATIONALITY}` as const; const beneficialOwnerNationality = SafeString(reimbursementAccountDraft?.[beneficialOwnerNationalityInputID]); const beneficialOwnerAddressCountryInputID = `${PREFIX}_${ownerBeingModifiedID}_${COUNTRY}` as const; @@ -32,143 +32,118 @@ function Confirmation({onNext, onMove, isEditing, ownerBeingModifiedID}: Confirm const {country, currency} = getCurrencyForNonUSDBankAccount(policy, reimbursementAccountDraft, reimbursementAccount); const isDocumentNeededStatus = getNeededDocumentsStatusForBeneficialOwner(currency, country, beneficialOwnerAddressCountry); - const summaryItems = useMemo( - () => [ - { - id: 'legal-name', - title: `${values.firstName} ${values.lastName}`, - description: translate('ownershipInfoStep.legalName'), - shouldShowRightIcon: true, - onPress: () => { - onMove(0); - }, + const summaryItems = [ + { + id: 'legal-name', + title: `${values.firstName} ${values.lastName}`, + description: translate('ownershipInfoStep.legalName'), + shouldShowRightIcon: true, + onPress: () => { + onMove(0); }, - { - id: 'nationality', - title: CONST.ALL_COUNTRIES[values.nationality as keyof typeof CONST.ALL_COUNTRIES], - description: translate('ownershipInfoStep.countryOfCitizenship'), - shouldShowRightIcon: true, - onPress: () => { - onMove(1); - }, + }, + { + id: 'nationality', + title: CONST.ALL_COUNTRIES[values.nationality as keyof typeof CONST.ALL_COUNTRIES], + description: translate('ownershipInfoStep.countryOfCitizenship'), + shouldShowRightIcon: true, + onPress: () => { + onMove(1); }, - { - id: 'ownership-percentage', - title: values.ownershipPercentage, - description: translate('ownershipInfoStep.ownershipPercentage'), - shouldShowRightIcon: true, - onPress: () => { - onMove(2); - }, + }, + { + id: 'ownership-percentage', + title: values.ownershipPercentage, + description: translate('ownershipInfoStep.ownershipPercentage'), + shouldShowRightIcon: true, + onPress: () => { + onMove(2); }, - { - id: 'date-of-birth', - title: values.dob, - description: translate('common.dob'), - shouldShowRightIcon: true, - onPress: () => { - onMove(3); - }, + }, + { + id: 'date-of-birth', + title: values.dob, + description: translate('common.dob'), + shouldShowRightIcon: true, + onPress: () => { + onMove(3); }, - ...(beneficialOwnerNationality === CONST.COUNTRY.US - ? [ - { - id: 'ssn', - title: values.ssn, - description: translate('common.ssnFull9'), - shouldShowRightIcon: true, - onPress: () => { - onMove(5); - }, + }, + ...(beneficialOwnerNationality === CONST.COUNTRY.US + ? [ + { + id: 'ssn', + title: values.ssn, + description: translate('common.ssnFull9'), + shouldShowRightIcon: true, + onPress: () => { + onMove(5); }, - ] - : []), - { - id: 'address', - title: `${values.street}, ${values.city}, ${values.state} ${values.zipCode}`, - description: translate('ownershipInfoStep.address'), - shouldShowRightIcon: true, - onPress: () => { - onMove(4); - }, + }, + ] + : []), + { + id: 'address', + title: `${values.street}, ${values.city}, ${values.state} ${values.zipCode}`, + description: translate('ownershipInfoStep.address'), + shouldShowRightIcon: true, + onPress: () => { + onMove(4); }, - ...(isDocumentNeededStatus.isProofOfOwnershipNeeded - ? [ - { - id: 'proof-of-ownership', - title: values.proofOfOwnership.map((file) => file.name).join(', '), - description: translate('ownershipInfoStep.proofOfBeneficialOwner'), - shouldShowRightIcon: true, - onPress: () => { - onMove(6); - }, + }, + ...(isDocumentNeededStatus.isProofOfOwnershipNeeded + ? [ + { + id: 'proof-of-ownership', + title: values.proofOfOwnership.map((file) => file.name).join(', '), + description: translate('ownershipInfoStep.proofOfBeneficialOwner'), + shouldShowRightIcon: true, + onPress: () => { + onMove(6); }, - ] - : []), - ...(isDocumentNeededStatus.isCopyOfIDNeeded - ? [ - { - id: 'copy-of-id', - title: values.copyOfID.map((file) => file.name).join(', '), - description: translate('ownershipInfoStep.copyOfID'), - shouldShowRightIcon: true, - onPress: () => { - onMove(6); - }, + }, + ] + : []), + ...(isDocumentNeededStatus.isCopyOfIDNeeded + ? [ + { + id: 'copy-of-id', + title: values.copyOfID.map((file) => file.name).join(', '), + description: translate('ownershipInfoStep.copyOfID'), + shouldShowRightIcon: true, + onPress: () => { + onMove(6); }, - ] - : []), - ...(isDocumentNeededStatus.isProofOfAddressNeeded - ? [ - { - id: 'address-proof', - title: values.addressProof.map((file) => file.name).join(', '), - description: translate('ownershipInfoStep.proofOfAddress'), - shouldShowRightIcon: true, - onPress: () => { - onMove(6); - }, + }, + ] + : []), + ...(isDocumentNeededStatus.isProofOfAddressNeeded + ? [ + { + id: 'address-proof', + title: values.addressProof.map((file) => file.name).join(', '), + description: translate('ownershipInfoStep.proofOfAddress'), + shouldShowRightIcon: true, + onPress: () => { + onMove(6); }, - ] - : []), - ...(isDocumentNeededStatus.isCodiceFiscaleNeeded - ? [ - { - id: 'codice-fiscale', - title: values.codiceFiscale.map((file) => file.name).join(', '), - description: translate('ownershipInfoStep.codiceFiscale'), - shouldShowRightIcon: true, - onPress: () => { - onMove(6); - }, + }, + ] + : []), + ...(isDocumentNeededStatus.isCodiceFiscaleNeeded + ? [ + { + id: 'codice-fiscale', + title: values.codiceFiscale.map((file) => file.name).join(', '), + description: translate('ownershipInfoStep.codiceFiscale'), + shouldShowRightIcon: true, + onPress: () => { + onMove(6); }, - ] - : []), - ], - [ - beneficialOwnerNationality, - isDocumentNeededStatus.isCodiceFiscaleNeeded, - isDocumentNeededStatus.isCopyOfIDNeeded, - isDocumentNeededStatus.isProofOfAddressNeeded, - isDocumentNeededStatus.isProofOfOwnershipNeeded, - onMove, - translate, - values.addressProof, - values.city, - values.codiceFiscale, - values.copyOfID, - values.dob, - values.firstName, - values.lastName, - values.nationality, - values.ownershipPercentage, - values.proofOfOwnership, - values.ssn, - values.state, - values.street, - values.zipCode, - ], - ); + }, + ] + : []), + ]; return ( (defaultValues[addressProofInputID]); const [uploadedCodiceFiscale, setUploadedCodiceFiscale] = useState(defaultValues[codiceFiscaleInputID]); - const STEP_FIELDS = useMemo( - () => [proofOfOwnershipInputID, addressProofInputID, copyOfIDInputID, codiceFiscaleInputID], - [addressProofInputID, codiceFiscaleInputID, copyOfIDInputID, proofOfOwnershipInputID], - ); + const STEP_FIELDS = [proofOfOwnershipInputID, addressProofInputID, copyOfIDInputID, codiceFiscaleInputID]; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => - getFieldRequiredErrors(values, STEP_FIELDS, translate), - [STEP_FIELDS, translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => + getFieldRequiredErrors(values, STEP_FIELDS, translate); const handleSelectFile = (files: FileObject[], uploadedFiles: FileObject[], inputID: string, setFiles: React.Dispatch>) => { setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {[inputID]: [...uploadedFiles, ...files]}); diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Name.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Name.tsx index 38e9f4cc049d..81e2ce156fec 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Name.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Name.tsx @@ -9,7 +9,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {SafeString} from 'expensify-common'; -import React, {useMemo} from 'react'; +import React from 'react'; type NameProps = SubPageProps & {isUserEnteringHisOwnData: boolean; ownerBeingModifiedID: string}; @@ -21,7 +21,7 @@ function Name({onNext, isEditing, onMove, isUserEnteringHisOwnData, ownerBeingMo const firstNameInputID = `${PREFIX}_${ownerBeingModifiedID}_${FIRST_NAME}` as const; const lastNameInputID = `${PREFIX}_${ownerBeingModifiedID}_${LAST_NAME}` as const; - const stepFields = useMemo(() => [firstNameInputID, lastNameInputID], [firstNameInputID, lastNameInputID]); + const stepFields = [firstNameInputID, lastNameInputID]; const formTitle = translate(isUserEnteringHisOwnData ? 'ownershipInfoStep.whatsYourName' : 'ownershipInfoStep.whatsTheOwnersName'); const formSubtitle = translate(isUserEnteringHisOwnData ? 'personalInfoStep.legalNameSubtitle' : 'beneficialOwnerInfoStep.legalNameSubtitle'); const defaultValues = { diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Nationality.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Nationality.tsx index 68b8ebc9e2d3..1d29debc98f4 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Nationality.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/Nationality.tsx @@ -17,7 +17,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {SafeString} from 'expensify-common'; -import React, {useCallback, useState} from 'react'; +import React, {useState} from 'react'; type NationalityProps = SubPageProps & {isUserEnteringHisOwnData: boolean; ownerBeingModifiedID: string}; @@ -37,12 +37,9 @@ function Nationality({onNext, isEditing, isUserEnteringHisOwnData, ownerBeingMod onNext(); }; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - return getFieldRequiredErrors(values, [nationalityInputID], translate); - }, - [nationalityInputID, translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => { + return getFieldRequiredErrors(values, [nationalityInputID], translate); + }; return ( ): FormInputErrors => { - const errors = getFieldRequiredErrors(values, [ownershipPercentageInputID], translate); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, [ownershipPercentageInputID], translate); - if (values[ownershipPercentageInputID] && !isValidOwnershipPercentage(SafeString(values[ownershipPercentageInputID]), totalOwnedPercentage, ownerBeingModifiedID)) { - errors[ownershipPercentageInputID] = translate('bankAccount.error.ownershipPercentage'); - } + if (values[ownershipPercentageInputID] && !isValidOwnershipPercentage(SafeString(values[ownershipPercentageInputID]), totalOwnedPercentage, ownerBeingModifiedID)) { + errors[ownershipPercentageInputID] = translate('bankAccount.error.ownershipPercentage'); + } - return errors; - }, - [ownerBeingModifiedID, ownershipPercentageInputID, totalOwnedPercentage, translate], - ); + return errors; + }; const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: [ownershipPercentageInputID], diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/SSN.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/SSN.tsx index 5765297947d3..74981fd5d370 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/SSN.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormSubSteps/SSN.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {SafeString} from 'expensify-common'; -import React, {useCallback} from 'react'; +import React from 'react'; type SSNProps = SubPageProps & {isUserEnteringHisOwnData: boolean; ownerBeingModifiedID: string}; @@ -26,18 +26,15 @@ function SSN({onNext, isEditing, onMove, isUserEnteringHisOwnData, ownerBeingMod const defaultSSN = SafeString(reimbursementAccountDraft?.[ssnInputID]); const formTitle = translate(isUserEnteringHisOwnData ? 'ownershipInfoStep.whatsYourSSN' : 'ownershipInfoStep.whatsTheOwnersSSN'); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, [ssnInputID], translate); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, [ssnInputID], translate); - if (values[ssnInputID] && !isValidSSNFullNine(SafeString(values[ssnInputID]))) { - errors[ssnInputID] = translate('additionalDetailsStep.ssnFull9Error'); - } + if (values[ssnInputID] && !isValidSSNFullNine(SafeString(values[ssnInputID]))) { + errors[ssnInputID] = translate('additionalDetailsStep.ssnFull9Error'); + } - return errors; - }, - [ssnInputID, translate], - ); + return errors; + }; const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: [ssnInputID], diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerInfo.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerInfo.tsx index 779d6c6501d3..9eb9bd20bf95 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerInfo.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerInfo.tsx @@ -19,7 +19,7 @@ import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import {SafeString, Str} from 'expensify-common'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import BeneficialOwnerDetailsFormPages from './BeneficialOwnerDetailsFormPages'; import BeneficialOwnersList from './BeneficialOwnersList'; @@ -48,7 +48,7 @@ function BeneficialOwnerInfo({onBackButtonPress, onSubmit, stepNames, currentSub const isUserOwner = reimbursementAccount?.achData?.corpay?.[OWNS_MORE_THAN_25_PERCENT] ?? reimbursementAccountDraft?.[OWNS_MORE_THAN_25_PERCENT] ?? false; const isAnyoneElseOwner = reimbursementAccount?.achData?.corpay?.[ANY_INDIVIDUAL_OWN_25_PERCENT_OR_MORE] ?? reimbursementAccountDraft?.[ANY_INDIVIDUAL_OWN_25_PERCENT_OR_MORE] ?? false; - const savedBeneficialOwnerValues = useMemo(() => getDraftValuesForBeneficialOwners(reimbursementAccount), [reimbursementAccount]); + const savedBeneficialOwnerValues = getDraftValuesForBeneficialOwners(reimbursementAccount); const ownerKeys = reimbursementAccountDraft?.beneficialOwnerKeys ?? savedBeneficialOwnerValues.beneficialOwnerKeys; // Populate the form draft with beneficial owner data from the server. @@ -69,27 +69,24 @@ function BeneficialOwnerInfo({onBackButtonPress, onSubmit, stepNames, currentSub const isSubmittingRef = useRef(false); - const submit = useCallback( - ({anyIndividualOwn25PercentOrMore}: {anyIndividualOwn25PercentOrMore?: boolean} = {}) => { - const currentOwnerKeys = reimbursementAccountDraft?.beneficialOwnerKeys ?? []; - const {ownerDetails, ownerFiles} = getOwnerDetailsAndOwnerFilesForBeneficialOwners(currentOwnerKeys, reimbursementAccountDraft); - - setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, { - [OWNS_MORE_THAN_25_PERCENT]: isUserOwner, - [ANY_INDIVIDUAL_OWN_25_PERCENT_OR_MORE]: isAnyoneElseOwner, - [BENEFICIAL_OWNERS]: JSON.stringify(ownerDetails), - }); - - isSubmittingRef.current = true; - saveCorpayOnboardingBeneficialOwners({ - inputs: JSON.stringify({...ownerDetails, anyIndividualOwn25PercentOrMore}), - ...ownerFiles, - beneficialOwnerIDs: currentOwnerKeys.length > 0 ? currentOwnerKeys.join(',') : undefined, - bankAccountID, - }); - }, - [bankAccountID, isAnyoneElseOwner, isUserOwner, reimbursementAccountDraft], - ); + const submit = ({anyIndividualOwn25PercentOrMore}: {anyIndividualOwn25PercentOrMore?: boolean} = {}) => { + const currentOwnerKeys = reimbursementAccountDraft?.beneficialOwnerKeys ?? []; + const {ownerDetails, ownerFiles} = getOwnerDetailsAndOwnerFilesForBeneficialOwners(currentOwnerKeys, reimbursementAccountDraft); + + setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, { + [OWNS_MORE_THAN_25_PERCENT]: isUserOwner, + [ANY_INDIVIDUAL_OWN_25_PERCENT_OR_MORE]: isAnyoneElseOwner, + [BENEFICIAL_OWNERS]: JSON.stringify(ownerDetails), + }); + + isSubmittingRef.current = true; + saveCorpayOnboardingBeneficialOwners({ + inputs: JSON.stringify({...ownerDetails, anyIndividualOwn25PercentOrMore}), + ...ownerFiles, + beneficialOwnerIDs: currentOwnerKeys.length > 0 ? currentOwnerKeys.join(',') : undefined, + bankAccountID, + }); + }; useEffect(() => { if (reimbursementAccount?.errors || reimbursementAccount?.isSavingCorpayOnboardingBeneficialOwnersFields || !reimbursementAccount?.isSuccess) { @@ -108,15 +105,12 @@ function BeneficialOwnerInfo({onBackButtonPress, onSubmit, stepNames, currentSub }; }, [reimbursementAccount?.errors, reimbursementAccount?.isSavingCorpayOnboardingBeneficialOwnersFields, reimbursementAccount?.isSuccess, onSubmit]); - const prepareOwnerDetailsForm = useCallback( - (ownerID: string) => { - setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {ownerBeingModifiedID: ownerID}); - Navigation.navigate(ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.BENEFICIAL_OWNER_INFO, subPage: SUB_PAGE_NAMES.NAME, backTo})); - }, - [policyID, backTo], - ); + const prepareOwnerDetailsForm = (ownerID: string) => { + setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {ownerBeingModifiedID: ownerID}); + Navigation.navigate(ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.BENEFICIAL_OWNER_INFO, subPage: SUB_PAGE_NAMES.NAME, backTo})); + }; - const handleOwnerDetailsFormFinished = useCallback(() => { + const handleOwnerDetailsFormFinished = () => { const ownerBeingModifiedID = reimbursementAccountDraft?.ownerBeingModifiedID ?? CONST.NON_USD_BANK_ACCOUNT.CURRENT_USER_KEY; const currentOwnerKeys = reimbursementAccountDraft?.beneficialOwnerKeys ?? []; const isFreshOwner = !currentOwnerKeys.includes(ownerBeingModifiedID); @@ -147,78 +141,66 @@ function BeneficialOwnerInfo({onBackButtonPress, onSubmit, stepNames, currentSub ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.BENEFICIAL_OWNER_INFO, subPage: SUB_PAGE_NAMES.ARE_THERE_MORE_BENEFICIAL_OWNERS, backTo}), ); } - }, [policyID, reimbursementAccountDraft, backTo]); + }; - const handleOwnerEdit = useCallback( - (ownerID: string) => { - setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {ownerBeingModifiedID: ownerID, isEditingCreatedOwner: true}); - Navigation.navigate(ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.BENEFICIAL_OWNER_INFO, subPage: SUB_PAGE_NAMES.NAME, backTo})); - }, - [policyID, backTo], - ); + const handleOwnerEdit = (ownerID: string) => { + setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {ownerBeingModifiedID: ownerID, isEditingCreatedOwner: true}); + Navigation.navigate(ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.BENEFICIAL_OWNER_INFO, subPage: SUB_PAGE_NAMES.NAME, backTo})); + }; + + const handleIsUserOwnerSelected = (value: boolean) => { + const currentOwnerKeys = reimbursementAccountDraft?.beneficialOwnerKeys ?? []; + setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {[OWNS_MORE_THAN_25_PERCENT]: value}); - const handleIsUserOwnerSelected = useCallback( - (value: boolean) => { - const currentOwnerKeys = reimbursementAccountDraft?.beneficialOwnerKeys ?? []; - setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {[OWNS_MORE_THAN_25_PERCENT]: value}); - - if (value) { - if (currentOwnerKeys.length >= 4) { - setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {beneficialOwnerKeys: currentOwnerKeys.slice(0, 3)}); - } - setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {ownerBeingModifiedID: CONST.NON_USD_BANK_ACCOUNT.CURRENT_USER_KEY}); - Navigation.navigate(ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.BENEFICIAL_OWNER_INFO, subPage: SUB_PAGE_NAMES.NAME, backTo})); - } else { - const filteredKeys = currentOwnerKeys.filter((key) => key !== CONST.NON_USD_BANK_ACCOUNT.CURRENT_USER_KEY); - setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {beneficialOwnerKeys: filteredKeys}); - Navigation.navigate( - ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.BENEFICIAL_OWNER_INFO, subPage: SUB_PAGE_NAMES.IS_ANYONE_ELSE_BENEFICIAL_OWNER, backTo}), - ); + if (value) { + if (currentOwnerKeys.length >= 4) { + setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {beneficialOwnerKeys: currentOwnerKeys.slice(0, 3)}); } - }, - [policyID, reimbursementAccountDraft?.beneficialOwnerKeys, backTo], - ); + setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {ownerBeingModifiedID: CONST.NON_USD_BANK_ACCOUNT.CURRENT_USER_KEY}); + Navigation.navigate(ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.BENEFICIAL_OWNER_INFO, subPage: SUB_PAGE_NAMES.NAME, backTo})); + } else { + const filteredKeys = currentOwnerKeys.filter((key) => key !== CONST.NON_USD_BANK_ACCOUNT.CURRENT_USER_KEY); + setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {beneficialOwnerKeys: filteredKeys}); + Navigation.navigate( + ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.BENEFICIAL_OWNER_INFO, subPage: SUB_PAGE_NAMES.IS_ANYONE_ELSE_BENEFICIAL_OWNER, backTo}), + ); + } + }; - const handleIsAnyoneElseOwnerSelected = useCallback( - (value: boolean) => { - setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {[ANY_INDIVIDUAL_OWN_25_PERCENT_OR_MORE]: value}); + const handleIsAnyoneElseOwnerSelected = (value: boolean) => { + setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {[ANY_INDIVIDUAL_OWN_25_PERCENT_OR_MORE]: value}); - if (!value && !isUserOwner) { - setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {beneficialOwnerKeys: []}); - submit({anyIndividualOwn25PercentOrMore: false}); - return; - } + if (!value && !isUserOwner) { + setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {beneficialOwnerKeys: []}); + submit({anyIndividualOwn25PercentOrMore: false}); + return; + } - if (!value && isUserOwner) { - setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {beneficialOwnerKeys: [CONST.NON_USD_BANK_ACCOUNT.CURRENT_USER_KEY]}); - Navigation.navigate(ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.BENEFICIAL_OWNER_INFO, subPage: SUB_PAGE_NAMES.BENEFICIAL_OWNERS_LIST, backTo})); - return; - } + if (!value && isUserOwner) { + setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {beneficialOwnerKeys: [CONST.NON_USD_BANK_ACCOUNT.CURRENT_USER_KEY]}); + Navigation.navigate(ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.BENEFICIAL_OWNER_INFO, subPage: SUB_PAGE_NAMES.BENEFICIAL_OWNERS_LIST, backTo})); + return; + } - if (!canAddMoreOwners) { - Navigation.navigate(ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.BENEFICIAL_OWNER_INFO, subPage: SUB_PAGE_NAMES.BENEFICIAL_OWNERS_LIST, backTo})); - return; - } + if (!canAddMoreOwners) { + Navigation.navigate(ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.BENEFICIAL_OWNER_INFO, subPage: SUB_PAGE_NAMES.BENEFICIAL_OWNERS_LIST, backTo})); + return; + } - prepareOwnerDetailsForm(Str.guid()); - }, - [canAddMoreOwners, isUserOwner, policyID, prepareOwnerDetailsForm, submit, backTo], - ); + prepareOwnerDetailsForm(Str.guid()); + }; - const handleAreThereMoreSelected = useCallback( - (value: boolean) => { - if (!value || !canAddMoreOwners) { - Navigation.navigate(ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.BENEFICIAL_OWNER_INFO, subPage: SUB_PAGE_NAMES.BENEFICIAL_OWNERS_LIST, backTo})); - return; - } + const handleAreThereMoreSelected = (value: boolean) => { + if (!value || !canAddMoreOwners) { + Navigation.navigate(ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.BENEFICIAL_OWNER_INFO, subPage: SUB_PAGE_NAMES.BENEFICIAL_OWNERS_LIST, backTo})); + return; + } - setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {[ANY_INDIVIDUAL_OWN_25_PERCENT_OR_MORE]: true}); - prepareOwnerDetailsForm(Str.guid()); - }, - [canAddMoreOwners, policyID, prepareOwnerDetailsForm, backTo], - ); + setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {[ANY_INDIVIDUAL_OWN_25_PERCENT_OR_MORE]: true}); + prepareOwnerDetailsForm(Str.guid()); + }; - const handleBackButtonPress = useCallback(() => { + const handleBackButtonPress = () => { clearErrors(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM); if (currentSubPage === SUB_PAGE_NAMES.IS_USER_BENEFICIAL_OWNER) { onBackButtonPress(); @@ -231,7 +213,7 @@ function BeneficialOwnerInfo({onBackButtonPress, onSubmit, stepNames, currentSub } else { Navigation.goBack(); } - }, [backTo, currentSubPage, onBackButtonPress, policyID]); + }; if (currentSubPage && !OUTER_SUB_PAGES.has(currentSubPage)) { return ( diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/BusinessInfo.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/BusinessInfo.tsx index 03a32fda79da..dd85ef8f5d6f 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/BusinessInfo.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/BusinessInfo.tsx @@ -23,7 +23,7 @@ import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import {Str} from 'expensify-common'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; import Address from './subSteps/Address'; @@ -86,10 +86,10 @@ function BusinessInfo({onBackButtonPress, onSubmit, policyID: policyIDProp, step const policyID = policyIDProp ?? reimbursementAccount?.achData?.policyID; const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const {country, currency} = getCurrencyForNonUSDBankAccount(policy, reimbursementAccountDraft, reimbursementAccount); - const businessInfoStepValues = useMemo(() => getSubStepValues(INPUT_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]); + const businessInfoStepValues = getSubStepValues(INPUT_KEYS, reimbursementAccountDraft, reimbursementAccount); const bankAccountID = reimbursementAccount?.achData?.bankAccountID ?? CONST.DEFAULT_NUMBER_ID; - const startFrom = useMemo(() => getInitialSubStepForBusinessInfoStep(businessInfoStepValues), [businessInfoStepValues]); + const startFrom = getInitialSubStepForBusinessInfoStep(businessInfoStepValues); const initialTargetSubPage = pages.at(startFrom)?.pageName ?? SUB_PAGE_NAMES.NAME; const shouldRedirect = !currentSubPage; @@ -107,7 +107,7 @@ function BusinessInfo({onBackButtonPress, onSubmit, policyID: policyIDProp, step getCorpayOnboardingFields(country); }, [country]); - const submit = useCallback(() => { + const submit = () => { isSubmittingRef.current = true; saveCorpayOnboardingCompanyDetails( { @@ -122,7 +122,7 @@ function BusinessInfo({onBackButtonPress, onSubmit, policyID: policyIDProp, step }, bankAccountID, ); - }, [businessInfoStepValues, isProduction, country, currency, isBusinessTypeRequired, bankAccountID]); + }; useEffect(() => { if (reimbursementAccount?.errors || reimbursementAccount?.isSavingCorpayOnboardingCompanyFields || !reimbursementAccount?.isSuccess) { @@ -141,10 +141,7 @@ function BusinessInfo({onBackButtonPress, onSubmit, policyID: policyIDProp, step }; }, [reimbursementAccount?.errors, reimbursementAccount?.isSavingCorpayOnboardingCompanyFields, reimbursementAccount?.isSuccess, onSubmit]); - const buildRoute = useCallback( - (pageName: string, action?: 'edit') => ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.BUSINESS_INFO, subPage: pageName, action, backTo}), - [backTo, policyID], - ); + const buildRoute = (pageName: string, action?: 'edit') => ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.BUSINESS_INFO, subPage: pageName, action, backTo}); const {CurrentPage, isEditing, currentPageName, pageIndex, nextPage, prevPage, moveTo} = useSubPage({pages, startFrom, onFinished: submit, buildRoute}); diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Address.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Address.tsx index ac2f64444680..21f8a51ce6d9 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Address.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Address.tsx @@ -11,7 +11,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; -import React, {useMemo} from 'react'; +import React from 'react'; type AddressProps = SubPageProps; @@ -33,7 +33,7 @@ function Address({onNext, onMove, isEditing}: AddressProps) { const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); - const onyxValues = useMemo(() => getSubStepValues(INPUT_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]); + const onyxValues = getSubStepValues(INPUT_KEYS, reimbursementAccountDraft, reimbursementAccount); const businessStepCountryDefaultValue = onyxValues[COMPANY_COUNTRY_CODE] ?? ''; const countryStepCountryValue = reimbursementAccount?.achData?.[INPUT_IDS.ADDITIONAL_DATA.COUNTRY] ?? ''; diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/AverageReimbursement.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/AverageReimbursement.tsx index 55f08fe7b4a9..5e26b38aa21a 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/AverageReimbursement.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/AverageReimbursement.tsx @@ -11,7 +11,7 @@ import getListOptionsFromCorpayPicklist from '@pages/ReimbursementAccount/NonUSD import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; -import React, {useMemo} from 'react'; +import React from 'react'; type AverageReimbursementProps = SubPageProps; @@ -27,24 +27,18 @@ function AverageReimbursement({onNext, onMove, isEditing}: AverageReimbursementP const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const {currency} = getCurrencyForNonUSDBankAccount(policy, reimbursementAccountDraft, reimbursementAccount); - const tradeVolumeRangeListOptions = useMemo( - () => getListOptionsFromCorpayPicklist(corpayOnboardingFields?.picklists.TradeVolumeRange), - [corpayOnboardingFields?.picklists.TradeVolumeRange], - ); + const tradeVolumeRangeListOptions = getListOptionsFromCorpayPicklist(corpayOnboardingFields?.picklists.TradeVolumeRange); - const pushRowFields = useMemo( - () => [ - { - inputID: TRADE_VOLUME, - defaultValue: reimbursementAccount?.achData?.corpay?.[TRADE_VOLUME] ?? '', - options: tradeVolumeRangeListOptions, - description: translate('businessInfoStep.averageReimbursementAmountInCurrency', currency), - modalHeaderTitle: translate('businessInfoStep.selectAverageReimbursement'), - searchInputTitle: translate('businessInfoStep.findAverageReimbursement'), - }, - ], - [reimbursementAccount, currency, tradeVolumeRangeListOptions, translate], - ); + const pushRowFields = [ + { + inputID: TRADE_VOLUME, + defaultValue: reimbursementAccount?.achData?.corpay?.[TRADE_VOLUME] ?? '', + options: tradeVolumeRangeListOptions, + description: translate('businessInfoStep.averageReimbursementAmountInCurrency', currency), + modalHeaderTitle: translate('businessInfoStep.selectAverageReimbursement'), + searchInputTitle: translate('businessInfoStep.findAverageReimbursement'), + }, + ]; const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/BusinessType.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/BusinessType.tsx index 3fba94889ef6..472bdfef34e3 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/BusinessType.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/BusinessType.tsx @@ -11,7 +11,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; -import React, {useMemo} from 'react'; +import React from 'react'; type BusinessTypeProps = SubPageProps; @@ -28,59 +28,44 @@ function BusinessType({onNext, isEditing, onMove}: BusinessTypeProps) { const country = reimbursementAccountDraft?.[COUNTRY] ?? reimbursementAccount?.achData?.[COUNTRY] ?? ''; const isBusinessTypeRequired = country !== CONST.COUNTRY.CA; - const incorporationTypeListOptions = useMemo(() => getListOptionsFromCorpayPicklist(corpayOnboardingFields?.picklists.ApplicantType), [corpayOnboardingFields?.picklists.ApplicantType]); - const natureOfBusinessListOptions = useMemo( - () => getListOptionsFromCorpayPicklist(corpayOnboardingFields?.picklists.NatureOfBusiness), - [corpayOnboardingFields?.picklists.NatureOfBusiness], - ); - const businessTypeListOptions = useMemo(() => getListOptionsFromCorpayPicklist(corpayOnboardingFields?.picklists.BusinessType), [corpayOnboardingFields?.picklists.BusinessType]); + const incorporationTypeListOptions = getListOptionsFromCorpayPicklist(corpayOnboardingFields?.picklists.ApplicantType); + const natureOfBusinessListOptions = getListOptionsFromCorpayPicklist(corpayOnboardingFields?.picklists.NatureOfBusiness); + const businessTypeListOptions = getListOptionsFromCorpayPicklist(corpayOnboardingFields?.picklists.BusinessType); const incorporationTypeDefaultValue = reimbursementAccount?.achData?.corpay?.[APPLICANT_TYPE_ID] ?? ''; const businessCategoryDefaultValue = reimbursementAccount?.achData?.corpay?.[BUSINESS_CATEGORY] ?? ''; const businessTypeDefaultValue = reimbursementAccount?.achData?.corpay?.[BUSINESS_TYPE_ID] ?? ''; - const pushRowFields = useMemo( - () => [ - { - inputID: APPLICANT_TYPE_ID, - defaultValue: incorporationTypeDefaultValue, - options: incorporationTypeListOptions, - description: translate('businessInfoStep.incorporationTypeName'), - modalHeaderTitle: translate('businessInfoStep.selectIncorporationType'), - searchInputTitle: translate('businessInfoStep.findIncorporationType'), - }, - { - inputID: BUSINESS_CATEGORY, - defaultValue: businessCategoryDefaultValue, - options: natureOfBusinessListOptions, - description: translate('businessInfoStep.businessCategory'), - modalHeaderTitle: translate('businessInfoStep.selectBusinessCategory'), - searchInputTitle: translate('businessInfoStep.findBusinessCategory'), - }, - ...(isBusinessTypeRequired - ? [ - { - inputID: BUSINESS_TYPE_ID, - defaultValue: businessTypeDefaultValue, - options: businessTypeListOptions, - description: translate('businessInfoStep.businessType'), - modalHeaderTitle: translate('businessInfoStep.selectBusinessType'), - searchInputTitle: translate('businessInfoStep.findBusinessType'), - }, - ] - : []), - ], - [ - businessCategoryDefaultValue, - businessTypeDefaultValue, - businessTypeListOptions, - incorporationTypeDefaultValue, - incorporationTypeListOptions, - isBusinessTypeRequired, - natureOfBusinessListOptions, - translate, - ], - ); + const pushRowFields = [ + { + inputID: APPLICANT_TYPE_ID, + defaultValue: incorporationTypeDefaultValue, + options: incorporationTypeListOptions, + description: translate('businessInfoStep.incorporationTypeName'), + modalHeaderTitle: translate('businessInfoStep.selectIncorporationType'), + searchInputTitle: translate('businessInfoStep.findIncorporationType'), + }, + { + inputID: BUSINESS_CATEGORY, + defaultValue: businessCategoryDefaultValue, + options: natureOfBusinessListOptions, + description: translate('businessInfoStep.businessCategory'), + modalHeaderTitle: translate('businessInfoStep.selectBusinessCategory'), + searchInputTitle: translate('businessInfoStep.findBusinessCategory'), + }, + ...(isBusinessTypeRequired + ? [ + { + inputID: BUSINESS_TYPE_ID, + defaultValue: businessTypeDefaultValue, + options: businessTypeListOptions, + description: translate('businessInfoStep.businessType'), + modalHeaderTitle: translate('businessInfoStep.selectBusinessType'), + searchInputTitle: translate('businessInfoStep.findBusinessType'), + }, + ] + : []), + ]; const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: isBusinessTypeRequired ? STEP_FIELDS : STEP_FIELDS_WITHOUT_BUSINESS_TYPE, diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Confirmation.tsx index 174993916e05..075faa350a93 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Confirmation.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Confirmation.tsx @@ -13,7 +13,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import {CONST as COMMON_CONST} from 'expensify-common'; -import React, {useMemo} from 'react'; +import React from 'react'; const BUSINESS_INFO_STEP_KEYS = INPUT_IDS.ADDITIONAL_DATA.CORPAY; const { @@ -60,156 +60,138 @@ function Confirmation({onNext, onMove, isEditing}: SubPageProps) { const [corpayOnboardingFields] = useOnyx(ONYXKEYS.CORPAY_ONBOARDING_FIELDS); const error = getLatestErrorMessage(reimbursementAccount); - const values = useMemo(() => getSubStepValues(BUSINESS_INFO_STEP_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]); + const values = getSubStepValues(BUSINESS_INFO_STEP_KEYS, reimbursementAccountDraft, reimbursementAccount); const isBusinessTypeRequired = values[COMPANY_COUNTRY_CODE] !== CONST.COUNTRY.CA; - const paymentVolume = useMemo( - () => displayStringValue(corpayOnboardingFields?.picklists.AnnualVolumeRange ?? [], values[ANNUAL_VOLUME]), - [corpayOnboardingFields?.picklists.AnnualVolumeRange, values], - ); - const businessCategory = useMemo( - () => displayStringValue(corpayOnboardingFields?.picklists.NatureOfBusiness ?? [], values[BUSINESS_CATEGORY]), - [corpayOnboardingFields?.picklists.NatureOfBusiness, values], - ); - const incorporationType = useMemo( - () => displayStringValue(corpayOnboardingFields?.picklists.ApplicantType ?? [], values[APPLICANT_TYPE_ID]), - [corpayOnboardingFields?.picklists.ApplicantType, values], - ); - const tradeVolumeRange = useMemo( - () => displayStringValue(corpayOnboardingFields?.picklists.TradeVolumeRange ?? [], values[TRADE_VOLUME]), - [corpayOnboardingFields?.picklists.TradeVolumeRange, values], - ); - const businessType = useMemo( - () => displayStringValue(corpayOnboardingFields?.picklists.BusinessType ?? [], values[BUSINESS_TYPE_ID]), - [corpayOnboardingFields?.picklists.BusinessType, values], - ); - - const summaryItems = useMemo( - () => [ - { - id: 'company-name', - title: values[COMPANY_NAME], - description: translate('businessInfoStep.legalBusinessName'), - shouldShowRightIcon: true, - onPress: () => { - onMove(0); - }, + const paymentVolume = displayStringValue(corpayOnboardingFields?.picklists.AnnualVolumeRange ?? [], values[ANNUAL_VOLUME]); + const businessCategory = displayStringValue(corpayOnboardingFields?.picklists.NatureOfBusiness ?? [], values[BUSINESS_CATEGORY]); + const incorporationType = displayStringValue(corpayOnboardingFields?.picklists.ApplicantType ?? [], values[APPLICANT_TYPE_ID]); + const tradeVolumeRange = displayStringValue(corpayOnboardingFields?.picklists.TradeVolumeRange ?? [], values[TRADE_VOLUME]); + const businessType = displayStringValue(corpayOnboardingFields?.picklists.BusinessType ?? [], values[BUSINESS_TYPE_ID]); + + const summaryItems = [ + { + id: 'company-name', + title: values[COMPANY_NAME], + description: translate('businessInfoStep.legalBusinessName'), + shouldShowRightIcon: true, + onPress: () => { + onMove(0); }, - { - id: 'company-website', - title: values[COMPANY_WEBSITE], - description: translate('businessInfoStep.companyWebsite'), - shouldShowRightIcon: true, - onPress: () => { - onMove(1); - }, + }, + { + id: 'company-website', + title: values[COMPANY_WEBSITE], + description: translate('businessInfoStep.companyWebsite'), + shouldShowRightIcon: true, + onPress: () => { + onMove(1); }, - { - id: 'registration-number', - title: values[BUSINESS_REGISTRATION_INCORPORATION_NUMBER], - description: translate('businessInfoStep.registrationNumber'), - shouldShowRightIcon: true, - onPress: () => { - onMove(4); - }, + }, + { + id: 'registration-number', + title: values[BUSINESS_REGISTRATION_INCORPORATION_NUMBER], + description: translate('businessInfoStep.registrationNumber'), + shouldShowRightIcon: true, + onPress: () => { + onMove(4); }, - { - id: 'tax-id-ein', - title: values[TAX_ID_EIN_NUMBER], - description: translate('businessInfoStep.taxIDEIN', values[COMPANY_COUNTRY_CODE]), - shouldShowRightIcon: true, - onPress: () => { - onMove(5); - }, + }, + { + id: 'tax-id-ein', + title: values[TAX_ID_EIN_NUMBER], + description: translate('businessInfoStep.taxIDEIN', values[COMPANY_COUNTRY_CODE]), + shouldShowRightIcon: true, + onPress: () => { + onMove(5); }, - { - id: 'business-address', - title: displayAddress(values[COMPANY_STREET], values[COMPANY_CITY], values[COMPANY_STATE], values[COMPANY_POSTAL_CODE], values[COMPANY_COUNTRY_CODE]), - description: translate('businessInfoStep.businessAddress'), - shouldShowRightIcon: true, - onPress: () => { - onMove(2); - }, + }, + { + id: 'business-address', + title: displayAddress(values[COMPANY_STREET], values[COMPANY_CITY], values[COMPANY_STATE], values[COMPANY_POSTAL_CODE], values[COMPANY_COUNTRY_CODE]), + description: translate('businessInfoStep.businessAddress'), + shouldShowRightIcon: true, + onPress: () => { + onMove(2); }, - { - id: 'phone-number', - title: values[BUSINESS_CONTACT_NUMBER], - description: translate('common.phoneNumber'), - shouldShowRightIcon: true, - onPress: () => { - onMove(3); - }, + }, + { + id: 'phone-number', + title: values[BUSINESS_CONTACT_NUMBER], + description: translate('common.phoneNumber'), + shouldShowRightIcon: true, + onPress: () => { + onMove(3); }, - { - id: 'email', - title: values[BUSINESS_CONFIRMATION_EMAIL], - description: translate('common.email'), - shouldShowRightIcon: true, - onPress: () => { - onMove(3); - }, + }, + { + id: 'email', + title: values[BUSINESS_CONFIRMATION_EMAIL], + description: translate('common.email'), + shouldShowRightIcon: true, + onPress: () => { + onMove(3); }, - { - id: 'incorporation-type', - title: incorporationType, - description: translate('businessInfoStep.incorporationTypeName'), - shouldShowRightIcon: true, - onPress: () => { - onMove(7); - }, + }, + { + id: 'incorporation-type', + title: incorporationType, + description: translate('businessInfoStep.incorporationTypeName'), + shouldShowRightIcon: true, + onPress: () => { + onMove(7); }, - ...(isBusinessTypeRequired - ? [ - { - id: 'business-type', - title: businessType, - description: translate('businessInfoStep.businessType'), - shouldShowRightIcon: true, - onPress: () => { - onMove(7); - }, + }, + ...(isBusinessTypeRequired + ? [ + { + id: 'business-type', + title: businessType, + description: translate('businessInfoStep.businessType'), + shouldShowRightIcon: true, + onPress: () => { + onMove(7); }, - ] - : []), - { - id: 'incorporation-location', - title: displayIncorporationLocation(values[FORMATION_INCORPORATION_COUNTRY_CODE], values[FORMATION_INCORPORATION_STATE]), - description: translate('businessInfoStep.incorporation'), - shouldShowRightIcon: true, - onPress: () => { - onMove(6); - }, + }, + ] + : []), + { + id: 'incorporation-location', + title: displayIncorporationLocation(values[FORMATION_INCORPORATION_COUNTRY_CODE], values[FORMATION_INCORPORATION_STATE]), + description: translate('businessInfoStep.incorporation'), + shouldShowRightIcon: true, + onPress: () => { + onMove(6); }, - { - id: 'business-category', - title: businessCategory, - description: translate('businessInfoStep.businessCategory'), - shouldShowRightIcon: true, - onPress: () => { - onMove(7); - }, + }, + { + id: 'business-category', + title: businessCategory, + description: translate('businessInfoStep.businessCategory'), + shouldShowRightIcon: true, + onPress: () => { + onMove(7); }, - { - id: 'annual-payment-volume', - title: paymentVolume, - description: translate('businessInfoStep.annualPaymentVolume'), - shouldShowRightIcon: true, - onPress: () => { - onMove(8); - }, + }, + { + id: 'annual-payment-volume', + title: paymentVolume, + description: translate('businessInfoStep.annualPaymentVolume'), + shouldShowRightIcon: true, + onPress: () => { + onMove(8); }, - { - id: 'trade-volume-range', - title: tradeVolumeRange, - description: translate('businessInfoStep.averageReimbursementAmount'), - shouldShowRightIcon: true, - onPress: () => { - onMove(9); - }, + }, + { + id: 'trade-volume-range', + title: tradeVolumeRange, + description: translate('businessInfoStep.averageReimbursementAmount'), + shouldShowRightIcon: true, + onPress: () => { + onMove(9); }, - ], - [businessCategory, businessType, incorporationType, isBusinessTypeRequired, onMove, paymentVolume, tradeVolumeRange, translate, values], - ); + }, + ]; return ( ): FormInputErrors => { - const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); - if (values[BUSINESS_CONTACT_NUMBER] && !isValidPhoneInternational(values[BUSINESS_CONTACT_NUMBER])) { - errors[BUSINESS_CONTACT_NUMBER] = translate('common.error.phoneNumber'); - } + if (values[BUSINESS_CONTACT_NUMBER] && !isValidPhoneInternational(values[BUSINESS_CONTACT_NUMBER])) { + errors[BUSINESS_CONTACT_NUMBER] = translate('common.error.phoneNumber'); + } - if (values[BUSINESS_CONFIRMATION_EMAIL] && !isValidEmail(values[BUSINESS_CONFIRMATION_EMAIL])) { - errors[BUSINESS_CONFIRMATION_EMAIL] = translate('common.error.email'); - } + if (values[BUSINESS_CONFIRMATION_EMAIL] && !isValidEmail(values[BUSINESS_CONFIRMATION_EMAIL])) { + errors[BUSINESS_CONFIRMATION_EMAIL] = translate('common.error.email'); + } - return errors; - }, - [translate], - ); + return errors; + }; const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/IncorporationLocation.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/IncorporationLocation.tsx index f784bf28fdf5..851f46fd6bae 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/IncorporationLocation.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/IncorporationLocation.tsx @@ -19,7 +19,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import {CONST as COMMON_CONST} from 'expensify-common'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; type IncorporationLocationProps = SubPageProps; @@ -50,19 +50,15 @@ function IncorporationLocation({onNext, isEditing}: IncorporationLocationProps) const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); - const onyxValues = useMemo( - () => - getSubStepValues( - { - FORMATION_INCORPORATION_COUNTRY_CODE, - FORMATION_INCORPORATION_STATE, - COMPANY_COUNTRY: COMPANY_COUNTRY_CODE, - COMPANY_STATE, - }, - reimbursementAccountDraft, - reimbursementAccount, - ), - [reimbursementAccount, reimbursementAccountDraft], + const onyxValues = getSubStepValues( + { + FORMATION_INCORPORATION_COUNTRY_CODE, + FORMATION_INCORPORATION_STATE, + COMPANY_COUNTRY: COMPANY_COUNTRY_CODE, + COMPANY_STATE, + }, + reimbursementAccountDraft, + reimbursementAccount, ); const incorporationCountryInitialValue = onyxValues[FORMATION_INCORPORATION_COUNTRY_CODE] !== '' ? onyxValues[FORMATION_INCORPORATION_COUNTRY_CODE] : onyxValues[COMPANY_COUNTRY_CODE]; @@ -73,12 +69,9 @@ function IncorporationLocation({onNext, isEditing}: IncorporationLocationProps) const [selectedState, setSelectedState] = useState(incorporationStateInitialValue); const shouldGatherState = isCountryWithSelectableState(selectedCountry); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - return getFieldRequiredErrors(values, shouldGatherState ? STEP_FIELDS : [FORMATION_INCORPORATION_COUNTRY_CODE], translate); - }, - [shouldGatherState, translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => { + return getFieldRequiredErrors(values, shouldGatherState ? STEP_FIELDS : [FORMATION_INCORPORATION_COUNTRY_CODE], translate); + }; const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Name.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Name.tsx index 7f2cd66d7920..8598bbd39e36 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Name.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Name.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; -import React, {useCallback} from 'react'; +import React from 'react'; type NameProps = SubPageProps; @@ -25,18 +25,15 @@ function Name({onNext, onMove, isEditing}: NameProps) { const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); const defaultValue = reimbursementAccount?.achData?.corpay?.[COMPANY_NAME] ?? ''; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); - if (values.companyName && !isValidCompanyName(values.companyName)) { - errors.companyName = translate('bankAccount.error.companyName'); - } + if (values.companyName && !isValidCompanyName(values.companyName)) { + errors.companyName = translate('bankAccount.error.companyName'); + } - return errors; - }, - [translate], - ); + return errors; + }; const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/PaymentVolume.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/PaymentVolume.tsx index e04474689f25..28f53ab8931a 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/PaymentVolume.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/PaymentVolume.tsx @@ -11,7 +11,7 @@ import getListOptionsFromCorpayPicklist from '@pages/ReimbursementAccount/NonUSD import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; -import React, {useMemo} from 'react'; +import React from 'react'; type PaymentVolumeProps = SubPageProps; @@ -27,26 +27,20 @@ function PaymentVolume({onNext, onMove, isEditing}: PaymentVolumeProps) { const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const {currency} = getCurrencyForNonUSDBankAccount(policy, reimbursementAccountDraft, reimbursementAccount); - const annualVolumeRangeListOptions = useMemo( - () => getListOptionsFromCorpayPicklist(corpayOnboardingFields?.picklists.AnnualVolumeRange), - [corpayOnboardingFields?.picklists.AnnualVolumeRange], - ); + const annualVolumeRangeListOptions = getListOptionsFromCorpayPicklist(corpayOnboardingFields?.picklists.AnnualVolumeRange); const annualVolumeDefaultValue = reimbursementAccount?.achData?.corpay?.[ANNUAL_VOLUME] ?? ''; - const pushRowFields = useMemo( - () => [ - { - inputID: ANNUAL_VOLUME, - defaultValue: annualVolumeDefaultValue, - options: annualVolumeRangeListOptions, - description: translate('businessInfoStep.annualPaymentVolumeInCurrency', currency), - modalHeaderTitle: translate('businessInfoStep.selectAnnualPaymentVolume'), - searchInputTitle: translate('businessInfoStep.findAnnualPaymentVolume'), - }, - ], - [annualVolumeDefaultValue, annualVolumeRangeListOptions, translate, currency], - ); + const pushRowFields = [ + { + inputID: ANNUAL_VOLUME, + defaultValue: annualVolumeDefaultValue, + options: annualVolumeRangeListOptions, + description: translate('businessInfoStep.annualPaymentVolumeInCurrency', currency), + modalHeaderTitle: translate('businessInfoStep.selectAnnualPaymentVolume'), + searchInputTitle: translate('businessInfoStep.findAnnualPaymentVolume'), + }, + ]; const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/TaxIDEINNumber.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/TaxIDEINNumber.tsx index 9e5834feb8eb..7fd8250c5425 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/TaxIDEINNumber.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/TaxIDEINNumber.tsx @@ -11,7 +11,7 @@ import {getFieldRequiredErrors, isValidTaxIDEINNumber} from '@libs/ValidationUti import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; -import React, {useCallback} from 'react'; +import React from 'react'; type TaxIDEINNumberProps = SubPageProps; @@ -27,18 +27,15 @@ function TaxIDEINNumber({onNext, onMove, isEditing}: TaxIDEINNumberProps) { const defaultValue = reimbursementAccount?.achData?.corpay?.[TAX_ID_EIN_NUMBER] ?? ''; const businessStepCountryValue = reimbursementAccount?.achData?.corpay?.[COMPANY_COUNTRY_CODE] ?? reimbursementAccountDraft?.[COMPANY_COUNTRY_CODE] ?? ''; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); - if (values[TAX_ID_EIN_NUMBER] && !isValidTaxIDEINNumber(values[TAX_ID_EIN_NUMBER], businessStepCountryValue)) { - errors[TAX_ID_EIN_NUMBER] = translate('businessInfoStep.error.taxIDEIN', businessStepCountryValue); - } + if (values[TAX_ID_EIN_NUMBER] && !isValidTaxIDEINNumber(values[TAX_ID_EIN_NUMBER], businessStepCountryValue)) { + errors[TAX_ID_EIN_NUMBER] = translate('businessInfoStep.error.taxIDEIN', businessStepCountryValue); + } - return errors; - }, - [businessStepCountryValue, translate], - ); + return errors; + }; const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Website.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Website.tsx index e739b43799c4..bb7205084405 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Website.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/subSteps/Website.tsx @@ -18,7 +18,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import {Str} from 'expensify-common'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; type WebsiteProps = SubPageProps; @@ -36,21 +36,18 @@ function Website({onNext, onMove, isEditing}: WebsiteProps) { const {currency} = getCurrencyForNonUSDBankAccount(policy, reimbursementAccountDraft, reimbursementAccount); const isWebsiteRequired = currency === CONST.CURRENCY.USD || CONST.CURRENCY.CAD; - const defaultWebsiteExample = useMemo(() => getDefaultCompanyWebsite(session, account, true), [session, account]); + const defaultWebsiteExample = getDefaultCompanyWebsite(session, account, true); const defaultCompanyWebsite = reimbursementAccount?.achData?.website ?? defaultWebsiteExample; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = isWebsiteRequired ? getFieldRequiredErrors(values, STEP_FIELDS, translate) : {}; + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = isWebsiteRequired ? getFieldRequiredErrors(values, STEP_FIELDS, translate) : {}; - if (values[COMPANY_WEBSITE] && !isValidWebsite(Str.sanitizeURL(values[COMPANY_WEBSITE], CONST.COMPANY_WEBSITE_DEFAULT_SCHEME))) { - errors[COMPANY_WEBSITE] = translate('bankAccount.error.website'); - } + if (values[COMPANY_WEBSITE] && !isValidWebsite(Str.sanitizeURL(values[COMPANY_WEBSITE], CONST.COMPANY_WEBSITE_DEFAULT_SCHEME))) { + errors[COMPANY_WEBSITE] = translate('bankAccount.error.website'); + } - return errors; - }, - [isWebsiteRequired, translate], - ); + return errors; + }; const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/ReimbursementAccount/NonUSD/Docusign/index.tsx b/src/pages/ReimbursementAccount/NonUSD/Docusign/index.tsx index 4c382288accf..77edc4f2fc03 100644 --- a/src/pages/ReimbursementAccount/NonUSD/Docusign/index.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/Docusign/index.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; -import React, {useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; const INPUT_KEYS = { PROVIDE_TRUTHFUL_INFORMATION: INPUT_IDS.ADDITIONAL_DATA.CORPAY.PROVIDE_TRUTHFUL_INFORMATION, @@ -26,7 +26,7 @@ const INPUT_KEYS = { function Docusign({onBackButtonPress, onSubmit, stepNames, currency}: NonUSDPageProps) { const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); - const finalStepValues = useMemo(() => getSubStepValues(INPUT_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]); + const finalStepValues = getSubStepValues(INPUT_KEYS, reimbursementAccountDraft, reimbursementAccount); const bankAccountID = reimbursementAccount?.achData?.bankAccountID ?? CONST.DEFAULT_NUMBER_ID; const submit = () => { diff --git a/src/pages/ReimbursementAccount/NonUSD/NonUSDVerifiedBankAccountFlowPage.tsx b/src/pages/ReimbursementAccount/NonUSD/NonUSDVerifiedBankAccountFlowPage.tsx index 77b421fe1b39..a3f1a47dbe90 100644 --- a/src/pages/ReimbursementAccount/NonUSD/NonUSDVerifiedBankAccountFlowPage.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/NonUSDVerifiedBankAccountFlowPage.tsx @@ -11,7 +11,7 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import type NonUSDPageProps from './types'; @@ -83,29 +83,29 @@ function NonUSDVerifiedBankAccountFlowPage({route}: NonUSDVerifiedBankAccountFlo const initialSignerSubPage = getInitialSubPageForSignerInfoStep(savedSignerEmail, savedSignerFullName, savedSecondSignerEmail, currency); const isAUD = currency === CONST.CURRENCY.AUD; - const pages = useMemo(() => { - const filteredPages = isDocusignStepRequired ? allPages : allPages.filter((page) => page.pageName !== PAGE_NAME.DOCUSIGN); - return filteredPages.map((page) => { - if (page.pageName !== PAGE_NAME.SIGNER_INFO) { - return page; - } - return { - ...page, - firstSubPage: initialSignerSubPage, - ...(isAUD ? {lastSubPage: SIGNER_INFO_SUB_PAGES.HANG_TIGHT} : {}), - }; - }); - }, [isDocusignStepRequired, isAUD, initialSignerSubPage]); - - const currentPageIndex = useMemo(() => { - const index = pages.findIndex((p) => p.pageName === currentPage); - return index >= 0 ? index : 0; - }, [pages, currentPage]); + const filteredPages = isDocusignStepRequired ? allPages : allPages.filter((page) => page.pageName !== PAGE_NAME.DOCUSIGN); + const pages = filteredPages.map((page) => { + if (page.pageName !== PAGE_NAME.SIGNER_INFO) { + return page; + } + return { + ...page, + firstSubPage: initialSignerSubPage, + ...(isAUD + ? { + lastSubPage: SIGNER_INFO_SUB_PAGES.HANG_TIGHT, + } + : {}), + }; + }); + + const index = pages.findIndex((p) => p.pageName === currentPage); + const currentPageIndex = index >= 0 ? index : 0; const currentEntry = pages.at(currentPageIndex); const CurrentPage = currentEntry?.component ?? Country; - const onSubmit = useCallback(() => { + const onSubmit = () => { const nextIndex = currentPageIndex + 1; if (nextIndex >= pages.length) { Navigation.goBack(); @@ -113,9 +113,9 @@ function NonUSDVerifiedBankAccountFlowPage({route}: NonUSDVerifiedBankAccountFlo } const nextPage = pages.at(nextIndex); Navigation.navigate(ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: nextPage?.pageName, subPage: nextPage?.firstSubPage, backTo})); - }, [backTo, currentPageIndex, pages, policyID]); + }; - const onBackButtonPress = useCallback(() => { + const onBackButtonPress = () => { const prevIndex = currentPageIndex - 1; if (prevIndex < 0) { Navigation.goBack(); @@ -123,7 +123,7 @@ function NonUSDVerifiedBankAccountFlowPage({route}: NonUSDVerifiedBankAccountFlo } const prevPage = pages.at(prevIndex); Navigation.goBack(ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: prevPage?.pageName, subPage: prevPage?.lastSubPage, backTo})); - }, [backTo, currentPageIndex, pages, policyID]); + }; return ( diff --git a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/EnterEmail.tsx b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/EnterEmail.tsx index f543d94c8d4e..2340d62402e5 100644 --- a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/EnterEmail.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/EnterEmail.tsx @@ -19,7 +19,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import {Str} from 'expensify-common'; -import React, {useCallback} from 'react'; +import React from 'react'; type EmailSubmitParams = {signerEmail: string; secondSignerEmail?: string}; @@ -49,34 +49,29 @@ function EnterEmail({onSubmit, isUserDirector, isLoading}: EnterEmailProps) { const shouldGatherOnlySecondSignerEmail = currency === CONST.CURRENCY.AUD && isUserDirector; const companyName = reimbursementAccount?.achData?.corpay?.[COMPANY_NAME] ?? ''; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - // The single visible input writes to SECOND_SIGNER_EMAIL when only the second signer's email is gathered, so require that field instead of the (hidden, unpopulated) SIGNER_EMAIL. - const primaryEmailField = shouldGatherOnlySecondSignerEmail ? SECOND_SIGNER_EMAIL : SIGNER_EMAIL; - const requiredFields: Array> = shouldGatherBothEmails - ? [primaryEmailField, SECOND_SIGNER_EMAIL] - : [primaryEmailField]; - const errors = getFieldRequiredErrors(values, requiredFields, translate); - if (!shouldGatherOnlySecondSignerEmail && values[SIGNER_EMAIL] && !Str.isValidEmail(values[SIGNER_EMAIL])) { - errors[SIGNER_EMAIL] = translate('bankAccount.error.email'); - } - - if (shouldGatherOnlySecondSignerEmail && values[SECOND_SIGNER_EMAIL] && !Str.isValidEmail(String(values[SECOND_SIGNER_EMAIL]))) { - errors[SECOND_SIGNER_EMAIL] = translate('bankAccount.error.email'); - } - - if (shouldGatherBothEmails && values[SECOND_SIGNER_EMAIL] && !Str.isValidEmail(String(values[SECOND_SIGNER_EMAIL]))) { - errors[SECOND_SIGNER_EMAIL] = translate('bankAccount.error.email'); - } - - if (shouldGatherBothEmails && values[SIGNER_EMAIL] === values[SECOND_SIGNER_EMAIL]) { - errors[SECOND_SIGNER_EMAIL] = translate('signerInfoStep.error.emailsMustBeDifferent'); - } - - return errors; - }, - [shouldGatherBothEmails, shouldGatherOnlySecondSignerEmail, translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => { + // The single visible input writes to SECOND_SIGNER_EMAIL when only the second signer's email is gathered, so require that field instead of the (hidden, unpopulated) SIGNER_EMAIL. + const primaryEmailField = shouldGatherOnlySecondSignerEmail ? SECOND_SIGNER_EMAIL : SIGNER_EMAIL; + const requiredFields: Array> = shouldGatherBothEmails ? [primaryEmailField, SECOND_SIGNER_EMAIL] : [primaryEmailField]; + const errors = getFieldRequiredErrors(values, requiredFields, translate); + if (!shouldGatherOnlySecondSignerEmail && values[SIGNER_EMAIL] && !Str.isValidEmail(values[SIGNER_EMAIL])) { + errors[SIGNER_EMAIL] = translate('bankAccount.error.email'); + } + + if (shouldGatherOnlySecondSignerEmail && values[SECOND_SIGNER_EMAIL] && !Str.isValidEmail(String(values[SECOND_SIGNER_EMAIL]))) { + errors[SECOND_SIGNER_EMAIL] = translate('bankAccount.error.email'); + } + + if (shouldGatherBothEmails && values[SECOND_SIGNER_EMAIL] && !Str.isValidEmail(String(values[SECOND_SIGNER_EMAIL]))) { + errors[SECOND_SIGNER_EMAIL] = translate('bankAccount.error.email'); + } + + if (shouldGatherBothEmails && values[SIGNER_EMAIL] === values[SECOND_SIGNER_EMAIL]) { + errors[SECOND_SIGNER_EMAIL] = translate('signerInfoStep.error.emailsMustBeDifferent'); + } + + return errors; + }; return ( ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.SIGNER_INFO, subPage: pageName, action, backTo}), - [policyID, backTo], - ); + const buildRoute = (pageName: string, action?: 'edit') => ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.SIGNER_INFO, subPage: pageName, action, backTo}); const {CurrentPage, isEditing, currentPageName, pageIndex, nextPage, prevPage, moveTo, isRedirecting} = useSubPage({ pages, @@ -73,7 +70,7 @@ function SignerDetailsFormPages({onBackToIsDirector, stepNames, policyID, onFini skipPages, }); - const handleBackButtonPress = useCallback(() => { + const handleBackButtonPress = () => { clearErrors(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM); if (isEditing) { Navigation.goBack(buildRoute(SUB_PAGE_NAMES.CONFIRMATION)); @@ -91,7 +88,7 @@ function SignerDetailsFormPages({onBackToIsDirector, stepNames, policyID, onFini } else { prevPage(); } - }, [buildRoute, isEditing, onBackToIsDirector, pageIndex, prevPage, skipPages]); + }; if (isRedirecting) { return ; diff --git a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/index.tsx b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/index.tsx index c4734d4d112f..faa6b055f1ce 100644 --- a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/index.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/index.tsx @@ -24,7 +24,7 @@ import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import {emailSelector} from '@selectors/Session'; import {Str} from 'expensify-common'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {EmailSubmitParams} from './EnterEmail'; @@ -61,7 +61,7 @@ function SignerInfo({onBackButtonPress, onSubmit, stepNames, currentSubPage, bac const isSubmittingRef = useRef(false); - const signerDraftValues = useMemo(() => getDraftValuesForSignerInfo(reimbursementAccount), [reimbursementAccount]); + const signerDraftValues = getDraftValuesForSignerInfo(reimbursementAccount); const signerFullNameDraft = reimbursementAccountDraft?.[SIGNER_FULL_NAME]; useEffect(() => { @@ -71,7 +71,7 @@ function SignerInfo({onBackButtonPress, onSubmit, stepNames, currentSubPage, bac setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, signerDraftValues); }, [signerFullNameDraft, signerDraftValues]); - const submit = useCallback(() => { + const submit = () => { isSubmittingRef.current = true; const {signerDetails, signerFiles} = getSignerDetailsAndSignerFilesForSignerInfo(reimbursementAccountDraft, signerEmail, isUserOwner); saveCorpayOnboardingDirectorInformation({ @@ -79,7 +79,7 @@ function SignerInfo({onBackButtonPress, onSubmit, stepNames, currentSubPage, bac ...signerFiles, bankAccountID, }); - }, [bankAccountID, isUserOwner, reimbursementAccountDraft, signerEmail]); + }; useEffect(() => { if (reimbursementAccount?.errors || reimbursementAccount?.isSavingCorpayOnboardingDirectorInformation || !reimbursementAccount?.isSuccess) { @@ -112,44 +112,38 @@ function SignerInfo({onBackButtonPress, onSubmit, stepNames, currentSubPage, bac } }, [reimbursementAccount?.errors, reimbursementAccount?.isAskingForCorpaySignerInformation, reimbursementAccount?.isAskingForCorpaySignerInformationSuccess, policyID, backTo]); - const handleEmailSubmit = useCallback( - (values: EmailSubmitParams) => { - const params = shouldSendOnlySecondSignerEmail - ? { - secondSignerEmail: values.secondSignerEmail, - policyID: String(policyID), - bankAccountID, - } - : { - signerEmail: values.signerEmail, - secondSignerEmail: values.secondSignerEmail, - policyID: String(policyID), - bankAccountID, - }; - - askForCorpaySignerInformation(params); - }, - [bankAccountID, policyID, shouldSendOnlySecondSignerEmail], - ); - - const handleIsDirectorSelected = useCallback( - (value: boolean) => { - if (!policyID && !value) { - setShowNoPolicyError(true); - return; - } - setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {isUserDirector: value}); - if (value) { - const firstFormPage = isUserOwner ? SUB_PAGE_NAMES.JOB_TITLE : SUB_PAGE_NAMES.NAME; - Navigation.navigate(ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.SIGNER_INFO, subPage: firstFormPage, backTo})); - } else { - Navigation.navigate(ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.SIGNER_INFO, subPage: SUB_PAGE_NAMES.ENTER_EMAIL, backTo})); - } - }, - [policyID, isUserOwner, backTo], - ); + const handleEmailSubmit = (values: EmailSubmitParams) => { + const params = shouldSendOnlySecondSignerEmail + ? { + secondSignerEmail: values.secondSignerEmail, + policyID: String(policyID), + bankAccountID, + } + : { + signerEmail: values.signerEmail, + secondSignerEmail: values.secondSignerEmail, + policyID: String(policyID), + bankAccountID, + }; + + askForCorpaySignerInformation(params); + }; + + const handleIsDirectorSelected = (value: boolean) => { + if (!policyID && !value) { + setShowNoPolicyError(true); + return; + } + setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {isUserDirector: value}); + if (value) { + const firstFormPage = isUserOwner ? SUB_PAGE_NAMES.JOB_TITLE : SUB_PAGE_NAMES.NAME; + Navigation.navigate(ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.SIGNER_INFO, subPage: firstFormPage, backTo})); + } else { + Navigation.navigate(ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.SIGNER_INFO, subPage: SUB_PAGE_NAMES.ENTER_EMAIL, backTo})); + } + }; - const handleBackButtonPress = useCallback(() => { + const handleBackButtonPress = () => { if (currentSubPage === SUB_PAGE_NAMES.ENTER_EMAIL) { clearErrors(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM); const backSubPage = isUserDirector ? SUB_PAGE_NAMES.CONFIRMATION : SUB_PAGE_NAMES.IS_DIRECTOR; @@ -159,12 +153,12 @@ function SignerInfo({onBackButtonPress, onSubmit, stepNames, currentSubPage, bac } else { onBackButtonPress(); } - }, [currentSubPage, isUserDirector, onBackButtonPress, policyID, backTo]); + }; - const handleBackToIsDirector = useCallback(() => { + const handleBackToIsDirector = () => { clearErrors(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM); Navigation.goBack(ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID, page: PAGE_NAME.SIGNER_INFO, subPage: SUB_PAGE_NAMES.IS_DIRECTOR, backTo})); - }, [policyID, backTo]); + }; if (currentSubPage !== SUB_PAGE_NAMES.IS_DIRECTOR && currentSubPage !== SUB_PAGE_NAMES.ENTER_EMAIL && currentSubPage !== SUB_PAGE_NAMES.HANG_TIGHT) { return ( diff --git a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/Address.tsx b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/Address.tsx index 319fdd7a1acf..9adb344c587d 100644 --- a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/Address.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/Address.tsx @@ -9,7 +9,7 @@ import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; type NameProps = SubPageProps; @@ -43,14 +43,8 @@ function Address({onNext, isEditing, onMove}: NameProps) { ); const [shouldValidateZipCodeFormat, setShouldValidateZipCodeFormat] = useState(defaultValues.country === CONST.COUNTRY.US); - const stepFieldsWithState = useMemo( - () => [inputKeys.street, inputKeys.city, inputKeys.state, inputKeys.zipCode, inputKeys.country], - [inputKeys.country, inputKeys.city, inputKeys.state, inputKeys.street, inputKeys.zipCode], - ); - const stepFieldsWithoutState = useMemo( - () => [inputKeys.street, inputKeys.city, inputKeys.zipCode, inputKeys.country], - [inputKeys.country, inputKeys.city, inputKeys.street, inputKeys.zipCode], - ); + const stepFieldsWithState = [inputKeys.street, inputKeys.city, inputKeys.state, inputKeys.zipCode, inputKeys.country]; + const stepFieldsWithoutState = [inputKeys.street, inputKeys.city, inputKeys.zipCode, inputKeys.country]; const stepFields = shouldDisplayStateSelector ? stepFieldsWithState : stepFieldsWithoutState; diff --git a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/Confirmation.tsx index 0b5ea1fc6a2c..ac51b8f395ca 100644 --- a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/Confirmation.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/Confirmation.tsx @@ -11,7 +11,7 @@ import getNeededDocumentsStatusForSignerInfo from '@pages/ReimbursementAccount/u import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; -import React, {useMemo} from 'react'; +import React from 'react'; type ConfirmationProps = SubPageProps; @@ -23,7 +23,7 @@ function Confirmation({onNext, onMove, isEditing}: ConfirmationProps) { const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); const isUserOwner = reimbursementAccount?.achData?.corpay?.[OWNS_MORE_THAN_25_PERCENT] ?? reimbursementAccountDraft?.[OWNS_MORE_THAN_25_PERCENT] ?? false; - const values = useMemo(() => getValuesForSignerInfo(reimbursementAccountDraft), [reimbursementAccountDraft]); + const values = getValuesForSignerInfo(reimbursementAccountDraft); const policyID = reimbursementAccount?.achData?.policyID; const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); diff --git a/src/pages/ReimbursementAccount/USD/BankInfo/subSteps/Manual.tsx b/src/pages/ReimbursementAccount/USD/BankInfo/subSteps/Manual.tsx index 9f3595ded405..ab3dd95bff83 100644 --- a/src/pages/ReimbursementAccount/USD/BankInfo/subSteps/Manual.tsx +++ b/src/pages/ReimbursementAccount/USD/BankInfo/subSteps/Manual.tsx @@ -20,7 +20,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; const BANK_INFO_STEP_KEYS = INPUT_IDS.BANK_INFO_STEP; const STEP_FIELDS = [BANK_INFO_STEP_KEYS.ROUTING_NUMBER, BANK_INFO_STEP_KEYS.ACCOUNT_NUMBER]; @@ -32,31 +32,28 @@ function Manual({onNext}: BankInfoSubStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {inputCallbackRef} = useAutoFocusInput(); - const defaultValues = useMemo(() => getSubStepValues(BANK_INFO_STEP_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]); + const defaultValues = getSubStepValues(BANK_INFO_STEP_KEYS, reimbursementAccountDraft, reimbursementAccount); const hasBankAccountData = !!reimbursementAccount?.achData?.bankAccountID; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); - const routingNumber = values.routingNumber?.trim(); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); + const routingNumber = values.routingNumber?.trim(); - if ( - values.accountNumber && - !CONST.BANK_ACCOUNT.REGEX.US_ACCOUNT_NUMBER.test(values.accountNumber.trim()) && - !CONST.BANK_ACCOUNT.REGEX.MASKED_US_ACCOUNT_NUMBER.test(values.accountNumber.trim()) - ) { - errors.accountNumber = translate('bankAccount.error.accountNumber'); - } else if (values.accountNumber && values.accountNumber === routingNumber) { - errors.accountNumber = translate('bankAccount.error.routingAndAccountNumberCannotBeSame'); - } - if (routingNumber && (!CONST.BANK_ACCOUNT.REGEX.SWIFT_BIC.test(routingNumber) || !isValidRoutingNumber(routingNumber))) { - errors.routingNumber = translate('bankAccount.error.routingNumber'); - } + if ( + values.accountNumber && + !CONST.BANK_ACCOUNT.REGEX.US_ACCOUNT_NUMBER.test(values.accountNumber.trim()) && + !CONST.BANK_ACCOUNT.REGEX.MASKED_US_ACCOUNT_NUMBER.test(values.accountNumber.trim()) + ) { + errors.accountNumber = translate('bankAccount.error.accountNumber'); + } else if (values.accountNumber && values.accountNumber === routingNumber) { + errors.accountNumber = translate('bankAccount.error.routingAndAccountNumberCannotBeSame'); + } + if (routingNumber && (!CONST.BANK_ACCOUNT.REGEX.SWIFT_BIC.test(routingNumber) || !isValidRoutingNumber(routingNumber))) { + errors.routingNumber = translate('bankAccount.error.routingNumber'); + } - return errors; - }, - [translate], - ); + return errors; + }; const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/ReimbursementAccount/USD/BankInfo/subSteps/Plaid.tsx b/src/pages/ReimbursementAccount/USD/BankInfo/subSteps/Plaid.tsx index bfce675e8883..3632589a1b5e 100644 --- a/src/pages/ReimbursementAccount/USD/BankInfo/subSteps/Plaid.tsx +++ b/src/pages/ReimbursementAccount/USD/BankInfo/subSteps/Plaid.tsx @@ -23,7 +23,7 @@ import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import {useIsFocused, useRoute} from '@react-navigation/native'; -import React, {useCallback, useEffect} from 'react'; +import React, {useEffect} from 'react'; type ReimbursementAccountNavigatorRoute = PlatformStackRouteProp; @@ -43,7 +43,7 @@ function Plaid({onNext}: BankInfoSubStepProps) { const prevIsFocused = usePrevious(isFocused); const selectedPlaidAccountID = reimbursementAccountDraft?.[BANK_INFO_STEP_KEYS.PLAID_ACCOUNT_ID] ?? ''; - const handleNextPress = useCallback(() => { + const handleNextPress = () => { const selectedPlaidBankAccount = (plaidData?.bankAccounts ?? []).find((account) => account.plaidAccountID === reimbursementAccountDraft?.[BANK_INFO_STEP_KEYS.PLAID_ACCOUNT_ID]); const bankAccountData = { @@ -58,7 +58,7 @@ function Plaid({onNext}: BankInfoSubStepProps) { updateReimbursementAccountDraft(bankAccountData); onNext(bankAccountData); - }, [plaidData, reimbursementAccountDraft, onNext]); + }; const bankAccountID = getBankAccountIDAsNumber(reimbursementAccount?.achData); diff --git a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormPages.tsx b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormPages.tsx index 2ef92904172b..1460aab2ec64 100644 --- a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormPages.tsx +++ b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/BeneficialOwnerDetailsFormPages.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import React, {useCallback} from 'react'; +import React from 'react'; import AddressUBO from './subSteps/BeneficialOwnerDetailsFormSubSteps/AddressUBO'; import ConfirmationUBO from './subSteps/BeneficialOwnerDetailsFormSubSteps/ConfirmationUBO'; @@ -65,10 +65,7 @@ function BeneficialOwnerDetailsFormPages({ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); const hasExistingBeneficialOwners = (reimbursementAccountDraft?.beneficialOwnerKeys ?? []).length > 0; - const buildRoute = useCallback( - (pageName: string, action?: 'edit') => ROUTES.BANK_ACCOUNT_USD_SETUP.getRoute({policyID, page: PAGE_NAMES.BENEFICIAL_OWNERS, subPage: pageName, action, backTo}), - [policyID, backTo], - ); + const buildRoute = (pageName: string, action?: 'edit') => ROUTES.BANK_ACCOUNT_USD_SETUP.getRoute({policyID, page: PAGE_NAMES.BENEFICIAL_OWNERS, subPage: pageName, action, backTo}); const {CurrentPage, isEditing, currentPageName, pageIndex, prevPage, nextPage, moveTo, isRedirecting} = useSubPage({ pages, @@ -77,7 +74,7 @@ function BeneficialOwnerDetailsFormPages({ buildRoute, }); - const handleBackButtonPress = useCallback(() => { + const handleBackButtonPress = () => { if (isEditing) { Navigation.goBack(buildRoute(SUB_PAGE_NAMES.CONFIRMATION)); return; @@ -94,7 +91,7 @@ function BeneficialOwnerDetailsFormPages({ } else { prevPage(); } - }, [buildRoute, isEditing, isEditingCreatedBeneficialOwner, pageIndex, prevPage, hasExistingBeneficialOwners]); + }; if (isRedirecting) { return ; diff --git a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/BeneficialOwnersStep.tsx b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/BeneficialOwnersStep.tsx index 28d3e52d7e1e..1d9a1f2efb08 100644 --- a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/BeneficialOwnersStep.tsx +++ b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/BeneficialOwnersStep.tsx @@ -17,7 +17,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {SafeString, Str} from 'expensify-common'; -import React, {useCallback, useEffect} from 'react'; +import React, {useEffect} from 'react'; import BeneficialOwnerDetailsFormPages from './BeneficialOwnerDetailsFormPages'; import CompanyOwnersListUBO from './subSteps/CompanyOwnersListUBO'; @@ -80,19 +80,13 @@ function BeneficialOwnersStep({onBackButtonPress, onSubmit, currentSubPage, poli Navigation.setParams({subPage} as Record); }, [currentSubPage, policyID, backTo, isAnyoneElseUBO, beneficialOwnerKeys.length, isUserUBO, hasCompletedBeneficialOwnersStep]); - const navigateToSubPage = useCallback( - (subPage: string) => { - Navigation.navigate(ROUTES.BANK_ACCOUNT_USD_SETUP.getRoute({policyID, page: PAGE_NAMES.BENEFICIAL_OWNERS, subPage, backTo})); - }, - [policyID, backTo], - ); + const navigateToSubPage = (subPage: string) => { + Navigation.navigate(ROUTES.BANK_ACCOUNT_USD_SETUP.getRoute({policyID, page: PAGE_NAMES.BENEFICIAL_OWNERS, subPage, backTo})); + }; - const navigateBackToSubPage = useCallback( - (subPage: string) => { - Navigation.goBack(ROUTES.BANK_ACCOUNT_USD_SETUP.getRoute({policyID, page: PAGE_NAMES.BENEFICIAL_OWNERS, subPage, backTo})); - }, - [policyID, backTo], - ); + const navigateBackToSubPage = (subPage: string) => { + Navigation.goBack(ROUTES.BANK_ACCOUNT_USD_SETUP.getRoute({policyID, page: PAGE_NAMES.BENEFICIAL_OWNERS, subPage, backTo})); + }; const submit = () => { const beneficialOwnerFields = ['firstName', 'lastName', 'dob', 'ssnLast4', 'street', 'city', 'state', 'zipCode']; diff --git a/src/pages/ReimbursementAccount/USD/BusinessInfo/BusinessInfo.tsx b/src/pages/ReimbursementAccount/USD/BusinessInfo/BusinessInfo.tsx index 35dcbef1f177..92aaccd86598 100644 --- a/src/pages/ReimbursementAccount/USD/BusinessInfo/BusinessInfo.tsx +++ b/src/pages/ReimbursementAccount/USD/BusinessInfo/BusinessInfo.tsx @@ -24,7 +24,7 @@ import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import {Str} from 'expensify-common'; import lodashPick from 'lodash/pick'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import AddressBusiness from './subSteps/AddressBusiness'; import ConfirmationBusiness from './subSteps/ConfirmationBusiness'; @@ -70,45 +70,36 @@ function BusinessInfo({onBackButtonPress, onSubmit, backTo}: BusinessInfoProps) const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); - const getBankAccountFields = useCallback( - (fieldNames: string[]) => ({ - ...lodashPick(reimbursementAccount?.achData, ...fieldNames), - ...lodashPick(reimbursementAccountDraft, ...fieldNames), - }), - [reimbursementAccount?.achData, reimbursementAccountDraft], - ); + const getBankAccountFields = (fieldNames: string[]) => ({ + ...lodashPick(reimbursementAccount?.achData, ...fieldNames), + ...lodashPick(reimbursementAccountDraft, ...fieldNames), + }); const policyID = reimbursementAccount?.achData?.policyID; const bankAccountID = getBankAccountIDAsNumber(reimbursementAccount?.achData); const markSubmitting = useReimbursementAccountSubmitCallback(onSubmit); - const values = useMemo(() => getSubStepValues(BUSINESS_INFO_STEP_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]); - - const submit = useCallback( - (isConfirmPage: boolean) => { - const companyWebsite = Str.sanitizeURL(values.website, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME); - updateCompanyInformationForBankAccount( - bankAccountID, - { - ...values, - ...getBankAccountFields(['routingNumber', 'accountNumber', 'bankName', 'plaidAccountID', 'plaidAccessToken', 'isSavings']), - companyTaxID: values.companyTaxID?.replaceAll(CONST.REGEX.NON_NUMERIC, ''), - companyPhone: parsePhoneNumber(values.companyPhone ?? '', {regionCode: CONST.COUNTRY.US}).number?.significant, - website: isValidWebsite(companyWebsite) ? companyWebsite : undefined, - }, - policyID, - isConfirmPage, - ); - }, - [bankAccountID, values, getBankAccountFields, policyID], - ); + const values = getSubStepValues(BUSINESS_INFO_STEP_KEYS, reimbursementAccountDraft, reimbursementAccount); + + const submit = (isConfirmPage: boolean) => { + const companyWebsite = Str.sanitizeURL(values.website, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME); + updateCompanyInformationForBankAccount( + bankAccountID, + { + ...values, + ...getBankAccountFields(['routingNumber', 'accountNumber', 'bankName', 'plaidAccountID', 'plaidAccessToken', 'isSavings']), + companyTaxID: values.companyTaxID?.replaceAll(CONST.REGEX.NON_NUMERIC, ''), + companyPhone: parsePhoneNumber(values.companyPhone ?? '', {regionCode: CONST.COUNTRY.US}).number?.significant, + website: isValidWebsite(companyWebsite) ? companyWebsite : undefined, + }, + policyID, + isConfirmPage, + ); + }; const isBankAccountVerifying = reimbursementAccount?.achData?.state === CONST.BANK_ACCOUNT.STATE.VERIFYING; - const startFrom = useMemo(() => (isBankAccountVerifying ? 0 : getInitialSubStepForBusinessInfo(values)), [values, isBankAccountVerifying]); + const startFrom = isBankAccountVerifying ? 0 : getInitialSubStepForBusinessInfo(values); - const buildRoute = useCallback( - (pageName: string, action?: 'edit') => ROUTES.BANK_ACCOUNT_USD_SETUP.getRoute({policyID, page: PAGE_NAMES.COMPANY, subPage: pageName, action, backTo}), - [policyID, backTo], - ); + const buildRoute = (pageName: string, action?: 'edit') => ROUTES.BANK_ACCOUNT_USD_SETUP.getRoute({policyID, page: PAGE_NAMES.COMPANY, subPage: pageName, action, backTo}); const {CurrentPage, isEditing, currentPageName, pageIndex, nextPage, prevPage, moveTo, isRedirecting} = useSubPage({ pages, diff --git a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/ConfirmationBusiness.tsx b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/ConfirmationBusiness.tsx index 3ae983c5b0f3..d980664fdd3e 100644 --- a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/ConfirmationBusiness.tsx +++ b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/ConfirmationBusiness.tsx @@ -23,7 +23,7 @@ import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import type {CONST as COMMON_CONST} from 'expensify-common'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; type States = keyof typeof COMMON_CONST.STATES; @@ -48,20 +48,17 @@ function ConfirmationBusiness({onNext, onMove}: SubPageProps) { const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, [BUSINESS_INFO_STEP_KEYS.HAS_NO_CONNECTION_TO_CANNABIS], translate); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, [BUSINESS_INFO_STEP_KEYS.HAS_NO_CONNECTION_TO_CANNABIS], translate); - if (!values.hasNoConnectionToCannabis) { - errors.hasNoConnectionToCannabis = translate('bankAccount.error.restrictedBusiness'); - } + if (!values.hasNoConnectionToCannabis) { + errors.hasNoConnectionToCannabis = translate('bankAccount.error.restrictedBusiness'); + } - return errors; - }, - [translate], - ); + return errors; + }; - const values = useMemo(() => getSubStepValues(BUSINESS_INFO_STEP_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]); + const values = getSubStepValues(BUSINESS_INFO_STEP_KEYS, reimbursementAccountDraft, reimbursementAccount); const defaultCheckboxState = reimbursementAccountDraft?.[BUSINESS_INFO_STEP_KEYS.HAS_NO_CONNECTION_TO_CANNABIS] ?? false; diff --git a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/IncorporationDateBusiness.tsx b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/IncorporationDateBusiness.tsx index e4a81d1b91bf..10c205fe4a67 100644 --- a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/IncorporationDateBusiness.tsx +++ b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/IncorporationDateBusiness.tsx @@ -18,7 +18,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; const COMPANY_INCORPORATION_DATE_KEY = INPUT_IDS.BUSINESS_INFO_STEP.INCORPORATION_DATE; @@ -32,20 +32,17 @@ function IncorporationDateBusiness({onNext, isEditing}: SubPageProps) { const isLoadingReimbursementAccount = isLoadingOnyxValue(reimbursementAccountResult); const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); - if (values.incorporationDate && !isValidDate(values.incorporationDate)) { - errors.incorporationDate = translate('common.error.dateInvalid'); - } else if (values.incorporationDate && !isValidPastDate(values.incorporationDate)) { - errors.incorporationDate = translate('bankAccount.error.incorporationDateFuture'); - } + if (values.incorporationDate && !isValidDate(values.incorporationDate)) { + errors.incorporationDate = translate('common.error.dateInvalid'); + } else if (values.incorporationDate && !isValidPastDate(values.incorporationDate)) { + errors.incorporationDate = translate('bankAccount.error.incorporationDateFuture'); + } - return errors; - }, - [translate], - ); + return errors; + }; const defaultCompanyIncorporationDate = reimbursementAccount?.achData?.incorporationDate ?? reimbursementAccountDraft?.incorporationDate ?? ''; diff --git a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/IndustryCode/IndustryCodeSelector.tsx b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/IndustryCode/IndustryCodeSelector.tsx index fea27e01e069..afeb3aadc469 100644 --- a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/IndustryCode/IndustryCodeSelector.tsx +++ b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/IndustryCode/IndustryCodeSelector.tsx @@ -9,7 +9,7 @@ import CONST from '@src/CONST'; import {ALL_NAICS, NAICS, NAICS_MAPPING_WITH_ID} from '@src/NAICS'; import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; type IndustryCodeSelectorProps = { @@ -39,30 +39,18 @@ function IndustryCodeSelector({onInputChange, value, errorText}: IndustryCodeSel }, []), ); - const codeOptions = useMemo(() => { - if (!searchValue) { - return NAICS.map((item) => { - return { - value: `${item.id}`, - text: `${item.id} - ${item.value}`, - keyForList: `${item.id}`, - isSelected: `${item.id}` === value, - }; - }); - } - - if (shouldDisplayChildItems) { - return (NAICS_MAPPING_WITH_ID[searchValue] ?? []).map((item) => { - return { - value: `${item.id}`, - text: `${item.id} - ${item.value}`, - keyForList: `${item.id}`, - isSelected: `${item.id}` === value, - }; - }); - } - - return ALL_NAICS.filter((item) => item.id.toString().toLowerCase().startsWith(searchValue.toLowerCase())).map((item) => { + let codeOptions; + if (!searchValue) { + codeOptions = NAICS.map((item) => { + return { + value: `${item.id}`, + text: `${item.id} - ${item.value}`, + keyForList: `${item.id}`, + isSelected: `${item.id}` === value, + }; + }); + } else if (shouldDisplayChildItems) { + codeOptions = (NAICS_MAPPING_WITH_ID[searchValue] ?? []).map((item) => { return { value: `${item.id}`, text: `${item.id} - ${item.value}`, @@ -70,26 +58,32 @@ function IndustryCodeSelector({onInputChange, value, errorText}: IndustryCodeSel isSelected: `${item.id}` === value, }; }); - }, [searchValue, shouldDisplayChildItems, value]); + } else { + codeOptions = ALL_NAICS.filter((item) => item.id.toString().toLowerCase().startsWith(searchValue.toLowerCase())).map((item) => { + return { + value: `${item.id}`, + text: `${item.id} - ${item.value}`, + keyForList: `${item.id}`, + isSelected: `${item.id}` === value, + }; + }); + } useEffect(() => { setSearchValue(value); }, [value]); - const textInputOptions = useMemo( - () => ({ - label: translate('companyStep.industryClassificationCodePlaceholder'), - onChangeText: (val: string | undefined) => { - setSearchValue(val); - setShouldDisplayChildItems(false); - onInputChange?.(val); - }, - value: searchValue, - disableAutoFocus: true, - errorText, - }), - [errorText, onInputChange, searchValue, translate], - ); + const textInputOptions = { + label: translate('companyStep.industryClassificationCodePlaceholder'), + onChangeText: (val: string | undefined) => { + setSearchValue(val); + setShouldDisplayChildItems(false); + onInputChange?.(val); + }, + value: searchValue, + disableAutoFocus: true, + errorText, + }; return ( diff --git a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/NameBusiness.tsx b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/NameBusiness.tsx index 3155a0ee5f83..f76d605b1ff6 100644 --- a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/NameBusiness.tsx +++ b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/NameBusiness.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; -import React, {useCallback} from 'react'; +import React from 'react'; const COMPANY_NAME_KEY = INPUT_IDS.BUSINESS_INFO_STEP.COMPANY_NAME; const STEP_FIELDS = [COMPANY_NAME_KEY]; @@ -33,18 +33,15 @@ function NameBusiness({onNext, onMove, isEditing}: SubPageProps) { bankAccountState !== CONST.BANK_ACCOUNT.STATE.VERIFYING ); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); - if (values.companyName && !isValidCompanyName(values.companyName)) { - errors.companyName = translate('bankAccount.error.companyName'); - } + if (values.companyName && !isValidCompanyName(values.companyName)) { + errors.companyName = translate('bankAccount.error.companyName'); + } - return errors; - }, - [translate], - ); + return errors; + }; const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/PhoneNumberBusiness.tsx b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/PhoneNumberBusiness.tsx index 70d4c2b59363..e717881a382a 100644 --- a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/PhoneNumberBusiness.tsx +++ b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/PhoneNumberBusiness.tsx @@ -15,7 +15,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; const COMPANY_PHONE_NUMBER_KEY = INPUT_IDS.BUSINESS_INFO_STEP.COMPANY_PHONE; @@ -30,18 +30,15 @@ function PhoneNumberBusiness({onNext, onMove, isEditing}: SubPageProps) { const defaultCompanyPhoneNumber = reimbursementAccount?.achData?.companyPhone ?? ''; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); - if (values.companyPhone && !isValidUSPhone(values.companyPhone, true)) { - errors.companyPhone = translate('bankAccount.error.phoneNumber'); - } + if (values.companyPhone && !isValidUSPhone(values.companyPhone, true)) { + errors.companyPhone = translate('bankAccount.error.phoneNumber'); + } - return errors; - }, - [translate], - ); + return errors; + }; const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TaxIdBusiness.tsx b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TaxIdBusiness.tsx index 675981a5b2cb..a50de4a017b2 100644 --- a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TaxIdBusiness.tsx +++ b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TaxIdBusiness.tsx @@ -15,7 +15,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; const COMPANY_TAX_ID_KEY = INPUT_IDS.BUSINESS_INFO_STEP.COMPANY_TAX_ID; @@ -39,18 +39,15 @@ function TaxIdBusiness({onNext, onMove, isEditing}: SubPageProps) { bankAccountState !== CONST.BANK_ACCOUNT.STATE.VERIFYING ); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); - if (values.companyTaxID && !isValidTaxID(values.companyTaxID)) { - errors.companyTaxID = translate('bankAccount.error.taxID'); - } + if (values.companyTaxID && !isValidTaxID(values.companyTaxID)) { + errors.companyTaxID = translate('bankAccount.error.taxID'); + } - return errors; - }, - [translate], - ); + return errors; + }; const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/BusinessTypeSelectorModal.tsx b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/BusinessTypeSelectorModal.tsx index f7140b87805e..5dff353e0449 100644 --- a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/BusinessTypeSelectorModal.tsx +++ b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/BusinessTypeSelectorModal.tsx @@ -11,7 +11,7 @@ import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; -import React, {useMemo} from 'react'; +import React from 'react'; import type {BusinessTypeItemType, IncorporationType} from './types'; @@ -30,16 +30,12 @@ function BusinessTypeSelectorModal({isVisible, currentBusinessType, onBusinessTy const styles = useThemeStyles(); const {translate} = useLocalize(); - const incorporationTypes = useMemo( - () => - Object.keys(CONST.INCORPORATION_TYPES).map((key) => ({ - value: key, - text: translate(`businessInfoStep.incorporationType.${key as IncorporationType}`), - keyForList: key, - isSelected: key === currentBusinessType, - })), - [currentBusinessType, translate], - ); + const incorporationTypes = Object.keys(CONST.INCORPORATION_TYPES).map((key) => ({ + value: key, + text: translate(`businessInfoStep.incorporationType.${key as IncorporationType}`), + keyForList: key, + isSelected: key === currentBusinessType, + })); return ( getDefaultCompanyWebsite(session, account, true), [session, account]); + const defaultWebsiteExample = getDefaultCompanyWebsite(session, account, true); const defaultCompanyWebsite = reimbursementAccount?.achData?.website ?? defaultWebsiteExample; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); - if (values.website && !isValidWebsite(Str.sanitizeURL(values.website, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME))) { - errors.website = translate('bankAccount.error.website'); - } + if (values.website && !isValidWebsite(Str.sanitizeURL(values.website, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME))) { + errors.website = translate('bankAccount.error.website'); + } - return errors; - }, - [translate], - ); + return errors; + }; const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: STEP_FIELDS, onNext: (values) => { diff --git a/src/pages/ReimbursementAccount/USD/CompleteVerification/CompleteVerification.tsx b/src/pages/ReimbursementAccount/USD/CompleteVerification/CompleteVerification.tsx index 2101519f4188..a90f531e7650 100644 --- a/src/pages/ReimbursementAccount/USD/CompleteVerification/CompleteVerification.tsx +++ b/src/pages/ReimbursementAccount/USD/CompleteVerification/CompleteVerification.tsx @@ -14,7 +14,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import ConfirmAgreements from './subSteps/ConfirmAgreements'; @@ -34,12 +34,12 @@ function CompleteVerification({onBackButtonPress, onSubmit}: CompleteVerificatio const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); const [lastPaymentMethod] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD); - const values = useMemo(() => getSubStepValues(COMPLETE_VERIFICATION_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]); + const values = getSubStepValues(COMPLETE_VERIFICATION_KEYS, reimbursementAccountDraft, reimbursementAccount); const policyID = reimbursementAccount?.achData?.policyID; const bankAccountID = getBankAccountIDAsNumber(reimbursementAccount?.achData); const markSubmitting = useReimbursementAccountSubmitCallback(onSubmit); - const submit = useCallback(() => { + const submit = () => { acceptACHContractForBankAccount( bankAccountID, { @@ -51,16 +51,7 @@ function CompleteVerification({onBackButtonPress, onSubmit}: CompleteVerificatio policyID ? lastPaymentMethod?.[policyID] : undefined, ); markSubmitting(); - }, [ - bankAccountID, - values.isAuthorizedToUseBankAccount, - values.certifyTrueInformation, - values.acceptTermsAndConditions, - reimbursementAccount?.achData?.acceptTerms, - policyID, - lastPaymentMethod, - markSubmitting, - ]); + }; return ( getSubStepValues(COMPLETE_VERIFICATION_KEYS, reimbursementAccountDraft, reimbursementAccount), - [reimbursementAccount, reimbursementAccountDraft], - ); + const confirmAgreementsValues = getSubStepValues(COMPLETE_VERIFICATION_KEYS, reimbursementAccountDraft, reimbursementAccount); const defaultValues = { isAuthorizedToUseBankAccount: confirmAgreementsValues.isAuthorizedToUseBankAccount ?? false, certifyTrueInformation: confirmAgreementsValues.certifyTrueInformation ?? false, acceptTermsAndConditions: (confirmAgreementsValues.acceptTermsAndConditions || reimbursementAccount?.achData?.acceptTerms) ?? false, }; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); - if (!isRequiredFulfilled(values.acceptTermsAndConditions)) { - errors.acceptTermsAndConditions = translate('common.error.acceptTerms'); - } + if (!isRequiredFulfilled(values.acceptTermsAndConditions)) { + errors.acceptTermsAndConditions = translate('common.error.acceptTerms'); + } - if (!isRequiredFulfilled(values.certifyTrueInformation)) { - errors.certifyTrueInformation = translate('completeVerificationStep.certifyTrueAndAccurateError'); - } + if (!isRequiredFulfilled(values.certifyTrueInformation)) { + errors.certifyTrueInformation = translate('completeVerificationStep.certifyTrueAndAccurateError'); + } - if (!isRequiredFulfilled(values.isAuthorizedToUseBankAccount)) { - errors.isAuthorizedToUseBankAccount = translate('completeVerificationStep.isAuthorizedToUseBankAccountError'); - } + if (!isRequiredFulfilled(values.isAuthorizedToUseBankAccount)) { + errors.isAuthorizedToUseBankAccount = translate('completeVerificationStep.isAuthorizedToUseBankAccountError'); + } - return errors; - }, - [translate], - ); + return errors; + }; return ( { + const submit = () => { const params: Record = {}; for (const [key, files] of Object.entries(uploadedFiles)) { const file = files.at(0); @@ -167,7 +167,7 @@ function KYBDocuments({onBackButtonPress, onSubmit}: KYBDocumentsProps) { bankAccountID, }); markSubmitting(); - }, [uploadedFiles, bankAccountID, markSubmitting]); + }; const footer = ( - )} - - ), - [ - styles.w100, - styles.mt5, - styles.mt0, - iouType, - skipConfirmation, - submitAndNavigateToNextPage, - currency, - policyID, - canUseTouchScreen, - chatReportID, - isEditing, - isExtraSmallScreenHeight, - buttonText, - ], + const footer = ( + + {iouType === CONST.IOU.TYPE.PAY && skipConfirmation ? ( + + ) : ( + + )} + ); return ( diff --git a/src/pages/iou/RejectReasonPage.tsx b/src/pages/iou/RejectReasonPage.tsx index b157858f53fd..a10aa1f6978a 100644 --- a/src/pages/iou/RejectReasonPage.tsx +++ b/src/pages/iou/RejectReasonPage.tsx @@ -28,7 +28,7 @@ import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/MoneyRequestRejectReasonForm'; import {getReportPolicyID} from '@selectors/Report'; -import React, {useCallback, useEffect} from 'react'; +import React, {useEffect} from 'react'; import RejectReasonFormView from './RejectReasonFormView'; @@ -83,17 +83,14 @@ function RejectReasonPage({route}: RejectReasonPageProps) { } }; - const validate = useCallback( - (values: FormOnyxValues) => { - const errors: FormInputErrors = getFieldRequiredErrors(values, [INPUT_IDS.COMMENT], translate); + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = getFieldRequiredErrors(values, [INPUT_IDS.COMMENT], translate); - if (!values.comment) { - errors.comment = translate('common.error.fieldRequired'); - } - return errors; - }, - [translate], - ); + if (!values.comment) { + errors.comment = translate('common.error.fieldRequired'); + } + return errors; + }; useEffect(() => { clearErrors(ONYXKEYS.FORMS.MONEY_REQUEST_REJECT_FORM); diff --git a/src/pages/iou/SplitList/useHandleInputFocus/index.android.ts b/src/pages/iou/SplitList/useHandleInputFocus/index.android.ts index 3db70c45bf40..512a1dae2ccb 100644 --- a/src/pages/iou/SplitList/useHandleInputFocus/index.android.ts +++ b/src/pages/iou/SplitList/useHandleInputFocus/index.android.ts @@ -2,22 +2,17 @@ import type {SplitListItemType} from '@components/SelectionList/ListItem/types'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; -import {useCallback} from 'react'; - import type UseHandleInputFocusProps from './types'; function useHandleInputFocus({listRef}: UseHandleInputFocusProps) { const isInLandscapeMode = useIsInLandscapeMode(); - return useCallback( - (item: SplitListItemType) => { - if (!listRef.current || isInLandscapeMode) { - return; - } - listRef.current?.scrollToFocusedInput(item); - }, - [listRef, isInLandscapeMode], - ); + return (item: SplitListItemType) => { + if (!listRef.current || isInLandscapeMode) { + return; + } + listRef.current?.scrollToFocusedInput(item); + }; } export default useHandleInputFocus; diff --git a/src/pages/iou/SplitList/useHandleInputFocus/index.ts b/src/pages/iou/SplitList/useHandleInputFocus/index.ts index ed91373cd43d..e2f725e608c5 100644 --- a/src/pages/iou/SplitList/useHandleInputFocus/index.ts +++ b/src/pages/iou/SplitList/useHandleInputFocus/index.ts @@ -1,19 +1,14 @@ import type {SplitListItemType} from '@components/SelectionList/ListItem/types'; -import {useCallback} from 'react'; - import type UseHandleInputFocusProps from './types'; function useHandleInputFocus({listRef}: UseHandleInputFocusProps) { - return useCallback( - (item: SplitListItemType) => { - if (!listRef.current) { - return; - } - listRef.current?.scrollToFocusedInput(item); - }, - [listRef], - ); + return (item: SplitListItemType) => { + if (!listRef.current) { + return; + } + listRef.current?.scrollToFocusedInput(item); + }; } export default useHandleInputFocus; diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index 8a5bdf509caa..26c30345826e 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -29,7 +29,7 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {TupleToUnion} from 'type-fest'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import type {WithWritableReportOrNotFoundProps} from './step/withWritableReportOrNotFound'; @@ -99,25 +99,24 @@ function DistanceRequestStartPage({ [CONST.IOU.TYPE.CREATE]: translate('iou.trackDistance'), }; - const transactionRequestType = useMemo(() => { - if (!transaction?.iouRequestType) { - // The tab navigator renders whichever tab was last selected, so the draft has to be typed from that - // same value. Preferring the last-created distance type instead rebuilds the draft as Odometer under - // a visible Map tab, leaving it without waypoints so tapping one opens the "Not here" page. - const rememberedRequestType = selectedTab ?? lastDistanceExpenseType ?? CONST.IOU.REQUEST_TYPE.DISTANCE_MAP; - - // Both of those values are remembered from a previous expense, so they go stale once the destination - // starts requiring map or GPS. Typing the draft from a tab that is no longer rendered would leave it - // as Manual or Odometer under a visible Map tab, which later steps then reject. - if (shouldHideManualAndOdometerTabs && (rememberedRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL || rememberedRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER)) { - return CONST.IOU.REQUEST_TYPE.DISTANCE_MAP; - } - - return rememberedRequestType; + let transactionRequestType; + if (!transaction?.iouRequestType) { + // The tab navigator renders whichever tab was last selected, so the draft has to be typed from that + // same value. Preferring the last-created distance type instead rebuilds the draft as Odometer under + // a visible Map tab, leaving it without waypoints so tapping one opens the "Not here" page. + const rememberedRequestType = selectedTab ?? lastDistanceExpenseType ?? CONST.IOU.REQUEST_TYPE.DISTANCE_MAP; + + // Both of those values are remembered from a previous expense, so they go stale once the destination + // starts requiring map or GPS. Typing the draft from a tab that is no longer rendered would leave it + // as Manual or Odometer under a visible Map tab, which later steps then reject. + if (shouldHideManualAndOdometerTabs && (rememberedRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL || rememberedRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER)) { + transactionRequestType = CONST.IOU.REQUEST_TYPE.DISTANCE_MAP; + } else { + transactionRequestType = rememberedRequestType; } - - return transaction.iouRequestType; - }, [transaction?.iouRequestType, selectedTab, lastDistanceExpenseType, shouldHideManualAndOdometerTabs]); + } else { + transactionRequestType = transaction.iouRequestType; + } const resetIOUTypeIfChanged = useResetIOUType({ reportID, @@ -142,9 +141,7 @@ function DistanceRequestStartPage({ const [tabBarContainerElement, setTabBarContainerElement] = useState(null); const [activeTabContainerElement, setActiveTabContainerElement] = useState(null); - const focusTrapContainerElements = useMemo(() => { - return [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element); - }, [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement]); + const focusTrapContainerElements = [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element); return ( >(() => { - if (!shouldUseTab) { - return new Set(); - } + let availableTabs: Set; + if (!shouldUseTab) { + availableTabs = new Set(); + } else { const tabs = new Set([CONST.TAB_REQUEST.MANUAL, CONST.TAB_REQUEST.SCAN]); if (iouType === CONST.IOU.TYPE.SPLIT) { tabs.add(CONST.TAB_REQUEST.DISTANCE); @@ -147,8 +147,8 @@ function IOURequestStartPage({ if (shouldShowTimeOption) { tabs.add(CONST.TAB_REQUEST.TIME); } - return tabs; - }, [shouldUseTab, iouType, shouldShowPerDiemOption, shouldShowTimeOption]); + availableTabs = tabs; + } // A quick-action deeplink (e.g. iOS home-screen "Scan receipt") bypasses startMoneyRequest // and leaves the previous flow's draft in place under OPTIMISTIC_TRANSACTION_ID. Detect it @@ -159,20 +159,18 @@ function IOURequestStartPage({ } const isStaleTransactionDraft = latchedDraftStaleness?.isStale ?? false; - const transactionRequestType = useMemo(() => { - if (transaction?.iouRequestType && !isStaleTransactionDraft) { - return transaction.iouRequestType; - } - if (!shouldUseTab) { - return CONST.IOU.REQUEST_TYPE.MANUAL; - } - // selectedTab must be valid for the currently-rendered tab set; otherwise let - // OnyxTabNavigator.onTabSelected initialize from the URL (which is authoritative). - if (selectedTab && availableTabs.has(selectedTab)) { - return selectedTab; - } - return undefined; - }, [transaction?.iouRequestType, isStaleTransactionDraft, shouldUseTab, selectedTab, availableTabs]); + let transactionRequestType; + if (transaction?.iouRequestType && !isStaleTransactionDraft) { + transactionRequestType = transaction.iouRequestType; + } else if (!shouldUseTab) { + transactionRequestType = CONST.IOU.REQUEST_TYPE.MANUAL; + } else // selectedTab must be valid for the currently-rendered tab set; otherwise let + // OnyxTabNavigator.onTabSelected initialize from the URL (which is authoritative). + if (selectedTab && availableTabs.has(selectedTab)) { + transactionRequestType = selectedTab; + } else { + transactionRequestType = undefined; + } const resetIOUTypeIfChanged = useResetIOUType({ reportID, @@ -223,9 +221,7 @@ function IOURequestStartPage({ const [tabBarContainerElement, setTabBarContainerElement] = useState(null); const [activeTabContainerElement, setActiveTabContainerElement] = useState(null); - const focusTrapContainerElements = useMemo(() => { - return [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element); - }, [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement]); + const focusTrapContainerElements = [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element); const onBackButtonPress = () => { navigateBack(); diff --git a/src/pages/iou/request/MoneyRequestAccountantSelector.tsx b/src/pages/iou/request/MoneyRequestAccountantSelector.tsx index 7803fc70800c..e635de3d53d9 100644 --- a/src/pages/iou/request/MoneyRequestAccountantSelector.tsx +++ b/src/pages/iou/request/MoneyRequestAccountantSelector.tsx @@ -27,7 +27,7 @@ import type {Accountant} from '@src/types/onyx/IOU'; import type {GestureResponderEvent} from 'react-native'; import lodashPick from 'lodash/pick'; -import React, {memo, useCallback, useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; const memoizedGetValidOptions = memoize(getValidOptions, {maxSize: 5, monitoringName: 'MoneyRequestAccountantSelector.getValidOptions'}); @@ -67,26 +67,21 @@ function MoneyRequestAccountantSelector({onFinish, onAccountantSelected, iouType searchUserInServer(debouncedSearchTerm.trim()); }, [debouncedSearchTerm]); - const optionsList = useMemo(() => { - if (!areOptionsInitialized || !didScreenTransitionEnd) { - return defaultListOptions; - } - - return memoizedGetValidOptions(personalDetailOptions ?? [], currentUserEmail, formatPhoneNumber, countryCode, loginList, { - excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, - searchString: debouncedSearchTerm, - includeUserToInvite: true, - }); - }, [areOptionsInitialized, didScreenTransitionEnd, personalDetailOptions, currentUserEmail, formatPhoneNumber, countryCode, loginList, debouncedSearchTerm]); - - const sections = useMemo(() => { - const newSections: Array> = []; - if (!areOptionsInitialized || !didScreenTransitionEnd) { - return newSections; - } - + const optionsList = + !areOptionsInitialized || !didScreenTransitionEnd + ? defaultListOptions + : memoizedGetValidOptions(personalDetailOptions ?? [], currentUserEmail, formatPhoneNumber, countryCode, loginList, { + excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, + searchString: debouncedSearchTerm, + includeUserToInvite: true, + }); + + let sections; + const newSections: Array> = []; + if (!areOptionsInitialized || !didScreenTransitionEnd) { + sections = newSections; + } else { let sectionIndex = 0; - if (optionsList.recentOptions.length > 0) { newSections.push({ title: translate('common.recents'), @@ -94,7 +89,6 @@ function MoneyRequestAccountantSelector({onFinish, onAccountantSelected, iouType sectionIndex: sectionIndex++, }); } - if (optionsList.personalDetails.length > 0) { newSections.push({ title: translate('common.contacts'), @@ -102,35 +96,27 @@ function MoneyRequestAccountantSelector({onFinish, onAccountantSelected, iouType sectionIndex: sectionIndex++, }); } - if (optionsList.userToInvite) { newSections.push({ data: [optionsList.userToInvite], sectionIndex: sectionIndex++, }); } + sections = newSections; + } - return newSections; - }, [areOptionsInitialized, didScreenTransitionEnd, optionsList.recentOptions, optionsList.personalDetails, optionsList.userToInvite, translate]); - - const selectAccountant = useCallback( - (option: Accountant) => { - onAccountantSelected(lodashPick(option, 'accountID', 'login')); - onFinish(); - }, - [onAccountantSelected, onFinish], - ); + const selectAccountant = (option: Accountant) => { + onAccountantSelected(lodashPick(option, 'accountID', 'login')); + onFinish(); + }; - const handleConfirmSelection = useCallback( - (keyEvent?: GestureResponderEvent | KeyboardEvent, option?: Accountant) => { - if (!option) { - return; - } + const handleConfirmSelection = (keyEvent?: GestureResponderEvent | KeyboardEvent, option?: Accountant) => { + if (!option) { + return; + } - selectAccountant(option); - }, - [selectAccountant], - ); + selectAccountant(option); + }; const getHeaderMessageText = () => { if (sections.length > 0) { @@ -143,16 +129,11 @@ function MoneyRequestAccountantSelector({onFinish, onAccountantSelected, iouType return getHeaderMessage(translate, debouncedSearchTerm, countryCode); }; - const shouldShowLoadingPlaceholder = useMemo(() => !areOptionsInitialized || !didScreenTransitionEnd, [areOptionsInitialized, didScreenTransitionEnd]); + const shouldShowLoadingPlaceholder = !areOptionsInitialized || !didScreenTransitionEnd; - const optionLength = useMemo(() => { - if (!areOptionsInitialized) { - return 0; - } - return sections.reduce((acc, section) => acc + section.data.length, 0); - }, [areOptionsInitialized, sections]); + const optionLength = !areOptionsInitialized ? 0 : sections.reduce((acc, section) => acc + section.data.length, 0); - const shouldShowListEmptyContent = useMemo(() => optionLength === 0 && !shouldShowLoadingPlaceholder, [optionLength, shouldShowLoadingPlaceholder]); + const shouldShowListEmptyContent = optionLength === 0 && !shouldShowLoadingPlaceholder; const textInputOptions = { value: searchTerm, @@ -182,4 +163,4 @@ function MoneyRequestAccountantSelector({onFinish, onAccountantSelected, iouType ); } -export default memo(MoneyRequestAccountantSelector, (prevProps, nextProps) => prevProps.iouType === nextProps.iouType); +export default MoneyRequestAccountantSelector; diff --git a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx index ae4aa5f7a2c5..5c2efb0e9c6b 100644 --- a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx +++ b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx @@ -30,8 +30,7 @@ import getEmptyArray from '@src/types/utils/getEmptyArray'; import type {GestureResponderEvent} from 'react-native'; import {SafeString} from 'expensify-common'; -import {deepEqual} from 'fast-equals'; -import React, {memo, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; type MoneyRequestAttendeesSelectorProps = { /** Callback to request parent modal to go to next step, which should be split */ @@ -254,5 +253,4 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde ); } -// eslint-disable-next-line rulesdir/no-deep-equal-in-memo -- attendees array is derived and may have unstable references -export default memo(MoneyRequestAttendeeSelector, (prevProps, nextProps) => deepEqual(prevProps.attendees, nextProps.attendees) && prevProps.iouType === nextProps.iouType); +export default MoneyRequestAttendeeSelector; diff --git a/src/pages/iou/request/step/DynamicIOURequestEditReport.tsx b/src/pages/iou/request/step/DynamicIOURequestEditReport.tsx index 4cdc6d5e84c3..94fbac45951d 100644 --- a/src/pages/iou/request/step/DynamicIOURequestEditReport.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestEditReport.tsx @@ -42,7 +42,7 @@ import type {PersonalDetails, Report} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import React, {useMemo} from 'react'; +import React from 'react'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -80,10 +80,7 @@ function DynamicIOURequestEditReport({route}: DynamicIOURequestEditReportProps) const [allPolicyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}`); const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); const personalDetails = usePersonalDetails(); - const ownerPersonalDetails = useMemo( - () => getPersonalDetailsForAccountID(selectedReport?.ownerAccountID, personalDetails) as PersonalDetails, - [personalDetails, selectedReport?.ownerAccountID], - ); + const ownerPersonalDetails = getPersonalDetailsForAccountID(selectedReport?.ownerAccountID, personalDetails) as PersonalDetails; const [transactions] = useTransactionsByID(transactionIDs); const managedCardTransactionID = transactions.find((transaction) => isUnreportedManagedCardTransaction(transaction))?.transactionID; const hasUnreportedManagedCardTransactions = !!managedCardTransactionID; diff --git a/src/pages/iou/request/step/DynamicIOURequestStepAttendees.tsx b/src/pages/iou/request/step/DynamicIOURequestStepAttendees.tsx index 0d3fcae34691..aac40ab5fc3f 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepAttendees.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepAttendees.tsx @@ -29,7 +29,7 @@ import type {Attendee} from '@src/types/onyx/IOU'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import {loginSelector} from '@selectors/PersonalDetails'; import {deepEqual} from 'fast-equals'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -80,7 +80,7 @@ function DynamicIOURequestStepAttendees({ backPathRef.current = backPath; }, [backPath]); - const saveAttendees = useCallback(() => { + const saveAttendees = () => { if (attendees.length <= 0) { return; } @@ -114,30 +114,7 @@ function DynamicIOURequestStepAttendees({ } Navigation.goBack(backPathRef.current, {shouldSkipFocusRestore: true}); - }, [ - isVendorMatchingBetaEnabled, - attendees, - previousAttendees, - isEditing, - transactionID, - report, - parentReport, - iouReportOwnerLogin, - policy, - policyTags, - policyCategories, - allTransactionViolations, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - isOffline, - delegateAccountID, - reportPolicyTags, - isTrackIntentUser, - getCurrencyDecimals, - getCurrencySymbol, - rules, - ]); + }; const navigateBack = () => { Navigation.goBack(backPathRef.current); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepCompanyInfo.tsx b/src/pages/iou/request/step/DynamicIOURequestStepCompanyInfo.tsx index b22490311976..04e0db526833 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepCompanyInfo.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepCompanyInfo.tsx @@ -35,7 +35,7 @@ import INPUT_IDS from '@src/types/form/MoneyRequestCompanyInfoForm'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import {Str} from 'expensify-common'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -59,7 +59,7 @@ function DynamicIOURequestStepCompanyInfo({route, report, transaction}: DynamicI const delegateAccountID = useDelegateAccountID(); const [session] = useOnyx(ONYXKEYS.SESSION); const [account] = useOnyx(ONYXKEYS.ACCOUNT); - const defaultWebsiteExample = useMemo(() => getDefaultCompanyWebsite(session, account), [session, account]); + const defaultWebsiteExample = getDefaultCompanyWebsite(session, account); const policyID = getIOURequestPolicyID(transaction, report); const policy = usePolicy(policyID); @@ -72,28 +72,25 @@ function DynamicIOURequestStepCompanyInfo({route, report, transaction}: DynamicI const formattedAmount = convertToDisplayString(Math.abs(transaction?.amount ?? 0), transaction?.currency); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, [INPUT_IDS.COMPANY_NAME, INPUT_IDS.COMPANY_WEBSITE], translate); - if (values.companyWebsite) { - const companyWebsite = Str.sanitizeURL(values.companyWebsite, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME); - if (!isValidWebsite(companyWebsite)) { - errors.companyWebsite = translate('bankAccount.error.website'); - } else { - const domain = extractUrlDomain(companyWebsite); - - if (!domain || !Str.isValidDomainName(domain)) { - errors.companyWebsite = translate('iou.invalidDomainError'); - } else if (isPublicDomain(domain)) { - errors.companyWebsite = translate('iou.publicDomainError'); - } + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, [INPUT_IDS.COMPANY_NAME, INPUT_IDS.COMPANY_WEBSITE], translate); + if (values.companyWebsite) { + const companyWebsite = Str.sanitizeURL(values.companyWebsite, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME); + if (!isValidWebsite(companyWebsite)) { + errors.companyWebsite = translate('bankAccount.error.website'); + } else { + const domain = extractUrlDomain(companyWebsite); + + if (!domain || !Str.isValidDomainName(domain)) { + errors.companyWebsite = translate('iou.invalidDomainError'); + } else if (isPublicDomain(domain)) { + errors.companyWebsite = translate('iou.publicDomainError'); } } + } - return errors; - }, - [translate], - ); + return errors; + }; const submit = (values: FormOnyxValues) => { const companyWebsite = Str.sanitizeURL(values.companyWebsite, CONST.COMPANY_WEBSITE_DEFAULT_SCHEME); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDate.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDate.tsx index 883822bbd2c6..ac2ed9fcd4af 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDate.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDate.tsx @@ -44,7 +44,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import {loginSelector} from '@selectors/PersonalDetails'; import lodashIsEmpty from 'lodash/isEmpty'; -import React, {useCallback} from 'react'; +import React from 'react'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -182,16 +182,13 @@ function DynamicIOURequestStepDate({ saveAndNavigateBack(); }; - const validate = useCallback( - (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; - if (!values[INPUT_IDS.MONEY_REQUEST_CREATED] || values[INPUT_IDS.MONEY_REQUEST_CREATED] === '') { - errors[INPUT_IDS.MONEY_REQUEST_CREATED] = translate('common.error.fieldRequired'); - } - return errors; - }, - [translate], - ); + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + if (!values[INPUT_IDS.MONEY_REQUEST_CREATED] || values[INPUT_IDS.MONEY_REQUEST_CREATED] === '') { + errors[INPUT_IDS.MONEY_REQUEST_CREATED] = translate('common.error.fieldRequired'); + } + return errors; + }; return ( { - if (!isTransactionDraft || iouType === CONST.IOU.TYPE.SPLIT_EXPENSE) { - return Parser.htmlToMarkdown(isEditingSplit && !lodashIsEmpty(splitDraftTransaction) ? (splitDraftTransaction?.comment?.comment ?? '') : (transaction?.comment?.comment ?? '')); - } - return isEditingSplit && !lodashIsEmpty(splitDraftTransaction) ? (splitDraftTransaction?.comment?.comment ?? '') : (transaction?.comment?.comment ?? ''); - }, [isTransactionDraft, iouType, isEditingSplit, splitDraftTransaction, transaction?.comment?.comment]); + let currentDescriptionInMarkdown; + if (!isTransactionDraft || iouType === CONST.IOU.TYPE.SPLIT_EXPENSE) { + currentDescriptionInMarkdown = Parser.htmlToMarkdown( + isEditingSplit && !lodashIsEmpty(splitDraftTransaction) ? (splitDraftTransaction?.comment?.comment ?? '') : (transaction?.comment?.comment ?? ''), + ); + } else { + currentDescriptionInMarkdown = isEditingSplit && !lodashIsEmpty(splitDraftTransaction) ? (splitDraftTransaction?.comment?.comment ?? '') : (transaction?.comment?.comment ?? ''); + } const [currentDescription, setCurrentDescription] = useState(currentDescriptionInMarkdown); const [isSaved, setIsSaved] = useState(false); @@ -117,18 +119,15 @@ function DynamicIOURequestStepDescription({ /** * @returns - An object containing the errors for each inputID */ - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = {}; + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = {}; - if (values.moneyRequestComment.length > CONST.DESCRIPTION_LIMIT) { - addErrorMessage(errors, 'moneyRequestComment', translate('common.error.characterLimitExceedCounter', values.moneyRequestComment.length, CONST.DESCRIPTION_LIMIT)); - } + if (values.moneyRequestComment.length > CONST.DESCRIPTION_LIMIT) { + addErrorMessage(errors, 'moneyRequestComment', translate('common.error.characterLimitExceedCounter', values.moneyRequestComment.length, CONST.DESCRIPTION_LIMIT)); + } - return errors; - }, - [translate], - ); + return errors; + }; const {navigateBack, armNavigateBack} = useNavigateBackOnSave(isSaved, backPath); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx index 5716d5cdf80c..c25cd5cf14a5 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx @@ -69,7 +69,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import {deepEqual} from 'fast-equals'; import isEmpty from 'lodash/isEmpty'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -108,7 +108,7 @@ function DynamicIOURequestStepDistance({ const isVendorMatchingBetaEnabled = isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING); const isArchived = useReportIsArchived(report?.reportID); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); - const iouReportOwnerLoginSelector = useMemo(() => personalDetailsLoginSelector(parentReport?.ownerAccountID), [parentReport?.ownerAccountID]); + const iouReportOwnerLoginSelector = personalDetailsLoginSelector(parentReport?.ownerAccountID); const [iouReportOwnerLogin] = useAllPersonalDetails(iouReportOwnerLoginSelector); const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); @@ -139,17 +139,14 @@ function DynamicIOURequestStepDistance({ const transactionWaypoints = currentTransaction?.comment?.waypoints; const areTransactionWaypointsEmpty = !transactionWaypoints || Object.values(transactionWaypoints).every((w) => isEmptyObject(w)); - const waypoints = useMemo(() => { - return ( - optimisticWaypoints ?? - (areTransactionWaypointsEmpty - ? { - waypoint0: {keyForList: 'start_waypoint'}, - waypoint1: {keyForList: 'stop_waypoint'}, - } - : transactionWaypoints) - ); - }, [optimisticWaypoints, transactionWaypoints, areTransactionWaypointsEmpty]); + const waypoints = + optimisticWaypoints ?? + (areTransactionWaypointsEmpty + ? { + waypoint0: {keyForList: 'start_waypoint'}, + waypoint1: {keyForList: 'stop_waypoint'}, + } + : transactionWaypoints); const reportAttributesDerived = useReportAttributes(); @@ -210,10 +207,7 @@ function DynamicIOURequestStepDistance({ // showing the previous value instead of "0 mi" until the BE returns the new route. const distanceInMeters = currentDistanceInMeters > 0 ? currentDistanceInMeters : getDistanceInMeters(transactionBackup, transactionBackup?.comment?.customUnit?.distanceUnit ?? distanceUnit); - const currentDistance = useMemo( - () => (distanceInMeters > 0 ? roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(distanceInMeters, distanceUnit)) : undefined), - [distanceInMeters, distanceUnit], - ); + const currentDistance = distanceInMeters > 0 ? roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(distanceInMeters, distanceUnit)) : undefined; // Mirrors the manual tab input. Stays `undefined` until that tab reports a value, so a map expense the // user never switched to Manual is not compared against an empty field. Once reported, an empty string @@ -224,13 +218,10 @@ function DynamicIOURequestStepDistance({ // A waypoint edit re-fetches the routes and resets the selection to the primary one, so the committed // selection refers to routes that no longer exist and can't be compared against. In that case anything // other than the primary route is a fresh pick the user made on the re-fetched routes. - const getHasSelectedRouteChanged = useCallback( - (committedTransaction: OnyxEntry, haveWaypointsChanged: boolean) => { - const selectedRouteKey = getSelectedRouteKey(currentTransaction); - return haveWaypointsChanged ? selectedRouteKey !== CONST.TRANSACTION.DEFAULT_ROUTE_KEY : selectedRouteKey !== getSelectedRouteKey(committedTransaction); - }, - [currentTransaction], - ); + const getHasSelectedRouteChanged = (committedTransaction: OnyxEntry, haveWaypointsChanged: boolean) => { + const selectedRouteKey = getSelectedRouteKey(currentTransaction); + return haveWaypointsChanged ? selectedRouteKey !== CONST.TRANSACTION.DEFAULT_ROUTE_KEY : selectedRouteKey !== getSelectedRouteKey(committedTransaction); + }; const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => { @@ -322,13 +313,11 @@ function DynamicIOURequestStepDistance({ // For quick button actions, we'll skip the confirmation page unless the report is archived or this is a workspace // request and the workspace requires a category or a tag - const shouldSkipConfirmation: boolean = useMemo(() => { - if (!skipConfirmation || !report?.reportID) { - return false; - } - - return iouType !== CONST.IOU.TYPE.SPLIT && !isArchived && !(isPolicyExpenseChatUtil(report) && ((policy?.requiresCategory ?? false) || (policy?.requiresTag ?? false))); - }, [report, skipConfirmation, policy?.requiresCategory, policy?.requiresTag, isArchived, iouType]); + const shouldSkipConfirmation: boolean = + !(!skipConfirmation || !report?.reportID) && + iouType !== CONST.IOU.TYPE.SPLIT && + !isArchived && + !(isPolicyExpenseChatUtil(report) && ((policy?.requiresCategory ?? false) || (policy?.requiresTag ?? false))); // The LOOKING_AROUND + self-DM flags are computed inline rather than hoisted into named consts on purpose: this // component is already at React Compiler's memoization-preservation limit, and adding another top-level reactive @@ -402,11 +391,11 @@ function DynamicIOURequestStepDistance({ transactionWasSavedRef: transactionWasSaved, }); - const navigateBack = useCallback(() => { + const navigateBack = () => { Navigation.goBack(backTo); - }, [backTo]); + }; - const navigateBackAfterSave = useCallback(() => { + const navigateBackAfterSave = () => { // Suppress the discard prompt — otherwise `beforeRemove` treats this post-save navigation as an abandoned dirty edit. suppressDiscardPrompt(); // When editing an individual split, the previous RHP screen is the edit-split page the user @@ -417,37 +406,34 @@ function DynamicIOURequestStepDistance({ return; } Navigation.closeRHPFlow(); - }, [isEditingSplit, backTo, suppressDiscardPrompt]); + }; // In the edit flow this page is rendered inside an OnyxTabNavigator. The header back honors an // explicit `backTo` (e.g. the edit-split page) and otherwise leaves the whole flow. - const navigateBackFromEditFlow = useCallback(() => { + const navigateBackFromEditFlow = () => { if (backTo) { Navigation.goBack(backTo); return; } Navigation.closeRHPFlow(); - }, [backTo]); + }; /** * Takes the user to the page for editing a specific waypoint * @param index of the waypoint to edit */ - const navigateToWaypointEditPage = useCallback( - (index: number) => { - // In the edit flow this page is wrapped in an OnyxTabNavigator, so Navigation.getActiveRoute() - // returns a URL with the tab suffix (e.g. "/distance-map") that doesn't match the stack entry. - // Navigation.goBack() then REPLACEs instead of POPs and crashes, so build the base URL - // explicitly there. The create flow has no tab navigator, so the production getActiveRoute() - // path is correct (GH #90037). - const waypointBase = - isEditing && backTo - ? createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, report?.reportID ?? reportID), backTo) - : Navigation.getActiveRoute(); - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_WAYPOINT.getRoute(index), waypointBase)); - }, - [action, iouType, transactionID, report?.reportID, reportID, backTo, isEditing], - ); + const navigateToWaypointEditPage = (index: number) => { + // In the edit flow this page is wrapped in an OnyxTabNavigator, so Navigation.getActiveRoute() + // returns a URL with the tab suffix (e.g. "/distance-map") that doesn't match the stack entry. + // Navigation.goBack() then REPLACEs instead of POPs and crashes, so build the base URL + // explicitly there. The create flow has no tab navigator, so the production getActiveRoute() + // path is correct (GH #90037). + const waypointBase = + isEditing && backTo + ? createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, report?.reportID ?? reportID), backTo) + : Navigation.getActiveRoute(); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_WAYPOINT.getRoute(index), waypointBase)); + }; const navigateToNextStep = useDistanceNavigation({ iouType, @@ -478,7 +464,7 @@ function DynamicIOURequestStepDistance({ introSelected, }); - const getError = useCallback(() => { + const getError = () => { // Get route error if available else show the invalid number of waypoints error. if (hasRouteError) { return getLatestErrorField(currentTransaction, 'route'); @@ -499,7 +485,7 @@ function DynamicIOURequestStepDistance({ } as Errors; } return {}; - }, [hasRouteError, currentTransaction, isWaypointsNullIslandError, translate, duplicateWaypointsError, atLeastTwoDifferentWaypointsError]); + }; type DataParams = { data: string[]; @@ -507,36 +493,33 @@ function DynamicIOURequestStepDistance({ const {waypointItems, getWaypoint, getWaypointKey, extractKey} = useWaypointItems(waypoints); - const updateWaypoints = useCallback( - ({data}: DataParams) => { - if (deepEqual(waypointItems, data)) { - return; - } + const updateWaypoints = ({data}: DataParams) => { + if (deepEqual(waypointItems, data)) { + return; + } - const newWaypoints: WaypointCollection = {}; - let emptyWaypointIndex = -1; - for (const [index, item] of data.entries()) { - newWaypoints[`waypoint${index}`] = getWaypoint(item) ?? {}; - // Find waypoint that BECOMES empty after dragging - if (isWaypointEmpty(newWaypoints[`waypoint${index}`]) && !isWaypointEmpty(waypoints[`waypoint${index}`])) { - emptyWaypointIndex = index; - } + const newWaypoints: WaypointCollection = {}; + let emptyWaypointIndex = -1; + for (const [index, item] of data.entries()) { + newWaypoints[`waypoint${index}`] = getWaypoint(item) ?? {}; + // Find waypoint that BECOMES empty after dragging + if (isWaypointEmpty(newWaypoints[`waypoint${index}`]) && !isWaypointEmpty(waypoints[`waypoint${index}`])) { + emptyWaypointIndex = index; } + } - setOptimisticWaypoints(newWaypoints); - const shouldPassSplitDraft = isEditingSplit && !isEmpty(splitDraftTransaction); + setOptimisticWaypoints(newWaypoints); + const shouldPassSplitDraft = isEditingSplit && !isEmpty(splitDraftTransaction); - Promise.all([ - removeWaypoint(currentTransaction, emptyWaypointIndex.toString(), shouldUseTransactionDraft(action), shouldPassSplitDraft ? splitDraftTransaction : undefined), - updateWaypointsUtil(transactionID, newWaypoints, transactionState, waypoints), - ]).then(() => { - setOptimisticWaypoints(null); - }); - }, - [waypointItems, getWaypoint, waypoints, isEditingSplit, splitDraftTransaction, currentTransaction, action, transactionID, transactionState], - ); + Promise.all([ + removeWaypoint(currentTransaction, emptyWaypointIndex.toString(), shouldUseTransactionDraft(action), shouldPassSplitDraft ? splitDraftTransaction : undefined), + updateWaypointsUtil(transactionID, newWaypoints, transactionState, waypoints), + ]).then(() => { + setOptimisticWaypoints(null); + }); + }; - const submitWaypoints = useCallback(() => { + const submitWaypoints = () => { if (blockDistanceRequestIfNeeded()) { return; } @@ -630,48 +613,9 @@ function DynamicIOURequestStepDistance({ suppressDiscardPrompt(); navigateToNextStep(); - }, [ - isVendorMatchingBetaEnabled, - allTransactionViolations, - blockDistanceRequestIfNeeded, - duplicateWaypointsError, - atLeastTwoDifferentWaypointsError, - hasRouteError, - isLoadingRoute, - isEditing, - isLoading, - isCreatingNewRequest, - navigateToNextStep, - navigateBackAfterSave, - suppressDiscardPrompt, - isEditingSplit, - originalSplitTransactionDraft, - transactionBackup, - getHasSelectedRouteChanged, - waypoints, - transaction, - report, - currentTransaction, - policy, - parentReport, - iouReportOwnerLogin, - recentWaypoints, - policyTags, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - delegateAccountID, - distanceOriginalPolicy, - reportPolicyTags, - isTrackIntentUser, - personalPolicy?.outputCurrency, - getCurrencyDecimals, - getCurrencySymbol, - rules, - ]); - - const submitManualDistance = useCallback(() => { + }; + + const submitManualDistance = () => { if (blockDistanceRequestIfNeeded()) { return; } @@ -772,141 +716,72 @@ function DynamicIOURequestStepDistance({ // immediately, instead of the stale backup, while the API request is still in flight. removeBackupTransaction(transaction?.transactionID); navigateBackAfterSave(); - }, [ - isVendorMatchingBetaEnabled, - allTransactionViolations, - blockDistanceRequestIfNeeded, - transactionBackup, - getHasSelectedRouteChanged, - duplicateWaypointsError, - atLeastTwoDifferentWaypointsError, - hasRouteError, - distanceRate, - isEditingSplit, - transaction, - currentTransaction, - currentDistance, - distanceUnit, - waypoints, - report, - parentReport, - iouReportOwnerLogin, - policy, - policyTags, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - recentWaypoints, - delegateAccountID, - distanceOriginalPolicy, - reportPolicyTags, - isTrackIntentUser, - personalPolicy?.outputCurrency, - navigateBackAfterSave, - translate, - transactionID, - action, - iouType, - splitDraftTransaction, - getCurrencyDecimals, - getCurrencySymbol, - rules, - ]); - - const renderItem = useCallback( - ({item, drag, isActive, getIndex}: RenderItemParams) => ( - - ), - [isLoadingRoute, navigateToWaypointEditPage, waypoints, getWaypointKey], - ); + }; - const handleManualInputChange = useCallback( - (newDistance: string) => { - isManuallyEditing.current = true; - setManualDistanceValue(newDistance); - if (!manualFormError) { - return; - } - setManualFormError(''); - }, - [manualFormError], + const renderItem = ({item, drag, isActive, getIndex}: RenderItemParams) => ( + ); - const errorState = useMemo( - () => ({ - shouldShowAtLeastTwoDifferentWaypointsError, - atLeastTwoDifferentWaypointsError, - duplicateWaypointsError, - hasRouteError, - getError, - }), - [shouldShowAtLeastTwoDifferentWaypointsError, atLeastTwoDifferentWaypointsError, duplicateWaypointsError, hasRouteError, getError], - ); + const handleManualInputChange = (newDistance: string) => { + isManuallyEditing.current = true; + setManualDistanceValue(newDistance); + if (!manualFormError) { + return; + } + setManualFormError(''); + }; - const loadingState = useMemo(() => ({isOffline, isLoadingRoute, shouldFetchRoute, isLoading}), [isOffline, isLoadingRoute, shouldFetchRoute, isLoading]); - - const renderMapTab = useCallback( - () => ( - - - - ), - [ - waypointItems, - waypoints, - extractKey, - updateWaypoints, - renderItem, - navigateToWaypointEditPage, - currentTransaction, - policy, - submitWaypoints, - buttonText, - errorState, - loadingState, - transactionState, - ], + const errorState = { + shouldShowAtLeastTwoDifferentWaypointsError, + atLeastTwoDifferentWaypointsError, + duplicateWaypointsError, + hasRouteError, + getError, + }; + + const loadingState = {isOffline, isLoadingRoute, shouldFetchRoute, isLoading}; + + const renderMapTab = () => ( + + + ); - const renderManualTab = useCallback( - () => ( - - - - ), - [currentDistance, distanceUnit, submitManualDistance, manualFormError, handleManualInputChange], + const renderManualTab = () => ( + + + ); if (isEditing) { diff --git a/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx b/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx index f900c977883d..02efa44875f2 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx @@ -41,7 +41,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import {loginSelector} from '@selectors/PersonalDetails'; -import React, {useCallback, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound'; @@ -100,19 +100,16 @@ function DynamicIOURequestStepMerchant({ const {navigateBack, armNavigateBack} = useNavigateBackOnSave(isSaved, backPath); - const validate = useCallback( - (value: FormOnyxValues) => { - const errors: FormInputErrors = {}; - const merchantError = getMerchantError(value.moneyRequestMerchant, isMerchantFieldRequired); + const validate = (value: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const merchantError = getMerchantError(value.moneyRequestMerchant, isMerchantFieldRequired); - if (merchantError) { - errors.moneyRequestMerchant = getMerchantErrorMessage(translate, merchantError); - } + if (merchantError) { + errors.moneyRequestMerchant = getMerchantErrorMessage(translate, merchantError); + } - return errors; - }, - [isMerchantFieldRequired, translate], - ); + return errors; + }; const updateMerchantRef = (value: string) => { setCurrentMerchant(value); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepSendFrom.tsx b/src/pages/iou/request/step/DynamicIOURequestStepSendFrom.tsx index f49703648907..c99b43ee0ba8 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepSendFrom.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepSendFrom.tsx @@ -16,7 +16,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {emailSelector} from '@selectors/Session'; -import React, {useMemo} from 'react'; +import React from 'react'; import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -39,28 +39,31 @@ function DynamicIOURequestStepSendFrom({route, transaction}: DynamicIOURequestSt const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const selectedWorkspace = useMemo(() => transaction?.participants?.find((participant) => participant.isSender), [transaction]); - - const workspaceOptions: WorkspaceListItem[] = useMemo(() => { - const availableWorkspaces = getActiveAdminWorkspaces(allPolicies, currentUserLogin).filter((policy) => canSendInvoiceFromWorkspace(policy)); - - return availableWorkspaces - .sort((policy1, policy2) => - sortWorkspacesBySelected( - {policyID: policy1.id, name: policy1.name}, - {policyID: policy2.id, name: policy2.name}, - selectedWorkspace?.policyID ? [selectedWorkspace?.policyID] : [], - localeCompare, - ), - ) - .map((policy) => ({ - text: policy.name, - value: policy.id, - policyID: policy.id, - keyForList: policy.id, - isSelected: selectedWorkspace?.policyID === policy.id, - })); - }, [allPolicies, currentUserLogin, selectedWorkspace?.policyID, localeCompare]); + const selectedWorkspace = transaction?.participants?.find((participant) => participant.isSender); + + const availableWorkspaces = getActiveAdminWorkspaces(allPolicies, currentUserLogin).filter((policy) => canSendInvoiceFromWorkspace(policy)); + const workspaceOptions: WorkspaceListItem[] = availableWorkspaces + .sort((policy1, policy2) => + sortWorkspacesBySelected( + { + policyID: policy1.id, + name: policy1.name, + }, + { + policyID: policy2.id, + name: policy2.name, + }, + selectedWorkspace?.policyID ? [selectedWorkspace?.policyID] : [], + localeCompare, + ), + ) + .map((policy) => ({ + text: policy.name, + value: policy.id, + policyID: policy.id, + keyForList: policy.id, + isSelected: selectedWorkspace?.policyID === policy.id, + })); const navigateBack = () => { Navigation.goBack(backPath); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepSubrate.tsx b/src/pages/iou/request/step/DynamicIOURequestStepSubrate.tsx index 3ca38f9bbff7..5658a0713a62 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepSubrate.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepSubrate.tsx @@ -41,7 +41,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {useNavigation} from '@react-navigation/native'; import {SafeString} from 'expensify-common'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -88,7 +88,7 @@ function DynamicIOURequestStepSubrate({ const styles = useThemeStyles(); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const {login: currentUserLogin} = useCurrentUserPersonalDetails(); - const policiesWithPerDiemEnabled = useMemo(() => getActivePoliciesWithExpenseChatAndPerDiemEnabled(allPolicies, currentUserLogin), [allPolicies, currentUserLogin]); + const policiesWithPerDiemEnabled = getActivePoliciesWithExpenseChatAndPerDiemEnabled(allPolicies, currentUserLogin); const hasMoreThanOnePolicyWithPerDiemEnabled = policiesWithPerDiemEnabled.length > 1; const iouPolicyID = getIOURequestPolicyID(transaction, report); const {policy} = usePolicyForTransaction({ @@ -116,12 +116,12 @@ function DynamicIOURequestStepSubrate({ const [subrateValue, setSubrateValue] = useState(currentSubrate?.id); const [quantityValue, setQuantityValue] = useState(() => (currentSubrate?.quantity ? String(currentSubrate.quantity) : undefined)); - const onChangeQuantity = useCallback((newValue: string) => { + const onChangeQuantity = (newValue: string) => { // replace all characters that are not spaces or digits let validQuantity = newValue.replaceAll(/[^0-9]/g, ''); validQuantity = validQuantity.match(/(?:\d *){1,12}/)?.[0] ?? ''; setQuantityValue(validQuantity); - }, []); + }; useEffect(() => { setSubrateValue(currentSubrate?.id); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepTag.tsx b/src/pages/iou/request/step/DynamicIOURequestStepTag.tsx index f735ef667998..aefdb5dbb24b 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepTag.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepTag.tsx @@ -31,7 +31,7 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound'; @@ -84,34 +84,35 @@ function DynamicIOURequestStepTag({ const transactionTag = getTag(currentTransaction); const tag = getTag(currentTransaction, tagListIndex); - const policyTagLists = useMemo(() => getTagLists(policyTags), [policyTags]); + const policyTagLists = getTagLists(policyTags); - const hasDependentTags = useMemo(() => hasDependentTagsPolicyUtils(policy, policyTags), [policy, policyTags]); + const hasDependentTags = hasDependentTagsPolicyUtils(policy, policyTags); // Surface the parent (original) transaction's tag for SPLIT_EXPENSE edits so the user can // re-select an "orphaned" tag from a deleted source workspace even when the active workspace // has no tags configured. Without this the picker would render an empty list with no way to // restore the previously selected value. const [allTransactionsForSplitParent] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); - const parentTransactionTag = useMemo(() => { - if (!isEditingSplit) { - return ''; - } + let parentTransactionTag; + if (!isEditingSplit) { + parentTransactionTag = ''; + } else { const parentTransactionID = transaction?.comment?.originalTransactionID; if (!parentTransactionID) { - return ''; + parentTransactionTag = ''; + } else { + const parentTransaction = allTransactionsForSplitParent?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${parentTransactionID}`]; + parentTransactionTag = parentTransaction?.tag ?? ''; } - const parentTransaction = allTransactionsForSplitParent?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${parentTransactionID}`]; - return parentTransaction?.tag ?? ''; - }, [isEditingSplit, transaction?.comment?.originalTransactionID, allTransactionsForSplitParent]); + } - const additionalTagsToInclude = useMemo(() => { - if (!parentTransactionTag) { - return undefined; - } + let additionalTagsToInclude: string[] | undefined; + if (!parentTransactionTag) { + additionalTagsToInclude = undefined; + } else { const parentTagAtIndex = getTagArrayFromName(parentTransactionTag).at(tagListIndex); - return parentTagAtIndex ? [parentTagAtIndex] : undefined; - }, [parentTransactionTag, tagListIndex]); + additionalTagsToInclude = parentTagAtIndex ? [parentTagAtIndex] : undefined; + } // Backfill the policy's tags here too, not just inside TagPicker: on a lazy-loaded account an absent collection // makes `hasEnabledTags` false, so without this the empty state renders, TagPicker never mounts, and the read it diff --git a/src/pages/iou/request/step/DynamicIOURequestStepTime.tsx b/src/pages/iou/request/step/DynamicIOURequestStepTime.tsx index 1e813a00b4d8..babad5151349 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepTime.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepTime.tsx @@ -31,7 +31,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -81,7 +81,7 @@ function DynamicIOURequestStepTime({ const shouldShowNotFound = !isValidMoneyRequestType(iouType) || isEmptyObject(policy) || (isEditPage && isEmptyObject(transaction?.comment?.customUnit)); const editBackPath = useDynamicBackPath(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TIME_EDIT.path); - const policiesWithPerDiemEnabled = useMemo(() => getActivePoliciesWithExpenseChatAndPerDiemEnabled(allPolicies, currentUserLogin), [allPolicies, currentUserLogin]); + const policiesWithPerDiemEnabled = getActivePoliciesWithExpenseChatAndPerDiemEnabled(allPolicies, currentUserLogin); const hasMoreThanOnePolicyWithPerDiemEnabled = policiesWithPerDiemEnabled.length > 1; const buildTimeBasePath = () => buildPerDiemTimeBasePath({transaction, action, iouType, transactionID, reportID, backToReport, hasMoreThanOnePolicyWithPerDiemEnabled}); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx b/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx index e19d22939ae8..9dccf882c7d3 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx @@ -53,7 +53,7 @@ import type SCREENS from '@src/SCREENS'; import type {PersonalDetails, Transaction} from '@src/types/onyx'; import {hasSeenTourSelector} from '@selectors/Onboarding'; -import React, {useCallback, useMemo, useRef, useState} from 'react'; +import React, {useRef, useState} from 'react'; type DynamicIOURequestStepUpgradeProps = PlatformStackScreenProps; @@ -101,7 +101,7 @@ function DynamicIOURequestStepUpgrade({ const {selectedTransactions, areAllMatchingItemsSelected, excludedTransactions} = useSearchSelectionContext(); const {clearSelectedTransactions} = useSearchSelectionActions(); const {currentSearchQueryJSON} = useSearchQueryContext(); - const selectedTransactionsKeys = useMemo(() => Object.keys(selectedTransactions), [selectedTransactions]); + const selectedTransactionsKeys = Object.keys(selectedTransactions); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); @@ -126,7 +126,7 @@ function DynamicIOURequestStepUpgrade({ const hasViolations = hasViolationsReportUtils(undefined, transactionViolations, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, session?.email ?? ''); const ownerAccountID = selectedReport?.ownerAccountID ?? currentUserPersonalDetails.accountID; - const ownerPersonalDetails = useMemo(() => getPersonalDetailsForAccountID(ownerAccountID, personalDetails) as PersonalDetails, [personalDetails, ownerAccountID]); + const ownerPersonalDetails = getPersonalDetailsForAccountID(ownerAccountID, personalDetails) as PersonalDetails; const feature = Object.values(CONST.UPGRADE_FEATURE_INTRO_MAPPING) .filter((value) => value.id !== CONST.UPGRADE_FEATURE_INTRO_MAPPING.policyPreventMemberChangingTitle.id) @@ -140,7 +140,7 @@ function DynamicIOURequestStepUpgrade({ } }; - const afterUpgradeAcknowledged = useCallback(() => { + const afterUpgradeAcknowledged = () => { const expenseReportID = policyDataRef.current?.expenseChatReportID ?? reportID; const policyID = policyDataRef.current?.policyID; @@ -268,44 +268,7 @@ function DynamicIOURequestStepUpgrade({ default: Navigation.goBack(); } - }, [ - isVendorMatchingBetaEnabled, - action, - upgradeBackTo, - shouldReturnToConfirmation, - navigateWithMicrotask, - reportID, - shouldSubmitExpense, - transactionID, - upgradePath, - selectedTransactionsKeys, - clearSelectedTransactions, - hasViolations, - isASAPSubmitBetaEnabled, - allPolicies, - allPolicyCategories, - session?.accountID, - session?.email, - ownerPersonalDetails, - transactions, - iouType, - isTrack, - allPolicyTags, - createReportForCurrentUser, - transactionViolations, - reports, - selfDMReportActions, - isTrackIntentUser, - delegateAccountID, - getCurrencyDecimals, - getCurrencySymbol, - rules, - cardList, - areAllMatchingItemsSelected, - currentSearchQueryJSON, - excludedTransactions, - isOffline, - ]); + }; const participant = transaction?.participants?.[0]; const adminParticipant = diff --git a/src/pages/iou/request/step/DynamicIOURequestStepWaypoint.tsx b/src/pages/iou/request/step/DynamicIOURequestStepWaypoint.tsx index 76ea739a5d0b..ada990c5df26 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepWaypoint.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepWaypoint.tsx @@ -39,7 +39,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {useNavigation} from '@react-navigation/native'; import lodashIsEmpty from 'lodash/isEmpty'; -import React, {useCallback, useMemo, useRef, useState} from 'react'; +import React, {useRef, useState} from 'react'; import {View} from 'react-native'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -104,14 +104,7 @@ function DynamicIOURequestStepWaypoint({ const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS, {selector: recentWaypointsSelector}); const [allRecentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); - const waypointDescriptionKey = useMemo(() => { - switch (parsedWaypointIndex) { - case 0: - return 'distance.waypointDescription.start'; - default: - return 'distance.waypointDescription.stop'; - } - }, [parsedWaypointIndex]); + const waypointDescriptionKey: TranslationPaths = parsedWaypointIndex === 0 ? 'distance.waypointDescription.start' : 'distance.waypointDescription.stop'; const locationBias = useLocationBias(allWaypoints, userLocation); const waypointAddress = currentWaypoint.address ?? ''; @@ -194,7 +187,7 @@ function DynamicIOURequestStepWaypoint({ goBack(); }; - const onScroll = useCallback(() => { + const onScroll = () => { if (!isSafari()) { return; } @@ -205,11 +198,11 @@ function DynamicIOURequestStepWaypoint({ setCaretHidden(false); } }); - }, [contentHeaderHeight]); + }; - const resetCaretHiddenValue = useCallback(() => { + const resetCaretHiddenValue = () => { setCaretHidden(false); - }, []); + }; return ( { - if (targetOwnerAccountID !== undefined) { - return targetOwnerAccountID; - } - - if (selectedReport?.ownerAccountID !== undefined) { - return selectedReport.ownerAccountID; - } - - return currentUserPersonalDetails.accountID; - }, [targetOwnerAccountID, selectedReport?.ownerAccountID, currentUserPersonalDetails.accountID]); + let resolvedReportOwnerAccountID; + if (targetOwnerAccountID !== undefined) { + resolvedReportOwnerAccountID = targetOwnerAccountID; + } else if (selectedReport?.ownerAccountID !== undefined) { + resolvedReportOwnerAccountID = selectedReport.ownerAccountID; + } else { + resolvedReportOwnerAccountID = currentUserPersonalDetails.accountID; + } const reportPolicy = usePolicy(selectedReport?.policyID); // Use the caller-provided transactionPolicyID so that the "Create report" button shows the correct workspace. // Each caller is responsible for passing the appropriate policyID (e.g., selectedReport?.policyID ?? transactionPolicyID). @@ -129,14 +126,14 @@ function IOURequestEditReportCommon({ const outstandingReportsPolicyID = shouldListAllPolicies(selectedPolicyID, personalPolicyID, selectedReport) ? undefined : selectedPolicyID; const outstandingReportsOffset = useRef(0); - const loadOutstandingReports = useCallback(() => { + const loadOutstandingReports = () => { // The list is empty when the expenses have several submitters, so there is nothing to load for it if (hasMultipleSubmitters) { return; } outstandingReportsOffset.current = 0; fetchOutstandingReports(resolvedReportOwnerAccountID, outstandingReportsPolicyID, 0); - }, [hasMultipleSubmitters, resolvedReportOwnerAccountID, outstandingReportsPolicyID]); + }; useEffect(() => { loadOutstandingReports(); @@ -159,24 +156,17 @@ function IOURequestEditReportCommon({ }); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); - const isSelectedReportUnreported = useMemo(() => !!(isUnreported ?? selectedReportID === CONST.REPORT.UNREPORTED_REPORT_ID), [isUnreported, selectedReportID]); - const isOwner = useMemo( - () => resolvedReportOwnerAccountID === currentUserPersonalDetails.accountID || isSelectedReportUnreported, - [resolvedReportOwnerAccountID, currentUserPersonalDetails.accountID, isSelectedReportUnreported], - ); - const isAdmin = useMemo(() => isPolicyAdmin(reportPolicy), [reportPolicy]); + const isSelectedReportUnreported = !!(isUnreported ?? selectedReportID === CONST.REPORT.UNREPORTED_REPORT_ID); + const isOwner = resolvedReportOwnerAccountID === currentUserPersonalDetails.accountID || isSelectedReportUnreported; + const isAdmin = isPolicyAdmin(reportPolicy); const isReportIOU = selectedReport ? isIOUReport(selectedReport) : false; const reportTransactions = useReportTransactions(selectedReportID); - const isCardTransaction = useMemo(() => { - if (!transactionIDs || !selectedReport) { - return false; - } - - return reportTransactions + const isCardTransaction = + !(!transactionIDs || !selectedReport) && + reportTransactions .filter((reportTransaction) => transactionIDs.includes(reportTransaction.transactionID)) .some((reportTransaction) => reportTransaction?.comment?.liabilityType === CONST.TRANSACTION.LIABILITY_TYPE.RESTRICT); - }, [transactionIDs, selectedReport, reportTransactions]); const shouldShowRemoveFromReport = !!(selectedReportID && selectedReportID !== CONST.REPORT.UNREPORTED_REPORT_ID && selectedReport && !isSelfDM(selectedReport)) && @@ -187,128 +177,97 @@ function IOURequestEditReportCommon({ const outstandingReports = useOutstandingReports(selectedReportID, selectedPolicyID, resolvedReportOwnerAccountID, isEditing); - const reportOptions: TransactionGroupListItem[] = useMemo(() => { - // Outstanding reports belong to one owner, so listing them would offer their reports for everyone else's expenses. - if (outstandingReports.length === 0 || hasMultipleSubmitters) { - return []; - } - - const reportOwnerLogin = personalDetails?.[resolvedReportOwnerAccountID]?.login; - - return outstandingReports - .sort((report1, report2) => sortOutstandingReportsBySelected(report1, report2, selectedReportID, localeCompare)) - .filter((report) => !debouncedSearchValue || report?.reportName?.toLowerCase().includes(debouncedSearchValue.toLowerCase())) - .filter((report): report is NonNullable => report !== undefined) - .filter((report) => { - const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; - - if (!canCreateExpensesOnPolicy(policy, reportOwnerLogin)) { - return false; - } - - if (isTimeRequest && !isTimeTrackingEnabled(policy)) { - return false; - } - - if (isPerDiemRequest && !canSubmitPerDiemExpenseFromWorkspace(policy)) { - return false; - } - - if (canAddTransaction(report, rules, undefined, true)) { - return true; - } - - const isReportPolicyAdmin = isPolicyAdmin(policy); - const isReportManager = report.managerID === currentUserPersonalDetails.accountID; - return isReportPolicyAdmin || isReportManager; - }) - .map((report) => { - const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; - return { - brickRoadIndicator: null, - alternateText: policy?.name, - text: report.reportName, - value: report.reportID, - keyForList: report.reportID, - isSelected: report.reportID === selectedReportID, - policyID: report.policyID, - reportID: report.reportID, - icons: getIconsForExpenseReport(report, personalDetails, policy, formatPhoneNumber, translate), - }; - }); - }, [ - debouncedSearchValue, - outstandingReports, - selectedReportID, - personalDetails, - localeCompare, - allPolicies, - currentUserPersonalDetails.accountID, - resolvedReportOwnerAccountID, - hasMultipleSubmitters, - isPerDiemRequest, - isTimeRequest, - translate, - formatPhoneNumber, - rules, - ]); + const reportOwnerLogin = personalDetails?.[resolvedReportOwnerAccountID]?.login; + // Outstanding reports belong to one owner, so listing them would offer their reports for everyone else's expenses. + const reportOptions: TransactionGroupListItem[] = + outstandingReports.length === 0 || hasMultipleSubmitters + ? [] + : outstandingReports + .sort((report1, report2) => sortOutstandingReportsBySelected(report1, report2, selectedReportID, localeCompare)) + .filter((report) => !debouncedSearchValue || report?.reportName?.toLowerCase().includes(debouncedSearchValue.toLowerCase())) + .filter((report): report is NonNullable => report !== undefined) + .filter((report) => { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; + if (!canCreateExpensesOnPolicy(policy, reportOwnerLogin)) { + return false; + } + if (isTimeRequest && !isTimeTrackingEnabled(policy)) { + return false; + } + if (isPerDiemRequest && !canSubmitPerDiemExpenseFromWorkspace(policy)) { + return false; + } + if (canAddTransaction(report, rules, undefined, true)) { + return true; + } + const isReportPolicyAdmin = isPolicyAdmin(policy); + const isReportManager = report.managerID === currentUserPersonalDetails.accountID; + return isReportPolicyAdmin || isReportManager; + }) + .map((report) => { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; + return { + brickRoadIndicator: null, + alternateText: policy?.name, + text: report.reportName, + value: report.reportID, + keyForList: report.reportID, + isSelected: report.reportID === selectedReportID, + policyID: report.policyID, + reportID: report.reportID, + icons: getIconsForExpenseReport(report, personalDetails, policy, formatPhoneNumber, translate), + }; + }); const navigateBack = () => { Navigation.goBack(backTo); }; - const checkIfPerDiemTransactionsCanBeMoved = useCallback( - (selectedReportPolicyID: string | undefined) => { - const transactionDetails = transactionIDs?.map((transactionID) => { - const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - return { - transaction, - isPerDiem: transaction ? isPerDiemRequestUtil(transaction) : false, - customUnitID: transaction?.comment?.customUnit?.customUnitID, - }; - }); + const checkIfPerDiemTransactionsCanBeMoved = (selectedReportPolicyID: string | undefined) => { + const transactionDetails = transactionIDs?.map((transactionID) => { + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + return { + transaction, + isPerDiem: transaction ? isPerDiemRequestUtil(transaction) : false, + customUnitID: transaction?.comment?.customUnit?.customUnitID, + }; + }); - const destinationPolicy = selectedReportPolicyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedReportPolicyID}`] : undefined; + const destinationPolicy = selectedReportPolicyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedReportPolicyID}`] : undefined; + + if (!isPerDiemEnabled(destinationPolicy) || !destinationPolicy?.customUnits || isEmptyObject(destinationPolicy.customUnits)) { + return false; + } - if (!isPerDiemEnabled(destinationPolicy) || !destinationPolicy?.customUnits || isEmptyObject(destinationPolicy.customUnits)) { + const invalidPerDiemTransaction = transactionDetails?.find((detail) => { + if (!detail.isPerDiem) { return false; } + const customUnitID = detail.customUnitID; + return !customUnitID || !destinationPolicy?.customUnits?.[customUnitID]; + }); - const invalidPerDiemTransaction = transactionDetails?.find((detail) => { - if (!detail.isPerDiem) { - return false; - } - const customUnitID = detail.customUnitID; - return !customUnitID || !destinationPolicy?.customUnits?.[customUnitID]; - }); - - return !invalidPerDiemTransaction; - }, - [transactionIDs, allPolicies, allTransactions], - ); + return !invalidPerDiemTransaction; + }; - const validatePerDiemMove = useCallback( - (policyID: string | undefined): boolean => { - if (transactionIDs?.length === 0) { - return false; - } - if (isPerDiemRequest) { - if (checkIfPerDiemTransactionsCanBeMoved(policyID)) { - return true; - } - showConfirmModal({ - title: translate('iou.moveExpenses'), - prompt: translate('iou.moveExpensesError'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - return false; + const validatePerDiemMove = (policyID: string | undefined): boolean => { + if (transactionIDs?.length === 0) { + return false; + } + if (isPerDiemRequest) { + if (checkIfPerDiemTransactionsCanBeMoved(policyID)) { + return true; } - return true; - }, - // `showConfirmModal` is recreated on every render, so it has to stay in the dep array to keep this callback correct. - [transactionIDs?.length, isPerDiemRequest, checkIfPerDiemTransactionsCanBeMoved, showConfirmModal, translate], - ); + showConfirmModal({ + title: translate('iou.moveExpenses'), + prompt: translate('iou.moveExpensesError'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + return false; + } + return true; + }; const handleSelectReport = (item: TransactionGroupListItem) => { if (item.value === selectedReportID) { @@ -354,7 +313,7 @@ function IOURequestEditReportCommon({ selectReport(item); }; - const handleCreateReport = useCallback(() => { + const handleCreateReport = () => { if (blockDistanceRequestIfNeeded(policyForMovingExpenses?.id)) { return; } @@ -362,18 +321,14 @@ function IOURequestEditReportCommon({ return; } createReport?.(); - }, [blockDistanceRequestIfNeeded, validatePerDiemMove, policyForMovingExpenses?.id, createReport]); + }; - const headerMessage = useMemo(() => (searchValue && !reportOptions.length ? translate('common.noResultsFound') : ''), [searchValue, reportOptions.length, translate]); + const headerMessage = searchValue && !reportOptions.length ? translate('common.noResultsFound') : ''; const policyForMovingExpensesName = policyForMovingExpenses?.name; - const createReportOption = useMemo(() => { - // A report per submitter would need one API call each, so "Auto report" serves them alone in a single call. - if (!createReport || hasMultipleSubmitters || (isEditing && !isOwner && !isAdmin)) { - return undefined; - } - - return ( + // A report per submitter would need one API call each, so "Auto report" serves them alone in a single call. + const createReportOption = + !createReport || hasMultipleSubmitters || (isEditing && !isOwner && !isAdmin) ? undefined : ( @@ -386,11 +341,10 @@ function IOURequestEditReportCommon({ ); - }, [createReport, hasMultipleSubmitters, isEditing, isOwner, isAdmin, handleCreateReport, icons.Document, translate, policyForMovingExpensesName]); // The destinations are chosen server-side, so there is nothing to apply optimistically and nothing to show for a // queued request. Blocking offline keeps the screen open and says why, rather than silently discarding the action. - const handleAutoReport = useCallback(() => { + const handleAutoReport = () => { if (isOffline) { showConfirmModal({ title: translate('common.youAppearToBeOffline'), @@ -401,27 +355,22 @@ function IOURequestEditReportCommon({ return; } autoReport?.(); - }, [isOffline, showConfirmModal, translate, autoReport]); - - const autoReportOption = useMemo(() => { - if (!autoReport || !hasMultipleSubmitters) { - return undefined; - } - - // A card this user cannot resolve, because it is missing or on a feed they do not administer, fails the - // whole move. - if (!areAllManagedCardsResolvable) { - return undefined; - } - - // These expenses are only valid on some workspaces, and the guards that decide which — per diem rates, the - // map/GPS rules on manual and odometer distance — need a destination the backend has not picked yet. Callers - // are expected to withhold the whole flow for such a selection; this keeps the row honest if one does not. - if (isPerDiemRequest || isManualDistanceRequest || isOdometerDistanceRequest) { - return undefined; - } + }; - return ( + let autoReportOption; + if (!autoReport || !hasMultipleSubmitters) { + autoReportOption = undefined; + } else // A card this user cannot resolve, because it is missing or on a feed they do not administer, fails the + // whole move. + if (!areAllManagedCardsResolvable) { + autoReportOption = undefined; + } else // These expenses are only valid on some workspaces, and the guards that decide which — per diem rates, the + // map/GPS rules on manual and odometer distance — need a destination the backend has not picked yet. Callers + // are expected to withhold the whole flow for such a selection; this keeps the row honest if one does not. + if (isPerDiemRequest || isManualDistanceRequest || isOdometerDistanceRequest) { + autoReportOption = undefined; + } else { + autoReportOption = ( ); - }, [ - icons.DocumentMagicWand, - areAllManagedCardsResolvable, - autoReport, - handleAutoReport, - hasMultipleSubmitters, - isManualDistanceRequest, - isOdometerDistanceRequest, - isPerDiemRequest, - translate, - ]); + } const listHeaderContent = createReportOption ?? autoReportOption; - const shouldShowNotFoundPage = useMemo(() => { - if (listHeaderContent) { - return false; - } - - if (outstandingReports.length === 0 || shouldShowNotFoundPageFromProps) { - return true; - } - - if (!selectedReport) { - return false; - } - + let shouldShowNotFoundPage; + if (listHeaderContent) { + shouldShowNotFoundPage = false; + } else if (outstandingReports.length === 0 || shouldShowNotFoundPageFromProps) { + shouldShowNotFoundPage = true; + } else if (!selectedReport) { + shouldShowNotFoundPage = false; + } else { const isOpen = isOpenReport(selectedReport); const isSubmitter = isReportOwner(selectedReport); // If the report is Open, then only submitters, admins can move expenses - return isOpen && !isAdmin && !isSubmitter; - }, [listHeaderContent, outstandingReports.length, shouldShowNotFoundPageFromProps, selectedReport, isAdmin]); + shouldShowNotFoundPage = isOpen && !isAdmin && !isSubmitter; + } return ( { - if (!isEditing) { - return report; - } - // When editing, report is the transaction thread. We need to get the actual chat report. - // Transaction thread's chatReportID points to the IOU/expense report, - // and the IOU/expense report's chatReportID points to the actual chat. - if (iouOrExpenseReport && isMoneyRequestReport(iouOrExpenseReport) && iouOrExpenseReport.chatReportID) { - return actualChatReport; - } + let chatReportForP2PCheck; + if (!isEditing) { + chatReportForP2PCheck = report; + } else // When editing, report is the transaction thread. We need to get the actual chat report. + // Transaction thread's chatReportID points to the IOU/expense report, + // and the IOU/expense report's chatReportID points to the actual chat. + if (iouOrExpenseReport && isMoneyRequestReport(iouOrExpenseReport) && iouOrExpenseReport.chatReportID) { + chatReportForP2PCheck = actualChatReport; + } else { // Fallback to the passed report if we can't traverse - return report; - }, [isEditing, report, iouOrExpenseReport, actualChatReport]); + chatReportForP2PCheck = report; + } const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT; const isCreateAction = action === CONST.IOU.ACTION.CREATE; const isSubmitAction = action === CONST.IOU.ACTION.SUBMIT; @@ -170,13 +169,7 @@ function IOURequestStepAmount({ // For quick button actions, we'll skip the confirmation page unless the report is archived or this is a workspace request, as // the user will have to add a merchant. - const shouldSkipConfirmation: boolean = useMemo(() => { - if (isSplitBill || !skipConfirmation || !report?.reportID) { - return false; - } - - return !(isReportArchived || isPolicyExpenseChat(report)); - }, [report, isSplitBill, skipConfirmation, isReportArchived]); + const shouldSkipConfirmation: boolean = !(isSplitBill || !skipConfirmation || !report?.reportID) && !(isReportArchived || isPolicyExpenseChat(report)); // Both self-DM signals are ORed on purpose. The navigate half (IOUAmountSubmission) reads participants at submit time, // but on a quick-action flow they are not populated yet when this pre-mount decision runs, so the participants check diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx index c1db115697ce..a0d69ab6c18a 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx @@ -82,12 +82,16 @@ import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import type {Report} from '@src/types/onyx'; +import type {Participant} from '@src/types/onyx/IOU'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type {Receipt} from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; +import type {OnyxCollection} from 'react-native-onyx'; + import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; -import React, {startTransition, useCallback, useEffect, useMemo, useState} from 'react'; +import React, {startTransition, useEffect, useState} from 'react'; import {View} from 'react-native'; import type {IOURequestStepConfirmationProps, StepConfirmationParams} from './types'; @@ -96,6 +100,47 @@ import useConfirmationTransactionPager from './useConfirmationTransactionPager'; import useParticipantPickerState from './useParticipantPickerState'; import useSubmitDestinationPreMount from './useSubmitDestinationPreMount'; +type ConfirmationParticipantsParams = Pick< + Parameters[0], + 'policy' | 'personalDetails' | 'conciergeReportID' | 'reportAttributesDerived' | 'currentUserAccountID' | 'localize' | 'rules' +> & { + participants: Participant[] | undefined; + iouType: IOUType; + privateIsArchivedMap: ReturnType; + participantsPolicies: ReturnType; + reportDrafts: OnyxCollection; +}; + +function useConfirmationParticipants({participants, iouType, privateIsArchivedMap, participantsPolicies, reportDrafts, ...reportOptions}: ConfirmationParticipantsParams) { + // Keep the projected rows immutable across the receipt-validation and list hooks. + 'use memo'; + + return ( + participants?.map((participant) => { + if (participant.isSender && iouType === CONST.IOU.TYPE.INVOICE) { + return participant; + } + const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${participant.reportID}`]; + const participantReportDraft = reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${participant.reportID}`]; + // Newly created draft workspaces are not in participantsPolicies yet. + const participantPolicy = + (participant.policyID ? participantsPolicies[participant.policyID] : reportOptions.policy) ?? + (participant.policyID === reportOptions.policy?.id ? reportOptions.policy : undefined); + // Phone contacts have an optimistic accountID but no reportID, so keep their participant text. + return participant.accountID || !participant.reportID + ? getParticipantsOption(participant, reportOptions.personalDetails, reportOptions.localize.translate) + : getReportOption({ + ...reportOptions, + participant, + privateIsArchived, + policy: participantPolicy, + reportDraft: participantReportDraft, + pendingDeleteMemberAccountIDs: undefined, + }); + }) ?? [] + ); +} + function IOURequestStepConfirmationContent({ report: reportReal, reportDraft, @@ -127,9 +172,7 @@ function IOURequestStepConfirmationContent({ const [participantReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${initialTransaction?.participants?.at(0)?.reportID}`); const hasMultipleTransactions = transactions.length > 1; - // Depend on transactions.length to avoid updating transactionIDs when only the transaction details change - // eslint-disable-next-line react-hooks/exhaustive-deps - const transactionIDs = useMemo(() => transactions?.map((item) => item.transactionID), [transactions.length]); + const transactionIDs = transactions?.map((item) => item.transactionID); const requestType = getRequestType(transaction); const isPerDiemRequest = requestType === CONST.IOU.REQUEST_TYPE.PER_DIEM; const isUnreported = transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; @@ -156,19 +199,15 @@ function IOURequestStepConfirmationContent({ * to ensure proper navigation after expense creation. */ const transactionReport = useReportOrReportDraft(transaction?.reportID); - const reportWithDraftFallback = useMemo(() => reportReal ?? reportDraft, [reportDraft, reportReal]); - const shouldHideToSection = useMemo(() => isMoneyRequestReport(reportWithDraftFallback), [reportWithDraftFallback]); - const report = useMemo( - () => - resolveReportForMoneyRequest({ - transaction, - transactionReport, - routeReport: reportWithDraftFallback, - reportNameValuePair, - rules, - }), - [transaction, transactionReport, reportWithDraftFallback, reportNameValuePair, rules], - ); + const reportWithDraftFallback = reportReal ?? reportDraft; + const shouldHideToSection = isMoneyRequestReport(reportWithDraftFallback); + const report = resolveReportForMoneyRequest({ + transaction, + transactionReport, + routeReport: reportWithDraftFallback, + reportNameValuePair, + rules, + }); const [reportDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT); const {policy} = usePolicyForTransaction({ @@ -238,18 +277,16 @@ function IOURequestStepConfirmationContent({ !transaction?.receipt?.isTestDriveReceipt; const gpsRequired = transaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && Object.values(receiptFiles).length && isScanRequest(transaction); - const headerTitle = useMemo(() => { - if (isCategorizingTrackExpense) { - return translate('iou.categorize'); - } - if (isSharingTrackExpense) { - return translate('iou.share'); - } - if (iouType === CONST.IOU.TYPE.INVOICE) { - return translate('workspace.invoices.sendInvoice'); - } - return translate('iou.confirmDetails'); - }, [iouType, translate, isSharingTrackExpense, isCategorizingTrackExpense]); + let headerTitle; + if (isCategorizingTrackExpense) { + headerTitle = translate('iou.categorize'); + } else if (isSharingTrackExpense) { + headerTitle = translate('iou.share'); + } else if (iouType === CONST.IOU.TYPE.INVOICE) { + headerTitle = translate('workspace.invoices.sendInvoice'); + } else { + headerTitle = translate('iou.confirmDetails'); + } useEffect(() => { if (!transaction?.transactionID || !transactionReport || iouType !== CONST.IOU.TYPE.PAY) { @@ -260,55 +297,20 @@ function IOURequestStepConfirmationContent({ const participantsPolicies = useParticipantsPolicies(transaction?.participants ?? []); - const participants = useMemo( - () => - transaction?.participants?.map((participant) => { - if (participant.isSender && iouType === CONST.IOU.TYPE.INVOICE) { - return participant; - } - const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${participant.reportID}`]; - const participantReportDraft = reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${participant.reportID}`]; - // participantsPolicies only holds persisted policies, so fall back to the transaction policy when it's this - // participant's workspace — e.g. a freshly created draft workspace ("Submit to my employer" with no existing one). - const participantPolicy = (participant.policyID ? participantsPolicies[participant.policyID] : policy) ?? (participant.policyID === policy?.id ? policy : undefined); - // Phone contacts always have an optimistic accountID but no reportID; getReportOption - // is designed for report-backed participants and discards participant.text, so route - // any participant without a reportID to getParticipantsOption instead. - return participant.accountID || !participant.reportID - ? getParticipantsOption(participant, personalDetails, translate) - : getReportOption({ - participant, - privateIsArchived, - policy: participantPolicy, - personalDetails, - conciergeReportID, - reportAttributesDerived, - reportDraft: participantReportDraft, - currentUserAccountID: currentUserPersonalDetails.accountID, - localize: {translate, dateFnsLocale, convertToDisplayString, formatPhoneNumber}, - rules, - // Passing pendingDeleteMemberAccountIDs as undefined is intentional, isValidReport keeps group chats out of this list because the config here leaves includeMultipleParticipantReports false. - pendingDeleteMemberAccountIDs: undefined, - }); - }) ?? [], - [ - dateFnsLocale, - transaction?.participants, - iouType, - personalDetails, - reportAttributesDerived, - privateIsArchivedMap, - participantsPolicies, - policy, - conciergeReportID, - reportDrafts, - translate, - convertToDisplayString, - currentUserPersonalDetails.accountID, - formatPhoneNumber, - rules, - ], - ); + const participants = useConfirmationParticipants({ + participants: transaction?.participants, + iouType, + privateIsArchivedMap, + participantsPolicies, + reportDrafts, + policy, + personalDetails, + conciergeReportID, + reportAttributesDerived, + currentUserAccountID: currentUserPersonalDetails.accountID, + localize: {translate, dateFnsLocale, convertToDisplayString, formatPhoneNumber}, + rules, + }); const { defaultParticipants, @@ -330,32 +332,29 @@ function IOURequestStepConfirmationContent({ blockDistanceRequestIfNeeded, }); - const isPolicyExpenseChat = useMemo(() => { - const hasPolicyExpenseChat = (participantList: typeof defaultParticipants) => - participantList.some((participant) => { - if (isPolicyExpenseChatUtils(participant)) { - return true; - } - - return ( - !!participant?.reportID && - isPolicyExpenseChatUtils( - getReportOrDraftReport(participant.reportID, undefined, undefined, reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${participant.reportID}`] ?? {}), - ) - ); - }); - - if (isPolicyExpenseChatUtils(report)) { - return true; - } - + let isPolicyExpenseChat; + const hasPolicyExpenseChat = (participantList: typeof defaultParticipants) => + participantList.some((participant) => { + if (isPolicyExpenseChatUtils(participant)) { + return true; + } + return ( + !!participant?.reportID && + isPolicyExpenseChatUtils( + getReportOrDraftReport(participant.reportID, undefined, undefined, reportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${participant.reportID}`] ?? {}), + ) + ); + }); + if (isPolicyExpenseChatUtils(report)) { + isPolicyExpenseChat = true; + } else { const transactionParticipants = transaction?.participants ?? []; if (hasPolicyExpenseChat(transactionParticipants)) { - return true; + isPolicyExpenseChat = true; + } else { + isPolicyExpenseChat = hasPolicyExpenseChat(defaultParticipants); } - - return hasPolicyExpenseChat(defaultParticipants); - }, [report, transaction?.participants, defaultParticipants, reportDrafts]); + } // The confirmation only validates the transaction it shows, so find the partially filled one across all receipts. const partiallyManuallyFilledScanID = transactions.find((item) => isPartiallyEnteredScanExpense(item, canEnterScanFieldsManually, isPolicyExpenseChat))?.transactionID; @@ -459,54 +458,51 @@ function IOURequestStepConfirmationContent({ }; }, []); - const handleSendMoney = useCallback( - (paymentMethod: PaymentMethodType | undefined) => { - if (isConfirmed) { - return; - } + const handleSendMoney = (paymentMethod: PaymentMethodType | undefined) => { + if (isConfirmed) { + return; + } - if (paymentMethod !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE && paymentMethod !== CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { - sendMoney(paymentMethod); - return; - } + if (paymentMethod !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE && paymentMethod !== CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { + sendMoney(paymentMethod); + return; + } - const participant = participants.at(0); - if (!participant) { - sendMoney(paymentMethod); - return; - } + const participant = participants.at(0); + if (!participant) { + sendMoney(paymentMethod); + return; + } - const resolvedReportIDs = optimisticP2PDestinationReportID - ? {optimisticChatReportID: optimisticP2PDestinationReportID, chatReportID: optimisticP2PDestinationReportID} - : resolveOptimisticChatReportID([participant.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserPersonalDetails.accountID], report); - const payDestinationReportID = optimisticP2PDestinationReportID ?? destinationReportID ?? resolvedReportIDs.chatReportID; - if (!payDestinationReportID || Navigation.getTopmostReportId() === payDestinationReportID) { - sendMoney(paymentMethod, {resolvedReportIDs}); - return; - } + const resolvedReportIDs = optimisticP2PDestinationReportID + ? {optimisticChatReportID: optimisticP2PDestinationReportID, chatReportID: optimisticP2PDestinationReportID} + : resolveOptimisticChatReportID([participant.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserPersonalDetails.accountID], report); + const payDestinationReportID = optimisticP2PDestinationReportID ?? destinationReportID ?? resolvedReportIDs.chatReportID; + if (!payDestinationReportID || Navigation.getTopmostReportId() === payDestinationReportID) { + sendMoney(paymentMethod, {resolvedReportIDs}); + return; + } - setIsConfirmed(true); - submitWithDismissFirst({ - executeWrite: (overrides) => - sendMoney(paymentMethod, { - shouldHandleNavigation: overrides.shouldHandleNavigation, - resolvedReportIDs, - shouldStartTracking: false, - }), - destinationReportID: payDestinationReportID, - telemetryContext: { - scenario: CONST.TELEMETRY.SUBMIT_EXPENSE_SCENARIO.SEND_MONEY, - iouType: CONST.IOU.TYPE.PAY, - requestType: CONST.IOU.TYPE.PAY, - isFromGlobalCreate: !report?.reportID, - hasReceipt: !!transaction?.receipt, - }, - }); - }, - [currentUserPersonalDetails.accountID, destinationReportID, isConfirmed, optimisticP2PDestinationReportID, setIsConfirmed, participants, report, sendMoney, transaction?.receipt], - ); + setIsConfirmed(true); + submitWithDismissFirst({ + executeWrite: (overrides) => + sendMoney(paymentMethod, { + shouldHandleNavigation: overrides.shouldHandleNavigation, + resolvedReportIDs, + shouldStartTracking: false, + }), + destinationReportID: payDestinationReportID, + telemetryContext: { + scenario: CONST.TELEMETRY.SUBMIT_EXPENSE_SCENARIO.SEND_MONEY, + iouType: CONST.IOU.TYPE.PAY, + requestType: CONST.IOU.TYPE.PAY, + isFromGlobalCreate: !report?.reportID, + hasReceipt: !!transaction?.receipt, + }, + }); + }; - const navigateBack = useCallback(() => { + const navigateBack = () => { // User is explicitly abandoning the flow - cancel any active telemetry span. // The orchestrator never calls navigateBack (it uses dismissModal), so this // reliably distinguishes user-initiated back from programmatic dismiss. @@ -570,41 +566,15 @@ function IOURequestStepConfirmationContent({ // This has selected the participants from the beginning and the participant field shouldn't be editable. navigateToStartMoneyRequestStep(requestType, iouType, initialTransactionID, reportID, action, backToReport); - }, [ - action, - isPerDiemRequest, - isCreatingTrackExpense, - transaction?.isFromGlobalCreate, - transaction?.isReusedRoute, - transaction?.receipt?.isTestReceipt, - transaction?.participantsAutoAssigned, - transaction?.reportID, - requestType, - iouType, - initialTransactionID, - reportID, - isMovingTransactionFromTrackExpense, - participantsAutoAssignedFromRoute, - backTo, - backToReport, - // usePreMountDestination compiles under React Compiler, so cleanupPreMount has a stable identity (it closes over refs - // only) - this dep does not churn navigateBack per render. - cleanupPreMount, - ]); - - const setBillable = useCallback( - (billable: boolean) => { - setMoneyRequestBillable(currentTransactionID, billable); - }, - [currentTransactionID], - ); + }; - const setReimbursable = useCallback( - (reimbursable: boolean) => { - setMoneyRequestReimbursable(currentTransactionID, reimbursable); - }, - [currentTransactionID], - ); + const setBillable = (billable: boolean) => { + setMoneyRequestBillable(currentTransactionID, billable); + }; + + const setReimbursable = (reimbursable: boolean) => { + setMoneyRequestReimbursable(currentTransactionID, reimbursable); + }; // This loading indicator is shown because the transaction originalCurrency is being updated later than the component mounts. // To prevent the component from rendering with the wrong currency, we show a loading indicator until the correct currency is set. @@ -790,7 +760,7 @@ function IOURequestStepConfirmationContent({ isLoadingReceipt: isStitchingReceipt || (isOdometerDistanceRequest && !hasVerifiedBlobs), isReceiptEditable: true, }} - iouType={iouType as Exclude} + iouType={iouType} reportID={reportID} isPolicyExpenseChat={isPolicyExpenseChat} policyID={policyID} diff --git a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx index 5c346163b03c..50e81fd1b1c4 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx @@ -49,7 +49,7 @@ import type {RenderItemParams} from 'react-native-draggable-flatlist/lib/typescr import type {OnyxEntry} from 'react-native-onyx'; import {deepEqual} from 'fast-equals'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -106,17 +106,14 @@ function IOURequestStepDistanceMap({ const transactionWaypoints = transaction?.comment?.waypoints; const areTransactionWaypointsEmpty = !transactionWaypoints || Object.values(transactionWaypoints).every((w) => isEmptyObject(w)); - const waypoints = useMemo(() => { - return ( - optimisticWaypoints ?? - (areTransactionWaypointsEmpty - ? { - waypoint0: {keyForList: 'start_waypoint'}, - waypoint1: {keyForList: 'stop_waypoint'}, - } - : transactionWaypoints) - ); - }, [optimisticWaypoints, transactionWaypoints, areTransactionWaypointsEmpty]); + const waypoints = + optimisticWaypoints ?? + (areTransactionWaypointsEmpty + ? { + waypoint0: {keyForList: 'start_waypoint'}, + waypoint1: {keyForList: 'stop_waypoint'}, + } + : transactionWaypoints); const reportAttributesDerived = useReportAttributes(); const transactionState: TransactionStateType = shouldUseTransactionDraft(action) ? CONST.TRANSACTION.STATE.DRAFT : CONST.TRANSACTION.STATE.CURRENT; @@ -160,13 +157,11 @@ function IOURequestStepDistanceMap({ // For quick button actions, we'll skip the confirmation page unless the report is archived or this is a workspace // request and the workspace requires a category or a tag - const shouldSkipConfirmation: boolean = useMemo(() => { - if (!skipConfirmation || !report?.reportID) { - return false; - } - - return iouType !== CONST.IOU.TYPE.SPLIT && !isArchived && !(isPolicyExpenseChatUtil(report) && ((policy?.requiresCategory ?? false) || (policy?.requiresTag ?? false))); - }, [report, skipConfirmation, policy?.requiresCategory, policy?.requiresTag, isArchived, iouType]); + const shouldSkipConfirmation: boolean = + !(!skipConfirmation || !report?.reportID) && + iouType !== CONST.IOU.TYPE.SPLIT && + !isArchived && + !(isPolicyExpenseChatUtil(report) && ((policy?.requiresCategory ?? false) || (policy?.requiresTag ?? false))); let buttonText = translate('common.next'); if (shouldSkipConfirmation) { if (iouType === CONST.IOU.TYPE.SPLIT) { @@ -217,17 +212,17 @@ function IOURequestStepDistanceMap({ setShouldShowAtLeastTwoDifferentWaypointsError(false); }, [atLeastTwoDifferentWaypointsError, duplicateWaypointsError, hasRouteError, isLoading, isLoadingRoute, nonEmptyWaypointsCount, transaction]); - const navigateBack = useCallback(() => { + const navigateBack = () => { Navigation.goBack(backTo); - }, [backTo]); + }; /** * Takes the user to the page for editing a specific waypoint * @param index of the waypoint to edit */ - const navigateToWaypointEditPage = useCallback((index: number) => { + const navigateToWaypointEditPage = (index: number) => { Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_WAYPOINT.getRoute(index))); - }, []); + }; const navigateToNextStep = useDistanceNavigation({ iouType, @@ -258,7 +253,7 @@ function IOURequestStepDistanceMap({ introSelected, }); - const getError = useCallback(() => { + const getError = () => { // Get route error if available else show the invalid number of waypoints error. if (hasRouteError) { return getLatestErrorField(transaction, 'route'); @@ -279,7 +274,7 @@ function IOURequestStepDistanceMap({ } as Errors; } return {}; - }, [hasRouteError, isWaypointsNullIslandError, duplicateWaypointsError, atLeastTwoDifferentWaypointsError, transaction, translate]); + }; type DataParams = { data: string[]; @@ -287,35 +282,32 @@ function IOURequestStepDistanceMap({ const {waypointItems, getWaypoint, getWaypointKey, extractKey} = useWaypointItems(waypoints); - const updateWaypoints = useCallback( - ({data}: DataParams) => { - if (deepEqual(waypointItems, data)) { - return; - } + const updateWaypoints = ({data}: DataParams) => { + if (deepEqual(waypointItems, data)) { + return; + } - const newWaypoints: WaypointCollection = {}; - let emptyWaypointIndex = -1; - for (const [index, item] of data.entries()) { - newWaypoints[`waypoint${index}`] = getWaypoint(item) ?? {}; - // Find waypoint that BECOMES empty after dragging - if (isWaypointEmpty(newWaypoints[`waypoint${index}`]) && !isWaypointEmpty(waypoints[`waypoint${index}`])) { - emptyWaypointIndex = index; - } + const newWaypoints: WaypointCollection = {}; + let emptyWaypointIndex = -1; + for (const [index, item] of data.entries()) { + newWaypoints[`waypoint${index}`] = getWaypoint(item) ?? {}; + // Find waypoint that BECOMES empty after dragging + if (isWaypointEmpty(newWaypoints[`waypoint${index}`]) && !isWaypointEmpty(waypoints[`waypoint${index}`])) { + emptyWaypointIndex = index; } + } - setOptimisticWaypoints(newWaypoints); + setOptimisticWaypoints(newWaypoints); - Promise.all([ - removeWaypoint(transaction, emptyWaypointIndex.toString(), shouldUseTransactionDraft(action), undefined), - updateWaypointsUtil(transactionID, newWaypoints, transactionState, waypoints), - ]).then(() => { - setOptimisticWaypoints(null); - }); - }, - [waypointItems, transaction, action, transactionID, transactionState, getWaypoint, waypoints], - ); + Promise.all([ + removeWaypoint(transaction, emptyWaypointIndex.toString(), shouldUseTransactionDraft(action), undefined), + updateWaypointsUtil(transactionID, newWaypoints, transactionState, waypoints), + ]).then(() => { + setOptimisticWaypoints(null); + }); + }; - const submitWaypoints = useCallback(() => { + const submitWaypoints = () => { if (blockDistanceRequestIfNeeded()) { return; } @@ -326,40 +318,34 @@ function IOURequestStepDistanceMap({ } suppressDiscardPrompt(); navigateToNextStep(); - }, [blockDistanceRequestIfNeeded, duplicateWaypointsError, atLeastTwoDifferentWaypointsError, hasRouteError, isLoadingRoute, isLoading, suppressDiscardPrompt, navigateToNextStep]); - - const renderItem = useCallback( - ({item, drag, isActive, getIndex}: RenderItemParams) => { - const index = getIndex?.(); - const sentryLabel = index === 0 ? CONST.SENTRY_LABEL.IOU_REQUEST_STEP.WAYPOINT_START_MENU_ITEM : CONST.SENTRY_LABEL.IOU_REQUEST_STEP.WAYPOINT_STOP_MENU_ITEM; - return ( - - ); - }, - [isLoadingRoute, navigateToWaypointEditPage, waypoints, getWaypointKey], - ); + }; - const errorState = useMemo( - () => ({ - shouldShowAtLeastTwoDifferentWaypointsError, - atLeastTwoDifferentWaypointsError, - duplicateWaypointsError, - hasRouteError, - getError, - }), - [shouldShowAtLeastTwoDifferentWaypointsError, atLeastTwoDifferentWaypointsError, duplicateWaypointsError, hasRouteError, getError], - ); + const renderItem = ({item, drag, isActive, getIndex}: RenderItemParams) => { + const index = getIndex?.(); + const sentryLabel = index === 0 ? CONST.SENTRY_LABEL.IOU_REQUEST_STEP.WAYPOINT_START_MENU_ITEM : CONST.SENTRY_LABEL.IOU_REQUEST_STEP.WAYPOINT_STOP_MENU_ITEM; + return ( + + ); + }; + + const errorState = { + shouldShowAtLeastTwoDifferentWaypointsError, + atLeastTwoDifferentWaypointsError, + duplicateWaypointsError, + hasRouteError, + getError, + }; - const loadingState = useMemo(() => ({isOffline, isLoadingRoute, shouldFetchRoute, isLoading}), [isOffline, isLoadingRoute, shouldFetchRoute, isLoading]); + const loadingState = {isOffline, isLoadingRoute, shouldFetchRoute, isLoading}; return ( shouldUseDefaultExpensePolicyUtil(iouType, defaultExpensePolicy, amountOwed, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, currentUserAccountIDParam), - [iouType, defaultExpensePolicy, amountOwed, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, currentUserAccountIDParam], + const shouldUseDefaultExpensePolicy = shouldUseDefaultExpensePolicyUtil( + iouType, + defaultExpensePolicy, + amountOwed, + userBillingGracePeriodEnds, + ownerBillingGracePeriodEnd, + currentUserAccountIDParam, ); const shouldAutoReportToDefaultWorkspace = shouldUseDefaultExpensePolicy && (!!defaultExpensePolicy?.autoReporting || !!personalPolicy?.autoReporting); const blockDistanceRequestIfNeeded = useBlockDistanceRequest({ @@ -294,7 +298,7 @@ function IOURequestStepDistanceOdometer({ })(); // Get image source for web (blob URL) or native (URI string) - const getImageSource = useCallback((image: FileObject | string | {uri?: string} | undefined): string | undefined => { + const getImageSource = (image: FileObject | string | {uri?: string} | undefined): string | undefined => { if (!image) { return undefined; } @@ -312,10 +316,10 @@ function IOURequestStepDistanceOdometer({ } // Native: Object with uri property (fallback) return image?.uri; - }, []); + }; - const startImageSource = useMemo(() => getImageSource(odometerStartImage), [getImageSource, odometerStartImage]); - const endImageSource = useMemo(() => getImageSource(odometerEndImage), [getImageSource, odometerEndImage]); + const startImageSource = getImageSource(odometerStartImage); + const endImageSource = getImageSource(odometerEndImage); const buttonText = (() => { if (shouldSkipConfirmation) { @@ -378,13 +382,13 @@ function IOURequestStepDistanceOdometer({ } }; - const navigateBack = useCallback(() => { + const navigateBack = () => { if (isEditingConfirmation) { Navigation.goBack(confirmationRoute); return; } Navigation.goBack(); - }, [confirmationRoute, isEditingConfirmation]); + }; const {handlePressStartImage, handlePressEndImage} = useOdometerImageHandlers({ action, @@ -605,7 +609,7 @@ function IOURequestStepDistanceOdometer({ onVisibilityChange: setIsDiscardModalVisible, }); - const handleSaveForLater = useCallback(async () => { + const handleSaveForLater = async () => { shouldBypassDiscardConfirmationRef.current = true; const normalizedStart = DistanceRequestUtils.normalizeOdometerText(startReading, fromLocaleDigit); @@ -636,7 +640,7 @@ function IOURequestStepDistanceOdometer({ return; } Navigation.closeRHPFlow(); - }, [fromLocaleDigit, startReading, endReading, odometerStartImage, odometerEndImage, translate, setFormError]); + }; return ( ; @@ -294,14 +294,14 @@ function IOURequestStepOdometerImage({ ); - const revokeDropBlobUrls = useCallback(() => { + const revokeDropBlobUrls = () => { for (const url of dropBlobUrlsRef.current) { if (url.startsWith('blob:')) { URL.revokeObjectURL(url); } } dropBlobUrlsRef.current = []; - }, []); + }; const handleDrop = (event: DragEvent) => { const files = Array.from(event.dataTransfer?.files ?? []).filter((file) => CONST.FILE_TYPE_REGEX.IMAGE.test(file.name)); diff --git a/src/pages/iou/request/step/IOURequestStepReuseRoute.tsx b/src/pages/iou/request/step/IOURequestStepReuseRoute.tsx index 5910cb93b9ac..358c0f20cf84 100644 --- a/src/pages/iou/request/step/IOURequestStepReuseRoute.tsx +++ b/src/pages/iou/request/step/IOURequestStepReuseRoute.tsx @@ -34,7 +34,7 @@ import type {ReusableDistanceRoute, Transaction} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import type {ReuseRouteListItemData} from './ReuseRouteListItem'; import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound'; @@ -75,10 +75,7 @@ function IOURequestStepReuseRoute({ const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`); const [reusableDistanceRoutes] = useOnyx(ONYXKEYS.REUSABLE_DISTANCE_ROUTES); - const availableRoutesSelector = useCallback( - (transactions: OnyxCollection) => getAvailableReusableDistanceRoutes(reusableDistanceRoutes, transactions, isOffline), - [reusableDistanceRoutes, isOffline], - ); + const availableRoutesSelector = (transactions: OnyxCollection) => getAvailableReusableDistanceRoutes(reusableDistanceRoutes, transactions, isOffline); const [availableRoutes] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { selector: availableRoutesSelector, }); @@ -114,13 +111,11 @@ function IOURequestStepReuseRoute({ // For quick button actions, we'll skip the confirmation page unless the report is archived or // this is a workspace request and the workspace requires a category or a tag - const shouldSkipConfirmation: boolean = useMemo(() => { - if (!skipConfirmation || !report?.reportID) { - return false; - } - - return iouType !== CONST.IOU.TYPE.SPLIT && !isArchived && !(isPolicyExpenseChatUtil(report) && ((policy?.requiresCategory ?? false) || (policy?.requiresTag ?? false))); - }, [report, skipConfirmation, policy?.requiresCategory, policy?.requiresTag, isArchived, iouType]); + const shouldSkipConfirmation: boolean = + !(!skipConfirmation || !report?.reportID) && + iouType !== CONST.IOU.TYPE.SPLIT && + !isArchived && + !(isPolicyExpenseChatUtil(report) && ((policy?.requiresCategory ?? false) || (policy?.requiresTag ?? false))); const navigateToNextStep = useDistanceNavigation({ iouType, diff --git a/src/pages/iou/request/step/IOURequestStepScan/DynamicReceiptView/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/DynamicReceiptView/index.tsx index d63f9d9adb8b..6482562cdd2a 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/DynamicReceiptView/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/DynamicReceiptView/index.tsx @@ -26,7 +26,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; type DynamicReceiptViewProps = PlatformStackScreenProps; @@ -79,11 +79,11 @@ function DynamicReceiptView({route}: DynamicReceiptViewProps) { Navigation.goBack(undefined, {afterTransition: handleDeleteReceipt}); }; - const handleGoBack = useCallback(() => { + const handleGoBack = () => { Navigation.goBack(backPath); - }, [backPath]); + }; - const handleDeleteReceiptPress = useCallback(async () => { + const handleDeleteReceiptPress = async () => { const result = await showConfirmModal({ title: translate('receipt.deleteReceipt'), prompt: translate('receipt.deleteConfirmation'), @@ -95,7 +95,7 @@ function DynamicReceiptView({route}: DynamicReceiptViewProps) { return; } deleteReceipt(); - }, [showConfirmModal, translate, deleteReceipt]); + }; return ( void; @@ -64,9 +64,9 @@ function NavigateGlobalCreateProvider({children, ...rest}: ProviderProps) { // Stable dispatch — context value identity never changes, so consumers // don't re-render on Onyx ticks. Late-binds to fnRef.current at call time. - const dispatch = useCallback((transactionIDs: string[], isMultiScanEnabled: boolean) => { + const dispatch = (transactionIDs: string[], isMultiScanEnabled: boolean) => { fnRef.current(transactionIDs, isMultiScanEnabled); - }, []); + }; useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional one-tick defer to keep heavy Onyx subscriptions off the camera-mount critical path diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/AttachmentStateContextProvider.tsx b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/AttachmentStateContextProvider.tsx index 098fa3c3d025..3e9a95afb2aa 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/AttachmentStateContextProvider.tsx +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/AttachmentStateContextProvider.tsx @@ -1,6 +1,6 @@ import type {AttachmentSource} from '@components/Attachments/types'; -import React, {createContext, useCallback, useMemo, useState} from 'react'; +import React, {createContext, useState} from 'react'; function convertSourceToString(source: AttachmentSource) { if (typeof source === 'string' || typeof source === 'number') { @@ -33,7 +33,7 @@ type Props = { function AttachmentStateContextProvider({children}: Props) { const [attachmentLoaded, setAttachmentLoadedState] = useState>({}); - const setAttachmentLoaded = useCallback((key: AttachmentSource, state = true) => { + const setAttachmentLoaded = (key: AttachmentSource, state = true) => { const url = convertSourceToString(key); if (!url) { return; @@ -42,14 +42,14 @@ function AttachmentStateContextProvider({children}: Props) { ...prevState, [url]: state, })); - }, []); + }; - const clearAttachmentLoaded = useCallback(() => { + const clearAttachmentLoaded = () => { setAttachmentLoadedState({}); - }, []); + }; - const isAttachmentLoaded = useCallback((key: AttachmentSource) => attachmentLoaded?.[convertSourceToString(key)] === true, [attachmentLoaded]); - const value = useMemo(() => ({setAttachmentLoaded, clearAttachmentLoaded, isAttachmentLoaded}), [setAttachmentLoaded, clearAttachmentLoaded, isAttachmentLoaded]); + const isAttachmentLoaded = (key: AttachmentSource) => attachmentLoaded?.[convertSourceToString(key)] === true; + const value = {setAttachmentLoaded, clearAttachmentLoaded, isAttachmentLoaded}; return {children}; } diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx index 86f14694f1a2..341b1c5a68af 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx @@ -32,7 +32,7 @@ import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import viewRef from '@src/types/utils/viewRef'; -import React, {memo, useCallback, useContext, useEffect, useMemo, useState} from 'react'; +import React, {useContext, useEffect, useState} from 'react'; import {StyleSheet, View} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; import Animated, {FadeIn, LayoutAnimationConfig, useSharedValue} from 'react-native-reanimated'; @@ -114,7 +114,7 @@ function AttachmentModalBaseContent({ addOfflineIndicatorBottomSafeAreaPadding: true, }); - const fallbackFile = useMemo(() => (originalFileName ? {name: originalFileName} : undefined), [originalFileName]); + const fallbackFile = originalFileName ? {name: originalFileName} : undefined; const [files, setFile] = useState(() => filesProp ?? fallbackFile); const fileToDisplay = Array.isArray(files) ? files.at(fileToDisplayIndex) : files; @@ -125,38 +125,29 @@ function AttachmentModalBaseContent({ /** * Keeps the attachment source in sync with the attachment displayed currently in the carousel. */ - const onNavigate = useCallback( - (attachment: Attachment) => { - setSource(attachment.source); - setFile(attachment.file); - setCurrentPreviewSource(attachment.previewSource); - setIsAuthTokenRequiredState(attachment.isAuthTokenRequired ?? false); - onCarouselAttachmentChange(attachment); - setCurrentAttachmentLink(attachment?.attachmentLink ?? ''); - }, - [onCarouselAttachmentChange, setFile], - ); + const onNavigate = (attachment: Attachment) => { + setSource(attachment.source); + setFile(attachment.file); + setCurrentPreviewSource(attachment.previewSource); + setIsAuthTokenRequiredState(attachment.isAuthTokenRequired ?? false); + onCarouselAttachmentChange(attachment); + setCurrentAttachmentLink(attachment?.attachmentLink ?? ''); + }; - const threeDotsMenuItems = useMemo( - () => (typeof threeDotsMenuItemsProp === 'function' ? threeDotsMenuItemsProp({file: fileToDisplay, source, isLocalSource}) : (threeDotsMenuItemsProp ?? [])), - [fileToDisplay, isLocalSource, source, threeDotsMenuItemsProp], - ); + const threeDotsMenuItems = typeof threeDotsMenuItemsProp === 'function' ? threeDotsMenuItemsProp({file: fileToDisplay, source, isLocalSource}) : (threeDotsMenuItemsProp ?? []); const [isDownloadButtonReadyToBeShown, setIsDownloadButtonReadyToBeShown] = useState(true); - const setDownloadButtonVisibility = useCallback( - (isButtonVisible: boolean) => { - if (isDownloadButtonReadyToBeShown === isButtonVisible) { - return; - } - setIsDownloadButtonReadyToBeShown(isButtonVisible); - }, - [isDownloadButtonReadyToBeShown], - ); + const setDownloadButtonVisibility = (isButtonVisible: boolean) => { + if (isDownloadButtonReadyToBeShown === isButtonVisible) { + return; + } + setIsDownloadButtonReadyToBeShown(isButtonVisible); + }; /** * Execute the onConfirm callback and close the modal. */ - const submitAndClose = useCallback(() => { + const submitAndClose = () => { // If the modal has already been closed or the confirm button is disabled // do not submit. if (isConfirmButtonDisabled) { @@ -184,7 +175,7 @@ function AttachmentModalBaseContent({ // Defer onConfirm to the next frame so the target screen has time to unfreeze and re-mount its refs (e.g. composerRef.clearWorklet) requestAnimationFrame(confirm); - }, [isConfirmButtonDisabled, onConfirm, onClose, files, source, confirmLeavesScreen]); + }; // Close the modal when the escape key is pressed useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => onClose?.(), {shouldBubble: true}); @@ -200,125 +191,79 @@ function AttachmentModalBaseContent({ const isEReceipt = transaction && !hasReceiptSource(transaction) && hasEReceipt(transaction); const isFileImage = typeof source !== 'function' && checkIsFileImage(source, fileToDisplay?.name, fileToDisplay?.type); const isSourceLoaded = isAttachmentLoaded?.(source) || (!!currentPreviewSource && isAttachmentLoaded?.(currentPreviewSource)); - const shouldShowDownloadButton = useMemo(() => { - const isValidContext = !isEmptyObject(report) || type === CONST.ATTACHMENT_TYPE.SEARCH || shouldAllowDownloadOutsideReportContext; - if (!isValidContext || isErrorInAttachment(source) || isEReceipt) { - return false; - } - - return !!onDownloadAttachment && isDownloadButtonReadyToBeShown && !shouldShowNotFoundPage && !isOffline && !isLocalSource && (!isFileImage || isSourceLoaded); - }, [ - isDownloadButtonReadyToBeShown, - isErrorInAttachment, - isFileImage, - isLocalSource, - isOffline, - isSourceLoaded, - onDownloadAttachment, - report, - shouldAllowDownloadOutsideReportContext, - shouldShowNotFoundPage, - source, - type, - isEReceipt, - ]); + let shouldShowDownloadButton; + const isValidContext = !isEmptyObject(report) || type === CONST.ATTACHMENT_TYPE.SEARCH || shouldAllowDownloadOutsideReportContext; + if (!isValidContext || isErrorInAttachment(source) || isEReceipt) { + shouldShowDownloadButton = false; + } else { + shouldShowDownloadButton = !!onDownloadAttachment && isDownloadButtonReadyToBeShown && !shouldShowNotFoundPage && !isOffline && !isLocalSource && (!isFileImage || isSourceLoaded); + } // We need to pass a shared value of type boolean to the context, so `falseSV` acts as a default value. const falseSV = useSharedValue(false); - const stateValue = useMemo( - () => ({ - pagerItems: [{source: sourceForAttachmentView, index: 0, isActive: true}], - activePage: 0, - pagerRef: undefined, - isPagerScrolling: falseSV, - isScrollEnabled: falseSV, - }), - [falseSV, sourceForAttachmentView], - ); + const stateValue: AttachmentCarouselPagerStateContextType = { + pagerItems: [{source: sourceForAttachmentView, index: 0, isActive: true}], + activePage: 0, + pagerRef: undefined, + isPagerScrolling: falseSV, + isScrollEnabled: falseSV, + }; - const actionsValue = useMemo( - () => ({ - onTap: () => {}, - onScaleChanged: () => {}, - onAttachmentError: setAttachmentError, - ...(shouldCloseOnSwipeDown ? {onSwipeDown: onClose} : {}), - }), - [setAttachmentError, shouldCloseOnSwipeDown, onClose], - ); + const actionsValue: AttachmentCarouselPagerActionsContextType = { + onTap: () => {}, + onScaleChanged: () => {}, + onAttachmentError: setAttachmentError, + ...(shouldCloseOnSwipeDown ? {onSwipeDown: onClose} : {}), + }; const shouldDisplayContent = !shouldShowNotFoundPage && !isLoading; - const Content = useMemo(() => { - if (AttachmentContent) { - return ( - - ); - } - - return !isEmptyObject(report) && shouldShowCarousel && type !== CONST.ATTACHMENT_TYPE.SEARCH ? ( - - ) : ( - !!sourceForAttachmentView && ( - - - - - - ) ); - }, [ - AttachmentContent, - accountID, - attachmentID, - stateValue, - actionsValue, - currentAttachmentLink, - fallbackSource, - fileToDisplay, - files, - isAuthTokenRequiredState, - isWorkspaceAvatar, - maybeIcon, - onNavigate, - pdfRotation, - report, - reportID, - setAttachmentError, - setDownloadButtonVisibility, - shouldShowCarousel, - sourceForAttachmentView, - sourceProp, - styles.mh5, - transaction, - type, - ]); + } else { + Content = + !isEmptyObject(report) && shouldShowCarousel && type !== CONST.ATTACHMENT_TYPE.SEARCH ? ( + + ) : ( + !!sourceForAttachmentView && ( + + + + + + ) + ); + } return ( @@ -406,4 +351,4 @@ function AttachmentModalBaseContent({ ); } -export default memo(AttachmentModalBaseContent); +export default AttachmentModalBaseContent; diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalContainer/index.native.tsx b/src/pages/media/AttachmentModalScreen/AttachmentModalContainer/index.native.tsx index 6614c699e601..289adb3accd2 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalContainer/index.native.tsx +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalContainer/index.native.tsx @@ -10,7 +10,7 @@ import AttachmentModalContext from '@pages/media/AttachmentModalScreen/Attachmen import type {AttachmentModalScreenType} from '@pages/media/AttachmentModalScreen/types'; import {SafeString} from 'expensify-common'; -import React, {memo, useCallback, useContext, useEffect} from 'react'; +import React, {useContext, useEffect} from 'react'; import type AttachmentModalContainerProps from './types'; @@ -18,33 +18,30 @@ function AttachmentModalContainer({con const attachmentsContext = useContext(AttachmentModalContext); const testID = typeof contentProps.source === 'string' ? contentProps.source : SafeString(contentProps.source); - const resetAttachmentModalAndClose = useCallback(() => { + const resetAttachmentModalAndClose = () => { attachmentsContext.setCurrentAttachment(undefined); onClose?.(); - }, [attachmentsContext, onClose]); + }; /** * Closes the modal. - * @param {boolean} [shouldCallDirectly] If true, directly calls `onModalClose`. + * @param options Modal close options, including whether to call `onModalClose` directly. * This is useful when you plan to continue navigating to another page after closing the modal, to avoid freezing the app due to navigating to another page first and dismissing the modal later. * If `shouldCallDirectly` is false or undefined, it calls `attachmentModalHandler.handleModalClose` to close the modal. * This ensures smooth modal closing behavior without causing delays in closing. */ - const closeScreen = useCallback( - (options?: AttachmentModalOnCloseOptions) => { - const close = () => { - resetAttachmentModalAndClose(); - Navigation.goBack(); - }; + const closeScreen = (options?: AttachmentModalOnCloseOptions) => { + const close = () => { + resetAttachmentModalAndClose(); + Navigation.goBack(); + }; - if (options?.shouldCallDirectly) { - close(); - } else { - attachmentModalHandler.handleModalClose(close); - } - }, - [resetAttachmentModalAndClose], - ); + if (options?.shouldCallDirectly) { + close(); + } else { + attachmentModalHandler.handleModalClose(close); + } + }; useEffect(() => { onShow?.(); @@ -70,4 +67,4 @@ function AttachmentModalContainer({con ); } -export default memo(AttachmentModalContainer); +export default AttachmentModalContainer; diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalContainer/index.tsx b/src/pages/media/AttachmentModalScreen/AttachmentModalContainer/index.tsx index f3ca45e2db77..050f587752bc 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalContainer/index.tsx +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalContainer/index.tsx @@ -9,7 +9,7 @@ import type {AttachmentModalScreenType} from '@pages/media/AttachmentModalScreen import CONST from '@src/CONST'; -import React, {useCallback, useContext, useEffect, useState} from 'react'; +import React, {useContext, useEffect, useState} from 'react'; import type AttachmentModalContainerProps from './types'; @@ -17,16 +17,16 @@ function AttachmentModalContainer({con const [isVisible, setIsVisible] = useState(true); const attachmentsContext = useContext(AttachmentModalContext); - const resetAttachmentModalAndClose = useCallback(() => { + const resetAttachmentModalAndClose = () => { attachmentsContext.setCurrentAttachment(undefined); setIsVisible(false); onClose?.(); - }, [attachmentsContext, onClose]); + }; - const closeModal = useCallback(() => { + const closeModal = () => { Navigation.dismissModal(); resetAttachmentModalAndClose(); - }, [resetAttachmentModalAndClose]); + }; useEffect(() => { onShow?.(); diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalContext.tsx b/src/pages/media/AttachmentModalScreen/AttachmentModalContext.tsx index 8a0a92f7ce00..d213c250da4b 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalContext.tsx +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalContext.tsx @@ -2,7 +2,7 @@ import {useCurrentReportIDState} from '@hooks/useCurrentReportID'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import type AttachmentModalScreenParams from './routes/types'; import type {AttachmentModalScreenBaseParams, AttachmentModalScreenType} from './types'; @@ -34,31 +34,24 @@ function AttachmentModalContextProvider({children}: ChildrenProps) { }, [currentReportID]); const currentAttachment = useRef(undefined); - const setCurrentAttachment = useCallback( - = AttachmentModalScreenParams>(attachmentProps: RouteParams | undefined) => { - currentAttachment.current = attachmentProps; + const setCurrentAttachment = = AttachmentModalScreenParams>( + attachmentProps: RouteParams | undefined, + ) => { + currentAttachment.current = attachmentProps; + }; + const getCurrentAttachment = = AttachmentModalScreenParams>() => + currentAttachment.current as RouteParams | undefined; + const contextValue = { + isAttachmentHidden: (reportActionID: string) => hiddenAttachments.current[reportActionID], + updateHiddenAttachments: (reportActionID: string, value: boolean) => { + hiddenAttachments.current = { + ...hiddenAttachments.current, + [reportActionID]: value, + }; }, - [], - ); - const getCurrentAttachment = useCallback( - = AttachmentModalScreenParams>() => - currentAttachment.current as RouteParams | undefined, - [], - ); - const contextValue = useMemo( - () => ({ - isAttachmentHidden: (reportActionID: string) => hiddenAttachments.current[reportActionID], - updateHiddenAttachments: (reportActionID: string, value: boolean) => { - hiddenAttachments.current = { - ...hiddenAttachments.current, - [reportActionID]: value, - }; - }, - setCurrentAttachment, - getCurrentAttachment, - }), - [setCurrentAttachment, getCurrentAttachment], - ); + setCurrentAttachment, + getCurrentAttachment, + }; return {children}; } diff --git a/src/pages/media/AttachmentModalScreen/index.tsx b/src/pages/media/AttachmentModalScreen/index.tsx index 27d7cdd9c48d..528acd480e13 100644 --- a/src/pages/media/AttachmentModalScreen/index.tsx +++ b/src/pages/media/AttachmentModalScreen/index.tsx @@ -2,7 +2,7 @@ import Log from '@libs/Log'; import SCREENS from '@src/SCREENS'; -import React, {useContext, useMemo} from 'react'; +import React, {useContext} from 'react'; import type {AttachmentModalScreenProps, AttachmentModalScreenType} from './types'; @@ -26,14 +26,19 @@ type NavigationType = AttachmentModalS */ function AttachmentModalScreen({route, navigation}: AttachmentModalScreenProps) { const attachmentsContext = useContext(AttachmentModalContext); - const routeWithContext = useMemo(() => { - const currentAttachment = attachmentsContext.getCurrentAttachment(); - - if (currentAttachment) { - return {...route, params: {...route.params, ...currentAttachment}}; - } - return route; - }, [attachmentsContext, route]); + let routeWithContext; + const currentAttachment = attachmentsContext.getCurrentAttachment(); + if (currentAttachment) { + routeWithContext = { + ...route, + params: { + ...route.params, + ...currentAttachment, + }, + }; + } else { + routeWithContext = route; + } if (route.name === SCREENS.REPORT_ATTACHMENTS) { return ( diff --git a/src/pages/media/AttachmentModalScreen/routes/ProfileAvatarModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/ProfileAvatarModalContent.tsx index 579ef1e83320..71feb6e44e0c 100644 --- a/src/pages/media/AttachmentModalScreen/routes/ProfileAvatarModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/ProfileAvatarModalContent.tsx @@ -16,7 +16,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; -import React, {useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import useDownloadAttachment from './hooks/useDownloadAttachment'; @@ -50,19 +50,16 @@ function ProfileAvatarModalContent({navigation, route}: AttachmentModalScreenPro const onDownloadAttachment = useDownloadAttachment(); - const contentProps = useMemo( - () => ({ - source, - originalFileName, - headerTitle: displayName, - isLoading, - shouldShowNotFoundPage, - maybeIcon: true, - onDownloadAttachment, - shouldCloseOnSwipeDown: true, - }), - [displayName, isLoading, onDownloadAttachment, originalFileName, shouldShowNotFoundPage, source], - ); + const contentProps: AttachmentModalBaseContentProps = { + source, + originalFileName, + headerTitle: displayName, + isLoading, + shouldShowNotFoundPage, + maybeIcon: true, + onDownloadAttachment, + shouldCloseOnSwipeDown: true, + }; return ( ) { const {source: sourceParam, originalFileName: originalFileNameParam, headerTitle, onShow, onClose} = route.params; - const source = useMemo(() => Number(sourceParam) || (typeof sourceParam === 'string' ? tryResolveUrlFromApiRoot(decodeURIComponent(sourceParam)) : undefined), [sourceParam]); + const source = Number(sourceParam) || (typeof sourceParam === 'string' ? tryResolveUrlFromApiRoot(decodeURIComponent(sourceParam)) : undefined); const originalFileName = originalFileNameParam ?? ''; const onDownloadAttachment = useDownloadAttachment({}); - const contentProps = useMemo( - () => ({ - source, - originalFileName, - headerTitle, - onDownloadAttachment, - }), - [headerTitle, onDownloadAttachment, originalFileName, source], - ); + const contentProps: AttachmentModalBaseContentProps = { + source, + originalFileName, + headerTitle, + onDownloadAttachment, + }; const modalType = useReportAttachmentModalType(source); return ( diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx index 691027b33546..b375fc69c45e 100644 --- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx @@ -58,7 +58,7 @@ import type {RotationDegrees} from 'react-fast-pdf'; import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import {getParentReportActionSelector, transactionThreadReportIDSelector} from '@selectors/ReportAction'; import {Str} from 'expensify-common'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import useDownloadAttachment from './hooks/useDownloadAttachment'; @@ -101,21 +101,18 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const draftTransactionID = isDraftTransaction ? transactionID : undefined; // Determine which transaction to use based on the scenario - const transaction = useMemo(() => { - if (isDraftTransaction) { - return transactionDraft; - } - - if (mergeTransactionID && mergeTransaction && transactionMain) { - // If we have a merge transaction, we need to use the receipt from the merge transaction - return { - ...transactionMain, - receipt: mergeTransaction.receipt, - }; - } - - return transactionMain; - }, [isDraftTransaction, mergeTransaction, mergeTransactionID, transactionDraft, transactionMain]); + let transaction; + if (isDraftTransaction) { + transaction = transactionDraft; + } else if (mergeTransactionID && mergeTransaction && transactionMain) { + // If we have a merge transaction, we need to use the receipt from the merge transaction + transaction = { + ...transactionMain, + receipt: mergeTransaction.receipt, + }; + } else { + transaction = transactionMain; + } useRestartOnOdometerImagesFailure(isDraftTransaction && isOdometerDistanceRequest(transaction) ? transaction : undefined, reportID, iouTypeParam ?? CONST.IOU.TYPE.SUBMIT, backToReport); @@ -202,7 +199,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const isPDF = !!fileName && Str.isPDF(fileName); const fileType = isOdometerImage ? odometerFileType : (transaction?.receipt?.type ?? CONST.IMAGE_FILE_FORMAT.JPEG); const isTrackExpenseActionValue = isTrackExpenseAction(parentReportAction); - const iouType = useMemo(() => iouTypeParam ?? (isTrackExpenseActionValue ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT), [isTrackExpenseActionValue, iouTypeParam]); + const iouType = iouTypeParam ?? (isTrackExpenseActionValue ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT); const {showConfirmModal} = useConfirmModal(); const [pdfRotation, setPdfRotation] = useState(0); @@ -302,7 +299,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre /** * Detach the receipt and close the modal. */ - const deleteReceiptAndClose = useCallback(() => { + const deleteReceiptAndClose = () => { detachReceipt({ transaction, transactionPolicy: policy, @@ -316,24 +313,12 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre transactionPolicyCategories: policyCategories, }); navigation.goBack(); - }, [ - transaction, - policy, - policyTagList, - transactionViolations, - transactionReport, - isVendorMatchingBetaEnabled, - transactionThread, - delegateAccountID, - currentUserPersonalDetails, - policyCategories, - navigation, - ]); + }; /** * Remove odometer image and close the modal. */ - const deleteOdometerImageAndClose = useCallback(() => { + const deleteOdometerImageAndClose = () => { if (!transaction?.transactionID || !imageType) { return; } @@ -344,7 +329,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre ? ROUTES.MONEY_REQUEST_STEP_DISTANCE_ODOMETER.getRoute(action ?? CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID) : ROUTES.DISTANCE_REQUEST_CREATE_TAB_ODOMETER.getRoute(action ?? CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, backToReport)); Navigation.goBack(odometerGoBackRoute || undefined); - }, [transaction, imageType, isDraftTransaction, isOdometerImage, isEditingConfirmation, action, iouType, transactionID, reportID, backToReport]); + }; const onDownloadAttachment = useDownloadAttachment({ isAuthTokenRequired, @@ -353,62 +338,43 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre const allowDownload = !isEReceipt; - const applyDurableReceipt = useCallback( - (imageUri: string, filename: string, file: File, isSameReceipt?: boolean) => { - if (!transaction?.transactionID) { - return Promise.resolve(); - } - return ReceiptStorage.adopt(imageUri, filename) - .then((durableName) => ReceiptStorage.toLocalUri(durableName)) - .catch((error: unknown) => { - logReceiptAdoptFailed({error, captureSource: 'replace'}); - return imageUri; - }) - .then((durableUri) => { - const durableFile = Object.assign(new File([file], file.name || filename, {type: file.type}), {uri: durableUri, source: durableUri}); - if (isOdometerImage) { - setMoneyRequestOdometerImage(transaction, imageType, durableFile, isDraftTransaction, !isEditingConfirmation); - } else if (isDraftTransaction) { - setMoneyRequestReceipt(transaction.transactionID, durableUri, filename, isDraftTransaction, fileType); - } else { - replaceReceipt({ - isVendorMatchingBetaEnabled, - transaction, - file: durableFile, - source: durableUri, - transactionPolicyCategories: policyCategories, - transactionPolicy: policy, - transactionPolicyTagList: policyTagList, - transactionViolations, - transactionReport, - delegateAccountID, - currentUserPersonalDetails, - transactionThreadReport: transactionThread, - ...(isSameReceipt ? {state: transaction?.receipt?.state, isSameReceipt: true} : {}), - }); - } - }); - }, - [ - transaction, - isDraftTransaction, - isOdometerImage, - isEditingConfirmation, - imageType, - fileType, - policyCategories, - policy, - policyTagList, - transactionViolations, - transactionReport, - isVendorMatchingBetaEnabled, - delegateAccountID, - currentUserPersonalDetails, - transactionThread, - ], - ); + const applyDurableReceipt = (imageUri: string, filename: string, file: File, isSameReceipt?: boolean) => { + if (!transaction?.transactionID) { + return Promise.resolve(); + } + return ReceiptStorage.adopt(imageUri, filename) + .then((durableName) => ReceiptStorage.toLocalUri(durableName)) + .catch((error: unknown) => { + logReceiptAdoptFailed({error, captureSource: 'replace'}); + return imageUri; + }) + .then((durableUri) => { + const durableFile = Object.assign(new File([file], file.name || filename, {type: file.type}), {uri: durableUri, source: durableUri}); + if (isOdometerImage) { + setMoneyRequestOdometerImage(transaction, imageType, durableFile, isDraftTransaction, !isEditingConfirmation); + } else if (isDraftTransaction) { + setMoneyRequestReceipt(transaction.transactionID, durableUri, filename, isDraftTransaction, fileType); + } else { + replaceReceipt({ + isVendorMatchingBetaEnabled, + transaction, + file: durableFile, + source: durableUri, + transactionPolicyCategories: policyCategories, + transactionPolicy: policy, + transactionPolicyTagList: policyTagList, + transactionViolations, + transactionReport, + delegateAccountID, + currentUserPersonalDetails, + transactionThreadReport: transactionThread, + ...(isSameReceipt ? {state: transaction?.receipt?.state, isSameReceipt: true} : {}), + }); + } + }); + }; - const rotateReceipt = useCallback(() => { + const rotateReceipt = () => { if (!transaction?.transactionID || !sourceUri || !isImage) { return; } @@ -441,34 +407,31 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre .catch(() => { setIsRotating(false); }); - }, [transaction?.transactionID, sourceUri, isImage, receiptFilename, fileName, fileType, applyDurableReceipt]); - - const shouldShowRotateAndCropReceiptButton = useMemo( - () => - canEditReceiptButtons && - transaction && - (hasReceiptSource(transaction) || (isOdometerImage && hasOdometerImageSource(transaction, imageType))) && - !isEReceipt && - !transaction?.receipt?.isTestDriveReceipt && - isImage, - [canEditReceiptButtons, transaction, isEReceipt, isOdometerImage, imageType, isImage], - ); + }; + + const shouldShowRotateAndCropReceiptButton = + canEditReceiptButtons && + transaction && + (hasReceiptSource(transaction) || (isOdometerImage && hasOdometerImageSource(transaction, imageType))) && + !isEReceipt && + !transaction?.receipt?.isTestDriveReceipt && + isImage; - const enterCropMode = useCallback(() => { + const enterCropMode = () => { setIsCropping(true); setCropRect(null); - }, []); + }; - const exitCropMode = useCallback(() => { + const exitCropMode = () => { setIsCropping(false); setCropRect(null); - }, []); + }; - const handleCropChange = useCallback((crop: CropRect) => { + const handleCropChange = (crop: CropRect) => { setCropRect(crop); - }, []); + }; - const saveCrop = useCallback(() => { + const saveCrop = () => { if (!transaction?.transactionID || !sourceUri || !isImage || !cropRect || cropRect.width < 1 || cropRect.height < 1) { exitCropMode(); return; @@ -516,105 +479,80 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre .catch(() => { setIsCropSaving(false); }); - }, [transaction?.transactionID, sourceUri, isImage, cropRect, receiptFilename, fileName, fileType, exitCropMode, applyDurableReceipt]); - - const threeDotsMenuItems: ThreeDotsMenuItemFactory = useCallback( - ({file, source: innerSource, isLocalSource}) => { - const menuItems = []; - if ((!isOffline && allowDownload && !isLocalSource) || !!draftTransactionID) { - menuItems.push({ - icon: expensifyIcons.Download, - text: translate('common.download'), - onSelected: () => onDownloadAttachment({source: innerSource, file}), - sentryLabel: CONST.SENTRY_LABEL.RECEIPT_MODAL.DOWNLOAD_RECEIPT, - }); - } - - const hasOnlyEReceipt = hasEReceipt(transaction) && !hasReceiptSource(transaction); - const isDeletableReceipt = - shouldShowDeleteReceiptButton && - !hasOnlyEReceipt && - hasReceipt(transaction) && - !isReceiptBeingScanned(transaction) && - !hasMissingSmartscanFields(transaction, transactionReport); - const isDraftOdometer = isOdometerImage && isDraftTransaction; - if (isDeletableReceipt || isDraftOdometer) { - menuItems.push({ - icon: expensifyIcons.Trashcan, - text: isOdometerImage ? translate('distance.odometer.deleteOdometerPhoto') : translate('receipt.deleteReceipt'), - onSelected: async () => { - const result = await showConfirmModal({ - title: isOdometerImage ? translate('distance.odometer.deleteOdometerPhoto') : translate('receipt.deleteReceipt'), - prompt: isOdometerImage ? translate('distance.odometer.deleteOdometerPhotoConfirmation') : translate('receipt.deleteConfirmation'), - confirmText: translate('common.delete'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }); - - if (result.action !== ModalActions.CONFIRM) { - return; - } - - if (isOdometerImage) { - deleteOdometerImageAndClose(); - } else { - deleteReceiptAndClose(); - } - }, - shouldCallAfterModalHide: true, - sentryLabel: CONST.SENTRY_LABEL.RECEIPT_MODAL.DELETE_RECEIPT, - }); - } - return menuItems; - }, - [ - isOdometerImage, - isOffline, - allowDownload, - draftTransactionID, - transaction, - shouldShowDeleteReceiptButton, - transactionReport, - isDraftTransaction, - translate, - expensifyIcons, - onDownloadAttachment, - showConfirmModal, - deleteOdometerImageAndClose, - deleteReceiptAndClose, - ], - ); + }; - const footerActionButtons = useMemo(() => { - if (isCropping) { - return ( - - - - - ); + const threeDotsMenuItems: ThreeDotsMenuItemFactory = ({file, source: innerSource, isLocalSource}) => { + const menuItems = []; + if ((!isOffline && allowDownload && !isLocalSource) || !!draftTransactionID) { + menuItems.push({ + icon: expensifyIcons.Download, + text: translate('common.download'), + onSelected: () => onDownloadAttachment({source: innerSource, file}), + sentryLabel: CONST.SENTRY_LABEL.RECEIPT_MODAL.DOWNLOAD_RECEIPT, + }); } - if (!shouldShowRotateAndCropReceiptButton && !shouldShowReplaceReceiptButton && !isOdometerImage) { - return null; + const hasOnlyEReceipt = hasEReceipt(transaction) && !hasReceiptSource(transaction); + const isDeletableReceipt = + shouldShowDeleteReceiptButton && !hasOnlyEReceipt && hasReceipt(transaction) && !isReceiptBeingScanned(transaction) && !hasMissingSmartscanFields(transaction, transactionReport); + const isDraftOdometer = isOdometerImage && isDraftTransaction; + if (isDeletableReceipt || isDraftOdometer) { + menuItems.push({ + icon: expensifyIcons.Trashcan, + text: isOdometerImage ? translate('distance.odometer.deleteOdometerPhoto') : translate('receipt.deleteReceipt'), + onSelected: async () => { + const result = await showConfirmModal({ + title: isOdometerImage ? translate('distance.odometer.deleteOdometerPhoto') : translate('receipt.deleteReceipt'), + prompt: isOdometerImage ? translate('distance.odometer.deleteOdometerPhotoConfirmation') : translate('receipt.deleteConfirmation'), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }); + + if (result.action !== ModalActions.CONFIRM) { + return; + } + + if (isOdometerImage) { + deleteOdometerImageAndClose(); + } else { + deleteReceiptAndClose(); + } + }, + shouldCallAfterModalHide: true, + sentryLabel: CONST.SENTRY_LABEL.RECEIPT_MODAL.DELETE_RECEIPT, + }); } + return menuItems; + }; - return ( + let footerActionButtons; + if (isCropping) { + footerActionButtons = ( + + + + + ); + } else if (!shouldShowRotateAndCropReceiptButton && !shouldShowReplaceReceiptButton && !isOdometerImage) { + footerActionButtons = null; + } else { + footerActionButtons = ( {!!shouldShowRotateAndCropReceiptButton && ( - ), - [translate, isFormLoading, styles.w100], + const saveButton = ( + ); return ( diff --git a/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx b/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx index f3fa01d66810..dd5f8171bab0 100644 --- a/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx @@ -22,7 +22,7 @@ import type {TranslationPaths} from '@src/languages/types'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; type DynamicCountrySelectionPageProps = PlatformStackScreenProps; @@ -34,40 +34,30 @@ function DynamicCountrySelectionPage({route}: DynamicCountrySelectionPageProps) const initialSelectedValue = useInitialSelection(currentCountry ?? undefined, {resetOnFocus: true}); const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; - const countries = useMemo( - () => - Object.keys(CONST.ALL_COUNTRIES).map((countryISO) => { - const countryName = translate(`allCountries.${countryISO}` as TranslationPaths); - return { - value: countryISO, - keyForList: countryISO, - text: countryName, - isSelected: currentCountry === countryISO, - searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), - }; - }), - [translate, currentCountry], - ); + const countries = Object.keys(CONST.ALL_COUNTRIES).map((countryISO) => { + const countryName = translate(`allCountries.${countryISO}` as TranslationPaths); + return { + value: countryISO, + keyForList: countryISO, + text: countryName, + isSelected: currentCountry === countryISO, + searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), + }; + }); const orderedCountries = moveInitialSelectionToTop(countries, initialSelectedValues); const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries); - const selectCountry = useCallback( - (option: Option) => { - Navigation.goBack(appendParam(backPath, 'country', option.value), {compareParams: false}); - }, - [backPath], - ); + const selectCountry = (option: Option) => { + Navigation.goBack(appendParam(backPath, 'country', option.value), {compareParams: false}); + }; - const textInputOptions = useMemo( - () => ({ - headerMessage: debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '', - label: translate('common.country'), - value: searchValue, - onChangeText: setSearchValue, - }), - [debouncedSearchValue, searchResults.length, searchValue, translate, setSearchValue], - ); + const textInputOptions = { + headerMessage: debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '', + label: translate('common.country'), + value: searchValue, + onChangeText: setSearchValue, + }; return ( ; @@ -41,44 +41,33 @@ function DynamicStateSelectionPage({route}: DynamicStateSelectionPageProps) { const initialSelectedValue = useInitialSelection(currentState ?? undefined, {resetOnFocus: true}); const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; - const countryStates = useMemo( - () => - Object.keys(COMMON_CONST.STATES).map((state) => { - const stateName = translate(`allStates.${state}.stateName` as TranslationPaths); - const stateISO = translate(`allStates.${state}.stateISO` as TranslationPaths); - return { - value: stateISO, - keyForList: stateISO, - text: stateName, - isSelected: currentState === stateISO, - searchValue: StringUtils.sanitizeString(`${stateISO}${stateName}`), - }; - }), - - [translate, currentState], - ); + const countryStates = Object.keys(COMMON_CONST.STATES).map((state) => { + const stateName = translate(`allStates.${state}.stateName` as TranslationPaths); + const stateISO = translate(`allStates.${state}.stateISO` as TranslationPaths); + return { + value: stateISO, + keyForList: stateISO, + text: stateName, + isSelected: currentState === stateISO, + searchValue: StringUtils.sanitizeString(`${stateISO}${stateName}`), + }; + }); const orderedCountryStates = moveInitialSelectionToTop(countryStates, initialSelectedValues); const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countryStates : orderedCountryStates); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; - const selectCountryState = useCallback( - (option: Option) => { - Navigation.goBack(appendParam(backPath, 'state', option.value), {compareParams: false}); - }, - [backPath], - ); + const selectCountryState = (option: Option) => { + Navigation.goBack(appendParam(backPath, 'state', option.value), {compareParams: false}); + }; - const textInputOptions = useMemo( - () => ({ - headerMessage, - // Label can be an empty string - label: label || translate('common.state'), - value: searchValue, - onChangeText: setSearchValue, - }), - [headerMessage, label, searchValue, setSearchValue, translate], - ); + const textInputOptions = { + headerMessage, + // Label can be an empty string + label: label || translate('common.state'), + value: searchValue, + onChangeText: setSearchValue, + }; return ( normalizeCountryCode(getCurrentAddress(privatePersonalDetails)) as Address, [privatePersonalDetails]); + const address = normalizeCountryCode(getCurrentAddress(privatePersonalDetails)) as Address; if (isLoading) { return ; diff --git a/src/pages/settings/Profile/ProfilePage.tsx b/src/pages/settings/Profile/ProfilePage.tsx index 3397032bb901..0379434e9fe5 100755 --- a/src/pages/settings/Profile/ProfilePage.tsx +++ b/src/pages/settings/Profile/ProfilePage.tsx @@ -64,7 +64,7 @@ import type {ValueOf} from 'type-fest'; import {useRoute} from '@react-navigation/native'; import {homeAndOfficeCommuterExclusionPolicyNameSelector} from '@selectors/Policy'; -import React, {useMemo, useRef} from 'react'; +import React, {useRef} from 'react'; import {View} from 'react-native'; import AgentAIPromptSection from './AgentAIPromptSection'; @@ -89,7 +89,7 @@ function ProfilePage() { const pronounsKey = currentUserPersonalDetails?.pronouns?.replace(CONST.PRONOUNS.PREFIX, '') ?? ''; return pronounsKey ? translate(`pronouns.${pronounsKey}` as TranslationPaths) : translate('profilePage.selectYourPronouns'); }; - const logins = useMemo(() => getContactMethodsOptions(translate, formatPhoneNumber, loginList, session?.email), [loginList, session?.email, translate, formatPhoneNumber]); + const logins = getContactMethodsOptions(translate, formatPhoneNumber, loginList, session?.email); const avatarURL = currentUserPersonalDetails?.avatar ?? ''; const accountID = currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID; diff --git a/src/pages/settings/Profile/PronounsPage.tsx b/src/pages/settings/Profile/PronounsPage.tsx index e7376cee4370..13386131ff67 100644 --- a/src/pages/settings/Profile/PronounsPage.tsx +++ b/src/pages/settings/Profile/PronounsPage.tsx @@ -22,7 +22,7 @@ import {updatePronouns as updatePronounsPersonalDetails} from '@userActions/Pers import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; type PronounEntry = ListItem & { value: string; @@ -56,57 +56,48 @@ function PronounsPage({currentUserPersonalDetails}: PronounsPageProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoadingApp]); - const filteredPronounsList = useMemo((): PronounEntry[] => { - const pronouns = CONST.PRONOUNS_LIST.map((value) => { - const fullPronounKey = `${CONST.PRONOUNS.PREFIX}${value}`; - - return { - text: translate(`pronouns.${value}`), - value: fullPronounKey, - keyForList: value, - isSelected: fullPronounKey === selectedPronouns, - }; - }).sort((a, b) => localeCompare(a.text.toLowerCase(), b.text.toLowerCase())); - - // Pin the frozen initial pronoun to the top of the full list before search filtering, so the pre-selected pronoun stays pinned while searching. - const orderedPronouns = moveInitialSelectionToTop(pronouns, initialPronoun ? [initialPronoun] : []); - - const trimmedSearch = searchValue.trim(); - - if (trimmedSearch.length === 0) { - return []; - } - return orderedPronouns.filter((pronoun) => pronoun.text.toLowerCase().indexOf(trimmedSearch.toLowerCase()) >= 0); - }, [searchValue, selectedPronouns, translate, localeCompare, initialPronoun]); + let filteredPronounsList: PronounEntry[]; + const pronouns = CONST.PRONOUNS_LIST.map((value) => { + const fullPronounKey = `${CONST.PRONOUNS.PREFIX}${value}`; + return { + text: translate(`pronouns.${value}`), + value: fullPronounKey, + keyForList: value, + isSelected: fullPronounKey === selectedPronouns, + }; + }).sort((a, b) => localeCompare(a.text.toLowerCase(), b.text.toLowerCase())); + + // Pin the frozen initial pronoun to the top of the full list before search filtering, so the pre-selected pronoun stays pinned while searching. + const orderedPronouns = moveInitialSelectionToTop(pronouns, initialPronoun ? [initialPronoun] : []); + const trimmedSearch = searchValue.trim(); + if (trimmedSearch.length === 0) { + filteredPronounsList = []; + } else { + filteredPronounsList = orderedPronouns.filter((pronoun) => pronoun.text.toLowerCase().indexOf(trimmedSearch.toLowerCase()) >= 0); + } const selectPronoun = (selectedPronoun: PronounEntry) => { setSelectedPronouns(selectedPronoun.value === selectedPronouns ? '' : (selectedPronoun?.value ?? '')); }; - const savePronouns = useCallback(() => { + const savePronouns = () => { updatePronounsPersonalDetails(selectedPronouns, currentUserAccountID); Navigation.goBack(); - }, [selectedPronouns, currentUserAccountID]); - - const confirmButtonOptions = useMemo( - () => ({ - showButton: true, - text: translate('common.save'), - onConfirm: savePronouns, - }), - [savePronouns, translate], - ); + }; - const textInputOptions = useMemo( - () => ({ - label: translate('pronounsPage.pronouns'), - value: searchValue, - onChangeText: setSearchValue, - placeholder: translate('pronounsPage.placeholderText'), - headerMessage: searchValue.trim() && filteredPronounsList?.length === 0 ? translate('common.noResultsFound') : '', - }), - [translate, searchValue, filteredPronounsList?.length], - ); + const confirmButtonOptions = { + showButton: true, + text: translate('common.save'), + onConfirm: savePronouns, + }; + + const textInputOptions = { + label: translate('pronounsPage.pronouns'), + value: searchValue, + onChangeText: setSearchValue, + placeholder: translate('pronounsPage.placeholderText'), + headerMessage: searchValue.trim() && filteredPronounsList?.length === 0 ? translate('common.noResultsFound') : '', + }; return ( ; @@ -72,33 +72,27 @@ function TimezoneSelectPage({currentUserPersonalDetails}: TimezoneSelectPageProp isDisabled: !!timezone.automatic || currentSelectedTimezone === timezone.selected, }; - const filterShownTimezones = useCallback( - (searchText: string) => { - setTimezoneInputText(searchText); - const searchWords = searchText.toLowerCase().match(/[a-z0-9]+/g) ?? []; - setTimezoneOptions( - allTimezones.filter((tz) => - searchWords.every((word) => - tz.text - .toLowerCase() - .replaceAll(/[^a-z0-9]/g, ' ') - .includes(word), - ), + const filterShownTimezones = (searchText: string) => { + setTimezoneInputText(searchText); + const searchWords = searchText.toLowerCase().match(/[a-z0-9]+/g) ?? []; + setTimezoneOptions( + allTimezones.filter((tz) => + searchWords.every((word) => + tz.text + .toLowerCase() + .replaceAll(/[^a-z0-9]/g, ' ') + .includes(word), ), - ); - }, - [allTimezones], - ); + ), + ); + }; - const textInputOptions = useMemo( - () => ({ - headerMessage: timezoneInputText.trim() && !timezoneOptions.length ? translate('common.noResultsFound') : '', - label: translate('timezonePage.timezone'), - value: timezoneInputText, - onChangeText: filterShownTimezones, - }), - [filterShownTimezones, timezoneInputText, timezoneOptions.length, translate], - ); + const textInputOptions = { + headerMessage: timezoneInputText.trim() && !timezoneOptions.length ? translate('common.noResultsFound') : '', + label: translate('timezonePage.timezone'), + value: timezoneInputText, + onChangeText: filterShownTimezones, + }; return ( { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); + }; const saveNotificationPreference = () => { updateNotificationPreference(report.reportID, currentUserNotificationPreference, selectedNotificationPreference, currentUserAccountID, undefined, undefined); diff --git a/src/pages/settings/Report/DynamicReportSettingsPage.tsx b/src/pages/settings/Report/DynamicReportSettingsPage.tsx index 927a7bbfe980..ccef5e64078b 100644 --- a/src/pages/settings/Report/DynamicReportSettingsPage.tsx +++ b/src/pages/settings/Report/DynamicReportSettingsPage.tsx @@ -34,7 +34,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type DynamicReportSettingsPageProps = WithReportOrNotFoundProps & PlatformStackScreenProps; @@ -49,15 +49,15 @@ function DynamicReportSettingsPage({report, policy}: DynamicReportSettingsPagePr const currentUserParticipant = currentUserAccountID ? report?.participants?.[currentUserAccountID] : undefined; const isArchivedNonExpenseReport = isArchivedNonExpenseReportUtils(report, isReportArchived); // The workspace the report is on, null if the user isn't a member of the workspace - const linkedWorkspace = useMemo(() => (report?.policyID && policy?.id === report?.policyID ? policy : undefined), [policy, report?.policyID]); + const linkedWorkspace = report?.policyID && policy?.id === report?.policyID ? policy : undefined; const isMoneyRequestReport = isMoneyRequestReportUtils(report); const shouldDisableSettings = isArchivedNonExpenseReport || isEmptyObject(report) || isSelfDM(report); const notificationPreferenceValue = getReportNotificationPreferenceForSettings(report, currentUserAccountID); const notificationPreference = notificationPreferenceValue ? translate(`notificationPreferencesPage.notificationPreferences.${notificationPreferenceValue}`) : ''; const writeCapability = isAdminRoom(report) ? CONST.REPORT.WRITE_CAPABILITIES.ADMINS : (report?.writeCapability ?? CONST.REPORT.WRITE_CAPABILITIES.ALL); const writeCapabilityText = translate(`writeCapabilityPage.writeCapability.${writeCapability}`); - const shouldAllowWriteCapabilityEditing = useMemo(() => canEditWriteCapability(report, linkedWorkspace, isReportArchived), [report, linkedWorkspace, isReportArchived]); - const shouldAllowChangeVisibility = useMemo(() => canEditRoomVisibility(linkedWorkspace, isArchivedNonExpenseReport), [linkedWorkspace, isArchivedNonExpenseReport]); + const shouldAllowWriteCapabilityEditing = canEditWriteCapability(report, linkedWorkspace, isReportArchived); + const shouldAllowChangeVisibility = canEditRoomVisibility(linkedWorkspace, isArchivedNonExpenseReport); const shouldShowNotificationPref = !isMoneyRequestReport && !!currentUserParticipant; diff --git a/src/pages/settings/Report/DynamicVisibilityPage.tsx b/src/pages/settings/Report/DynamicVisibilityPage.tsx index 8282fbe79b9c..af8c44f78038 100644 --- a/src/pages/settings/Report/DynamicVisibilityPage.tsx +++ b/src/pages/settings/Report/DynamicVisibilityPage.tsx @@ -23,7 +23,7 @@ import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {RoomVisibility} from '@src/types/onyx/Report'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; type DynamicVisibilityProps = WithReportOrNotFoundProps; @@ -38,19 +38,15 @@ function DynamicVisibilityPage({report}: DynamicVisibilityProps) { const [draftVisibility, setDraftVisibility] = useState(undefined); const selectedVisibility = draftVisibility ?? report?.visibility; - const visibilityOptions = useMemo( - () => - Object.values(CONST.REPORT.VISIBILITY) - .filter((visibilityOption) => visibilityOption !== CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE) - .map((visibilityOption) => ({ - text: translate(`newRoomPage.visibilityOptions.${visibilityOption}`), - value: visibilityOption, - alternateText: translate(`newRoomPage.${visibilityOption}Description`), - keyForList: visibilityOption, - isSelected: visibilityOption === selectedVisibility, - })), - [translate, selectedVisibility], - ); + const visibilityOptions = Object.values(CONST.REPORT.VISIBILITY) + .filter((visibilityOption) => visibilityOption !== CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE) + .map((visibilityOption) => ({ + text: translate(`newRoomPage.visibilityOptions.${visibilityOption}`), + value: visibilityOption, + alternateText: translate(`newRoomPage.${visibilityOption}Description`), + keyForList: visibilityOption, + isSelected: visibilityOption === selectedVisibility, + })); const goBack = () => { Navigation.goBack(backPath); diff --git a/src/pages/settings/Report/ReportDetailsColumnsPage.tsx b/src/pages/settings/Report/ReportDetailsColumnsPage.tsx index 44cc662cf3b1..d3c8f56b39ba 100644 --- a/src/pages/settings/Report/ReportDetailsColumnsPage.tsx +++ b/src/pages/settings/Report/ReportDetailsColumnsPage.tsx @@ -25,7 +25,7 @@ import arraysEqual from '@src/utils/arraysEqual'; import type {OnyxCollection} from 'react-native-onyx'; import {useRoute} from '@react-navigation/native'; -import React, {useMemo} from 'react'; +import React from 'react'; /** * Default selected columns for the report details table. @@ -74,16 +74,13 @@ function ReportDetailsColumnsPage() { // When no custom columns are saved, compute which columns getColumnsToShow would // return for this report so data-driven columns (e.g. Exchange rate, Original amount, // Tax rate, Tax amount) appear pre-selected when they have data on the table. - const effectiveColumns = useMemo(() => { - const savedColumns = (reportDetailsColumns ?? []).filter(isReportDetailsCustomColumn); - if (savedColumns.length > 0) { - return savedColumns; - } - - if (!reportTransactions?.length) { - return REPORT_DETAILS_DEFAULT_COLUMNS; - } - + let effectiveColumns; + const savedColumns = (reportDetailsColumns ?? []).filter(isReportDetailsCustomColumn); + if (savedColumns.length > 0) { + effectiveColumns = savedColumns; + } else if (!reportTransactions?.length) { + effectiveColumns = REPORT_DETAILS_DEFAULT_COLUMNS; + } else { const visibleColumns = getColumnsToShow({ currentAccountID: currentUserDetails?.accountID, data: reportTransactions, @@ -97,8 +94,8 @@ function ReportDetailsColumnsPage() { }); // Filter to only columns available in the custom columns list (drops RECEIPT/TYPE/COMMENTS etc.) - return visibleColumns.filter(isReportDetailsCustomColumn); - }, [reportDetailsColumns, reportTransactions, currentUserDetails?.accountID, report, policy]); + effectiveColumns = visibleColumns.filter(isReportDetailsCustomColumn); + } const allColumns = ALL_REPORT_DETAILS_CUSTOM_COLUMNS.filter(isColumnAvailable); const currentColumns = effectiveColumns.filter(isColumnAvailable); diff --git a/src/pages/settings/Report/RoomNamePage.tsx b/src/pages/settings/Report/RoomNamePage.tsx index b40245dc35b1..b039e7047271 100644 --- a/src/pages/settings/Report/RoomNamePage.tsx +++ b/src/pages/settings/Report/RoomNamePage.tsx @@ -27,7 +27,7 @@ import INPUT_IDS from '@src/types/form/RoomNameForm'; import type {Report} from '@src/types/onyx'; import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; type RoomNamePageProps = { @@ -44,47 +44,41 @@ function RoomNamePage({report, navigateBackTo}: RoomNamePageProps) { const isReportArchived = useReportIsArchived(report?.reportID); const policy = usePolicy(report?.policyID); - const goBack = useCallback(() => { + const goBack = () => { Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(navigateBackTo)); - }, [navigateBackTo]); + }; - const validate = useCallback( - (values: FormOnyxValues) => { - const errors = {}; + const validate = (values: FormOnyxValues) => { + const errors = {}; - // We should skip validation hence we return an empty errors and we skip Form submission on the onSubmit method - if (values.roomName === report?.reportName) { - return errors; - } + // We should skip validation hence we return an empty errors and we skip Form submission on the onSubmit method + if (values.roomName === report?.reportName) { + return errors; + } - if (!values.roomName || values.roomName === CONST.POLICY.ROOM_PREFIX) { - // We error if the user doesn't enter a room name or left blank - addErrorMessage(errors, 'roomName', translate('newRoomPage.pleaseEnterRoomName')); - } else if (!isValidRoomNameWithoutLimits(values.roomName)) { - // We error if the room name has invalid characters - addErrorMessage(errors, 'roomName', translate('newRoomPage.roomNameInvalidError')); - } else if (isReservedRoomName(values.roomName)) { - // Certain names are reserved for default rooms and should not be used for policy rooms. - addErrorMessage(errors, 'roomName', translate('newRoomPage.roomNameReservedError', values.roomName)); - } else if (isExistingRoomName(values.roomName, reports, report?.policyID)) { - // The room name can't be set to one that already exists on the policy - addErrorMessage(errors, 'roomName', translate('newRoomPage.roomAlreadyExistsError')); - } else if (values.roomName.length > CONST.TITLE_CHARACTER_LIMIT) { - addErrorMessage(errors, 'roomName', translate('common.error.characterLimitExceedCounter', values.roomName.length, CONST.TITLE_CHARACTER_LIMIT)); - } + if (!values.roomName || values.roomName === CONST.POLICY.ROOM_PREFIX) { + // We error if the user doesn't enter a room name or left blank + addErrorMessage(errors, 'roomName', translate('newRoomPage.pleaseEnterRoomName')); + } else if (!isValidRoomNameWithoutLimits(values.roomName)) { + // We error if the room name has invalid characters + addErrorMessage(errors, 'roomName', translate('newRoomPage.roomNameInvalidError')); + } else if (isReservedRoomName(values.roomName)) { + // Certain names are reserved for default rooms and should not be used for policy rooms. + addErrorMessage(errors, 'roomName', translate('newRoomPage.roomNameReservedError', values.roomName)); + } else if (isExistingRoomName(values.roomName, reports, report?.policyID)) { + // The room name can't be set to one that already exists on the policy + addErrorMessage(errors, 'roomName', translate('newRoomPage.roomAlreadyExistsError')); + } else if (values.roomName.length > CONST.TITLE_CHARACTER_LIMIT) { + addErrorMessage(errors, 'roomName', translate('common.error.characterLimitExceedCounter', values.roomName.length, CONST.TITLE_CHARACTER_LIMIT)); + } - return errors; - }, - [report?.reportName, report?.policyID, reports, translate], - ); + return errors; + }; - const updatePolicyRoomName = useCallback( - (values: FormOnyxValues) => { - updatePolicyRoomNameReportAction(report, values.roomName); - goBack(); - }, - [report, goBack], - ); + const updatePolicyRoomName = (values: FormOnyxValues) => { + updatePolicyRoomNameReportAction(report, values.roomName); + goBack(); + }; return ( ; function AddCategoryPage({route}: AddCategoryPageProps) { const [form] = useOnyx(ONYXKEYS.FORMS.EXPENSE_RULE_FORM); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); - const availableNonPersonalPolicyCategoriesSelector = useCallback( - (allPolicyCategories: OnyxCollection) => getAvailableNonPersonalPolicyCategories(allPolicyCategories, personalPolicyID), - [personalPolicyID], - ); + const availableNonPersonalPolicyCategoriesSelector = (allPolicyCategories: OnyxCollection) => + getAvailableNonPersonalPolicyCategories(allPolicyCategories, personalPolicyID); const [allPolicyCategories = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, { selector: availableNonPersonalPolicyCategoriesSelector, }); diff --git a/src/pages/settings/Rules/Fields/AddTagPage.tsx b/src/pages/settings/Rules/Fields/AddTagPage.tsx index 43177eac89ef..ee4b11b4e7d8 100644 --- a/src/pages/settings/Rules/Fields/AddTagPage.tsx +++ b/src/pages/settings/Rules/Fields/AddTagPage.tsx @@ -18,7 +18,7 @@ import getEmptyArray from '@src/types/utils/getEmptyArray'; import type {ValueOf} from 'type-fest'; -import React, {useMemo} from 'react'; +import React from 'react'; type AddTagPageProps = PlatformStackScreenProps; @@ -34,21 +34,23 @@ function AddTagPage({route}: AddTagPageProps) { const formTag = formTags.at(orderWeight); const parentTagPath = formTags.slice(0, orderWeight).join(':'); - const tagItems = useMemo(() => { - const tags: Array<{name: string; value: string}> = []; - - for (const tag of Object.values(tagList?.tags ?? {})) { - if (tag.name !== formTag && !tag.enabled) { - continue; - } - if (!matchesParentTagPath(tag, parentTagPath)) { - continue; - } - tags.push({name: getCleanedTagName(tag.name), value: tag.name}); + const tags: Array<{ + name: string; + value: string; + }> = []; + for (const tag of Object.values(tagList?.tags ?? {})) { + if (tag.name !== formTag && !tag.enabled) { + continue; } - - return tags; - }, [tagList?.tags, formTag, parentTagPath]); + if (!matchesParentTagPath(tag, parentTagPath)) { + continue; + } + tags.push({ + name: getCleanedTagName(tag.name), + value: tag.name, + }); + } + const tagItems = tags; const selectedTagItem = tagItems.find(({value}) => value === formTag); diff --git a/src/pages/settings/Rules/RulePageBase.tsx b/src/pages/settings/Rules/RulePageBase.tsx index 6c06945b0a10..b32bd7b3e804 100644 --- a/src/pages/settings/Rules/RulePageBase.tsx +++ b/src/pages/settings/Rules/RulePageBase.tsx @@ -39,7 +39,7 @@ import getEmptyArray from '@src/types/utils/getEmptyArray'; import type {OnyxCollection} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; type RulePageBaseProps = { @@ -98,17 +98,14 @@ function RulePageBase({titleKey, testID, hash}: RulePageBaseProps) { useEffect(() => () => clearDraftRule(), []); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); - const categoriesSelector = useCallback( - (allPolicyCategories: OnyxCollection) => { - const categories = getAvailableNonPersonalPolicyCategories(allPolicyCategories, personalPolicyID); - return ( - Object.values(categories ?? {}) - .filter((policyCategories) => hasEnabledOptions(policyCategories ?? {})) - .flatMap((policyCategories) => Object.values(policyCategories ?? {})).length > 0 - ); - }, - [personalPolicyID], - ); + const categoriesSelector = (allPolicyCategories: OnyxCollection) => { + const categories = getAvailableNonPersonalPolicyCategories(allPolicyCategories, personalPolicyID); + return ( + Object.values(categories ?? {}) + .filter((policyCategories) => hasEnabledOptions(policyCategories ?? {})) + .flatMap((policyCategories) => Object.values(policyCategories ?? {})).length > 0 + ); + }; const [hasPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, { selector: categoriesSelector, }); diff --git a/src/pages/settings/Security/SecuritySettingsPage.tsx b/src/pages/settings/Security/SecuritySettingsPage.tsx index 67c12c082a69..13e7c4b56a29 100644 --- a/src/pages/settings/Security/SecuritySettingsPage.tsx +++ b/src/pages/settings/Security/SecuritySettingsPage.tsx @@ -41,7 +41,7 @@ import type WithSentryLabel from '@src/types/utils/SentryLabel'; import type {StyleProp, ViewStyle} from 'react-native'; -import React, {useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import useSecuritySettingsSectionIllustration from './useSecuritySettingsSectionIllustration'; @@ -94,99 +94,16 @@ function SecuritySettingsPage() { openSecuritySettingsPage(); }, []); - const securityMenuItems = useMemo(() => { - const baseMenuItems: BaseMenuItemType[] = []; - - // Agent accounts can't have two-factor/multifactor authentication, so hide those options for them. - if (isAgentAccount === false) { - baseMenuItems.push({ - translationKey: 'twoFactorAuth.headerTitle', - icon: icons.Shield, - sentryLabel: CONST.SENTRY_LABEL.SETTINGS_SECURITY.TWO_FACTOR_AUTH, - action: () => { - if (isActingAsDelegate) { - showDelegateNoAccessModal(); - return; - } - if (isAccountLocked) { - showLockedAccountModal(); - return; - } - Navigation.navigate(getTwoFactorAuthRoute()); - }, - }); - } - - if (isAgentAccount === false && hasEverRegisteredForMultifactorAuthentication) { - baseMenuItems.push({ - translationKey: 'multifactorAuthentication.revoke.title', - icon: icons.Fingerprint, - sentryLabel: CONST.SENTRY_LABEL.SETTINGS_SECURITY.REVOKE_MFA, - action: () => { - Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_REVOKE); - }, - }); - } - - // Merging accounts doesn't apply to agent accounts, so hide it when copiloting into an agent. - if (!isCopilotingIntoAgent) { - baseMenuItems.push({ - translationKey: 'mergeAccountsPage.mergeAccount', - icon: icons.ArrowCollapse, - sentryLabel: CONST.SENTRY_LABEL.SETTINGS_SECURITY.MERGE_ACCOUNTS, - action: () => { - if (isAccountLocked) { - showLockedAccountModal(); - return; - } - if (privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.INVOICING) { - Navigation.navigate( - ROUTES.SETTINGS_MERGE_ACCOUNTS_RESULT.getRoute(currentUserPersonalDetails.login ?? '', CONST.MERGE_ACCOUNT_RESULTS.ERR_INVOICING, ROUTES.SETTINGS_SECURITY), - ); - return; - } - - Navigation.navigate(ROUTES.SETTINGS_MERGE_ACCOUNTS.route); - }, - }); - } - - if (isAccountLocked) { - baseMenuItems.push({ - translationKey: 'lockAccountPage.unlockAccount', - icon: icons.UserLock, - sentryLabel: CONST.SENTRY_LABEL.SETTINGS_SECURITY.LOCK_UNLOCK_ACCOUNT, - action: waitForNavigate(() => Navigation.navigate(ROUTES.SETTINGS_UNLOCK_ACCOUNT)), - }); - } else { - baseMenuItems.push({ - translationKey: 'lockAccountPage.reportSuspiciousActivity', - icon: icons.UserLock, - sentryLabel: CONST.SENTRY_LABEL.SETTINGS_SECURITY.LOCK_UNLOCK_ACCOUNT, - action: waitForNavigate(() => Navigation.navigate(ROUTES.SETTINGS_LOCK_ACCOUNT)), - }); - } - - // Device management applies to a real user's own logged-in devices, not to an agent, so hide it when copiloting into an agent. - if (!isCopilotingIntoAgent) { - const deviceManagementBrickRoadIndicator = hasDeviceManagementErrorValue ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined; - baseMenuItems.push({ - translationKey: 'deviceManagementPage.title', - icon: icons.Monitor, - brickRoadIndicator: deviceManagementBrickRoadIndicator, - sentryLabel: CONST.SENTRY_LABEL.SETTINGS_SECURITY.DEVICE_MANAGEMENT, - action: () => Navigation.navigate(ROUTES.SETTINGS_DEVICE_MANAGEMENT), - }); - } + const baseMenuItems: BaseMenuItemType[] = []; + // Agent accounts can't have two-factor/multifactor authentication, so hide those options for them. + if (isAgentAccount === false) { baseMenuItems.push({ - translationKey: 'closeAccountPage.closeAccount', - icon: icons.ClosedSign, - sentryLabel: CONST.SENTRY_LABEL.SETTINGS_SECURITY.CLOSE_ACCOUNT, - action: async () => { - // Copiloting into an agent is also a delegate session and must fall through to the - // agent-delete flow below, so only plain delegates are blocked here. - if (isActingAsDelegate && !isCopilotingIntoAgent) { + translationKey: 'twoFactorAuth.headerTitle', + icon: icons.Shield, + sentryLabel: CONST.SENTRY_LABEL.SETTINGS_SECURITY.TWO_FACTOR_AUTH, + action: () => { + if (isActingAsDelegate) { showDelegateNoAccessModal(); return; } @@ -194,93 +111,145 @@ function SecuritySettingsPage() { showLockedAccountModal(); return; } - // When copiloting into an agent, "close account" deletes the agent and ends the copilot session - // instead of running the usual close-account flow (which targets a real user's account). - if (isCopilotingIntoAgent) { - // Ending the copilot session is a network side-effect request (DisconnectAsDelegate) that isn't - // persisted to the SequentialQueue, so it can't be queued and retried while offline. Require a - // connection before starting the flow instead of letting it stall. - if (isOffline) { - showConfirmModal({ - title: translate('common.youAppearToBeOffline'), - prompt: translate('common.thisFeatureRequiresInternet'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - return; - } - const ruleBotEnforcedPolicy = getRuleBotEnforcedPolicy(session?.accountID, allPolicies); - if (ruleBotEnforcedPolicy) { - showRuleBotGuardModal('deleteAgent', ruleBotEnforcedPolicy.id); - return; - } - const result = await showConfirmModal({ - title: translate('editAgentPage.deleteAgentTitle'), - prompt: translate('editAgentPage.deleteAgentMessage'), - confirmText: translate('common.delete'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }); - if (result.action !== ModalActions.CONFIRM) { - return; - } - // The DeleteAgent command must be issued by the agent's owner, but while copiloting the session is - // authenticated as the agent itself. So capture the agent's identity, end the copilot session - // (which restores the owner's session and auth token), and only then delete the agent as the - // owner. Skip the delete unless the owner was restored with fresh tokens. - const agentAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; - const agentLogin = currentUserPersonalDetails.login; - const disconnectResult = await disconnect({stashedCredentials, stashedSession}); - if (disconnectResult !== DISCONNECT_RESULT.RESTORED) { - return; - } - deleteAgent(agentAccountID, agentLogin, allPolicies, false); + Navigation.navigate(getTwoFactorAuthRoute()); + }, + }); + } + if (isAgentAccount === false && hasEverRegisteredForMultifactorAuthentication) { + baseMenuItems.push({ + translationKey: 'multifactorAuthentication.revoke.title', + icon: icons.Fingerprint, + sentryLabel: CONST.SENTRY_LABEL.SETTINGS_SECURITY.REVOKE_MFA, + action: () => { + Navigation.navigate(ROUTES.MULTIFACTOR_AUTHENTICATION_REVOKE); + }, + }); + } + + // Merging accounts doesn't apply to agent accounts, so hide it when copiloting into an agent. + if (!isCopilotingIntoAgent) { + baseMenuItems.push({ + translationKey: 'mergeAccountsPage.mergeAccount', + icon: icons.ArrowCollapse, + sentryLabel: CONST.SENTRY_LABEL.SETTINGS_SECURITY.MERGE_ACCOUNTS, + action: () => { + if (isAccountLocked) { + showLockedAccountModal(); + return; + } + if (privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.INVOICING) { + Navigation.navigate( + ROUTES.SETTINGS_MERGE_ACCOUNTS_RESULT.getRoute(currentUserPersonalDetails.login ?? '', CONST.MERGE_ACCOUNT_RESULTS.ERR_INVOICING, ROUTES.SETTINGS_SECURITY), + ); return; } - clearDraftValues(ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM); - Navigation.navigate(ROUTES.SETTINGS_CLOSE); + Navigation.navigate(ROUTES.SETTINGS_MERGE_ACCOUNTS.route); }, }); - return baseMenuItems.map((item) => ({ - key: item.translationKey, - title: translate(item.translationKey), - icon: item.icon, - onPress: item.action, - shouldShowRightIcon: true, - link: '', - wrapperStyle: [styles.sectionMenuItemTopDescription], - sentryLabel: item.sentryLabel, - brickRoadIndicator: item.brickRoadIndicator, - })); - }, [ - icons.ArrowCollapse, - icons.ClosedSign, - icons.UserLock, - icons.Shield, - icons.Fingerprint, - icons.Monitor, - isAccountLocked, - isActingAsDelegate, - isAgentAccount, - isCopilotingIntoAgent, - getTwoFactorAuthRoute, - showDelegateNoAccessModal, - showLockedAccountModal, - showConfirmModal, - showRuleBotGuardModal, - session?.accountID, - stashedCredentials, - stashedSession, - allPolicies, - isOffline, - privateSubscription?.type, - currentUserPersonalDetails.login, - waitForNavigate, - translate, - styles.sectionMenuItemTopDescription, - hasEverRegisteredForMultifactorAuthentication, - hasDeviceManagementErrorValue, - ]); + } + if (isAccountLocked) { + baseMenuItems.push({ + translationKey: 'lockAccountPage.unlockAccount', + icon: icons.UserLock, + sentryLabel: CONST.SENTRY_LABEL.SETTINGS_SECURITY.LOCK_UNLOCK_ACCOUNT, + action: waitForNavigate(() => Navigation.navigate(ROUTES.SETTINGS_UNLOCK_ACCOUNT)), + }); + } else { + baseMenuItems.push({ + translationKey: 'lockAccountPage.reportSuspiciousActivity', + icon: icons.UserLock, + sentryLabel: CONST.SENTRY_LABEL.SETTINGS_SECURITY.LOCK_UNLOCK_ACCOUNT, + action: waitForNavigate(() => Navigation.navigate(ROUTES.SETTINGS_LOCK_ACCOUNT)), + }); + } + + // Device management applies to a real user's own logged-in devices, not to an agent, so hide it when copiloting into an agent. + if (!isCopilotingIntoAgent) { + const deviceManagementBrickRoadIndicator = hasDeviceManagementErrorValue ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined; + baseMenuItems.push({ + translationKey: 'deviceManagementPage.title', + icon: icons.Monitor, + brickRoadIndicator: deviceManagementBrickRoadIndicator, + sentryLabel: CONST.SENTRY_LABEL.SETTINGS_SECURITY.DEVICE_MANAGEMENT, + action: () => Navigation.navigate(ROUTES.SETTINGS_DEVICE_MANAGEMENT), + }); + } + baseMenuItems.push({ + translationKey: 'closeAccountPage.closeAccount', + icon: icons.ClosedSign, + sentryLabel: CONST.SENTRY_LABEL.SETTINGS_SECURITY.CLOSE_ACCOUNT, + action: async () => { + // Copiloting into an agent is also a delegate session and must fall through to the + // agent-delete flow below, so only plain delegates are blocked here. + if (isActingAsDelegate && !isCopilotingIntoAgent) { + showDelegateNoAccessModal(); + return; + } + if (isAccountLocked) { + showLockedAccountModal(); + return; + } + // When copiloting into an agent, "close account" deletes the agent and ends the copilot session + // instead of running the usual close-account flow (which targets a real user's account). + if (isCopilotingIntoAgent) { + // Ending the copilot session is a network side-effect request (DisconnectAsDelegate) that isn't + // persisted to the SequentialQueue, so it can't be queued and retried while offline. Require a + // connection before starting the flow instead of letting it stall. + if (isOffline) { + showConfirmModal({ + title: translate('common.youAppearToBeOffline'), + prompt: translate('common.thisFeatureRequiresInternet'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + return; + } + const ruleBotEnforcedPolicy = getRuleBotEnforcedPolicy(session?.accountID, allPolicies); + if (ruleBotEnforcedPolicy) { + showRuleBotGuardModal('deleteAgent', ruleBotEnforcedPolicy.id); + return; + } + const result = await showConfirmModal({ + title: translate('editAgentPage.deleteAgentTitle'), + prompt: translate('editAgentPage.deleteAgentMessage'), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }); + if (result.action !== ModalActions.CONFIRM) { + return; + } + // The DeleteAgent command must be issued by the agent's owner, but while copiloting the session is + // authenticated as the agent itself. So capture the agent's identity, end the copilot session + // (which restores the owner's session and auth token), and only then delete the agent as the + // owner. Skip the delete unless the owner was restored with fresh tokens. + const agentAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; + const agentLogin = currentUserPersonalDetails.login; + const disconnectResult = await disconnect({ + stashedCredentials, + stashedSession, + }); + if (disconnectResult !== DISCONNECT_RESULT.RESTORED) { + return; + } + deleteAgent(agentAccountID, agentLogin, allPolicies, false); + return; + } + clearDraftValues(ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM); + Navigation.navigate(ROUTES.SETTINGS_CLOSE); + }, + }); + const securityMenuItems = baseMenuItems.map((item) => ({ + key: item.translationKey, + title: translate(item.translationKey), + icon: item.icon, + onPress: item.action, + shouldShowRightIcon: true, + link: '', + wrapperStyle: [styles.sectionMenuItemTopDescription], + sentryLabel: item.sentryLabel, + brickRoadIndicator: item.brickRoadIndicator, + })); if (isAgentAccount === undefined) { return null; diff --git a/src/pages/settings/Security/TwoFactorAuth/DisablePage.tsx b/src/pages/settings/Security/TwoFactorAuth/DisablePage.tsx index 54e5fe4d0047..e95b12c4167d 100644 --- a/src/pages/settings/Security/TwoFactorAuth/DisablePage.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/DisablePage.tsx @@ -16,7 +16,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import isEmpty from 'lodash/isEmpty'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; import ToggleTwoFactorAuthForm from './ToggleTwoFactorAuthForm'; @@ -44,22 +44,22 @@ function DisablePage() { Navigation.navigate(ROUTES.SETTINGS_2FA_DISABLED, {forceReplace: true}); }, [account?.requiresTwoFactorAuth]); - const closeModal = useCallback(() => { + const closeModal = () => { clearDisableTwoFactorAuthErrors(); // Go back to the previous page because the user can't disable 2FA and this page is no longer relevant Navigation.goBack(); - }, []); + }; const {showConfirmModal} = useConfirmModal(); - const showTwoFactorAuthRequireModal = useCallback(() => { + const showTwoFactorAuthRequireModal = () => { return showConfirmModal({ title: translate('twoFactorAuth.twoFactorAuthCannotDisable'), prompt: translate('twoFactorAuth.twoFactorAuthRequired'), confirmText: translate('common.buttonConfirm'), shouldShowCancelButton: false, }); - }, [translate, showConfirmModal]); + }; useEffect(() => { if (isEmpty(account?.errorFields?.requiresTwoFactorAuth ?? {})) { diff --git a/src/pages/settings/Security/TwoFactorAuth/DynamicVerifyPage.tsx b/src/pages/settings/Security/TwoFactorAuth/DynamicVerifyPage.tsx index 16961d209499..0cb4aa90f7cd 100644 --- a/src/pages/settings/Security/TwoFactorAuth/DynamicVerifyPage.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/DynamicVerifyPage.tsx @@ -31,7 +31,7 @@ import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView} from 'react-native'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; import ToggleTwoFactorAuthForm from './ToggleTwoFactorAuthForm'; @@ -83,11 +83,11 @@ function DynamicVerifyPage() { } const scrollViewRef = useRef>(null); - const handleInputFocus = useCallback(() => { + const handleInputFocus = () => { requestAnimationFrame(() => { scrollViewRef.current?.scrollToEnd({animated: true}); }); - }, []); + }; return ( ; @@ -39,30 +39,33 @@ type TwoFactorAuthWrapperProps = ChildrenProps & { function TwoFactorAuthWrapper({stepName, title, stepCounter, onBackButtonPress, shouldEnableKeyboardAvoidingView = true, shouldEnableMaxHeight = true, children}: TwoFactorAuthWrapperProps) { const [account] = useOnyx(ONYXKEYS.ACCOUNT); - const shouldShowNotFound = useMemo(() => { - if (!account) { - return true; - } - + let shouldShowNotFound; + if (!account) { + shouldShowNotFound = true; + } else { const is2FAEnabled = !!account.requiresTwoFactorAuth; - switch (stepName) { case CONST.TWO_FACTOR_AUTH_STEPS.COPY_CODES: case CONST.TWO_FACTOR_AUTH_STEPS.ENABLED: case CONST.TWO_FACTOR_AUTH_STEPS.DISABLE: case CONST.TWO_FACTOR_AUTH_STEPS.REPLACE_VERIFY_NEW: case CONST.TWO_FACTOR_AUTH_STEPS.REPLACE_VERIFY_OLD: - return false; + shouldShowNotFound = false; + break; case CONST.TWO_FACTOR_AUTH_STEPS.VERIFY: - return !account.codesAreCopied; + shouldShowNotFound = !account.codesAreCopied; + break; case CONST.TWO_FACTOR_AUTH_STEPS.SUCCESS: - return !is2FAEnabled; + shouldShowNotFound = !is2FAEnabled; + break; case CONST.TWO_FACTOR_AUTH_STEPS.DISABLED: - return is2FAEnabled; + shouldShowNotFound = is2FAEnabled; + break; default: - return false; + shouldShowNotFound = false; + break; } - }, [account, stepName]); + } const route = useRoute(); const backTo = (route.params as {backTo?: Route} | undefined)?.backTo; diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx index e8769686c6c6..37ac664236ee 100644 --- a/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx @@ -16,7 +16,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type BillingBannerProps = { @@ -70,56 +70,40 @@ function BillingBanner({ const {shouldUseNarrowLayout, isInLandscapeMode} = useResponsiveLayout(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['DotIndicator']); - const rightIconComponent = useMemo(() => { - if (rightIcon) { - if (onRightIconPress && rightIconAccessibilityLabel) { - const rightIconButton = ( - - - - ); - return rightIconTooltipText ? {rightIconButton} : rightIconButton; - } - - return ( + let rightIconComponent; + if (rightIcon) { + if (onRightIconPress && rightIconAccessibilityLabel) { + const rightIconButton = ( + + + + ); + rightIconComponent = rightIconTooltipText ? {rightIconButton} : rightIconButton; + } else { + rightIconComponent = ( ); } - - return ( - !!brickRoadIndicator && ( - - ) + } else { + rightIconComponent = !!brickRoadIndicator && ( + ); - }, [ - brickRoadIndicator, - onRightIconPress, - rightIcon, - rightIconAccessibilityLabel, - rightIconTooltipText, - rightIconSentryLabel, - styles.touchableButtonImage, - styles.threeDotsMenuIconWidth, - theme.danger, - theme.icon, - theme.success, - expensifyIcons.DotIndicator, - ]); + } return ( diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/EarlyDiscountBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/EarlyDiscountBanner.tsx index 3b735c599d8f..c7cce48449be 100644 --- a/src/pages/settings/Subscription/CardSection/BillingBanner/EarlyDiscountBanner.tsx +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/EarlyDiscountBanner.tsx @@ -19,7 +19,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import BillingBanner from './BillingBanner'; @@ -57,62 +57,41 @@ function EarlyDiscountBanner({isSubscriptionPage, onboardingHelpDropdownButton, return () => clearInterval(intervalID); }, [firstDayFreeTrial]); - const dismissButton = useMemo( - () => - discountInfo?.discountType === 25 && ( - - { - setIsDismissed(true); - onDismissedDiscountBanner?.(); - }} - role={CONST.ROLE.BUTTON} - accessibilityLabel={translate('common.close')} - sentryLabel={CONST.SENTRY_LABEL.EARLY_DISCOUNT_BANNER.DISMISS_BUTTON} - > - - - - ), - [theme.icon, translate, onDismissedDiscountBanner, discountInfo?.discountType, icons.Close], + const dismissButton = discountInfo?.discountType === 25 && ( + + { + setIsDismissed(true); + onDismissedDiscountBanner?.(); + }} + role={CONST.ROLE.BUTTON} + accessibilityLabel={translate('common.close')} + sentryLabel={CONST.SENTRY_LABEL.EARLY_DISCOUNT_BANNER.DISMISS_BUTTON} + > + + + ); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); - const rightComponent = useMemo(() => { - const smallScreenStyle = shouldDisplayButtonsInSeparateLine ? [styles.flex0, styles.flexBasis100, styles.justifyContentCenter] : []; - return ( - - {onboardingHelpDropdownButton} - - {!shouldDisplayButtonsInSeparateLine && dismissButton} - - ); - }, [ - shouldDisplayButtonsInSeparateLine, - hasActiveScheduledCall, - styles.flex0, - styles.flexBasis100, - styles.justifyContentCenter, - styles.flexRow, - styles.gap2, - styles.alignItemsCenter, - styles.earlyDiscountButton, - styles.flexGrow2, - styles.mr2, - onboardingHelpDropdownButton, - translate, - dismissButton, - ]); + const smallScreenStyle = shouldDisplayButtonsInSeparateLine ? [styles.flex0, styles.flexBasis100, styles.justifyContentCenter] : []; + const rightComponent = ( + + {onboardingHelpDropdownButton} + + {!shouldDisplayButtonsInSeparateLine && dismissButton} + + ); if (!firstDayFreeTrial || !lastDayFreeTrial || !discountInfo) { return null; diff --git a/src/pages/settings/Subscription/CardSection/CardSection.tsx b/src/pages/settings/Subscription/CardSection/CardSection.tsx index 7ced8375a07b..aa228294e500 100644 --- a/src/pages/settings/Subscription/CardSection/CardSection.tsx +++ b/src/pages/settings/Subscription/CardSection/CardSection.tsx @@ -40,7 +40,7 @@ import ROUTES from '@src/ROUTES'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useRoute} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import type {BillingStatusResult} from './utils'; @@ -82,7 +82,7 @@ function CardSection() { const [subscriptionRetryBillingStatusSuccessful] = useOnyx(ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL); const [subscriptionRetryBillingStatusFailed] = useOnyx(ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED); const {isOffline} = useNetwork(); - const defaultCard = useMemo(() => Object.values(fundList ?? {}).find((card) => card.accountData?.additionalData?.isBillingCard), [fundList]); + const defaultCard = Object.values(fundList ?? {}).find((card) => card.accountData?.additionalData?.isBillingCard); const [firstDayFreeTrial] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL); const [lastDayFreeTrial] = useOnyx(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL); const [billingDisputePending] = useOnyx(ONYXKEYS.NVP_PRIVATE_BILLING_DISPUTE_PENDING); @@ -93,10 +93,10 @@ function CardSection() { const [ownerTravelBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_TRAVEL_BILLING_GRACE_PERIOD_END); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [isGrandfatheredFree] = useOnyx(ONYXKEYS.NVP_PRIVATE_GRANDFATHERED_FREE); - const requestRefund = useCallback(() => { + const requestRefund = () => { requestRefundByUser(); Navigation.goBackToHome(); - }, []); + }; const {showConfirmModal} = useConfirmModal(); const refundPrompt = ; diff --git a/src/pages/settings/Subscription/CardSection/CardSectionActions/index.tsx b/src/pages/settings/Subscription/CardSection/CardSectionActions/index.tsx index f4b9d6c58f94..91adf0508f62 100644 --- a/src/pages/settings/Subscription/CardSection/CardSectionActions/index.tsx +++ b/src/pages/settings/Subscription/CardSection/CardSectionActions/index.tsx @@ -9,7 +9,7 @@ import Navigation from '@navigation/Navigation'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import React, {useMemo} from 'react'; +import React from 'react'; const anchorAlignment = { horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, @@ -20,21 +20,18 @@ function CardSectionActions() { const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['CreditCard', 'MoneyCircle']); - const overflowMenu: ThreeDotsMenuProps['menuItems'] = useMemo( - () => [ - { - icon: icons.CreditCard, - text: translate('subscription.cardSection.changeCard'), - onSelected: () => Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD), - }, - { - icon: icons.MoneyCircle, - text: translate('subscription.cardSection.changeCurrency'), - onSelected: () => Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION_CHANGE_BILLING_CURRENCY), - }, - ], - [translate, icons.CreditCard, icons.MoneyCircle], - ); + const overflowMenu: ThreeDotsMenuProps['menuItems'] = [ + { + icon: icons.CreditCard, + text: translate('subscription.cardSection.changeCard'), + onSelected: () => Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD), + }, + { + icon: icons.MoneyCircle, + text: translate('subscription.cardSection.changeCurrency'), + onSelected: () => Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION_CHANGE_BILLING_CURRENCY), + }, + ]; return ( { + const openAddPaymentCardScreen = () => { Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD); - }, []); + }; const handleAddPaymentCardPress = () => { if (isActingAsDelegate) { showDelegateNoAccessModal(); diff --git a/src/pages/settings/Subscription/ExpensifyCodePage/index.tsx b/src/pages/settings/Subscription/ExpensifyCodePage/index.tsx index 287bc209f6be..d2eee74a2478 100644 --- a/src/pages/settings/Subscription/ExpensifyCodePage/index.tsx +++ b/src/pages/settings/Subscription/ExpensifyCodePage/index.tsx @@ -43,18 +43,15 @@ function ExpensifyCodePage() { [INPUT_IDS.EXPENSIFY_CODE]: '', }; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - return getFieldRequiredErrors(values, [INPUT_IDS.EXPENSIFY_CODE], translate); - }, - [translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => { + return getFieldRequiredErrors(values, [INPUT_IDS.EXPENSIFY_CODE], translate); + }; - const handleSubmit = useCallback((values: FormOnyxValues) => { + const handleSubmit = (values: FormOnyxValues) => { applyExpensifyCode(values[INPUT_IDS.EXPENSIFY_CODE]); clearDraftValues(ONYXKEYS.FORMS.SUBSCRIPTION_EXPENSIFY_CODE_FORM); setHasSubmitted(true); - }, []); + }; useFocusEffect( useCallback(() => { diff --git a/src/pages/settings/Subscription/PaymentCard/ChangeBillingCurrency/index.tsx b/src/pages/settings/Subscription/PaymentCard/ChangeBillingCurrency/index.tsx index 778596e890bb..3f2b1913b8f1 100644 --- a/src/pages/settings/Subscription/PaymentCard/ChangeBillingCurrency/index.tsx +++ b/src/pages/settings/Subscription/PaymentCard/ChangeBillingCurrency/index.tsx @@ -27,7 +27,7 @@ import INPUT_IDS from '@src/types/form/ChangeBillingCurrencyForm'; import type {ValueOf} from 'type-fest'; -import React, {useCallback, useEffect} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; type Currency = ValueOf; @@ -69,16 +69,13 @@ function ChangeBillingCurrency() { return errors; }; - const onSubmit = useCallback( - (values: FormOnyxValues) => { - if (!values?.securityCode) { - Navigation.goBack(); - return; - } - PaymentMethods.updateBillingCurrency(currency, values.securityCode); - }, - [currency], - ); + const onSubmit = (values: FormOnyxValues) => { + if (!values?.securityCode) { + Navigation.goBack(); + return; + } + PaymentMethods.updateBillingCurrency(currency, values.securityCode); + }; return ( diff --git a/src/pages/settings/Subscription/PaymentCard/DynamicPaymentCardCurrencySelectorPage.tsx b/src/pages/settings/Subscription/PaymentCard/DynamicPaymentCardCurrencySelectorPage.tsx index 8db475f380e8..9d8a2319e4cc 100644 --- a/src/pages/settings/Subscription/PaymentCard/DynamicPaymentCardCurrencySelectorPage.tsx +++ b/src/pages/settings/Subscription/PaymentCard/DynamicPaymentCardCurrencySelectorPage.tsx @@ -23,7 +23,7 @@ import INPUT_IDS from '@src/types/form/ChangeBillingCurrencyForm'; import type {ValueOf} from 'type-fest'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; type Currency = ValueOf; @@ -52,18 +52,14 @@ function DynamicPaymentCardCurrencySelectorPage() { const [draftCurrency, setDraftCurrency] = useState(); const selectedCurrency = draftCurrency ?? currentCurrency; - const currencyOptions = useMemo( - () => - (Object.keys(CONST.PAYMENT_CARD_CURRENCY) as Currency[]) - .filter((currency) => currency !== CONST.PAYMENT_CARD_CURRENCY.EUR || canUseEurBilling) - .map((currency) => ({ - text: currency, - value: currency, - keyForList: currency, - isSelected: currency === selectedCurrency, - })), - [selectedCurrency, canUseEurBilling], - ); + const currencyOptions = (Object.keys(CONST.PAYMENT_CARD_CURRENCY) as Currency[]) + .filter((currency) => currency !== CONST.PAYMENT_CARD_CURRENCY.EUR || canUseEurBilling) + .map((currency) => ({ + text: currency, + value: currency, + keyForList: currency, + isSelected: currency === selectedCurrency, + })); const saveAndGoBack = () => { if (isChangeBillingCurrencyFlow) { diff --git a/src/pages/settings/Subscription/PaymentCard/index.tsx b/src/pages/settings/Subscription/PaymentCard/index.tsx index 3cdd93f0203a..94145dce4a00 100644 --- a/src/pages/settings/Subscription/PaymentCard/index.tsx +++ b/src/pages/settings/Subscription/PaymentCard/index.tsx @@ -36,7 +36,7 @@ import SCREENS from '@src/SCREENS'; import {useRoute} from '@react-navigation/native'; import {accountIDSelector} from '@selectors/Session'; -import {useCallback, useEffect, useMemo, useState} from 'react'; +import {useEffect, useState} from 'react'; import {StyleSheet, View} from 'react-native'; function AddPaymentCard() { @@ -60,14 +60,13 @@ function AddPaymentCard() { const isAnnual = privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL; const {asset: ShieldYellow} = useMemoizedLazyAsset(() => loadIllustration('ShieldYellow' as IllustrationName)); - const billingCard = useMemo(() => { - const userBillingCard = userBillingFundID ? fundList?.[`${userBillingFundID}`] : undefined; - if (userBillingCard?.accountData) { - return userBillingCard; - } - - return getCardForSubscriptionBilling(fundList); - }, [fundList, userBillingFundID]); + let billingCard; + const userBillingCard = userBillingFundID ? fundList?.[`${userBillingFundID}`] : undefined; + if (userBillingCard?.accountData) { + billingCard = userBillingCard; + } else { + billingCard = getCardForSubscriptionBilling(fundList); + } const isSaveTheWorldAddPaymentCardRoute = route.name === SCREENS.SAVE_THE_WORLD.ADD_PAYMENT_CARD; const shouldShowBlockingView = isSaveTheWorldAddPaymentCardRoute && !!billingCard; @@ -95,22 +94,19 @@ function AddPaymentCard() { }; }, []); - const addPaymentCard = useCallback( - (values: FormOnyxValues) => { - const cardData = { - cardNumber: getMCardNumberString(values.cardNumber), - cardMonth: getMonthFromExpirationDateString(values.expirationDate), - cardYear: getYearFromExpirationDateString(values.expirationDate), - cardCVV: values.securityCode, - addressName: values.nameOnCard, - addressZip: values.addressZipCode, - currency: values.currency ?? CONST.PAYMENT_CARD_CURRENCY.USD, - }; - setHasSubmittedPaymentCard(true); - addSubscriptionPaymentCard(accountID ?? CONST.DEFAULT_NUMBER_ID, cardData, fundList, route.name); - }, - [accountID, fundList, route.name], - ); + const addPaymentCard = (values: FormOnyxValues) => { + const cardData = { + cardNumber: getMCardNumberString(values.cardNumber), + cardMonth: getMonthFromExpirationDateString(values.expirationDate), + cardYear: getYearFromExpirationDateString(values.expirationDate), + cardCVV: values.securityCode, + addressName: values.nameOnCard, + addressZip: values.addressZipCode, + currency: values.currency ?? CONST.PAYMENT_CARD_CURRENCY.USD, + }; + setHasSubmittedPaymentCard(true); + addSubscriptionPaymentCard(accountID ?? CONST.DEFAULT_NUMBER_ID, cardData, fundList, route.name); + }; const [formData] = useOnyx(ONYXKEYS.FORMS.ADD_PAYMENT_CARD_FORM); const prevFormDataSetupComplete = usePrevious(!!formData?.setupComplete); diff --git a/src/pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCardActionButton.tsx b/src/pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCardActionButton.tsx index db5cbda97a66..965481fbf1d9 100644 --- a/src/pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCardActionButton.tsx +++ b/src/pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCardActionButton.tsx @@ -23,7 +23,7 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {StyleProp, ViewStyle} from 'react-native'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import type {PersonalPolicyTypeExcludedProps} from './SubscriptionPlanCard'; @@ -50,15 +50,10 @@ function SubscriptionPlanCardActionButton({subscriptionPlan, isFromComparisonMod const [privatePromoDiscount] = useOnyx(ONYXKEYS.NVP_PRIVATE_PROMO_DISCOUNT); const isAnnual = privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL; const {isSecretPromoCode} = getPrivatePromoDiscountInfo(privatePromoDiscount, isAnnual); - const ownerPolicies = useMemo(() => getOwnedPaidPolicies(policies, currentUserAccountID), [policies, currentUserAccountID]); + const ownerPolicies = getOwnedPaidPolicies(policies, currentUserAccountID); - const [canPerformUpgrade, policy] = useMemo(() => { - const firstPolicy = ownerPolicies.at(0); - if (!firstPolicy || ownerPolicies.length > 1) { - return [false, undefined]; - } - return [isPolicyAdmin(firstPolicy), firstPolicy]; - }, [ownerPolicies]); + const policy = ownerPolicies.length === 1 ? ownerPolicies.at(0) : undefined; + const canPerformUpgrade = !!policy && isPolicyAdmin(policy); const handlePlanPress = (planType: PersonalPolicyTypeExcludedProps) => { closeComparisonModal?.(); diff --git a/src/pages/settings/Subscription/SubscriptionSettings/index.tsx b/src/pages/settings/Subscription/SubscriptionSettings/index.tsx index 75b69717c011..29c63c3eb7e2 100644 --- a/src/pages/settings/Subscription/SubscriptionSettings/index.tsx +++ b/src/pages/settings/Subscription/SubscriptionSettings/index.tsx @@ -56,7 +56,7 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {StyleProp, TextStyle} from 'react-native'; import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; function SubscriptionSettings() { @@ -86,7 +86,7 @@ function SubscriptionSettings() { const [privateTaxExempt] = useOnyx(ONYXKEYS.NVP_PRIVATE_TAX_EXEMPT); const [freebieCredits] = useOnyx(ONYXKEYS.NVP_PRIVATE_FREEBIE_CREDITS); const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); - const defaultCard = useMemo(() => Object.values(fundList ?? {}).find((card) => card.accountData?.additionalData?.isBillingCard), [fundList]); + const defaultCard = Object.values(fundList ?? {}).find((card) => card.accountData?.additionalData?.isBillingCard); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); diff --git a/src/pages/settings/Subscription/SubscriptionSize/subPages/Size.tsx b/src/pages/settings/Subscription/SubscriptionSize/subPages/Size.tsx index 4cde4fef9268..3560572512a1 100644 --- a/src/pages/settings/Subscription/SubscriptionSize/subPages/Size.tsx +++ b/src/pages/settings/Subscription/SubscriptionSize/subPages/Size.tsx @@ -17,7 +17,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/SubscriptionSizeForm'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; type SizeProps = SubPageProps; @@ -39,21 +39,18 @@ function Size({onNext}: SizeProps) { [INPUT_IDS.SUBSCRIPTION_SIZE]: `${privateSubscription?.userCount ?? ''}`, }; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, [INPUT_IDS.SUBSCRIPTION_SIZE], translate); - if (values[INPUT_IDS.SUBSCRIPTION_SIZE] && !isValidSubscriptionSize(values[INPUT_IDS.SUBSCRIPTION_SIZE])) { - errors.subscriptionSize = translate('subscription.subscriptionSize.error.size'); - } + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, [INPUT_IDS.SUBSCRIPTION_SIZE], translate); + if (values[INPUT_IDS.SUBSCRIPTION_SIZE] && !isValidSubscriptionSize(values[INPUT_IDS.SUBSCRIPTION_SIZE])) { + errors.subscriptionSize = translate('subscription.subscriptionSize.error.size'); + } - if (Number(values[INPUT_IDS.SUBSCRIPTION_SIZE]) === privateSubscription?.userCount) { - errors.subscriptionSize = translate('subscription.subscriptionSize.error.sameSize'); - } + if (Number(values[INPUT_IDS.SUBSCRIPTION_SIZE]) === privateSubscription?.userCount) { + errors.subscriptionSize = translate('subscription.subscriptionSize.error.sameSize'); + } - return errors; - }, - [privateSubscription?.userCount, translate], - ); + return errors; + }; return ( requestValidateCodeAction({reasonCode: COMMON_CONST.VALIDATE_CODE_REASONS.VALIDATE_ACCOUNT}); - const handleSubmitForm = useCallback( - (validateCode: string) => { - validateSecondaryLogin(contactMethod, validateCode); - }, - [contactMethod], - ); + const handleSubmitForm = (validateCode: string) => { + validateSecondaryLogin(contactMethod, validateCode); + }; - const handleCloseWithFallback = useCallback(() => { + const handleCloseWithFallback = () => { if (handleClose) { handleClose(); return; } Navigation.goBack(navigateBackTo); - }, [handleClose, navigateBackTo]); + }; // Handle navigation once the user is validated. // This transition must happen exactly once: after it runs, this page stays mounted (see the loading state below), diff --git a/src/pages/settings/Wallet/ActivatePhysicalCardPageBase.tsx b/src/pages/settings/Wallet/ActivatePhysicalCardPageBase.tsx index d692f5329d87..c4058bad4981 100644 --- a/src/pages/settings/Wallet/ActivatePhysicalCardPageBase.tsx +++ b/src/pages/settings/Wallet/ActivatePhysicalCardPageBase.tsx @@ -25,7 +25,7 @@ import SCREENS from '@src/SCREENS'; import type {CardList} from '@src/types/onyx'; import {getEmptyObject, isEmptyObject} from '@src/types/utils/EmptyObject'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; type ActivatePhysicalCardPageBaseProps = { @@ -88,7 +88,7 @@ function ActivatePhysicalCardPageBase({cardID = '', navigateBackTo, isFromDomain setLastFourDigits(text); }; - const submitAndNavigateToNextPage = useCallback(() => { + const submitAndNavigateToNextPage = () => { setCanShowError(true); activateCardCodeInputRef.current?.blur(); @@ -101,7 +101,7 @@ function ActivatePhysicalCardPageBase({cardID = '', navigateBackTo, isFromDomain } activatePhysicalExpensifyCard(lastFourDigits, inactiveCard?.cardID); - }, [lastFourDigits, inactiveCard?.cardID, translate]); + }; if (isEmptyObject(inactiveCard)) { return ; diff --git a/src/pages/settings/Wallet/BankAccountPurposePage/substeps/CountrySelection.tsx b/src/pages/settings/Wallet/BankAccountPurposePage/substeps/CountrySelection.tsx index caea031a2678..ea1c15a423b6 100644 --- a/src/pages/settings/Wallet/BankAccountPurposePage/substeps/CountrySelection.tsx +++ b/src/pages/settings/Wallet/BankAccountPurposePage/substeps/CountrySelection.tsx @@ -15,7 +15,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; function CountrySelection() { const [country] = useOnyx(ONYXKEYS.COUNTRY); @@ -23,36 +23,29 @@ function CountrySelection() { const {translate} = useLocalize(); const styles = useThemeStyles(); - const initialCountry = useMemo(() => { - const outputCurrency = personalPolicy?.outputCurrency; - - if (!outputCurrency) { - return ''; - } - - if (CONST.BBA_EU_ORIGINAL_CURRENCY_COUNTRY_MAP[outputCurrency]) { - return CONST.BBA_EU_ORIGINAL_CURRENCY_COUNTRY_MAP[outputCurrency]; - } - + let initialCountry; + const outputCurrency = personalPolicy?.outputCurrency; + if (!outputCurrency) { + initialCountry = ''; + } else if (CONST.BBA_EU_ORIGINAL_CURRENCY_COUNTRY_MAP[outputCurrency]) { + initialCountry = CONST.BBA_EU_ORIGINAL_CURRENCY_COUNTRY_MAP[outputCurrency]; + } else { const countriesWithCurrency = Object.entries(CONST.BBA_COUNTRY_CURRENCY_MAP) .filter(([, currency]) => currency === outputCurrency) .map(([countryCode]) => countryCode); - if (countriesWithCurrency.length === 1) { - return countriesWithCurrency.at(0) ?? ''; - } - - if (countriesWithCurrency.length > 1) { + initialCountry = countriesWithCurrency.at(0) ?? ''; + } else if (countriesWithCurrency.length > 1) { if (country && countriesWithCurrency.includes(country)) { - return country; + initialCountry = country; + } else { + initialCountry = ''; } - return ''; + } else { + const isSupportedCountry = !!country && !!CONST.BBA_COUNTRY_CURRENCY_MAP[country]; + initialCountry = isSupportedCountry ? country : ''; } - - const isSupportedCountry = !!country && !!CONST.BBA_COUNTRY_CURRENCY_MAP[country]; - - return isSupportedCountry ? country : ''; - }, [personalPolicy?.outputCurrency, country]); + } const [selectedCountry, setSelectedCountry] = useState(initialCountry); const [shouldShowError, setShouldShowError] = useState(false); diff --git a/src/pages/settings/Wallet/ChooseTransferAccountPage.tsx b/src/pages/settings/Wallet/ChooseTransferAccountPage.tsx index 96f4318d44e1..e4f74c1538f3 100644 --- a/src/pages/settings/Wallet/ChooseTransferAccountPage.tsx +++ b/src/pages/settings/Wallet/ChooseTransferAccountPage.tsx @@ -26,7 +26,7 @@ import type {AccountData, BankAccount} from '@src/types/onyx'; import type {BankName} from '@src/types/onyx/Bank'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; type BankAccountListItem = ListItem & { @@ -68,41 +68,42 @@ function ChooseTransferAccountPage() { const [draftAccountID, setDraftAccountID] = useState(); const currentSelectedAccountID = draftAccountID ?? selectedAccountID; - const bankAccountOptions = useMemo(() => { - const options = Object.values(bankAccountsList ?? {}).map((bankAccount, index): BankAccountListItem => { - const bankName = (bankAccount.accountData?.additionalData?.bankName ?? '') as BankName; - const bankAccountNumber = bankAccount.accountData?.accountNumber ?? ''; - const bankAccountID = bankAccount.accountData?.bankAccountID ?? bankAccount.methodID; - const {icon, iconSize, iconStyles} = getBankIcon({bankName, styles}); - return { - value: bankAccountID, - text: bankAccount.title, - leftElement: icon ? ( - - - - ) : null, - alternateText: `${translate('workspace.expensifyCard.accountEndingIn')} ${getLastFourDigits(bankAccountNumber)}`, - keyForList: `${bankAccountID}-${index}`, - isSelected: bankAccountID?.toString() === currentSelectedAccountID, - bankAccount, - }; + const options = Object.values(bankAccountsList ?? {}).map((bankAccount, index): BankAccountListItem => { + const bankName = (bankAccount.accountData?.additionalData?.bankName ?? '') as BankName; + const bankAccountNumber = bankAccount.accountData?.accountNumber ?? ''; + const bankAccountID = bankAccount.accountData?.bankAccountID ?? bankAccount.methodID; + const {icon, iconSize, iconStyles} = getBankIcon({ + bankName, + styles, }); - return options; - }, [bankAccountsList, currentSelectedAccountID, styles, translate]); - - const initiallyFocusedItemKey = useMemo(() => { - if (!selectedAccountID) { - return undefined; - } + return { + value: bankAccountID, + text: bankAccount.title, + leftElement: icon ? ( + + + + ) : null, + alternateText: `${translate('workspace.expensifyCard.accountEndingIn')} ${getLastFourDigits(bankAccountNumber)}`, + keyForList: `${bankAccountID}-${index}`, + isSelected: bankAccountID?.toString() === currentSelectedAccountID, + bankAccount, + }; + }); + const bankAccountOptions = options; + + let initiallyFocusedItemKey; + if (!selectedAccountID) { + initiallyFocusedItemKey = undefined; + } else { const selectedOption = bankAccountOptions.find((option) => option.value?.toString() === selectedAccountID.toString()); - return selectedOption?.keyForList; - }, [bankAccountOptions, selectedAccountID]); + initiallyFocusedItemKey = selectedOption?.keyForList; + } const saveTransferAccount = () => { const selectedOption = bankAccountOptions.find((option) => option.value?.toString() === currentSelectedAccountID); diff --git a/src/pages/settings/Wallet/DynamicAddBankAccountVerifyAccountPage.tsx b/src/pages/settings/Wallet/DynamicAddBankAccountVerifyAccountPage.tsx index 75be70e24249..d686ca372f2a 100644 --- a/src/pages/settings/Wallet/DynamicAddBankAccountVerifyAccountPage.tsx +++ b/src/pages/settings/Wallet/DynamicAddBankAccountVerifyAccountPage.tsx @@ -12,7 +12,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import React, {useMemo} from 'react'; +import React from 'react'; type DynamicAddBankAccountVerifyAccountPageProps = PlatformStackScreenProps; @@ -21,7 +21,7 @@ function DynamicAddBankAccountVerifyAccountPage({route}: DynamicAddBankAccountVe const backPath = useDynamicBackPath(DYNAMIC_ROUTES.ADD_BANK_ACCOUNT_VERIFY_ACCOUNT.path); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const currentUserEmail = getCurrentUserEmail(); - const isAdmin = useMemo(() => hasActiveAdminWorkspaces(currentUserEmail ?? '', allPolicies), [currentUserEmail, allPolicies]); + const isAdmin = hasActiveAdminWorkspaces(currentUserEmail ?? '', allPolicies); // This forward path must agree with the validated branch of openPersonalBankAccountSetupView. let navigateForwardTo; if (shouldSetUpUSBankAccount === 'true') { diff --git a/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/subPages/AverageReimbursement.tsx b/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/subPages/AverageReimbursement.tsx index c95b005eae2c..31c81452dbfd 100644 --- a/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/subPages/AverageReimbursement.tsx +++ b/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/subPages/AverageReimbursement.tsx @@ -10,7 +10,7 @@ import type {BusinessInfoSubPageProps} from '@pages/settings/Wallet/EnableGlobal import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/EnableGlobalReimbursementsForm'; -import React, {useMemo} from 'react'; +import React from 'react'; const {TRADE_VOLUME} = INPUT_IDS; const STEP_FIELDS = [TRADE_VOLUME]; @@ -20,24 +20,18 @@ function AverageReimbursements({onNext, onMove, isEditing, currency}: BusinessIn const [enableGlobalReimbursementsDraft] = useOnyx(ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS_DRAFT); const [corpayOnboardingFields] = useOnyx(ONYXKEYS.CORPAY_ONBOARDING_FIELDS); - const tradeVolumeRangeListOptions = useMemo( - () => getListOptionsFromCorpayPicklist(corpayOnboardingFields?.picklists.TradeVolumeRange), - [corpayOnboardingFields?.picklists.TradeVolumeRange], - ); - - const pushRowFields = useMemo( - () => [ - { - inputID: TRADE_VOLUME, - defaultValue: enableGlobalReimbursementsDraft?.[TRADE_VOLUME] ?? '', - options: tradeVolumeRangeListOptions, - description: translate('businessInfoStep.averageReimbursementAmountInCurrency', currency), - modalHeaderTitle: translate('businessInfoStep.selectAverageReimbursement'), - searchInputTitle: translate('businessInfoStep.findAverageReimbursement'), - }, - ], - [enableGlobalReimbursementsDraft, currency, tradeVolumeRangeListOptions, translate], - ); + const tradeVolumeRangeListOptions = getListOptionsFromCorpayPicklist(corpayOnboardingFields?.picklists.TradeVolumeRange); + + const pushRowFields = [ + { + inputID: TRADE_VOLUME, + defaultValue: enableGlobalReimbursementsDraft?.[TRADE_VOLUME] ?? '', + options: tradeVolumeRangeListOptions, + description: translate('businessInfoStep.averageReimbursementAmountInCurrency', currency), + modalHeaderTitle: translate('businessInfoStep.selectAverageReimbursement'), + searchInputTitle: translate('businessInfoStep.findAverageReimbursement'), + }, + ]; const handleSubmit = useEnableGlobalReimbursementsStepFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/subPages/BusinessType.tsx b/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/subPages/BusinessType.tsx index daef9a705ad9..8001abcfa6fa 100644 --- a/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/subPages/BusinessType.tsx +++ b/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/subPages/BusinessType.tsx @@ -12,7 +12,7 @@ import type {BusinessInfoSubPageProps} from '@pages/settings/Wallet/EnableGlobal import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/EnableGlobalReimbursementsForm'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; const {APPLICANT_TYPE_ID, BUSINESS_CATEGORY} = INPUT_IDS; @@ -24,33 +24,27 @@ function BusinessType({onNext, onMove, isEditing}: BusinessInfoSubPageProps) { const [enableGlobalReimbursementsDraft] = useOnyx(ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS_DRAFT); const [corpayOnboardingFields] = useOnyx(ONYXKEYS.CORPAY_ONBOARDING_FIELDS); - const incorporationTypeListOptions = useMemo(() => getListOptionsFromCorpayPicklist(corpayOnboardingFields?.picklists.ApplicantType), [corpayOnboardingFields?.picklists.ApplicantType]); - const natureOfBusinessListOptions = useMemo( - () => getListOptionsFromCorpayPicklist(corpayOnboardingFields?.picklists.NatureOfBusiness), - [corpayOnboardingFields?.picklists.NatureOfBusiness], - ); + const incorporationTypeListOptions = getListOptionsFromCorpayPicklist(corpayOnboardingFields?.picklists.ApplicantType); + const natureOfBusinessListOptions = getListOptionsFromCorpayPicklist(corpayOnboardingFields?.picklists.NatureOfBusiness); - const pushRowFields = useMemo( - () => [ - { - inputID: APPLICANT_TYPE_ID, - defaultValue: enableGlobalReimbursementsDraft?.[APPLICANT_TYPE_ID] ?? '', - options: incorporationTypeListOptions, - description: translate('businessInfoStep.incorporationTypeName'), - modalHeaderTitle: translate('businessInfoStep.selectIncorporationType'), - searchInputTitle: translate('businessInfoStep.findIncorporationType'), - }, - { - inputID: BUSINESS_CATEGORY, - defaultValue: enableGlobalReimbursementsDraft?.[BUSINESS_CATEGORY] ?? '', - options: natureOfBusinessListOptions, - description: translate('businessInfoStep.businessCategory'), - modalHeaderTitle: translate('businessInfoStep.selectBusinessCategory'), - searchInputTitle: translate('businessInfoStep.findBusinessCategory'), - }, - ], - [enableGlobalReimbursementsDraft, incorporationTypeListOptions, natureOfBusinessListOptions, translate], - ); + const pushRowFields = [ + { + inputID: APPLICANT_TYPE_ID, + defaultValue: enableGlobalReimbursementsDraft?.[APPLICANT_TYPE_ID] ?? '', + options: incorporationTypeListOptions, + description: translate('businessInfoStep.incorporationTypeName'), + modalHeaderTitle: translate('businessInfoStep.selectIncorporationType'), + searchInputTitle: translate('businessInfoStep.findIncorporationType'), + }, + { + inputID: BUSINESS_CATEGORY, + defaultValue: enableGlobalReimbursementsDraft?.[BUSINESS_CATEGORY] ?? '', + options: natureOfBusinessListOptions, + description: translate('businessInfoStep.businessCategory'), + modalHeaderTitle: translate('businessInfoStep.selectBusinessCategory'), + searchInputTitle: translate('businessInfoStep.findBusinessCategory'), + }, + ]; const handleSubmit = useEnableGlobalReimbursementsStepFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/subPages/Confirmation.tsx b/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/subPages/Confirmation.tsx index 2611e0ef3d80..0659afb608e5 100644 --- a/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/subPages/Confirmation.tsx +++ b/src/pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsBusinessPage/subPages/Confirmation.tsx @@ -10,7 +10,7 @@ import type {BusinessInfoSubPageProps} from '@pages/settings/Wallet/EnableGlobal import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/EnableGlobalReimbursementsForm'; -import React, {useMemo} from 'react'; +import React from 'react'; const {BUSINESS_REGISTRATION_INCORPORATION_NUMBER, ANNUAL_VOLUME, APPLICANT_TYPE_ID, TRADE_VOLUME, BUSINESS_CATEGORY} = INPUT_IDS; @@ -25,73 +25,58 @@ function Confirmation({onNext, onMove, isEditing}: BusinessInfoSubPageProps) { const [corpayOnboardingFields] = useOnyx(ONYXKEYS.CORPAY_ONBOARDING_FIELDS); const error = getLatestErrorMessage(enableGlobalReimbursementsDraft); - const paymentVolume = useMemo( - () => displayStringValue(corpayOnboardingFields?.picklists.AnnualVolumeRange ?? [], enableGlobalReimbursementsDraft?.[ANNUAL_VOLUME] ?? ''), - [corpayOnboardingFields?.picklists.AnnualVolumeRange, enableGlobalReimbursementsDraft], - ); - const businessCategory = useMemo( - () => displayStringValue(corpayOnboardingFields?.picklists.NatureOfBusiness ?? [], enableGlobalReimbursementsDraft?.[BUSINESS_CATEGORY] ?? ''), - [corpayOnboardingFields?.picklists.NatureOfBusiness, enableGlobalReimbursementsDraft], - ); - const businessType = useMemo( - () => displayStringValue(corpayOnboardingFields?.picklists.ApplicantType ?? [], enableGlobalReimbursementsDraft?.[APPLICANT_TYPE_ID] ?? ''), - [corpayOnboardingFields?.picklists.ApplicantType, enableGlobalReimbursementsDraft], - ); - const tradeVolumeRange = useMemo( - () => displayStringValue(corpayOnboardingFields?.picklists.TradeVolumeRange ?? [], enableGlobalReimbursementsDraft?.[TRADE_VOLUME] ?? ''), - [corpayOnboardingFields?.picklists.TradeVolumeRange, enableGlobalReimbursementsDraft], - ); + const paymentVolume = displayStringValue(corpayOnboardingFields?.picklists.AnnualVolumeRange ?? [], enableGlobalReimbursementsDraft?.[ANNUAL_VOLUME] ?? ''); + const businessCategory = displayStringValue(corpayOnboardingFields?.picklists.NatureOfBusiness ?? [], enableGlobalReimbursementsDraft?.[BUSINESS_CATEGORY] ?? ''); + const businessType = displayStringValue(corpayOnboardingFields?.picklists.ApplicantType ?? [], enableGlobalReimbursementsDraft?.[APPLICANT_TYPE_ID] ?? ''); + const tradeVolumeRange = displayStringValue(corpayOnboardingFields?.picklists.TradeVolumeRange ?? [], enableGlobalReimbursementsDraft?.[TRADE_VOLUME] ?? ''); - const summaryItems = useMemo( - () => [ - { - id: 'registration-number', - title: enableGlobalReimbursementsDraft?.[BUSINESS_REGISTRATION_INCORPORATION_NUMBER] ?? '', - description: translate('businessInfoStep.registrationNumber'), - shouldShowRightIcon: true, - onPress: () => { - onMove(0); - }, + const summaryItems = [ + { + id: 'registration-number', + title: enableGlobalReimbursementsDraft?.[BUSINESS_REGISTRATION_INCORPORATION_NUMBER] ?? '', + description: translate('businessInfoStep.registrationNumber'), + shouldShowRightIcon: true, + onPress: () => { + onMove(0); }, - { - id: 'business-type', - title: businessType, - description: translate('businessInfoStep.businessType'), - shouldShowRightIcon: true, - onPress: () => { - onMove(1); - }, + }, + { + id: 'business-type', + title: businessType, + description: translate('businessInfoStep.businessType'), + shouldShowRightIcon: true, + onPress: () => { + onMove(1); }, - { - id: 'business-category', - title: businessCategory, - description: translate('businessInfoStep.businessCategory'), - shouldShowRightIcon: true, - onPress: () => { - onMove(1); - }, + }, + { + id: 'business-category', + title: businessCategory, + description: translate('businessInfoStep.businessCategory'), + shouldShowRightIcon: true, + onPress: () => { + onMove(1); }, - { - id: 'annual-payment-volume', - title: paymentVolume, - description: translate('businessInfoStep.annualPaymentVolume'), - shouldShowRightIcon: true, - onPress: () => { - onMove(2); - }, + }, + { + id: 'annual-payment-volume', + title: paymentVolume, + description: translate('businessInfoStep.annualPaymentVolume'), + shouldShowRightIcon: true, + onPress: () => { + onMove(2); }, - { - id: 'trade-volume-range', - title: tradeVolumeRange, - description: translate('businessInfoStep.averageReimbursementAmount'), - shouldShowRightIcon: true, - onPress: () => { - onMove(3); - }, + }, + { + id: 'trade-volume-range', + title: tradeVolumeRange, + description: translate('businessInfoStep.averageReimbursementAmount'), + shouldShowRightIcon: true, + onPress: () => { + onMove(3); }, - ], - [businessCategory, businessType, enableGlobalReimbursementsDraft, onMove, paymentVolume, tradeVolumeRange, translate], - ); + }, + ]; return ( getListOptionsFromCorpayPicklist(corpayOnboardingFields?.picklists.AnnualVolumeRange), - [corpayOnboardingFields?.picklists.AnnualVolumeRange], - ); - - const pushRowFields = useMemo( - () => [ - { - inputID: ANNUAL_VOLUME, - defaultValue: enableGlobalReimbursementsDraft?.[ANNUAL_VOLUME] ?? '', - options: annualVolumeRangeListOptions, - description: translate('businessInfoStep.annualPaymentVolumeInCurrency', currency), - modalHeaderTitle: translate('businessInfoStep.selectAnnualPaymentVolume'), - searchInputTitle: translate('businessInfoStep.findAnnualPaymentVolume'), - }, - ], - [enableGlobalReimbursementsDraft, currency, annualVolumeRangeListOptions, translate], - ); + const annualVolumeRangeListOptions = getListOptionsFromCorpayPicklist(corpayOnboardingFields?.picklists.AnnualVolumeRange); + + const pushRowFields = [ + { + inputID: ANNUAL_VOLUME, + defaultValue: enableGlobalReimbursementsDraft?.[ANNUAL_VOLUME] ?? '', + options: annualVolumeRangeListOptions, + description: translate('businessInfoStep.annualPaymentVolumeInCurrency', currency), + modalHeaderTitle: translate('businessInfoStep.selectAnnualPaymentVolume'), + searchInputTitle: translate('businessInfoStep.findAnnualPaymentVolume'), + }, + ]; const handleSubmit = useEnableGlobalReimbursementsStepFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/settings/Wallet/ExpensifyCardPage/ChangePINPage.tsx b/src/pages/settings/Wallet/ExpensifyCardPage/ChangePINPage.tsx index 731ec9f27ca7..3096d2c5c4c4 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage/ChangePINPage.tsx +++ b/src/pages/settings/Wallet/ExpensifyCardPage/ChangePINPage.tsx @@ -23,7 +23,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import React, {useCallback, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; type ChangePINPageProps = PlatformStackScreenProps; @@ -40,19 +40,16 @@ function ChangePINPageContent({cardID, wasPINBlocked}: {cardID: string; wasPINBl const [confirmPIN, setConfirmPIN] = useState(''); const [error, setError] = useState(''); - const handlePINChange = useCallback( - (value: string) => { - setError(''); - if (isConfirmStep) { - setConfirmPIN(value); - } else { - setEnteredPIN(value); - } - }, - [isConfirmStep], - ); + const handlePINChange = (value: string) => { + setError(''); + if (isConfirmStep) { + setConfirmPIN(value); + } else { + setEnteredPIN(value); + } + }; - const validatePIN = useCallback((): string => { + const validatePIN = (): string => { const PINToValidate = isConfirmStep ? confirmPIN : enteredPIN; if (PINToValidate.length !== CONST.EXPENSIFY_CARD.PIN.LENGTH) { @@ -67,9 +64,9 @@ function ChangePINPageContent({cardID, wasPINBlocked}: {cardID: string; wasPINBl return translate('cardPage.invalidPin'); } return ''; - }, [isConfirmStep, confirmPIN, enteredPIN, translate]); + }; - const handleSubmit = useCallback(() => { + const handleSubmit = () => { const validationError = validatePIN(); if (validationError) { setError(validationError); @@ -87,7 +84,7 @@ function ChangePINPageContent({cardID, wasPINBlocked}: {cardID: string; wasPINBl cardID, wasPINBlocked, }); - }, [validatePIN, isConfirmStep, setIsConfirmStep, executeScenario, confirmPIN, cardID, wasPINBlocked]); + }; const currentPIN = isConfirmStep ? confirmPIN : enteredPIN; const title = isConfirmStep ? translate('cardPage.confirmYourChangedPin') : translate('cardPage.changeYourPin'); diff --git a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx index 29be6a0127da..59714932c9b1 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx +++ b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx @@ -90,7 +90,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useMemo} from 'react'; +import React, {useCallback} from 'react'; import {View} from 'react-native'; type ExpensifyCardPageProps = @@ -141,27 +141,22 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { const personalDetails = usePersonalDetails(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Flag', 'MoneySearch', 'FreezeCard', 'Key', 'Eye', 'CreditCardLock']); - const cardsToShow = useMemo(() => { - if (shouldDisplayCardDomain) { - return getDomainCards(cardList)[domain]?.filter((card) => !card?.nameValuePairs?.issuedBy || !card?.nameValuePairs?.isVirtual) ?? []; - } - return [cardList?.[cardID]]; - }, [shouldDisplayCardDomain, cardList, cardID, domain]); - const currentCard = useMemo(() => cardsToShow?.find((card) => String(card?.cardID) === cardID) ?? cardsToShow?.at(0), [cardsToShow, cardID]); + const cardsToShow = shouldDisplayCardDomain + ? (getDomainCards(cardList)[domain]?.filter((card) => !card?.nameValuePairs?.issuedBy || !card?.nameValuePairs?.isVirtual) ?? []) + : [cardList?.[cardID]]; + const currentCard = cardsToShow?.find((card) => String(card?.cardID) === cardID) ?? cardsToShow?.at(0); // Any of the domain's cards can be the one awaiting approval, and the CTA has to open that card's flow. - const cardPendingDigitalWalletApproval = useMemo(() => cardsToShow?.find((card) => isCardPendingDigitalWalletApproval(card)), [cardsToShow]); + const cardPendingDigitalWalletApproval = cardsToShow?.find((card) => isCardPendingDigitalWalletApproval(card)); useRefreshPendingDigitalWalletApproval(); - const virtualCards = useMemo(() => cardsToShow?.filter((card) => card?.nameValuePairs?.isVirtual && !isTravelCard(card)), [cardsToShow]); - const travelCards = useMemo(() => cardsToShow?.filter((card) => card?.nameValuePairs?.isVirtual && isTravelCard(card)), [cardsToShow]); - const physicalCards = useMemo(() => cardsToShow?.filter((card) => !card?.nameValuePairs?.isVirtual), [cardsToShow]); - const cardToAdd = useMemo(() => { - return virtualCards?.at(0); - }, [virtualCards]); + const virtualCards = cardsToShow?.filter((card) => card?.nameValuePairs?.isVirtual && !isTravelCard(card)); + const travelCards = cardsToShow?.filter((card) => card?.nameValuePairs?.isVirtual && isTravelCard(card)); + const physicalCards = cardsToShow?.filter((card) => !card?.nameValuePairs?.isVirtual); + const cardToAdd = virtualCards?.at(0); const isCardDetailsLoading = useAllVirtualCardDetailsLoading(); - const currentPhysicalCard = useMemo(() => physicalCards?.find((card) => String(card?.cardID) === cardID) ?? physicalCards?.at(0), [physicalCards, cardID]); + const currentPhysicalCard = physicalCards?.find((card) => String(card?.cardID) === cardID) ?? physicalCards?.at(0); const revealedPIN = useRevealedPhysicalCardPin(String(currentPhysicalCard?.cardID)); const revealedCardDetails = useAllRevealedVirtualCardDetails(); @@ -217,30 +212,27 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { const isWorkspaceAdmin = isPolicyAdmin(policyForCurrentCard, session?.email); const canUnfreezeCard = canManageCardFreeze && (frozenByAccountID === session?.accountID || isWorkspaceAdmin); - const spendRule = useMemo(() => getSpendRuleByCardID(cardSettings ? {privateExpensifyCardSettings: cardSettings} : undefined, cardID), [cardSettings, cardID]); + const spendRule = getSpendRuleByCardID(cardSettings ? {privateExpensifyCardSettings: cardSettings} : undefined, cardID); const spendRulesSummary = spendRule ? getSpendRuleSummaryText(spendRule.formValues, currency, translate, convertToDisplayString) : []; - const navigateToSpendRulesPage = useCallback(() => { + const navigateToSpendRulesPage = () => { if (!policyIDForCurrentCard) { return; } Navigation.navigate(ROUTES.SETTINGS_WALLET_EXPENSIFY_CARD_SPEND_RULES.getRoute(policyIDForCurrentCard, spendRule?.ruleID)); - }, [policyIDForCurrentCard, spendRule?.ruleID]); - - const spendRulesTitleComponent = useMemo( - () => ( - - {spendRulesSummary.map((summary) => ( - - {summary} - - ))} - - ), - [spendRulesSummary], + }; + + const spendRulesTitleComponent = ( + + {spendRulesSummary.map((summary) => ( + + {summary} + + ))} + ); const shouldShowReportVirtualCardFraudRows = !isSignedInAsDelegate && virtualCards.length > 0; const shouldShowReportTravelCardFraudRows = !isSignedInAsDelegate && isTravelCard(cardList?.[cardID]) && travelCards.length > 0; @@ -251,18 +243,15 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { const shouldShowPhysicalCardFooterButton = currentPhysicalCard?.state === CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED || currentPhysicalCard?.state === CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED; - const scarfOverlayStyle = useMemo( - () => ({ - top: 0, - left: (variables.cardPreviewWidth - variables.cardScarfOverlayWidth) / 2, - zIndex: variables.cardScarfOverlayZIndex, - width: variables.cardScarfOverlayWidth, - height: variables.cardScarfOverlayHeight, - }), - [], - ); + const scarfOverlayStyle: ViewStyle = { + top: 0, + left: (variables.cardPreviewWidth - variables.cardScarfOverlayWidth) / 2, + zIndex: variables.cardScarfOverlayZIndex, + width: variables.cardScarfOverlayWidth, + height: variables.cardScarfOverlayHeight, + }; - const handleFreezePress = useCallback(() => { + const handleFreezePress = () => { showConfirmModal({ title: `${translate('cardPage.freezeCard')}?`, shouldSetModalVisibility: false, @@ -276,9 +265,9 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { } freezeCard(Number(currentCard?.fundID ?? CONST.DEFAULT_NUMBER_ID), currentCard, session?.accountID ?? CONST.DEFAULT_NUMBER_ID); }); - }, [currentCard, session?.accountID, showConfirmModal, translate]); + }; - const handleUnfreezePress = useCallback(() => { + const handleUnfreezePress = () => { showConfirmModal({ title: `${translate('cardPage.unfreezeCard')}?`, shouldSetModalVisibility: false, @@ -291,15 +280,15 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { } unfreezeCard(Number(currentCard?.fundID ?? CONST.DEFAULT_NUMBER_ID), currentCard, session?.accountID ?? CONST.DEFAULT_NUMBER_ID); }); - }, [currentCard, session?.accountID, showConfirmModal, translate]); + }; - const handleAskToUnfreezePress = useCallback(() => { + const handleAskToUnfreezePress = () => { const cardHolderWorkspaceChatReportID = getPolicyExpenseChat(currentCard?.accountID, policyIDForCurrentCard)?.reportID; if (!cardHolderWorkspaceChatReportID) { return; } Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(cardHolderWorkspaceChatReportID)); - }, [currentCard?.accountID, policyIDForCurrentCard]); + }; const navigateToTransactions = () => navigateToCardTransactions(cardID); diff --git a/src/pages/settings/Wallet/ImportTransactionsCardNamePage.tsx b/src/pages/settings/Wallet/ImportTransactionsCardNamePage.tsx index 56219e5754f2..d137c01055ac 100644 --- a/src/pages/settings/Wallet/ImportTransactionsCardNamePage.tsx +++ b/src/pages/settings/Wallet/ImportTransactionsCardNamePage.tsx @@ -19,7 +19,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ImportTransactionsForm'; -import React, {useCallback} from 'react'; +import React from 'react'; import {Keyboard} from 'react-native'; function ImportTransactionsCardNamePage() { @@ -28,27 +28,24 @@ function ImportTransactionsCardNamePage() { const {inputCallbackRef} = useAutoFocusInput(); const [importedSpreadsheet] = useOnyx(ONYXKEYS.IMPORTED_SPREADSHEET); - const submit = useCallback((values: FormOnyxValues) => { + const submit = (values: FormOnyxValues) => { setImportTransactionCardName(values.cardDisplayName.trim()); Keyboard.dismiss(); Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack()); - }, []); + }; - const validate = useCallback( - (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; - const name = values.cardDisplayName.trim(); + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const name = values.cardDisplayName.trim(); - const {isValid, byteLength} = isValidInputLength(name, CONST.TITLE_CHARACTER_LIMIT); + const {isValid, byteLength} = isValidInputLength(name, CONST.TITLE_CHARACTER_LIMIT); - if (!isValid) { - addErrorMessage(errors, 'cardDisplayName', translate('common.error.characterLimitExceedCounter', byteLength, CONST.TITLE_CHARACTER_LIMIT)); - } + if (!isValid) { + addErrorMessage(errors, 'cardDisplayName', translate('common.error.characterLimitExceedCounter', byteLength, CONST.TITLE_CHARACTER_LIMIT)); + } - return errors; - }, - [translate], - ); + return errors; + }; return ( { + const navigateToCardNameSelection = () => { Navigation.navigate(ROUTES.SETTINGS_WALLET_IMPORT_TRANSACTIONS_CARD_NAME); - }, []); + }; - const navigateToCurrencySelection = useCallback(() => { + const navigateToCurrencySelection = () => { Navigation.navigate(ROUTES.SETTINGS_WALLET_IMPORT_TRANSACTIONS_CURRENCY); - }, []); + }; - const handleNext = useCallback(() => { + const handleNext = () => { if (!cardDisplayName) { setShouldShowError(true); return; @@ -52,7 +52,7 @@ function ImportTransactionsPage() { setImportTransactionSettings(cardDisplayName, currency, isReimbursable, flipAmountSign); // No cardID = creating a new card Navigation.navigate(ROUTES.SETTINGS_WALLET_IMPORT_TRANSACTIONS_SPREADSHEET.getRoute()); - }, [cardDisplayName, currency, isReimbursable, flipAmountSign]); + }; const shouldShowCardDisplayNameError = shouldShowError && !cardDisplayName; diff --git a/src/pages/settings/Wallet/ImportTransactionsSpreadsheetPage.tsx b/src/pages/settings/Wallet/ImportTransactionsSpreadsheetPage.tsx index d9ca7103f811..fca86d4eb4d2 100644 --- a/src/pages/settings/Wallet/ImportTransactionsSpreadsheetPage.tsx +++ b/src/pages/settings/Wallet/ImportTransactionsSpreadsheetPage.tsx @@ -21,7 +21,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {cardByIdSelector} from '@selectors/Card'; import {accountIDSelector} from '@selectors/Session'; -import React, {useCallback} from 'react'; +import React from 'react'; type ImportTransactionsSpreadsheetPageProps = PlatformStackScreenProps; @@ -32,7 +32,7 @@ function ImportTransactionsSpreadsheetPage({route}: ImportTransactionsSpreadshee const [importedSpreadsheet] = useOnyx(ONYXKEYS.IMPORTED_SPREADSHEET); const [savedColumnLayouts, savedColumnLayoutsMetadata] = useOnyx(ONYXKEYS.NVP_SAVED_CSV_COLUMN_LAYOUT_LIST); const [accountID = CONST.DEFAULT_NUMBER_ID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); - const cardSelector = useCallback((cardList: OnyxEntry) => (cardID ? cardByIdSelector(String(cardID))(cardList) : undefined), [cardID]); + const cardSelector = (cardList: OnyxEntry) => (cardID ? cardByIdSelector(String(cardID))(cardList) : undefined); const [existingCard] = useOnyx(ONYXKEYS.CARD_LIST, {selector: cardSelector}); const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES); const {setIsClosing} = useCloseImportPage(); diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/AccountHolderInformation.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/AccountHolderInformation.tsx index b9e00704debf..2b70432e4c9e 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/AccountHolderInformation.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/AccountHolderInformation.tsx @@ -22,7 +22,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {CorpayFormField} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; function getInputComponent(field: CorpayFormField) { @@ -58,31 +58,25 @@ function AccountHolderInformation({isEditing, onNext, formValues, fieldsMap}: Cu shouldSaveDraft: true, }); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - return getValidationErrors(values, fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_HOLDER_DETAILS], translate); - }, - [fieldsMap, translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => { + return getValidationErrors(values, fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_HOLDER_DETAILS], translate); + }; - const getStyle = useCallback( - (field: CorpayFormField, index: number) => { - if ((field.valueSet ?? []).length > 0) { - return [styles.mhn5, index === 0 ? styles.pb1 : styles.pv1]; - } - if (CONST.CORPAY_FIELDS.SPECIAL_LIST_REGION_KEYS.includes(field.id)) { - return [styles.mhn5, index === 0 ? styles.pb1 : styles.pv1]; - } - if (CONST.CORPAY_FIELDS.SPECIAL_LIST_ADDRESS_KEYS.includes(field.id)) { - return [index === 0 ? styles.pb2 : styles.pv2]; - } - if (field.id === CONST.CORPAY_FIELDS.ACCOUNT_HOLDER_COUNTRY_KEY) { - return [styles.mhn5, index === 0 ? styles.pb1 : styles.pv1]; - } + const getStyle = (field: CorpayFormField, index: number) => { + if ((field.valueSet ?? []).length > 0) { + return [styles.mhn5, index === 0 ? styles.pb1 : styles.pv1]; + } + if (CONST.CORPAY_FIELDS.SPECIAL_LIST_REGION_KEYS.includes(field.id)) { + return [styles.mhn5, index === 0 ? styles.pb1 : styles.pv1]; + } + if (CONST.CORPAY_FIELDS.SPECIAL_LIST_ADDRESS_KEYS.includes(field.id)) { return [index === 0 ? styles.pb2 : styles.pv2]; - }, - [styles.mhn5, styles.pb1, styles.pb2, styles.pv1, styles.pv2], - ); + } + if (field.id === CONST.CORPAY_FIELDS.ACCOUNT_HOLDER_COUNTRY_KEY) { + return [styles.mhn5, index === 0 ? styles.pb1 : styles.pv1]; + } + return [index === 0 ? styles.pb2 : styles.pv2]; + }; return ( ): FormInputErrors => { - if (!fieldData.isRequired || values[CONST.CORPAY_FIELDS.ACCOUNT_TYPE_KEY]) { - return {}; - } - return {[CONST.CORPAY_FIELDS.ACCOUNT_TYPE_KEY]: translate('common.error.pleaseSelectOne')}; - }, - [fieldData.isRequired, translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => { + if (!fieldData.isRequired || values[CONST.CORPAY_FIELDS.ACCOUNT_TYPE_KEY]) { + return {}; + } + return {[CONST.CORPAY_FIELDS.ACCOUNT_TYPE_KEY]: translate('common.error.pleaseSelectOne')}; + }; - const options = useMemo( - () => - (fieldData.valueSet ?? []).map((item) => { - return { - value: item.id, - label: item.text, - }; - }), - [fieldData.valueSet], - ); + const options = (fieldData.valueSet ?? []).map((item) => { + return { + value: item.id, + label: item.text, + }; + }); return ( { - if (formValues.bankCurrency === value) { - return; - } - fetchCorpayFields(formValues.bankCountry, value); - onMove(CONST.CORPAY_FIELDS.INDEXES.MAPPING.BANK_ACCOUNT_DETAILS, false); - }, - [formValues.bankCountry, formValues.bankCurrency, onMove], - ); + const onCurrencySelected = (value: string) => { + if (formValues.bankCurrency === value) { + return; + } + fetchCorpayFields(formValues.bankCountry, value); + onMove(CONST.CORPAY_FIELDS.INDEXES.MAPPING.BANK_ACCOUNT_DETAILS, false); + }; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - return getValidationErrors(values, fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_DETAILS], translate); - }, - [fieldsMap, translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => { + return getValidationErrors(values, fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_DETAILS], translate); + }; const icons = useMemoizedLazyExpensifyIcons(['QuestionMark']); const currencyHeaderContent = ( diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/BankInformation.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/BankInformation.tsx index 8f97bbf1a787..62847bb97e58 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/BankInformation.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/BankInformation.tsx @@ -21,7 +21,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {CorpayFormField} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; function getInputComponent(field: CorpayFormField) { @@ -54,28 +54,22 @@ function BankInformation({isEditing, onNext, formValues, fieldsMap}: CustomSubPa shouldSaveDraft: true, }); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - return getValidationErrors(values, fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.BANK_INFORMATION], translate); - }, - [fieldsMap, translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => { + return getValidationErrors(values, fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.BANK_INFORMATION], translate); + }; - const getStyle = useCallback( - (field: CorpayFormField, index: number) => { - if ((field.valueSet ?? []).length > 0) { - return [styles.mhn5, index === 0 ? styles.pb1 : styles.pv1]; - } - if (CONST.CORPAY_FIELDS.SPECIAL_LIST_REGION_KEYS.includes(field.id)) { - return [styles.mhn5, index === 0 ? styles.pb1 : styles.pv1]; - } - if (CONST.CORPAY_FIELDS.SPECIAL_LIST_ADDRESS_KEYS.includes(field.id)) { - return [index === 0 ? styles.pb2 : styles.pv2]; - } + const getStyle = (field: CorpayFormField, index: number) => { + if ((field.valueSet ?? []).length > 0) { + return [styles.mhn5, index === 0 ? styles.pb1 : styles.pv1]; + } + if (CONST.CORPAY_FIELDS.SPECIAL_LIST_REGION_KEYS.includes(field.id)) { + return [styles.mhn5, index === 0 ? styles.pb1 : styles.pv1]; + } + if (CONST.CORPAY_FIELDS.SPECIAL_LIST_ADDRESS_KEYS.includes(field.id)) { return [index === 0 ? styles.pb2 : styles.pv2]; - }, - [styles.mhn5, styles.pb1, styles.pb2, styles.pv1, styles.pv2], - ); + } + return [index === 0 ? styles.pb2 : styles.pv2]; + }; return ( ): FormInputErrors => { - const errors: FormInputErrors = {}; - if (!values.acceptTerms) { - errors.acceptTerms = translate('common.error.acceptTerms'); - } - return errors; - }, - [translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + if (!values.acceptTerms) { + errors.acceptTerms = translate('common.error.acceptTerms'); + } + return errors; + }; const errorMessage = getLatestErrorMessage(reimbursementAccount); diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/CountrySelection.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/CountrySelection.tsx index e1412fd7db5a..a82a4b27d757 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/CountrySelection.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/CountrySelection.tsx @@ -14,13 +14,13 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {isUserValidatedSelector} from '@selectors/Account'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; function CountrySelection({isEditing, onNext, onMove, formValues, fieldsMap}: CustomSubPageProps) { const [isUserValidated] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isUserValidatedSelector}); const [selectedCountry, setSelectedCountry] = useState(formValues.bankCountry || ''); - const onCountrySelected = useCallback(() => { + const onCountrySelected = () => { if (COUNTRIES_US_BANK_FLOW.includes(selectedCountry)) { if (isUserValidated) { Navigation.navigate(ROUTES.SETTINGS_ADD_US_BANK_ACCOUNT_ENTRY_POINT); @@ -35,9 +35,9 @@ function CountrySelection({isEditing, onNext, onMove, formValues, fieldsMap}: Cu } fetchCorpayFields(selectedCountry); onMove(CONST.CORPAY_FIELDS.INDEXES.MAPPING.BANK_ACCOUNT_DETAILS, false); - }, [fieldsMap, formValues.bankCountry, onMove, isUserValidated, onNext, selectedCountry]); + }; - const countries = useMemo(() => Object.keys(CONST.ALL_COUNTRIES).filter((countryISO) => !CONST.CORPAY_FIELDS.EXCLUDED_COUNTRIES.includes(countryISO)), []); + const countries = Object.keys(CONST.ALL_COUNTRIES).filter((countryISO) => !CONST.CORPAY_FIELDS.EXCLUDED_COUNTRIES.includes(countryISO)); return ( { - if (isNeedingAction && shouldShowDefaultBadge) { - return ( - - ); - } - if (item.isCardFrozen) { - return ( - - ); - } - if (item.connectionStatus) { - return ( - - ); - } - if (item.isInactive) { - return ( - - ); - } - return undefined; - }, [isNeedingAction, shouldShowDefaultBadge, item.connectionStatus, item.isCardFrozen, item.isInactive, icons.FreezeCard, styles.ml0, styles.mr1, translate]); + let descriptionAddon; + if (isNeedingAction && shouldShowDefaultBadge) { + descriptionAddon = ( + + ); + } else if (item.isCardFrozen) { + descriptionAddon = ( + + ); + } else if (item.connectionStatus) { + descriptionAddon = ( + + ); + } else if (item.isInactive) { + descriptionAddon = ( + + ); + } else { + descriptionAddon = undefined; + } const rightComponent = showThreeDotsMenu ? ( diff --git a/src/pages/settings/Wallet/PersonalCardEditNamePage.tsx b/src/pages/settings/Wallet/PersonalCardEditNamePage.tsx index 1be476bc189f..7e340353c632 100644 --- a/src/pages/settings/Wallet/PersonalCardEditNamePage.tsx +++ b/src/pages/settings/Wallet/PersonalCardEditNamePage.tsx @@ -32,14 +32,14 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {OnyxEntry} from 'react-native-onyx'; import {cardByIdSelector} from '@selectors/Card'; -import React, {useCallback} from 'react'; +import React from 'react'; type PersonalCardEditNamePageProps = PlatformStackScreenProps; function PersonalCardEditNamePage({route}: PersonalCardEditNamePageProps) { const {cardID} = route.params; const [customCardNames, customCardNamesMetadata] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES); - const cardSelector = useCallback((cardList: OnyxEntry) => cardByIdSelector(cardID)(cardList), [cardID]); + const cardSelector = (cardList: OnyxEntry) => cardByIdSelector(cardID)(cardList); const [card] = useOnyx(ONYXKEYS.CARD_LIST, {selector: cardSelector}); const [cardholder] = usePersonalDetail(card?.accountID); const isCSVImportedPersonalCard = !!card && (card.bank === CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD || card.bank.includes(CONST.COMPANY_CARD.FEED_BANK_NAME.CSV)); diff --git a/src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx b/src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx index 9e28866d9db1..37c780259aea 100644 --- a/src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx +++ b/src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx @@ -29,7 +29,7 @@ import type {ValueOf} from 'type-fest'; import {cardByIdSelector} from '@selectors/Card'; import {format, parseISO, subDays} from 'date-fns'; -import React, {useCallback, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; type DateOption = ValueOf; @@ -41,7 +41,7 @@ function PersonalCardEditTransactionStartDatePage({route}: PersonalCardEditTrans const {translate} = useLocalize(); const styles = useThemeStyles(); - const cardSelector = useCallback((cardList: OnyxEntry) => cardByIdSelector(cardID)(cardList), [cardID]); + const cardSelector = (cardList: OnyxEntry) => cardByIdSelector(cardID)(cardList); const [card] = useOnyx(ONYXKEYS.CARD_LIST, {selector: cardSelector}); const currentStartDate = card?.scrapeMinDate; diff --git a/src/pages/settings/Wallet/PersonalCards/upgrade/UpgradeConfirmation.tsx b/src/pages/settings/Wallet/PersonalCards/upgrade/UpgradeConfirmation.tsx index 36f52ab0a4a3..fba23ddd0125 100644 --- a/src/pages/settings/Wallet/PersonalCards/upgrade/UpgradeConfirmation.tsx +++ b/src/pages/settings/Wallet/PersonalCards/upgrade/UpgradeConfirmation.tsx @@ -10,7 +10,7 @@ import Navigation from '@libs/Navigation/Navigation'; import ROUTES from '@src/ROUTES'; -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; function UpgradeConfirmation({addCompanyCard, addPersonalCard}: {addCompanyCard: () => void; addPersonalCard: () => void}) { @@ -19,10 +19,10 @@ function UpgradeConfirmation({addCompanyCard, addPersonalCard}: {addCompanyCard: const {environmentURL} = useEnvironment(); const [subscriptionLink, setSubscriptionLink] = useState(''); - const updateSubscriptionLink = useCallback(() => { + const updateSubscriptionLink = () => { const backTo = Navigation.getActiveRoute(); setSubscriptionLink(`${environmentURL}/${ROUTES.SETTINGS_SUBSCRIPTION.getRoute(backTo)}`); - }, [environmentURL]); + }; useEffect(() => { Navigation.isNavigationReady().then(() => updateSubscriptionLink()); diff --git a/src/pages/settings/Wallet/ReportVirtualCardFraudConfirmationPage.tsx b/src/pages/settings/Wallet/ReportVirtualCardFraudConfirmationPage.tsx index c22cba9868f3..da524769f796 100644 --- a/src/pages/settings/Wallet/ReportVirtualCardFraudConfirmationPage.tsx +++ b/src/pages/settings/Wallet/ReportVirtualCardFraudConfirmationPage.tsx @@ -19,7 +19,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; type ReportVirtualCardFraudConfirmationPageProps = PlatformStackScreenProps; @@ -36,14 +36,14 @@ function ReportVirtualCardFraudConfirmationPage({ const isCardTerminatedWithoutReplacement = !!physicalCardForm?.cardTerminatedWithoutReplacement; const description = isCardTerminatedWithoutReplacement ? 'reportFraudConfirmationPage.descriptionCardNotReplaced' : 'reportFraudConfirmationPage.description'; - const close = useCallback(() => { + const close = () => { if (isCardTerminatedWithoutReplacement) { Navigation.goBack(ROUTES.SETTINGS_WALLET); return; } Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAIN_CARD.getRoute(cardID)); - }, [cardID, isCardTerminatedWithoutReplacement]); + }; return ( { + const handleSubmit = () => { Navigation.navigate(ROUTES.SETTINGS_REPORT_FRAUD_VERIFY_ACCOUNT.getRoute(String(cardID))); - }, [cardID]); + }; if (isEmptyObject(virtualCard) && !formData?.cardID) { return ; diff --git a/src/pages/settings/Wallet/TransactionsImportedPage.tsx b/src/pages/settings/Wallet/TransactionsImportedPage.tsx index 83dca0e7f377..2ca8afd4b82f 100644 --- a/src/pages/settings/Wallet/TransactionsImportedPage.tsx +++ b/src/pages/settings/Wallet/TransactionsImportedPage.tsx @@ -29,7 +29,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {cardByIdSelector} from '@selectors/Card'; import {accountIDSelector} from '@selectors/Session'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; type TransactionsImportedPageProps = PlatformStackScreenProps; @@ -39,7 +39,7 @@ function TransactionsImportedPage({route}: TransactionsImportedPageProps) { const [spreadsheet, spreadsheetMetadata] = useOnyx(ONYXKEYS.IMPORTED_SPREADSHEET); const [savedColumnLayouts] = useOnyx(ONYXKEYS.NVP_SAVED_CSV_COLUMN_LAYOUT_LIST); const [accountID = CONST.DEFAULT_NUMBER_ID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); - const cardSelector = useCallback((cardList: OnyxEntry) => (existingCardID ? cardByIdSelector(String(existingCardID))(cardList) : undefined), [existingCardID]); + const cardSelector = (cardList: OnyxEntry) => (existingCardID ? cardByIdSelector(String(existingCardID))(cardList) : undefined); const [existingCard] = useOnyx(ONYXKEYS.CARD_LIST, {selector: cardSelector}); const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES); const [isImporting, setIsImporting] = useState(false); @@ -76,21 +76,18 @@ function TransactionsImportedPage({route}: TransactionsImportedPageProps) { applySavedColumnMappings(spreadsheet.data, savedLayout); }, [existingCardID, spreadsheet?.data, savedColumnLayouts]); - const columnRoles: ColumnRole[] = useMemo( - () => [ - {text: translate('common.ignore'), value: CONST.CSV_IMPORT_COLUMNS.IGNORE}, - {text: translate('common.date'), value: CONST.CSV_IMPORT_COLUMNS.DATE, isRequired: true}, - {text: translate('common.merchant'), value: CONST.CSV_IMPORT_COLUMNS.MERCHANT, isRequired: true}, - {text: translate('common.category'), value: CONST.CSV_IMPORT_COLUMNS.CATEGORY}, - {text: translate('common.tag'), value: CONST.CSV_IMPORT_COLUMNS.TAG}, - {text: translate('iou.amount'), value: CONST.CSV_IMPORT_COLUMNS.AMOUNT, isRequired: true}, - ], - [translate], - ); + const columnRoles: ColumnRole[] = [ + {text: translate('common.ignore'), value: CONST.CSV_IMPORT_COLUMNS.IGNORE}, + {text: translate('common.date'), value: CONST.CSV_IMPORT_COLUMNS.DATE, isRequired: true}, + {text: translate('common.merchant'), value: CONST.CSV_IMPORT_COLUMNS.MERCHANT, isRequired: true}, + {text: translate('common.category'), value: CONST.CSV_IMPORT_COLUMNS.CATEGORY}, + {text: translate('common.tag'), value: CONST.CSV_IMPORT_COLUMNS.TAG}, + {text: translate('iou.amount'), value: CONST.CSV_IMPORT_COLUMNS.AMOUNT, isRequired: true}, + ]; - const requiredColumns = useMemo(() => columnRoles.filter((role) => role.isRequired), [columnRoles]); + const requiredColumns = columnRoles.filter((role) => role.isRequired); - const validate = useCallback(() => { + const validate = () => { const columns = Object.values(spreadsheet?.columns ?? {}); let errors: Errors = {}; @@ -110,7 +107,7 @@ function TransactionsImportedPage({route}: TransactionsImportedPageProps) { } } return errors; - }, [spreadsheet, requiredColumns, translate, columnRoles]); + }; const closeImportPageAndModal = () => { setIsClosing(true); diff --git a/src/pages/settings/Wallet/TravelCVVPage/TravelCVVVerifyAccountPage.tsx b/src/pages/settings/Wallet/TravelCVVPage/TravelCVVVerifyAccountPage.tsx index 1b3f744b9476..76fb31fdad47 100644 --- a/src/pages/settings/Wallet/TravelCVVPage/TravelCVVVerifyAccountPage.tsx +++ b/src/pages/settings/Wallet/TravelCVVPage/TravelCVVVerifyAccountPage.tsx @@ -15,7 +15,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {CONST} from 'expensify-common'; -import React, {useCallback} from 'react'; +import React from 'react'; import {useTravelCVVActions, useTravelCVVState} from './TravelCVVContextProvider'; @@ -33,9 +33,9 @@ function TravelCVVVerifyAccountPage() { const primaryLogin = usePrimaryContactMethod(); const travelCard = getTravelBillingCard(cardList); - const navigateBack = useCallback(() => { + const navigateBack = () => { Navigation.goBack(ROUTES.SETTINGS_WALLET_TRAVEL_CVV); - }, []); + }; const handleRevealCardDetails = (validateCode: string) => { if (!travelCard?.cardID) { diff --git a/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx b/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx index 64240a933773..b76c4e4c2a5a 100644 --- a/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx +++ b/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx @@ -32,7 +32,7 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; type ShareBankAccountProps = PlatformStackScreenProps; @@ -77,7 +77,7 @@ function UnshareBankAccount({route}: ShareBankAccountProps) { } }, [totalAdmins, shouldShowSuccess]); - const showUnshareErrorModal = useCallback(() => { + const showUnshareErrorModal = () => { if (isErrorModalShownRef.current) { return; } @@ -107,7 +107,7 @@ function UnshareBankAccount({route}: ShareBankAccountProps) { isErrorModalShownRef.current = false; clearUnshareBankAccountErrors(Number(bankAccountID)); }); - }, [bankAccountID, isFocused, showConfirmModal, closeModal, styles.flexRow, styles.renderHTML, translate]); + }; useEffect(() => { if (!isExpensifyCardError) { diff --git a/src/pages/settings/Wallet/WalletPage/index.tsx b/src/pages/settings/Wallet/WalletPage/index.tsx index c10e0c633542..9586c9decb38 100644 --- a/src/pages/settings/Wallet/WalletPage/index.tsx +++ b/src/pages/settings/Wallet/WalletPage/index.tsx @@ -72,7 +72,7 @@ import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import {isCollectingDepositAccountsSelector} from '@selectors/Policy'; import debounce from 'lodash/debounce'; import isEmpty from 'lodash/isEmpty'; -import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useContext, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {CardPressHandlerParams, PaymentMethodPressHandlerParams} from './types'; @@ -171,13 +171,13 @@ function WalletPage() { const hasEligibleShareRecipient = hasEligibleBankAccountShareRecipient(allPolicies, currentUserLogin, paymentMethod?.selectedPaymentMethod?.bankAccountID?.toString()); const paidGroupPolicy = Object.values(allPolicies ?? {}).find(isPaidGroupPolicy); - const updateShouldShowLoadingSpinner = useCallback(() => { + const updateShouldShowLoadingSpinner = () => { // In order to prevent a loop, only update state of the spinner if there is a change const showLoadingSpinner = isLoadingPaymentMethods ?? false; if (showLoadingSpinner !== shouldShowLoadingSpinner) { setShouldShowLoadingSpinner(showLoadingSpinner && !network.isOffline); } - }, [isLoadingPaymentMethods, network.isOffline, shouldShowLoadingSpinner]); + }; const debounceSetShouldShowLoadingSpinner = debounce(updateShouldShowLoadingSpinner, CONST.TIMING.SHOW_LOADING_SPINNER_DEBOUNCE_TIME); @@ -318,7 +318,7 @@ function WalletPage() { openPersonalBankAccountSetupView({}); }; - const makeDefaultPaymentMethod = useCallback(() => { + const makeDefaultPaymentMethod = () => { const paymentCardList = fundList ?? {}; // Find the previous default payment method so we can revert if the MakeDefaultPaymentMethod command errors const paymentMethods = formatPaymentMethods(bankAccountList ?? {}, paymentCardList, styles, translate); @@ -339,22 +339,9 @@ function WalletPage() { makeDefaultPaymentMethodPaymentMethods(0, paymentMethod.selectedPaymentMethod.fundID ?? CONST.DEFAULT_NUMBER_ID, previousPaymentMethod, currentPaymentMethod); } resetSelectedPaymentMethodData(); - }, [ - fundList, - bankAccountList, - styles, - translate, - paymentMethod.selectedPaymentMethodType, - paymentMethod.methodID, - paymentMethod.selectedPaymentMethod.bankAccountID, - paymentMethod.selectedPaymentMethod.fundID, - resetSelectedPaymentMethodData, - allPolicies, - policyMemberBankAccounts, - currentUserAccountID, - ]); + }; - const deletePaymentMethod = useCallback(() => { + const deletePaymentMethod = () => { const bankAccountID = paymentMethod.selectedPaymentMethod.bankAccountID; const fundID = paymentMethod.selectedPaymentMethod.fundID; let newBankAccountID: number | undefined; @@ -400,20 +387,7 @@ function WalletPage() { deletePaymentCard(fundID); } resetSelectedPaymentMethodData(); - }, [ - paymentMethod.selectedPaymentMethod.bankAccountID, - paymentMethod.selectedPaymentMethod.fundID, - paymentMethod.isSelectedPaymentMethodDefault, - paymentMethod.selectedPaymentMethodType, - paymentMethod.methodID, - resetSelectedPaymentMethodData, - fundList, - bankAccountList, - styles, - translate, - lastUsedPaymentMethods, - personalPolicyID, - ]); + }; /** * Navigate to the appropriate page after completing the KYC flow, depending on what initiated it @@ -425,7 +399,7 @@ function WalletPage() { /** * Show confirmation modal for deleting a personal card and delete it if confirmed */ - const confirmDeleteCard = useCallback(async () => { + const confirmDeleteCard = async () => { if (!selectedCard?.cardID) { return; } @@ -444,7 +418,7 @@ function WalletPage() { deletePersonalCard({cardID: selectedCard.cardID, card: selectedCard, allTransactions, allReports, savedColumnLayout}); } setSelectedCard(undefined); - }, [selectedCard, showConfirmModal, translate, allTransactions, allReports, savedColumnLayouts]); + }; useEffect(() => { // If the user was previously offline, skip debouncing showing the loader @@ -500,22 +474,19 @@ function WalletPage() { /> ); - const bottomMountItem = useMemo( - () => ({ - text: paymentMethod.formattedSelectedPaymentMethod.title, - icon: paymentMethod.formattedSelectedPaymentMethod.icon?.icon, - iconHeight: paymentMethod.formattedSelectedPaymentMethod.icon?.iconHeight ?? paymentMethod.formattedSelectedPaymentMethod.icon?.iconSize, - iconWidth: paymentMethod.formattedSelectedPaymentMethod.icon?.iconWidth ?? paymentMethod.formattedSelectedPaymentMethod.icon?.iconSize, - iconStyles: paymentMethod.formattedSelectedPaymentMethod.icon?.iconStyles, - description: paymentMethod.formattedSelectedPaymentMethod.description, - wrapperStyle: [styles.mb4, styles.ph5, styles.pt3], - interactive: false, - displayInDefaultIconColor: true, - }), - [paymentMethod.formattedSelectedPaymentMethod, styles.mb4, styles.ph5, styles.pt3], - ); + const bottomMountItem = { + text: paymentMethod.formattedSelectedPaymentMethod.title, + icon: paymentMethod.formattedSelectedPaymentMethod.icon?.icon, + iconHeight: paymentMethod.formattedSelectedPaymentMethod.icon?.iconHeight ?? paymentMethod.formattedSelectedPaymentMethod.icon?.iconSize, + iconWidth: paymentMethod.formattedSelectedPaymentMethod.icon?.iconWidth ?? paymentMethod.formattedSelectedPaymentMethod.icon?.iconSize, + iconStyles: paymentMethod.formattedSelectedPaymentMethod.icon?.iconStyles, + description: paymentMethod.formattedSelectedPaymentMethod.description, + wrapperStyle: [styles.mb4, styles.ph5, styles.pt3], + interactive: false, + displayInDefaultIconColor: true, + }; - const showDeleteAccountModal = useCallback(async () => { + const showDeleteAccountModal = async () => { const result = await showConfirmModal({ title: translate('walletPage.deleteAccount'), prompt: translate('walletPage.deleteConfirmation'), @@ -529,138 +500,113 @@ function WalletPage() { return; } deletePaymentMethod(); - }, [showConfirmModal, translate, resetSelectedPaymentMethodData, deletePaymentMethod]); - - const threeDotMenuItems = useMemo( - () => [ - ...(shouldUseNarrowLayout ? [bottomMountItem] : []), - ...(shouldShowMakeDefaultButton - ? [ - { - text: translate('walletPage.setDefaultConfirmation'), - icon: icons.Star, - onSelected: () => { - if (isAccountLocked) { - closeModal(() => showLockedAccountModal()); - return; - } - makeDefaultPaymentMethod(); - }, - numberOfLinesTitle: 0, + }; + + const threeDotMenuItems = [ + ...(shouldUseNarrowLayout ? [bottomMountItem] : []), + ...(shouldShowMakeDefaultButton + ? [ + { + text: translate('walletPage.setDefaultConfirmation'), + icon: icons.Star, + onSelected: () => { + if (isAccountLocked) { + closeModal(() => showLockedAccountModal()); + return; + } + makeDefaultPaymentMethod(); }, - ] - : []), - ...(shouldShowEnableGlobalReimbursementsButton - ? [ - { - text: translate('common.enableGlobalReimbursements'), - icon: icons.Globe, - onSelected: () => { - if (isAccountLocked) { - closeModal(() => showLockedAccountModal()); - return; - } - if (isWiseGlobalReimbursementsEnabled) { - closeModal(() => - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WISE_GLOBAL_REIMBURSEMENTS_INTRO.getRoute(paymentMethod.selectedPaymentMethod.bankAccountID))), - ); - return; - } + numberOfLinesTitle: 0, + }, + ] + : []), + ...(shouldShowEnableGlobalReimbursementsButton + ? [ + { + text: translate('common.enableGlobalReimbursements'), + icon: icons.Globe, + onSelected: () => { + if (isAccountLocked) { + closeModal(() => showLockedAccountModal()); + return; + } + if (isWiseGlobalReimbursementsEnabled) { closeModal(() => - Navigation.navigate( - ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.getRoute( - paymentMethod.selectedPaymentMethod.bankAccountID, - CONST.ENABLE_GLOBAL_REIMBURSEMENTS.PAGE_NAME.BUSINESS_INFO.REGISTRATION_NUMBER, - ), - ), + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WISE_GLOBAL_REIMBURSEMENTS_INTRO.getRoute(paymentMethod.selectedPaymentMethod.bankAccountID))), ); - }, + return; + } + closeModal(() => + Navigation.navigate( + ROUTES.SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS.getRoute( + paymentMethod.selectedPaymentMethod.bankAccountID, + CONST.ENABLE_GLOBAL_REIMBURSEMENTS.PAGE_NAME.BUSINESS_INFO.REGISTRATION_NUMBER, + ), + ), + ); }, - ] - : []), - ...(shouldShowEditNicknameButton - ? [ - { - text: translate('walletPage.editNickname'), - icon: icons.Pencil, - onSelected: () => { - if (isAccountLocked) { - closeModal(() => showLockedAccountModal()); - return; - } - closeModal(() => Navigation.navigate(ROUTES.SETTINGS_WALLET_EDIT_BANK_ACCOUNT_NICKNAME.getRoute(paymentMethod.selectedPaymentMethod.bankAccountID))); - }, + }, + ] + : []), + ...(shouldShowEditNicknameButton + ? [ + { + text: translate('walletPage.editNickname'), + icon: icons.Pencil, + onSelected: () => { + if (isAccountLocked) { + closeModal(() => showLockedAccountModal()); + return; + } + closeModal(() => Navigation.navigate(ROUTES.SETTINGS_WALLET_EDIT_BANK_ACCOUNT_NICKNAME.getRoute(paymentMethod.selectedPaymentMethod.bankAccountID))); }, - ] - : []), - ...(shouldShowShareButton && hasEligibleShareRecipient - ? [ - { - text: translate('common.share'), - icon: icons.UserPlus, - onSelected: () => { - if (isAccountLocked) { - closeModal(() => showLockedAccountModal()); - return; - } - closeModal(() => Navigation.navigate(ROUTES.SETTINGS_WALLET_SHARE_BANK_ACCOUNT.getRoute(paymentMethod.selectedPaymentMethod.bankAccountID))); - }, + }, + ] + : []), + ...(shouldShowShareButton && hasEligibleShareRecipient + ? [ + { + text: translate('common.share'), + icon: icons.UserPlus, + onSelected: () => { + if (isAccountLocked) { + closeModal(() => showLockedAccountModal()); + return; + } + closeModal(() => Navigation.navigate(ROUTES.SETTINGS_WALLET_SHARE_BANK_ACCOUNT.getRoute(paymentMethod.selectedPaymentMethod.bankAccountID))); }, - ] - : []), - ...(shouldShowUnshareButton - ? [ - { - text: translate('common.unshare'), - icon: icons.UserMinus, - onSelected: () => { - if (isAccountLocked) { - closeModal(() => showLockedAccountModal()); - return; - } - closeModal(() => Navigation.navigate(ROUTES.SETTINGS_WALLET_UNSHARE_BANK_ACCOUNT.getRoute(paymentMethod.selectedPaymentMethod.bankAccountID))); - }, + }, + ] + : []), + ...(shouldShowUnshareButton + ? [ + { + text: translate('common.unshare'), + icon: icons.UserMinus, + onSelected: () => { + if (isAccountLocked) { + closeModal(() => showLockedAccountModal()); + return; + } + closeModal(() => Navigation.navigate(ROUTES.SETTINGS_WALLET_UNSHARE_BANK_ACCOUNT.getRoute(paymentMethod.selectedPaymentMethod.bankAccountID))); }, - ] - : []), - { - text: translate('common.delete'), - icon: icons.Trashcan, - onSelected: () => { - if (isAccountLocked) { - closeModal(() => showLockedAccountModal()); - return; - } - closeModal(() => { - showDeleteAccountModal(); - }); - }, + }, + ] + : []), + { + text: translate('common.delete'), + icon: icons.Trashcan, + onSelected: () => { + if (isAccountLocked) { + closeModal(() => showLockedAccountModal()); + return; + } + closeModal(() => { + showDeleteAccountModal(); + }); }, - ], - [ - shouldUseNarrowLayout, - bottomMountItem, - shouldShowMakeDefaultButton, - translate, - icons.Star, - icons.UserPlus, - icons.UserMinus, - icons.Trashcan, - icons.Globe, - icons.Pencil, - shouldShowEditNicknameButton, - shouldShowShareButton, - hasEligibleShareRecipient, - shouldShowUnshareButton, - shouldShowEnableGlobalReimbursementsButton, - isWiseGlobalReimbursementsEnabled, - isAccountLocked, - makeDefaultPaymentMethod, - showLockedAccountModal, - paymentMethod.selectedPaymentMethod.bankAccountID, - showDeleteAccountModal, - ], - ); + }, + ]; const onAddPersonalCardPress = () => { if (!paidGroupPolicy && Object.keys(personalCardList).length >= 2) { @@ -705,55 +651,53 @@ function WalletPage() { Navigation.navigate(ROUTES.WORKSPACES_LIST.route); }; - const cardThreeDotsMenuItems = useMemo(() => { - const shouldShowCSVImportItems = selectedCard?.bank === CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD; - return [ - ...(shouldUseNarrowLayout ? [bottomMountItem] : []), - { - text: translate('workspace.common.viewTransactions'), - icon: icons.MoneySearch, - onSelected: () => { - closeModal(() => { - Navigation.navigate( - ROUTES.SEARCH_ROOT.getRoute({ - query: buildCannedSearchQuery({ - type: CONST.SEARCH.DATA_TYPES.EXPENSE, - cardID: String(paymentMethod.methodID), - }), - searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + const shouldShowCSVImportItems = selectedCard?.bank === CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD; + const cardThreeDotsMenuItems = [ + ...(shouldUseNarrowLayout ? [bottomMountItem] : []), + { + text: translate('workspace.common.viewTransactions'), + icon: icons.MoneySearch, + onSelected: () => { + closeModal(() => { + Navigation.navigate( + ROUTES.SEARCH_ROOT.getRoute({ + query: buildCannedSearchQuery({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + cardID: String(paymentMethod.methodID), }), - ); - }); - }, + searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + }), + ); + }); }, - ...(shouldShowCSVImportItems - ? [ - { - text: translate('spreadsheet.importSpreadsheet'), - icon: icons.Table, - onSelected: () => { - closeModal(() => { - Navigation.navigate(ROUTES.SETTINGS_WALLET_IMPORT_TRANSACTIONS_SPREADSHEET.getRoute(Number(paymentMethod.methodID))); - }); - }, + }, + ...(shouldShowCSVImportItems + ? [ + { + text: translate('spreadsheet.importSpreadsheet'), + icon: icons.Table, + onSelected: () => { + closeModal(() => { + Navigation.navigate(ROUTES.SETTINGS_WALLET_IMPORT_TRANSACTIONS_SPREADSHEET.getRoute(Number(paymentMethod.methodID))); + }); }, - ] - : []), - ...(shouldShowCSVImportItems - ? [ - { - text: translate('common.delete'), - icon: icons.Trashcan, - onSelected: () => { - closeModal(() => { - confirmDeleteCard(); - }); - }, + }, + ] + : []), + ...(shouldShowCSVImportItems + ? [ + { + text: translate('common.delete'), + icon: icons.Trashcan, + onSelected: () => { + closeModal(() => { + confirmDeleteCard(); + }); }, - ] - : []), - ]; - }, [bottomMountItem, confirmDeleteCard, icons.MoneySearch, icons.Table, icons.Trashcan, paymentMethod.methodID, selectedCard?.bank, shouldUseNarrowLayout, translate]); + }, + ] + : []), + ]; if (isAppLoadPending) { return ( diff --git a/src/pages/signin/LoginForm/BaseLoginForm.tsx b/src/pages/signin/LoginForm/BaseLoginForm.tsx index b091d5a5ff9a..0f7cf30d281f 100644 --- a/src/pages/signin/LoginForm/BaseLoginForm.tsx +++ b/src/pages/signin/LoginForm/BaseLoginForm.tsx @@ -43,7 +43,7 @@ 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 React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; import {View} from 'react-native'; import type LoginFormProps from './types'; @@ -75,53 +75,47 @@ function BaseLoginForm({submitBehavior = 'submit', isVisible, ref}: BaseLoginFor /** * Validate the input value and set the error for formError */ - const validate = useCallback( - (value: string) => { - const loginTrim = StringUtils.removeInvisibleCharacters(value.trim()); - if (!loginTrim) { - setFormError('common.pleaseEnterEmailOrPhoneNumber'); - return false; - } + const validate = (value: string) => { + const loginTrim = StringUtils.removeInvisibleCharacters(value.trim()); + if (!loginTrim) { + setFormError('common.pleaseEnterEmailOrPhoneNumber'); + return false; + } - const phoneLogin = appendCountryCode(getPhoneNumberWithoutSpecialChars(loginTrim), countryCode); - const parsedPhoneNumber = parsePhoneNumber(phoneLogin); + const phoneLogin = appendCountryCode(getPhoneNumberWithoutSpecialChars(loginTrim), countryCode); + const parsedPhoneNumber = parsePhoneNumber(phoneLogin); - if (!isValidEmailWithTLD(loginTrim) && !parsedPhoneNumber.possible) { - if (isNumericWithSpecialChars(loginTrim)) { - setFormError('common.error.phoneNumber'); - } else { - setFormError('loginForm.error.invalidFormatEmailLogin'); - } - return false; + if (!isValidEmailWithTLD(loginTrim) && !parsedPhoneNumber.possible) { + if (isNumericWithSpecialChars(loginTrim)) { + setFormError('common.error.phoneNumber'); + } else { + setFormError('loginForm.error.invalidFormatEmailLogin'); } + return false; + } - setFormError(undefined); - return true; - }, - [setFormError, countryCode], - ); + setFormError(undefined); + return true; + }; /** * Handle text input and validate the text input if it is blurred */ - const onTextInput = useCallback( - (text: string) => { - setLogin(text); - if (firstBlurred.current) { - validate(text); - } + const onTextInput = (text: string) => { + setLogin(text); + if (firstBlurred.current) { + validate(text); + } - if (!!account?.errors || !!account?.message) { - clearAccountMessages(); - } + if (!!account?.errors || !!account?.message) { + clearAccountMessages(); + } - // Clear the "Account successfully closed" message when the user starts typing - if (closeAccount?.success && !isInputAutoFilled(input.current)) { - setDefaultData(); - } - }, - [account?.errors, account?.message, closeAccount?.success, input, setLogin, validate], - ); + // Clear the "Account successfully closed" message when the user starts typing + if (closeAccount?.success && !isInputAutoFilled(input.current)) { + setDefaultData(); + } + }; function getSignInWithStyles() { return shouldUseNarrowLayout ? [styles.mt1] : [styles.mt5, styles.mb5]; @@ -130,7 +124,7 @@ function BaseLoginForm({submitBehavior = 'submit', isVisible, ref}: BaseLoginFor /** * Check that all the form fields are valid, then trigger the submit callback */ - const validateAndSubmitForm = useCallback(() => { + const validateAndSubmitForm = () => { if (!!isOffline || !!account?.isLoading || isLoading.current) { return; } @@ -165,7 +159,7 @@ function BaseLoginForm({submitBehavior = 'submit', isVisible, ref}: BaseLoginFor // Check if this login has an account associated with it or not beginSignIn(parsedPhoneNumber.possible && parsedPhoneNumber.number?.e164 ? parsedPhoneNumber.number.e164 : loginTrim); - }, [login, account?.isLoading, closeAccount?.success, isOffline, validate, countryCode]); + }; useEffect(() => { // Call clearAccountMessages on the login page (home route). @@ -230,13 +224,13 @@ function BaseLoginForm({submitBehavior = 'submit', isVisible, ref}: BaseLoginFor }, })); - const serverErrorText = useMemo(() => (account ? getLatestErrorMessage(account) : ''), [account]); + const serverErrorText = account ? getLatestErrorMessage(account) : ''; const shouldShowServerError = !!serverErrorText && !formError; const isSigningWithAppleOrGoogle = useRef(false); - const setIsSigningWithAppleOrGoogle = useCallback((isPressed: boolean) => (isSigningWithAppleOrGoogle.current = isPressed), []); + const setIsSigningWithAppleOrGoogle = (isPressed: boolean) => (isSigningWithAppleOrGoogle.current = isPressed); const submitContainerRef = useRef | HTMLDivElement>(null); - const handleFocus = useCallback(() => { + const handleFocus = () => { if (!isMobileWebKit()) { return; } @@ -247,7 +241,7 @@ function BaseLoginForm({submitBehavior = 'submit', isVisible, ref}: BaseLoginFor behavior: 'smooth', block: 'end', }); - }, []); + }; const handleSignIn = () => setIsSigningWithAppleOrGoogle(true); diff --git a/src/pages/signin/SAMLSignInPage/index.native.tsx b/src/pages/signin/SAMLSignInPage/index.native.tsx index d3c06c0ee093..13d2883e03c9 100644 --- a/src/pages/signin/SAMLSignInPage/index.native.tsx +++ b/src/pages/signin/SAMLSignInPage/index.native.tsx @@ -25,7 +25,7 @@ import type {AppStateStatus} from 'react-native'; import * as Sentry from '@sentry/react-native'; import {dismissAuthSession, openAuthSessionAsync} from 'expo-web-browser'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import handleSAMLLoginError from './handleSAMLLoginError'; @@ -90,25 +90,22 @@ function SAMLSignInPage() { return true; }; - const handleExitSAMLFlow = useCallback( - (reason: ExitSAMLFlowReason) => { - // Closing a stuck in-app browser settles its promise, which lands here a second time. - if (hasExitedSAMLFlow.current) { - return; - } - hasExitedSAMLFlow.current = true; - dismissOpenAuthSession('exit'); + const handleExitSAMLFlow = (reason: ExitSAMLFlowReason) => { + // Closing a stuck in-app browser settles its promise, which lands here a second time. + if (hasExitedSAMLFlow.current) { + return; + } + hasExitedSAMLFlow.current = true; + dismissOpenAuthSession('exit'); - // Clear the guard we set before opening the in-app browser so we don't block future reauthentication - setIsAuthenticatingWithShortLivedToken(false); - Navigation.isNavigationReady().then(() => { - addBreadcrumb('goBack', {reason, openForMs: msSince(authSessionOpenedAt.current), lastBackgroundMs: lastBackgroundMs.current}); - Navigation.goBack(); - clearSignInData(); - }); - }, - [dismissOpenAuthSession], - ); + // Clear the guard we set before opening the in-app browser so we don't block future reauthentication + setIsAuthenticatingWithShortLivedToken(false); + Navigation.isNavigationReady().then(() => { + addBreadcrumb('goBack', {reason, openForMs: msSince(authSessionOpenedAt.current), lastBackgroundMs: lastBackgroundMs.current}); + Navigation.goBack(); + clearSignInData(); + }); + }; useEffect( () => () => { @@ -128,53 +125,50 @@ function SAMLSignInPage() { /** * Handles in-app navigation once we get a response back from Expensify */ - const handleNavigationStateChange = useCallback( - (url: string) => { - // If we've gotten a callback then remove the option to navigate back to the sign-in page - if (url.includes('loginCallback')) { - shouldShowNavigation(false); - } + const handleNavigationStateChange = (url: string) => { + // If we've gotten a callback then remove the option to navigate back to the sign-in page + if (url.includes('loginCallback')) { + shouldShowNavigation(false); + } - const searchParams = new URLSearchParams(new URL(url).search); - const jsonParam = searchParams.get('json'); + const searchParams = new URLSearchParams(new URL(url).search); + const jsonParam = searchParams.get('json'); - let shortLivedAuthToken: string | null = null; - if (jsonParam) { - try { - const decodedData = JSON.parse(jsonParam) as Record; - shortLivedAuthToken = decodedData.shortLivedAuthToken ?? null; - if (decodedData.error) { - Log.hmmm('SAMLSignInPage - SAML login returned error', {error: decodedData.error}); - } - } catch (parseError) { - Log.hmmm('SAMLSignInPage - Failed to parse JSON parameter', {error: parseError}); + let shortLivedAuthToken: string | null = null; + if (jsonParam) { + try { + const decodedData = JSON.parse(jsonParam) as Record; + shortLivedAuthToken = decodedData.shortLivedAuthToken ?? null; + if (decodedData.error) { + Log.hmmm('SAMLSignInPage - SAML login returned error', {error: decodedData.error}); } - } else { - Log.hmmm('SAMLSignInPage - No JSON parameter found in callback URL'); + } catch (parseError) { + Log.hmmm('SAMLSignInPage - Failed to parse JSON parameter', {error: parseError}); } + } else { + Log.hmmm('SAMLSignInPage - No JSON parameter found in callback URL'); + } - // A forced re-auth leaves account.isLoading true until sign-in, so the token alone decides here. - if (credentials?.login && shortLivedAuthToken) { - Log.info('SAMLSignInPage - Successfully received shortLivedAuthToken. Signing in...'); - signInWithShortLivedAuthToken(shortLivedAuthToken, true, lastVisitedPath, credentials?.login); - return; - } + // A forced re-auth leaves account.isLoading true until sign-in, so the token alone decides here. + if (credentials?.login && shortLivedAuthToken) { + Log.info('SAMLSignInPage - Successfully received shortLivedAuthToken. Signing in...'); + signInWithShortLivedAuthToken(shortLivedAuthToken, true, lastVisitedPath, credentials?.login); + return; + } - // The browser returned but we couldn't sign in (no JSON parameter, a parse failure, or no token), so clear - // the guard we set before opening it and send the user back to a clean state. Otherwise the guard stays - // true, and since loginCallback URLs hide the back button and leave account.isLoading true, the user gets - // stuck on the loading screen with future reauthenticate() calls aborting. - setIsAuthenticatingWithShortLivedToken(false); - clearSignInData(); - setAccountError(translate('common.error.login')); - Navigation.isNavigationReady().then(() => { - // We must call goBack() to remove the /transition route from history - Navigation.goBack(); - Navigation.navigate(ROUTES.HOME); - }); - }, - [credentials?.login, lastVisitedPath, translate], - ); + // The browser returned but we couldn't sign in (no JSON parameter, a parse failure, or no token), so clear + // the guard we set before opening it and send the user back to a clean state. Otherwise the guard stays + // true, and since loginCallback URLs hide the back button and leave account.isLoading true, the user gets + // stuck on the loading screen with future reauthenticate() calls aborting. + setIsAuthenticatingWithShortLivedToken(false); + clearSignInData(); + setAccountError(translate('common.error.login')); + Navigation.isNavigationReady().then(() => { + // We must call goBack() to remove the /transition route from history + Navigation.goBack(); + Navigation.navigate(ROUTES.HOME); + }); + }; useEffect(() => { // Don't open auth session more than once. If user cancels it we should navigate back to ROUTES.HOME diff --git a/src/pages/signin/SignInModal.tsx b/src/pages/signin/SignInModal.tsx index 3b3e7e370099..0cb3844a4f4b 100644 --- a/src/pages/signin/SignInModal.tsx +++ b/src/pages/signin/SignInModal.tsx @@ -17,7 +17,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import React, {useEffect, useMemo, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import type {SignInPageRef} from './SignInPage'; @@ -33,7 +33,7 @@ function SignInModal() { // Use of SignInPageWrapped (with shouldEnableMaxHeight prop in SignInPageWrapper) is a workaround for Safari not supporting interactive-widget=resizes-content. // This allows better scrolling experience after keyboard shows for modals with input, that are larger than remaining screen height. // More info https://github.com/Expensify/App/pull/62799#issuecomment-2943136220. - const SignInPageBase = useMemo(() => (isMobileSafari() ? SignInPageWrapped : SignInPage), []); + const SignInPageBase = isMobileSafari() ? SignInPageWrapped : SignInPage; useEffect(() => { const isAnonymousUser = session?.authTokenType === CONST.AUTH_TOKEN_TYPES.ANONYMOUS; diff --git a/src/pages/signin/SignInPage.tsx b/src/pages/signin/SignInPage.tsx index 9e881b40eb4c..1c58b52196a4 100644 --- a/src/pages/signin/SignInPage.tsx +++ b/src/pages/signin/SignInPage.tsx @@ -32,7 +32,7 @@ import type {Ref} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {Str} from 'expensify-common'; -import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; import type {InputHandle} from './LoginForm/types'; import type {SignInPageLayoutRef} from './SignInPageLayout/types'; @@ -310,7 +310,7 @@ function SignInPage({ref, shouldResetTabTitle = true}: SignInPageProps) { // Read the ref at call time (via a stable callback) instead of during render, so both React // Compilers can memoize this component. LoginForm calls this optionally, so an always-defined // callback that no-ops until the layout ref is attached is equivalent to passing the raw method. - const scrollPageToTop = useCallback(() => signInPageLayoutRef.current?.scrollPageToTop(), []); + const scrollPageToTop = () => signInPageLayoutRef.current?.scrollPageToTop(); const navigateBack = () => { if ( diff --git a/src/pages/signin/SignInPageLayout/BackgroundImage/index.tsx b/src/pages/signin/SignInPageLayout/BackgroundImage/index.tsx index 9a0949b3a325..4978d04beed5 100644 --- a/src/pages/signin/SignInPageLayout/BackgroundImage/index.tsx +++ b/src/pages/signin/SignInPageLayout/BackgroundImage/index.tsx @@ -5,7 +5,7 @@ import TransitionTracker from '@libs/Navigation/TransitionTracker'; import CONST from '@src/CONST'; -import React, {lazy, Suspense, useEffect, useMemo, useState} from 'react'; +import React, {lazy, Suspense, useEffect, useState} from 'react'; import Animated, {FadeIn} from 'react-native-reanimated'; import type BackgroundImageProps from './types'; @@ -26,12 +26,7 @@ function BackgroundImage({width, isSmallScreen = false}: BackgroundImageProps) { const [isInteractionComplete, setIsInteractionComplete] = useState(false); const isAnonymous = isAnonymousUser(); - const BackgroundComponent = useMemo(() => { - if (isSmallScreen) { - return BackgroundMobile; - } - return BackgroundDesktop; - }, [isSmallScreen]); + const BackgroundComponent = isSmallScreen ? BackgroundMobile : BackgroundDesktop; useEffect(() => { if (!isAnonymous) { diff --git a/src/pages/signin/SignInPageLayout/SignInHeroImage.tsx b/src/pages/signin/SignInPageLayout/SignInHeroImage.tsx index 3256b745c1d7..d6e1e1c3a07b 100644 --- a/src/pages/signin/SignInPageLayout/SignInHeroImage.tsx +++ b/src/pages/signin/SignInPageLayout/SignInHeroImage.tsx @@ -11,26 +11,25 @@ import {isAnonymousUser} from '@libs/actions/Session'; import variables from '@styles/variables'; -import React, {useMemo} from 'react'; +import React from 'react'; function SignInHeroImage() { const styles = useThemeStyles(); const isReduceMotionEnabled = Accessibility.useReducedMotion(); const illustrations = useMemoizedLazyIllustrations(['Hands']); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); - const imageSize = useMemo(() => { - if (shouldUseNarrowLayout) { - return { - height: variables.signInHeroImageMobileHeight, - width: variables.signInHeroImageMobileWidth, - }; - } - - return { + let imageSize; + if (shouldUseNarrowLayout) { + imageSize = { + height: variables.signInHeroImageMobileHeight, + width: variables.signInHeroImageMobileWidth, + }; + } else { + imageSize = { height: isMediumScreenWidth ? variables.signInHeroImageTabletHeight : variables.signInHeroImageDesktopHeight, width: isMediumScreenWidth ? variables.signInHeroImageTabletWidth : variables.signInHeroImageDesktopWidth, }; - }, [shouldUseNarrowLayout, isMediumScreenWidth]); + } if (isReduceMotionEnabled) { return ( diff --git a/src/pages/signin/SignInPageLayout/index.tsx b/src/pages/signin/SignInPageLayout/index.tsx index 582c0a3a811f..e82ffbf637e2 100644 --- a/src/pages/signin/SignInPageLayout/index.tsx +++ b/src/pages/signin/SignInPageLayout/index.tsx @@ -24,7 +24,7 @@ import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView} from 'react-native'; -import React, {useEffect, useImperativeHandle, useMemo, useRef} from 'react'; +import React, {useEffect, useImperativeHandle, useRef} from 'react'; import {View} from 'react-native'; import type {SignInPageLayoutProps} from './types'; @@ -54,13 +54,10 @@ function SignInPageLayout({ const {windowHeight} = useWindowDimensions(); const {shouldUseNarrowLayout, isMediumScreenWidth, isLargeScreenWidth} = useResponsiveLayout(); - const {containerStyles, contentContainerStyles} = useMemo( - () => ({ - containerStyles: shouldUseNarrowLayout ? [styles.flex1] : [styles.flex1, styles.signInPageInner], - contentContainerStyles: [styles.flex1, shouldUseNarrowLayout ? styles.flexColumn : styles.flexRow], - }), - [shouldUseNarrowLayout, styles], - ); + const {containerStyles, contentContainerStyles} = { + containerStyles: shouldUseNarrowLayout ? [styles.flex1] : [styles.flex1, styles.signInPageInner], + contentContainerStyles: [styles.flex1, shouldUseNarrowLayout ? styles.flexColumn : styles.flexRow], + }; // To scroll on both mobile and web, we need to set the container height manually const containerHeight = windowHeight - topInsets - bottomInsets; @@ -76,7 +73,7 @@ function SignInPageLayout({ scrollPageToTop, })); - const scrollViewStyles = useMemo(() => scrollViewContentContainerStyles(styles), [styles]); + const scrollViewStyles = scrollViewContentContainerStyles(styles); const backgroundImageHeight = Math.max(variables.signInContentMinHeight, containerHeight); diff --git a/src/pages/signin/SignUpWelcomeForm.tsx b/src/pages/signin/SignUpWelcomeForm.tsx index 4eb97d299881..51005be8df20 100644 --- a/src/pages/signin/SignUpWelcomeForm.tsx +++ b/src/pages/signin/SignUpWelcomeForm.tsx @@ -16,7 +16,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {Str} from 'expensify-common'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import ChangeExpensifyLoginLink from './ChangeExpensifyLoginLink'; @@ -30,7 +30,7 @@ function SignUpWelcomeForm() { const [login] = useOnyx(ONYXKEYS.CREDENTIALS, {selector: (credentials) => credentials?.login}); const [preferredLocale] = useOnyx(ONYXKEYS.NVP_PREFERRED_LOCALE); const [marketingAttribution] = useOnyx(ONYXKEYS.MARKETING_ATTRIBUTION); - const serverErrorText = useMemo(() => (account ? getLatestErrorMessage(account) : ''), [account]); + const serverErrorText = account ? getLatestErrorMessage(account) : ''; const isPhoneSignup = Str.isSMSLogin(login ?? ''); const [hasSMSMarketingConsent, setHasSMSMarketingConsent] = useState(false); const marketingSMSConsentLabel = translate('welcomeSignUpForm.marketingSMSConsent'); diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx index 4aa61bf6552e..0607477e93e3 100755 --- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -41,7 +41,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useIsFocused} from '@react-navigation/native'; import {CONST as COMMON_CONST} from 'expensify-common'; -import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; import {View} from 'react-native'; import type ValidateCodeFormProps from './types'; @@ -170,21 +170,21 @@ function BaseValidateCodeForm({autoComplete, isUsingRecoveryCode, setIsUsingReco /** * Clear local sign in states */ - const clearLocalSignInData = useCallback(() => { + const clearLocalSignInData = () => { setTwoFactorAuthCode(''); setFormError({}); setValidateCode(''); setIsUsingRecoveryCode(false); setRecoveryCode(''); - }, [setIsUsingRecoveryCode]); + }; /** * Clears local and Onyx sign in states */ - const clearSignInData = useCallback(() => { + const clearSignInData = () => { clearLocalSignInData(); sessionActionsClearSignInData(); - }, [clearLocalSignInData]); + }; useImperativeHandle(ref, () => ({ clearSignInData, @@ -247,7 +247,7 @@ function BaseValidateCodeForm({autoComplete, isUsingRecoveryCode, setIsUsingReco /** * Check that all the form fields are valid, then trigger the submit callback */ - const validateAndSubmitForm = useCallback(() => { + const validateAndSubmitForm = () => { if (account?.isLoading) { return; } @@ -304,24 +304,11 @@ function BaseValidateCodeForm({autoComplete, isUsingRecoveryCode, setIsUsingReco } else { signIn(validateCode, preferredLocale, recoveryCodeOr2faCode, credentials?.login, credentials?.validateCode, credentials?.authToken); } - }, [ - account?.isLoading, - account?.errors, - account?.requiresTwoFactorAuth, - credentials?.validateCode, - credentials?.accountID, - credentials?.login, - credentials?.authToken, - isUsingRecoveryCode, - recoveryCode, - twoFactorAuthCode, - validateCode, - preferredLocale, - ]); - - const handleCountdownFinish = useCallback(() => { + }; + + const handleCountdownFinish = () => { setIsCountdownRunning(false); - }, []); + }; return ( diff --git a/src/pages/tasks/DynamicTaskShareDestinationSelectorModal.tsx b/src/pages/tasks/DynamicTaskShareDestinationSelectorModal.tsx index d60acad69c6d..3f40ba42ba80 100644 --- a/src/pages/tasks/DynamicTaskShareDestinationSelectorModal.tsx +++ b/src/pages/tasks/DynamicTaskShareDestinationSelectorModal.tsx @@ -28,7 +28,7 @@ import type {Report, ReportNameValuePairs} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; const reportFilter = (reportOptions: Array>, reportNameValuePairs: OnyxCollection) => @@ -74,43 +74,35 @@ function DynamicTaskShareDestinationSelectorModal() { const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); - const filteredOptions = useMemo(() => { - const filteredReports = reportFilter(availableOptions.recentReports as Array>, reportNameValuePairs); - return { - ...availableOptions, - recentReports: filteredReports ?? [], - }; - }, [availableOptions, reportNameValuePairs]); - - const data = useMemo( - () => - filteredOptions.recentReports && filteredOptions.recentReports.length > 0 - ? filteredOptions.recentReports.map((option) => ({ - ...option, - text: option.text ?? '', - alternateText: option.alternateText ?? undefined, - keyForList: option.keyForList ?? '', - isDisabled: option.isDisabled ?? undefined, - login: option.login ?? undefined, - })) - : [], - [filteredOptions.recentReports], - ); + const filteredReports = reportFilter(availableOptions.recentReports as Array>, reportNameValuePairs); + const filteredOptions = { + ...availableOptions, + recentReports: filteredReports ?? [], + }; + + const data = + filteredOptions.recentReports && filteredOptions.recentReports.length > 0 + ? filteredOptions.recentReports.map((option) => ({ + ...option, + text: option.text ?? '', + alternateText: option.alternateText ?? undefined, + keyForList: option.keyForList ?? '', + isDisabled: option.isDisabled ?? undefined, + login: option.login ?? undefined, + })) + : []; useEffect(() => { searchInServer(debouncedSearchTerm); }, [debouncedSearchTerm]); - const textInputOptions = useMemo( - () => ({ - onChangeText: setSearchTerm, - value: searchTerm, - headerMessage: getHeaderMessage(translate, filteredOptions.recentReports && filteredOptions.recentReports.length !== 0, false, searchTerm, countryCode, false), - label: translate('selectionList.nameEmailOrPhoneNumber'), - hint: isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : '', - }), - [countryCode, filteredOptions.recentReports, searchTerm, setSearchTerm, translate, isOffline], - ); + const textInputOptions = { + onChangeText: setSearchTerm, + value: searchTerm, + headerMessage: getHeaderMessage(translate, filteredOptions.recentReports && filteredOptions.recentReports.length !== 0, false, searchTerm, countryCode, false), + label: translate('selectionList.nameEmailOrPhoneNumber'), + hint: isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : '', + }; return ( ): FormInputErrors => { - const errors = {}; - const taskDescriptionLength = getCommentLength(values.description); - if (values?.description && taskDescriptionLength > CONST.DESCRIPTION_LIMIT) { - addErrorMessage(errors, 'description', translate('common.error.characterLimitExceedCounter', taskDescriptionLength, CONST.DESCRIPTION_LIMIT)); - } - - return errors; - }, - [translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = {}; + const taskDescriptionLength = getCommentLength(values.description); + if (values?.description && taskDescriptionLength > CONST.DESCRIPTION_LIMIT) { + addErrorMessage(errors, 'description', translate('common.error.characterLimitExceedCounter', taskDescriptionLength, CONST.DESCRIPTION_LIMIT)); + } + + return errors; + }; const submit = (values: FormOnyxValues) => { if (values.description !== Parser.htmlToMarkdown(report?.description ?? '', {accountIDToName}) && !isEmptyObject(report)) { diff --git a/src/pages/tasks/TaskTitlePage.tsx b/src/pages/tasks/TaskTitlePage.tsx index ad8d57795bf5..5beaf3a5b06d 100644 --- a/src/pages/tasks/TaskTitlePage.tsx +++ b/src/pages/tasks/TaskTitlePage.tsx @@ -36,7 +36,7 @@ import INPUT_IDS from '@src/types/form/EditTaskForm'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {delegateEmailSelector} from '@selectors/Account'; -import React, {useCallback, useRef} from 'react'; +import React, {useRef} from 'react'; import {View} from 'react-native'; type TaskTitlePageProps = WithReportOrNotFoundProps & WithCurrentUserPersonalDetailsProps; @@ -48,23 +48,20 @@ function TaskTitlePage({report, currentUserPersonalDetails}: TaskTitlePageProps) const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); const accountIDToName = useMentionedAccountIDToNameMap(report?.reportName); - const validate = useCallback( - ({title}: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; + const validate = ({title}: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; - const parsedTitle = getParsedComment(title, undefined, undefined, [...CONST.TASK_TITLE_DISABLED_RULES]); - const parsedTitleLength = getCommentLength(parsedTitle); + const parsedTitle = getParsedComment(title, undefined, undefined, [...CONST.TASK_TITLE_DISABLED_RULES]); + const parsedTitleLength = getCommentLength(parsedTitle); - if (!parsedTitle) { - addErrorMessage(errors, INPUT_IDS.TITLE, translate('newTaskPage.pleaseEnterTaskName')); - } else if (parsedTitleLength > CONST.TASK_TITLE_CHARACTER_LIMIT) { - addErrorMessage(errors, INPUT_IDS.TITLE, translate('common.error.characterLimitExceedCounter', parsedTitleLength, CONST.TASK_TITLE_CHARACTER_LIMIT)); - } + if (!parsedTitle) { + addErrorMessage(errors, INPUT_IDS.TITLE, translate('newTaskPage.pleaseEnterTaskName')); + } else if (parsedTitleLength > CONST.TASK_TITLE_CHARACTER_LIMIT) { + addErrorMessage(errors, INPUT_IDS.TITLE, translate('common.error.characterLimitExceedCounter', parsedTitleLength, CONST.TASK_TITLE_CHARACTER_LIMIT)); + } - return errors; - }, - [translate], - ); + return errors; + }; const submit = (values: FormOnyxValues) => { if (values.title !== Parser.htmlToMarkdown(report?.reportName ?? '', {accountIDToName}) && !isEmptyObject(report)) { diff --git a/src/pages/workspace/DynamicWorkspaceInvitePage.tsx b/src/pages/workspace/DynamicWorkspaceInvitePage.tsx index f62fc1f0fa96..3915471195b1 100644 --- a/src/pages/workspace/DynamicWorkspaceInvitePage.tsx +++ b/src/pages/workspace/DynamicWorkspaceInvitePage.tsx @@ -45,7 +45,7 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {Keyboard} from 'react-native'; import AccessOrNotFoundWrapper from './AccessOrNotFoundWrapper'; @@ -83,28 +83,23 @@ function DynamicWorkspaceInvitePageContent({route, policy, invitedEmailsToAccoun useNetwork({onReconnect: openWorkspaceInvitePage}); - const excludedUsers = useMemo(() => { - const ineligibleInvites = getIneligibleInvitees(policy?.employeeList); - return ineligibleInvites.reduce( - (acc, login) => { - acc[login] = true; - return acc; - }, - {} as Record, - ); - }, [policy?.employeeList]); - - const softExclusions = useMemo( - () => getSoftExclusionsForGuideAndAccountManager(policy, account?.accountManagerAccountID, personalDetails), - [policy, account?.accountManagerAccountID, personalDetails], + const ineligibleInvites = getIneligibleInvitees(policy?.employeeList); + const excludedUsers = ineligibleInvites.reduce( + (acc, login) => { + acc[login] = true; + return acc; + }, + {} as Record, ); - const initialSelected = useMemo(() => new Set(Object.values(invitedEmailsToAccountIDsDraft ?? {}).map(String)), [invitedEmailsToAccountIDsDraft]); + const softExclusions = getSoftExclusionsForGuideAndAccountManager(policy, account?.accountManagerAccountID, personalDetails); - const initialExtraOptions = useMemo(() => { - if (!invitedEmailsToAccountIDsDraft) { - return []; - } + const initialSelected = new Set(Object.values(invitedEmailsToAccountIDsDraft ?? {}).map(String)); + + let initialExtraOptions: OptionData[]; + if (!invitedEmailsToAccountIDsDraft) { + initialExtraOptions = []; + } else { const filteredNewEmails = []; for (const [email, accountID] of Object.entries(invitedEmailsToAccountIDsDraft)) { if (!email.toLowerCase().trim() || !accountID) { @@ -126,11 +121,14 @@ function DynamicWorkspaceInvitePageContent({route, policy, invitedEmailsToAccoun loginsToExclude: excludedUsers, }); if (option) { - newOptions.push({...option, isSelected: true}); + newOptions.push({ + ...option, + isSelected: true, + }); } } - return newOptions; - }, [countryCode, excludedUsers, formatPhoneNumber, invitedEmailsToAccountIDsDraft, personalDetails]); + initialExtraOptions = newOptions; + } const {searchTerm, debouncedSearchTerm, setSearchTerm, availableOptions, selectedOptions, selectedNonExistingOptions, toggleSelection, areOptionsInitialized} = usePersonalDetailSearchSelector({ @@ -146,13 +144,11 @@ function DynamicWorkspaceInvitePageContent({route, policy, invitedEmailsToAccoun agentsWhitelist: agentsAccountIDs, }); - const sections: Array> = useMemo(() => { - const sectionsArr = []; - - if (!areOptionsInitialized) { - return []; - } - + let sections: Array>; + const sectionsArr = []; + if (!areOptionsInitialized) { + sections = []; + } else { // Selected non-existing users section (top) if (selectedNonExistingOptions.length > 0) { sectionsArr.push({ @@ -179,18 +175,14 @@ function DynamicWorkspaceInvitePageContent({route, policy, invitedEmailsToAccoun sectionIndex: 2, }); } + sections = sectionsArr; + } - return sectionsArr; - }, [areOptionsInitialized, selectedNonExistingOptions, availableOptions.personalDetails, availableOptions.userToInvite, translate]); - - const handleToggleSelection = useCallback( - (option: OptionData) => { - toggleSelection(option); - }, - [toggleSelection], - ); + const handleToggleSelection = (option: OptionData) => { + toggleSelection(option); + }; - const inviteUser = useCallback(() => { + const inviteUser = () => { Keyboard.dismiss(); HttpUtils.cancelPendingRequests(READ_COMMANDS.SEARCH_FOR_USERS); @@ -205,51 +197,47 @@ function DynamicWorkspaceInvitePageContent({route, policy, invitedEmailsToAccoun } setWorkspaceInviteMembersDraft(route.params.policyID, invitedEmailsToAccountIDs); Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_INVITE_MESSAGE.path)); - }, [route.params.policyID, selectedOptions]); + }; - const [policyName, shouldShowAlertPrompt] = useMemo(() => [policy?.name ?? '', !isEmptyObject(policy?.errors)], [policy?.name, policy?.errors]); + const [policyName, shouldShowAlertPrompt] = [policy?.name ?? '', !isEmptyObject(policy?.errors)]; - const headerMessage = useMemo(() => { - if (sections.length > 0) { - return ''; - } + let headerMessage; + if (sections.length > 0) { + headerMessage = ''; + } else { const searchValue = debouncedSearchTerm.trim().toLowerCase(); if (CONST.EXPENSIFY_EMAILS_OBJECT[searchValue]) { - return translate('messages.errorMessageInvalidEmail'); - } - if (excludedUsers[parsePhoneNumber(appendCountryCode(searchValue, countryCode)).possible ? addSMSDomainIfPhoneNumber(appendCountryCode(searchValue, countryCode)) : searchValue]) { - return translate('messages.userIsAlreadyMember', searchValue, policyName); + headerMessage = translate('messages.errorMessageInvalidEmail'); + } else if ( + excludedUsers[parsePhoneNumber(appendCountryCode(searchValue, countryCode)).possible ? addSMSDomainIfPhoneNumber(appendCountryCode(searchValue, countryCode)) : searchValue] + ) { + headerMessage = translate('messages.userIsAlreadyMember', searchValue, policyName); + } else { + headerMessage = getHeaderMessage(translate, searchValue, countryCode); } - return getHeaderMessage(translate, searchValue, countryCode); - }, [sections.length, debouncedSearchTerm, excludedUsers, countryCode, translate, policyName]); - - const footerContent = useMemo( - () => ( - - ), - [inviteUser, selectedOptions.length, shouldShowAlertPrompt, styles.flexBasisAuto, styles.flexGrow0, styles.flexReset, styles.flexShrink0, translate], + } + + const footerContent = ( + ); useEffect(() => { searchUserInServer(debouncedSearchTerm); }, [debouncedSearchTerm]); - const textInputOptions = useMemo( - () => ({ - label: translate('selectionList.nameEmailOrPhoneNumber'), - value: searchTerm, - onChangeText: setSearchTerm, - headerMessage, - }), - [searchTerm, setSearchTerm, headerMessage, translate], - ); + const textInputOptions = { + label: translate('selectionList.nameEmailOrPhoneNumber'), + value: searchTerm, + onChangeText: setSearchTerm, + headerMessage, + }; return ( { + const closePage = () => { Navigation.closeRHPFlow(); - }, []); + }; return ( >(null); const icons = useMemoizedLazyExpensifyIcons(['Building', 'Download', 'FallbackAvatar', 'Location', 'Plus', 'RemoveMembers', 'Sync', 'Table', 'UserPencil']); const employeePersonalDetails = usePersonalDetailsByLogins(Object.keys(policy?.employeeList ?? {})); - const policyMemberEmailsToAccountIDs = useMemo( - () => getMemberAccountIDsForWorkspace(policy?.employeeList, employeePersonalDetails, true), - [policy?.employeeList, employeePersonalDetails], - ); + const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList, employeePersonalDetails, true); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const styles = useThemeStyles(); const {pageGutter} = useLayoutSpacing(); @@ -158,91 +155,96 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers // Group policies (Collect/Control + Submit) allow member management. const canManageMembers = isGroupPolicy(policy); - const isLoading = useMemo( - () => !isOfflineAndNoMemberDataAvailable && (!isPersonalDetailsReady(personalDetails) || isEmptyObject(policy?.employeeList)), - [isOfflineAndNoMemberDataAvailable, personalDetails, policy?.employeeList], - ); + const isLoading = !isOfflineAndNoMemberDataAvailable && (!isPersonalDetailsReady(personalDetails) || isEmptyObject(policy?.employeeList)); const isMobileSelectionModeEnabled = useMobileSelectionMode(); const [session] = useOnyx(ONYXKEYS.SESSION); const isFocused = useIsFocused(); const policyID = route.params.policyID; const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); - const outstandingReportsForPolicySelector = useMemo(() => createOutstandingReportsForPolicySelector(policyID), [policyID]); + const outstandingReportsForPolicySelector = createOutstandingReportsForPolicySelector(policyID); const [outstandingReportsForPolicy] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID, {selector: outstandingReportsForPolicySelector}); const privateIsArchivedMap = usePrivateIsArchivedMap(); const [invitedEmailsToAccountIDsDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID}`); const [memberBankAccounts] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_MEMBER_BANK_ACCOUNTS}${policyID}`); - const accountIDs = useMemo(() => Object.values(policyMemberEmailsToAccountIDs ?? {}).map((accountID) => Number(accountID)), [policyMemberEmailsToAccountIDs]); + const accountIDs = Object.values(policyMemberEmailsToAccountIDs ?? {}).map((accountID) => Number(accountID)); const prevAccountIDs = usePrevious(accountIDs); - const invitedEmails = useMemo(() => Object.keys(invitedEmailsToAccountIDsDraft ?? {}), [invitedEmailsToAccountIDsDraft]); + const invitedEmails = Object.keys(invitedEmailsToAccountIDsDraft ?? {}); const ownerDetails = personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID]; - const {approvalWorkflows} = useMemo( - () => - convertPolicyEmployeesToApprovalWorkflows({ - policy, - personalDetails: personalDetails ?? {}, - localeCompare, - }), - [personalDetails, policy, localeCompare], - ); + const {approvalWorkflows} = convertPolicyEmployeesToApprovalWorkflows({ + policy, + personalDetails: personalDetails ?? {}, + localeCompare, + }); const canSelectMultiple = canWriteMembers && (shouldUseNarrowLayout ? isMobileSelectionModeEnabled : true); - const confirmModalPrompt = useMemo(() => { - const hiddenText = translate('common.hidden'); - const approverEmail = selectedEmployees.find( - (selectedEmployee) => - isPolicyApprover(policy, selectedEmployee) || - isApproverOfOutstandingPolicyReports(policyMemberEmailsToAccountIDs[selectedEmployee], outstandingReportsForPolicy, privateIsArchivedMap), + let confirmModalPrompt; + const hiddenText = translate('common.hidden'); + const approverEmail = selectedEmployees.find( + (selectedEmployee) => + isPolicyApprover(policy, selectedEmployee) || + isApproverOfOutstandingPolicyReports(policyMemberEmailsToAccountIDs[selectedEmployee], outstandingReportsForPolicy, privateIsArchivedMap), + ); + if (approverEmail) { + const approverAccountID = policyMemberEmailsToAccountIDs[approverEmail]; + confirmModalPrompt = translate( + 'workspace.people.removeMembersWarningPrompt', + getDisplayNameForParticipant({ + accountID: approverAccountID, + formatPhoneNumber, + hiddenTranslation: hiddenText, + }), + getDisplayNameForParticipant({ + accountID: policy?.ownerAccountID, + formatPhoneNumber, + hiddenTranslation: hiddenText, + }), ); - - if (approverEmail) { - const approverAccountID = policyMemberEmailsToAccountIDs[approverEmail]; - return translate( - 'workspace.people.removeMembersWarningPrompt', - getDisplayNameForParticipant({accountID: approverAccountID, formatPhoneNumber, hiddenTranslation: hiddenText}), - getDisplayNameForParticipant({accountID: policy?.ownerAccountID, formatPhoneNumber, hiddenTranslation: hiddenText}), - ); - } - + } else { const exporters = getConnectionExporters(policy); const userExporter = selectedEmployees.find((selectedEmployee) => exporters.includes(selectedEmployee)); - if (userExporter) { const exporterAccountID = policyMemberEmailsToAccountIDs[userExporter]; - return translate('workspace.people.removeMemberPromptExporter', { - memberName: getDisplayNameForParticipant({accountID: exporterAccountID, formatPhoneNumber, hiddenTranslation: hiddenText}), - workspaceOwner: getDisplayNameForParticipant({accountID: policy?.ownerAccountID, formatPhoneNumber, hiddenTranslation: hiddenText}), + confirmModalPrompt = translate('workspace.people.removeMemberPromptExporter', { + memberName: getDisplayNameForParticipant({ + accountID: exporterAccountID, + formatPhoneNumber, + hiddenTranslation: hiddenText, + }), + workspaceOwner: getDisplayNameForParticipant({ + accountID: policy?.ownerAccountID, + formatPhoneNumber, + hiddenTranslation: hiddenText, + }), + }); + } else { + const firstSelectedEmployeeAccountID = policyMemberEmailsToAccountIDs[selectedEmployees[0]]; + confirmModalPrompt = translate('workspace.people.removeMembersPrompt', { + count: selectedEmployees.length, + memberName: formatPhoneNumber(getPersonalDetailsByID(firstSelectedEmployeeAccountID, personalDetails)?.displayName ?? ''), }); } - - const firstSelectedEmployeeAccountID = policyMemberEmailsToAccountIDs[selectedEmployees[0]]; - return translate('workspace.people.removeMembersPrompt', { - count: selectedEmployees.length, - memberName: formatPhoneNumber(getPersonalDetailsByID(firstSelectedEmployeeAccountID, personalDetails)?.displayName ?? ''), - }); - }, [selectedEmployees, policyMemberEmailsToAccountIDs, translate, policy, formatPhoneNumber, personalDetails, outstandingReportsForPolicy, privateIsArchivedMap]); + } /** * Open the modal to invite a user */ - const inviteUser = useCallback(() => { + const inviteUser = () => { if (isAccountLocked) { showLockedAccountModal(); return; } clearInviteDraft(route.params.policyID); Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_INVITE.path)); - }, [route.params.policyID, isAccountLocked, showLockedAccountModal]); + }; /** * Remove selected users from the workspace * Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details */ - // eslint-disable-next-line react-hooks/exhaustive-deps const removeUsers = () => { // Check if any of the members are approvers const hasApprovers = selectedEmployees.some((email) => isPolicyApprover(policy, email)); @@ -282,7 +284,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers /** * Show the modal to confirm removal of the selected members */ - const askForConfirmationToRemove = useCallback(() => { + const askForConfirmationToRemove = () => { const isRuleBotSelected = selectedEmployees.some((email) => isRuleBotEnforcingRules(policyMemberEmailsToAccountIDs[email], policy)); if (isRuleBotSelected) { showRuleBotGuardModal('remove', policyID); @@ -317,41 +319,35 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers removeUsers(); }); - }, [confirmModalPrompt, removeUsers, selectedEmployees, policyMemberEmailsToAccountIDs, policy, policyID, showConfirmModal, showRuleBotGuardModal, translate, formatPhoneNumber]); + }; /** Opens the member details page */ - const openMemberDetails = useCallback( - (accountID: number, login?: string) => { - if (!canWriteMembers || !canManageMembers) { - // Pass the login so ProfilePage can render a fallback identity for members whose personal details - // aren't loaded (their accountID is generated and can't be resolved from a spinner otherwise). - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.PROFILE.getRoute(accountID, login))); - return; - } - clearWorkspaceOwnerChangeFlow(policyID); - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(route.params.policyID, accountID)); - }); - }, - [canWriteMembers, canManageMembers, policyID, route.params.policyID], - ); + const openMemberDetails = (accountID: number, login?: string) => { + if (!canWriteMembers || !canManageMembers) { + // Pass the login so ProfilePage can render a fallback identity for members whose personal details + // aren't loaded (their accountID is generated and can't be resolved from a spinner otherwise). + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.PROFILE.getRoute(accountID, login))); + return; + } + clearWorkspaceOwnerChangeFlow(policyID); + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(route.params.policyID, accountID)); + }); + }; - const dismissError = useCallback( - (login: string, accountID: number, pendingAction?: PendingAction) => { - if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - clearDeleteMemberError(route.params.policyID, login); - return; - } + const dismissError = (login: string, accountID: number, pendingAction?: PendingAction) => { + if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + clearDeleteMemberError(route.params.policyID, login); + return; + } - if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE) { - clearUpdateMemberRoleError(route.params.policyID, login); - return; - } + if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE) { + clearUpdateMemberRoleError(route.params.policyID, login); + return; + } - clearAddMemberError(route.params.policyID, login, accountID); - }, - [route.params.policyID], - ); + clearAddMemberError(route.params.policyID, login, accountID); + }; const changeMemberRole = (login: string, accountID: number, currentRole: string | undefined, newRole: ValueOf) => { if (newRole === currentRole || !canMemberAssignRole(policy, currentUserLogin ?? '', newRole)) { @@ -371,13 +367,13 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers updateMemberRoleInline(policy, login, accountID, currentRole, newRole); }; - const invitedPrimaryToSecondaryLogins = useMemo(() => invertObject(policy?.primaryLoginsInvited ?? {}), [policy?.primaryLoginsInvited]); + const invitedPrimaryToSecondaryLogins = invertObject(policy?.primaryLoginsInvited ?? {}); const isControlPolicyWithWideLayout = !shouldUseNarrowLayout && isControlPolicy(policy); const filteredMembers = useWorkspaceMembers(policy); - const hasAnyCustomField1 = useMemo(() => filteredMembers.some(({policyEmployee}) => !!policyEmployee.employeeUserID), [filteredMembers]); - const hasAnyCustomField2 = useMemo(() => filteredMembers.some(({policyEmployee}) => !!policyEmployee.employeePayrollID), [filteredMembers]); + const hasAnyCustomField1 = filteredMembers.some(({policyEmployee}) => !!policyEmployee.employeeUserID); + const hasAnyCustomField2 = filteredMembers.some(({policyEmployee}) => !!policyEmployee.employeePayrollID); const shouldShowCustomField1Column = isControlPolicyWithWideLayout && hasAnyCustomField1; const shouldShowCustomField2Column = isControlPolicyWithWideLayout && hasAnyCustomField2; const shouldShowOfficeLocations = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && hasOfficeLocations(policy); @@ -392,84 +388,62 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers // Role assignment is a Collect/Control capability. Submit locks every member to Editor. const canAssignMemberRole = isGroupPolicy(policy) && !isSubmitWorkspace; - const data: WorkspaceMemberRowData[] = useMemo(() => { - const ownerDisplayRole = isSubmitWorkspace ? CONST.POLICY.ROLE.EDITOR : CONST.POLICY.ROLE.OWNER; - const assignablePayerRoles = PAYER_ROLES.filter((payerRole) => canMemberAssignRole(policy, currentUserLogin ?? '', payerRole)); - const policyRoles = Object.values(CONST.POLICY.ROLE); - - return filteredMembers.map(({policyEmployee, accountID, details}) => { - const isPendingDeleteOrError = canEditWorkspaceSettings && (policyEmployee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || !isEmptyObject(policyEmployee.errors)); - const employeeRole = policyRoles.find((policyRole) => policyRole === policyEmployee.role); - const role = policy?.owner === details.login ? ownerDisplayRole : employeeRole; - - const login = details.login ?? ''; - const memberEmail = formatPhoneNumber(login); - const memberName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: details, translate, formatPhoneNumber}); - const isOwner = policy?.owner === login; - const isCurrentUser = accountID === session?.accountID; - const isReimburser = isPolicyReimburser(policy, login); - const canReimburserChangeRole = assignablePayerRoles.some((payerRole) => payerRole !== policyEmployee.role); - const canEditRole = - canAssignMemberRole && - !isSelectionModeActive && - !isPendingDeleteOrError && - !isOwner && - !isCurrentUser && - canMemberAssignRole(policy, currentUserLogin ?? '', policyEmployee.role) && - (!isReimburser || canReimburserChangeRole); - - return { - keyForList: login, - role, - login, - accountID, - name: memberName, - email: memberEmail, - employeeUserID: policyEmployee.employeeUserID, - employeePayrollID: policyEmployee.employeePayrollID, - officeLocationName: shouldShowOfficeLocationColumn ? getMemberOfficeLocation(officeLocations, policyEmployee.officeID)?.name : undefined, - bankAccountLastFour: memberBankAccounts?.[accountID]?.bankAccountLastFour, - isInteractive: !details.isOptimisticPersonalDetail, - isSelectionDisabled: - !canWriteMembers || - accountID === policy?.ownerAccountID || - accountID === session?.accountID || - !canMemberManageMemberWithRole(policy, currentUserLogin ?? '', policyEmployee.role), - shouldShowEmployeeUserID: shouldShowCustomField1Column, - shouldShowEmployeePayrollID: shouldShowCustomField2Column, - errors: getLatestErrorMessageField(policyEmployee), - pendingAction: policyEmployee.pendingAction, - disabled: isPendingDeleteOrError, - canEditRole, - onChangeRole: (newRole) => changeMemberRole(login, accountID, policyEmployee.role, newRole), - // Note which secondary login was used to invite this primary login - invitedSecondaryLogin: details?.login ? (invitedPrimaryToSecondaryLogins[details.login] ?? '') : '', - action: () => openMemberDetails(accountID, login), - dismissError: () => dismissError(login, accountID, policyEmployee.pendingAction), - }; + const ownerDisplayRole = isSubmitWorkspace ? CONST.POLICY.ROLE.EDITOR : CONST.POLICY.ROLE.OWNER; + const assignablePayerRoles = PAYER_ROLES.filter((payerRole) => canMemberAssignRole(policy, currentUserLogin ?? '', payerRole)); + const policyRoles = Object.values(CONST.POLICY.ROLE); + const data: WorkspaceMemberRowData[] = filteredMembers.map(({policyEmployee, accountID, details}) => { + const isPendingDeleteOrError = canEditWorkspaceSettings && (policyEmployee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || !isEmptyObject(policyEmployee.errors)); + const employeeRole = policyRoles.find((policyRole) => policyRole === policyEmployee.role); + const role = policy?.owner === details.login ? ownerDisplayRole : employeeRole; + const login = details.login ?? ''; + const memberEmail = formatPhoneNumber(login); + const memberName = temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: details, + translate, + formatPhoneNumber, }); - }, [ - filteredMembers, - canEditWorkspaceSettings, - canWriteMembers, - canAssignMemberRole, - currentUserLogin, - policy, - isSubmitWorkspace, - isSelectionModeActive, - formatPhoneNumber, - translate, - session?.accountID, - shouldShowCustomField1Column, - shouldShowCustomField2Column, - shouldShowOfficeLocationColumn, - officeLocations, - memberBankAccounts, - invitedPrimaryToSecondaryLogins, - openMemberDetails, - dismissError, - changeMemberRole, - ]); + const isOwner = policy?.owner === login; + const isCurrentUser = accountID === session?.accountID; + const isReimburser = isPolicyReimburser(policy, login); + const canReimburserChangeRole = assignablePayerRoles.some((payerRole) => payerRole !== policyEmployee.role); + const canEditRole = + canAssignMemberRole && + !isSelectionModeActive && + !isPendingDeleteOrError && + !isOwner && + !isCurrentUser && + canMemberAssignRole(policy, currentUserLogin ?? '', policyEmployee.role) && + (!isReimburser || canReimburserChangeRole); + return { + keyForList: login, + role, + login, + accountID, + name: memberName, + email: memberEmail, + employeeUserID: policyEmployee.employeeUserID, + employeePayrollID: policyEmployee.employeePayrollID, + officeLocationName: shouldShowOfficeLocationColumn ? getMemberOfficeLocation(officeLocations, policyEmployee.officeID)?.name : undefined, + bankAccountLastFour: memberBankAccounts?.[accountID]?.bankAccountLastFour, + isInteractive: !details.isOptimisticPersonalDetail, + isSelectionDisabled: + !canWriteMembers || + accountID === policy?.ownerAccountID || + accountID === session?.accountID || + !canMemberManageMemberWithRole(policy, currentUserLogin ?? '', policyEmployee.role), + shouldShowEmployeeUserID: shouldShowCustomField1Column, + shouldShowEmployeePayrollID: shouldShowCustomField2Column, + errors: getLatestErrorMessageField(policyEmployee), + pendingAction: policyEmployee.pendingAction, + disabled: isPendingDeleteOrError, + canEditRole, + onChangeRole: (newRole) => changeMemberRole(login, accountID, policyEmployee.role, newRole), + // Note which secondary login was used to invite this primary login + invitedSecondaryLogin: details?.login ? (invitedPrimaryToSecondaryLogins[details.login] ?? '') : '', + action: () => openMemberDetails(accountID, login), + dismissError: () => dismissError(login, accountID, policyEmployee.pendingAction), + }; + }); useEffect(() => { if (!isFocused) { @@ -500,13 +474,12 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers useMergeSyncResultsPage(policyID); - const headerMessage = useMemo(() => { - if (isOfflineAndNoMemberDataAvailable) { - return translate('workspace.common.mustBeOnlineToViewMembers'); - } - - return !isLoading && isEmptyObject(policy?.employeeList) ? translate('workspace.common.memberNotFound') : ''; - }, [isLoading, policy?.employeeList, translate, isOfflineAndNoMemberDataAvailable]); + let headerMessage; + if (isOfflineAndNoMemberDataAvailable) { + headerMessage = translate('workspace.common.mustBeOnlineToViewMembers'); + } else { + headerMessage = !isLoading && isEmptyObject(policy?.employeeList) ? translate('workspace.common.memberNotFound') : ''; + } const memberCount = data.filter((member) => member.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length; const connectedHRProvider = getConnectedHRProvider(policy); @@ -637,7 +610,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers return options; }; - const showRequiresInternetModal = useCallback(() => { + const showRequiresInternetModal = () => { showConfirmModal({ title: translate('common.youAppearToBeOffline'), prompt: translate('common.thisFeatureRequiresInternet'), @@ -645,13 +618,12 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers shouldShowCancelButton: false, shouldHandleNavigationBack: true, }); - }, [showConfirmModal, translate]); - - const secondaryActions = useMemo(() => { - if (!canWriteMembers) { - return []; - } + }; + let secondaryActions: Array>>; + if (!canWriteMembers) { + secondaryActions = []; + } else { const menuItems: Array>> = [ { icon: icons.Table, @@ -677,7 +649,6 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers close(showRequiresInternetModal); return; } - close(() => { downloadMembersCSV(policyID, showDownloadErrorModal, translate); }); @@ -685,7 +656,6 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers value: CONST.POLICY.SECONDARY_ACTIONS.DOWNLOAD_CSV, }, ]; - const hrProvider = getConnectedHRProvider(policy); if (hrProvider) { menuItems.push({ @@ -696,35 +666,17 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers close(showRequiresInternetModal); return; } - if (showMergeManualSyncLimitModalIfReached(policy, hrProvider.connectionName, translate, showConfirmModal)) { return; } - close(() => syncConnection(policy, hrProvider.connectionName)); }, value: CONST.POLICY.SECONDARY_ACTIONS.SYNC_WITH_HR, disabled: isHRSyncInProgress, }); } - - return menuItems; - }, [ - canWriteMembers, - icons.Table, - icons.Download, - icons.Sync, - translate, - isAccountLocked, - isOffline, - policyID, - showLockedAccountModal, - showRequiresInternetModal, - showConfirmModal, - showDownloadErrorModal, - isHRSyncInProgress, - policy, - ]); + secondaryActions = menuItems; + } const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); diff --git a/src/pages/workspace/WorkspaceNamePage.tsx b/src/pages/workspace/WorkspaceNamePage.tsx index ddf268b9e9af..407c219b8d1c 100644 --- a/src/pages/workspace/WorkspaceNamePage.tsx +++ b/src/pages/workspace/WorkspaceNamePage.tsx @@ -18,7 +18,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/WorkspaceSettingsForm'; -import React, {useCallback} from 'react'; +import React from 'react'; import {Keyboard, View} from 'react-native'; import type {WithPolicyProps} from './withPolicy'; @@ -33,36 +33,30 @@ function WorkspaceNamePage({policy}: Props) { const getReviewWorkspaceSettingsTaskCompletion = useReviewWorkspaceSettingsTaskCompletion(); const {translate} = useLocalize(); - const submit = useCallback( - (values: FormOnyxValues) => { - if (!policy || policy.isPolicyUpdating) { - return; - } - - updateGeneralSettings(policy, values.name.trim(), policy.outputCurrency, getReviewWorkspaceSettingsTaskCompletion()); - Keyboard.dismiss(); - Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack()); - }, - [policy, getReviewWorkspaceSettingsTaskCompletion], - ); - - const validate = useCallback( - (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; - const name = values.name.trim(); - - if (!isRequiredFulfilled(name)) { - errors.name = translate('workspace.editor.nameIsRequiredError'); - } else if ([...name].length > CONST.TITLE_CHARACTER_LIMIT) { - // Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16 - // code units. - addErrorMessage(errors, 'name', translate('common.error.characterLimitExceedCounter', [...name].length, CONST.TITLE_CHARACTER_LIMIT)); - } - - return errors; - }, - [translate], - ); + const submit = (values: FormOnyxValues) => { + if (!policy || policy.isPolicyUpdating) { + return; + } + + updateGeneralSettings(policy, values.name.trim(), policy.outputCurrency, getReviewWorkspaceSettingsTaskCompletion()); + Keyboard.dismiss(); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack()); + }; + + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const name = values.name.trim(); + + if (!isRequiredFulfilled(name)) { + errors.name = translate('workspace.editor.nameIsRequiredError'); + } else if ([...name].length > CONST.TITLE_CHARACTER_LIMIT) { + // Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16 + // code units. + addErrorMessage(errors, 'name', translate('common.error.characterLimitExceedCounter', [...name].length, CONST.TITLE_CHARACTER_LIMIT)); + } + + return errors; + }; return ( (options: Array<{value: TValue}>, value: FormValue | undefined) { @@ -105,7 +105,7 @@ function WorkspaceNewRoomPage({ref, policyID: lockedPolicyID}: WorkspaceNewRoomP const {contentHeaderHeight} = useContentHeaderHeight(); const [visibility, setVisibility] = useState>(CONST.REPORT.VISIBILITY.RESTRICTED); const [writeCapability, setWriteCapability] = useState>(CONST.REPORT.WRITE_CAPABILITIES.ALL); - const visibilityDescription = useMemo(() => translate(`newRoomPage.${visibility}Description`), [translate, visibility]); + const visibilityDescription = translate(`newRoomPage.${visibility}Description`); const roomPageInputRef = useRef(null); useImperativeHandle(ref, () => ({ @@ -113,16 +113,12 @@ function WorkspaceNewRoomPage({ref, policyID: lockedPolicyID}: WorkspaceNewRoomP isValidInput: () => !!roomPageInputRef.current, })); - const workspaceOptions = useMemo( - () => - getPoliciesForRoomCreation(policies, session?.email) - .map((policy) => ({ - label: policy.name, - value: policy.id, - })) - .sort((a, b) => localeCompare(a.label, b.label)), - [policies, session?.email, localeCompare], - ); + const workspaceOptions = getPoliciesForRoomCreation(policies, session?.email) + .map((policy) => ({ + label: policy.name, + value: policy.id, + })) + .sort((a, b) => localeCompare(a.label, b.label)); const [policyID, setPolicyID] = useState(() => { if (lockedPolicyID) { return lockedPolicyID; @@ -132,13 +128,7 @@ function WorkspaceNewRoomPage({ref, policyID: lockedPolicyID}: WorkspaceNewRoomP } return ''; }); - const isAdminPolicy = useMemo(() => { - if (!policyID) { - return false; - } - - return isPolicyAdmin(policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]); - }, [policyID, policies]); + const isAdminPolicy = !!policyID && isPolicyAdmin(policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]); /** * @param values - form input values passed by the Form component @@ -213,65 +203,54 @@ function WorkspaceNewRoomPage({ref, policyID: lockedPolicyID}: WorkspaceNewRoomP * @param values - form input values passed by the Form component * @returns an object containing validation errors, if any were found during validation */ - const validate = useCallback( - (values: FormOnyxValues): OnyxCommon.Errors => { - if (!shouldEnableValidation) { - return {}; - } + const validate = (values: FormOnyxValues): OnyxCommon.Errors => { + if (!shouldEnableValidation) { + return {}; + } - const errors: {policyID?: string; roomName?: string} = {}; - const validationPolicyID = isLocked ? policyID : values.policyID; - - if (!values.roomName || values.roomName === CONST.POLICY.ROOM_PREFIX) { - // We error if the user doesn't enter a room name or left blank - addErrorMessage(errors, 'roomName', translate('newRoomPage.pleaseEnterRoomName')); - } else if (values.roomName !== CONST.POLICY.ROOM_PREFIX && !isValidRoomNameWithoutLimits(values.roomName)) { - // We error if the room name has invalid characters - addErrorMessage(errors, 'roomName', translate('newRoomPage.roomNameInvalidError')); - } else if (isReservedRoomName(values.roomName)) { - // Certain names are reserved for default rooms and should not be used for policy rooms. - addErrorMessage(errors, 'roomName', translate('newRoomPage.roomNameReservedError', values.roomName)); - } else if (isExistingRoomName(values.roomName, reports, validationPolicyID)) { - // Certain names are reserved for default rooms and should not be used for policy rooms. - addErrorMessage(errors, 'roomName', translate('newRoomPage.roomAlreadyExistsError')); - } else if (values.roomName.length > CONST.TITLE_CHARACTER_LIMIT) { - addErrorMessage(errors, 'roomName', translate('common.error.characterLimitExceedCounter', values.roomName.length, CONST.TITLE_CHARACTER_LIMIT)); - } + const errors: {policyID?: string; roomName?: string} = {}; + const validationPolicyID = isLocked ? policyID : values.policyID; + + if (!values.roomName || values.roomName === CONST.POLICY.ROOM_PREFIX) { + // We error if the user doesn't enter a room name or left blank + addErrorMessage(errors, 'roomName', translate('newRoomPage.pleaseEnterRoomName')); + } else if (values.roomName !== CONST.POLICY.ROOM_PREFIX && !isValidRoomNameWithoutLimits(values.roomName)) { + // We error if the room name has invalid characters + addErrorMessage(errors, 'roomName', translate('newRoomPage.roomNameInvalidError')); + } else if (isReservedRoomName(values.roomName)) { + // Certain names are reserved for default rooms and should not be used for policy rooms. + addErrorMessage(errors, 'roomName', translate('newRoomPage.roomNameReservedError', values.roomName)); + } else if (isExistingRoomName(values.roomName, reports, validationPolicyID)) { + // Certain names are reserved for default rooms and should not be used for policy rooms. + addErrorMessage(errors, 'roomName', translate('newRoomPage.roomAlreadyExistsError')); + } else if (values.roomName.length > CONST.TITLE_CHARACTER_LIMIT) { + addErrorMessage(errors, 'roomName', translate('common.error.characterLimitExceedCounter', values.roomName.length, CONST.TITLE_CHARACTER_LIMIT)); + } - const descriptionLength = getCommentLength(values.reportDescription, {policyID}); - if (descriptionLength > CONST.REPORT_DESCRIPTION.MAX_LENGTH) { - addErrorMessage(errors, 'reportDescription', translate('common.error.characterLimitExceedCounter', descriptionLength, CONST.REPORT_DESCRIPTION.MAX_LENGTH)); - } + const descriptionLength = getCommentLength(values.reportDescription, {policyID}); + if (descriptionLength > CONST.REPORT_DESCRIPTION.MAX_LENGTH) { + addErrorMessage(errors, 'reportDescription', translate('common.error.characterLimitExceedCounter', descriptionLength, CONST.REPORT_DESCRIPTION.MAX_LENGTH)); + } - if (!isLocked && !values.policyID) { - errors.policyID = translate('newRoomPage.pleaseSelectWorkspace'); - } + if (!isLocked && !values.policyID) { + errors.policyID = translate('newRoomPage.pleaseSelectWorkspace'); + } - return errors; - }, - [reports, policyID, translate, shouldEnableValidation, isLocked], - ); + return errors; + }; - const writeCapabilityOptions = useMemo( - () => - Object.values(CONST.REPORT.WRITE_CAPABILITIES).map((value) => ({ - value, - label: translate(`writeCapabilityPage.writeCapability.${value}`), - })), - [translate], - ); + const writeCapabilityOptions = Object.values(CONST.REPORT.WRITE_CAPABILITIES).map((value) => ({ + value, + label: translate(`writeCapabilityPage.writeCapability.${value}`), + })); - const visibilityOptions = useMemo( - () => - Object.values(CONST.REPORT.VISIBILITY) - .filter((visibilityOption) => visibilityOption !== CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE) - .map((visibilityOption) => ({ - label: translate(`newRoomPage.visibilityOptions.${visibilityOption}`), - value: visibilityOption, - description: translate(`newRoomPage.${visibilityOption}Description`), - })), - [translate], - ); + const visibilityOptions = Object.values(CONST.REPORT.VISIBILITY) + .filter((visibilityOption) => visibilityOption !== CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE) + .map((visibilityOption) => ({ + label: translate(`newRoomPage.visibilityOptions.${visibilityOption}`), + value: visibilityOption, + description: translate(`newRoomPage.${visibilityOption}Description`), + })); return ( { - const tempAddress = policy?.address; - const {streetLineOne, streetLineTwo} = getWorkspaceAddressStreetLines(tempAddress?.addressStreet, tempAddress?.addressStreet2); - const result = { - street: streetLineOne, - street2: streetLineTwo, - city: tempAddress?.city?.trim() ?? '', - state: tempAddress?.state?.trim() ?? '', - zip: tempAddress?.zipCode?.trim().toUpperCase() ?? '', - country: tempAddress?.country ?? '', - }; - return result; - }, [policy?.address]); + const tempAddress = policy?.address; + const {streetLineOne, streetLineTwo} = getWorkspaceAddressStreetLines(tempAddress?.addressStreet, tempAddress?.addressStreet2); + const result = { + street: streetLineOne, + street2: streetLineTwo, + city: tempAddress?.city?.trim() ?? '', + state: tempAddress?.state?.trim() ?? '', + zip: tempAddress?.zipCode?.trim().toUpperCase() ?? '', + country: tempAddress?.country ?? '', + }; + const address: Address = result; const updatePolicyAddress = (values: FormOnyxValues) => { if (!policy) { diff --git a/src/pages/workspace/WorkspaceOverviewClientIDPage.tsx b/src/pages/workspace/WorkspaceOverviewClientIDPage.tsx index 3cd426948f8d..17bc05c0078b 100644 --- a/src/pages/workspace/WorkspaceOverviewClientIDPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewClientIDPage.tsx @@ -17,7 +17,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/WorkspaceClientIDForm'; -import React, {useCallback} from 'react'; +import React from 'react'; import {Keyboard} from 'react-native'; import type {WithPolicyProps} from './withPolicy'; @@ -32,18 +32,15 @@ function WorkspaceOverviewClientIDPage({policy}: Props) { const {translate} = useLocalize(); const [account] = useOnyx(ONYXKEYS.ACCOUNT); - const submit = useCallback( - (values: FormOnyxValues) => { - if (!policy || policy.isPolicyUpdating) { - return; - } + const submit = (values: FormOnyxValues) => { + if (!policy || policy.isPolicyUpdating) { + return; + } - updateWorkspaceClientID(policy.id, values.clientID.trim(), policy.clientID); - Keyboard.dismiss(); - Navigation.goBack(); - }, - [policy], - ); + updateWorkspaceClientID(policy.id, values.clientID.trim(), policy.clientID); + Keyboard.dismiss(); + Navigation.goBack(); + }; return ( Parser.htmlToMarkdown(policy?.description ?? translate('workspace.common.defaultDescription'), {reportIDToName})); /** - * @param {Object} values - form input values passed by the Form component - * @returns {Boolean} + * @param values - form input values passed by the Form component */ - const validate = useCallback( - (values: FormOnyxValues) => { - const errors = {}; + const validate = (values: FormOnyxValues) => { + const errors = {}; - if (values.description.length > CONST.DESCRIPTION_LIMIT) { - addErrorMessage(errors, 'description', translate('common.error.characterLimitExceedCounter', values.description.length, CONST.DESCRIPTION_LIMIT)); - } + if (values.description.length > CONST.DESCRIPTION_LIMIT) { + addErrorMessage(errors, 'description', translate('common.error.characterLimitExceedCounter', values.description.length, CONST.DESCRIPTION_LIMIT)); + } - return errors; - }, - [translate], - ); + return errors; + }; - const submit = useCallback( - (values: FormOnyxValues) => { - if (!policy || policy.isPolicyUpdating) { - return; - } - - updateWorkspaceDescription(policy.id, values.description.trim(), policy.description, getReviewWorkspaceSettingsTaskCompletion()); - Keyboard.dismiss(); - Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack()); - }, - [policy, getReviewWorkspaceSettingsTaskCompletion], - ); + const submit = (values: FormOnyxValues) => { + if (!policy || policy.isPolicyUpdating) { + return; + } + + updateWorkspaceDescription(policy.id, values.description.trim(), policy.description, getReviewWorkspaceSettingsTaskCompletion()); + Keyboard.dismiss(); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack()); + }; return ( getRulesDocumentSourceURL(policy?.rulesDocumentURL, policyID, session?.encryptedAuthToken ?? ''), - [policy?.rulesDocumentURL, policyID, session?.encryptedAuthToken], - ); + const rulesDocumentSourceURL = getRulesDocumentSourceURL(policy?.rulesDocumentURL, policyID, session?.encryptedAuthToken ?? ''); const hasRulesDocument = !!policy?.rulesDocumentURL; const hasCustomRulesText = !StringUtils.isEmptyString(policy?.customRules ?? ''); - const handleRulesDocumentPicked = useCallback( - (files: FileObject[]) => { - const file = files.at(0); - if (!policyID || !file) { - return; - } - setPendingRulesDocumentFile(file); - }, - [policyID], - ); + const handleRulesDocumentPicked = (files: FileObject[]) => { + const file = files.at(0); + if (!policyID || !file) { + return; + } + setPendingRulesDocumentFile(file); + }; const rulesDocumentURL = policy?.rulesDocumentURL; - const getRulesDocumentMenuItems = useCallback( - (openPicker: (options: {onPicked: (files: FileObject[]) => void}) => void): PopoverMenuItem[] => [ - { - text: translate('common.replace'), - icon: expensifyIcons.Upload, - shouldCallAfterModalHide: true, - onSelected: () => { - openPicker({ - onPicked: handleRulesDocumentPicked, - }); - }, + const getRulesDocumentMenuItems = (openPicker: (options: {onPicked: (files: FileObject[]) => void}) => void): PopoverMenuItem[] => [ + { + text: translate('common.replace'), + icon: expensifyIcons.Upload, + shouldCallAfterModalHide: true, + onSelected: () => { + openPicker({ + onPicked: handleRulesDocumentPicked, + }); }, - { - text: translate('common.remove'), - icon: expensifyIcons.Trashcan, - onSelected: () => { - if (!policyID || !rulesDocumentURL) { - return; - } - deletePolicyRulesDocument(policyID, rulesDocumentURL); - }, + }, + { + text: translate('common.remove'), + icon: expensifyIcons.Trashcan, + onSelected: () => { + if (!policyID || !rulesDocumentURL) { + return; + } + deletePolicyRulesDocument(policyID, rulesDocumentURL); }, - ], - [translate, expensifyIcons, handleRulesDocumentPicked, policyID, rulesDocumentURL], - ); + }, + ]; const shouldShowExpensePolicySection = isPolicyAdmin || hasRulesDocument || hasCustomRulesText; // The primary office is listed first and the rest by name, since the server returns them keyed by random officeIDs @@ -296,14 +287,14 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const isPendingDelete = isPendingDeletePolicy(policy); const prevIsPendingDelete = usePrevious(isPendingDelete); - const mentionReportContextValue = useMemo(() => ({policyID, currentReportID: undefined, exactlyMatch: true}), [policyID]); + const mentionReportContextValue = {policyID, currentReportID: undefined, exactlyMatch: true}; - const fetchPolicyData = useCallback(() => { + const fetchPolicyData = () => { if (policyDraftID || !isFocused) { return; } openPolicyProfilePage(routePolicyID); - }, [policyDraftID, isFocused, routePolicyID]); + }; useNetwork({onReconnect: fetchPolicyData}); diff --git a/src/pages/workspace/WorkspaceOverviewSharePage.tsx b/src/pages/workspace/WorkspaceOverviewSharePage.tsx index c836751d4580..920393c0d105 100644 --- a/src/pages/workspace/WorkspaceOverviewSharePage.tsx +++ b/src/pages/workspace/WorkspaceOverviewSharePage.tsx @@ -30,7 +30,7 @@ import ROUTES from '@src/ROUTES'; import type {ImageSourcePropType} from 'react-native'; -import React, {useMemo, useRef} from 'react'; +import React, {useRef} from 'react'; import {View} from 'react-native'; import type {WithPolicyProps} from './withPolicy'; @@ -65,12 +65,7 @@ function WorkspaceOverviewSharePage({policy}: WithPolicyProps) { const logoBackgroundColor = !hasAvatar ? defaultWorkspaceAvatarColors.backgroundColor?.toString() : undefined; const svgLogoFillColor = !hasAvatar ? defaultWorkspaceAvatarColors.fill : undefined; - const adminRoomReportID = useMemo(() => { - if (!policy?.id) { - return undefined; - } - return getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, policy?.id)?.reportID ?? String(policy?.chatReportIDAdmins); - }, [policy?.chatReportIDAdmins, policy?.id]); + const adminRoomReportID = !policy?.id ? undefined : (getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, policy?.id)?.reportID ?? String(policy?.chatReportIDAdmins)); const adminsRoomLink = adminRoomReportID ? `${urlWithTrailingSlash}${ROUTES.REPORT_WITH_ID.getRoute(adminRoomReportID)}` : ''; diff --git a/src/pages/workspace/WorkspacePageWithSections.tsx b/src/pages/workspace/WorkspacePageWithSections.tsx index 647869f5d52d..2b64e999e7fa 100644 --- a/src/pages/workspace/WorkspacePageWithSections.tsx +++ b/src/pages/workspace/WorkspacePageWithSections.tsx @@ -31,7 +31,7 @@ import type {ReactNode} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {useIsFocused} from '@react-navigation/native'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; @@ -159,7 +159,7 @@ function WorkspacePageWithSections({ fetchData(policyID, shouldSkipVBBACall); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const shouldShowPolicy = useMemo(() => shouldShowPolicyUtil(policy, false, currentUserLogin), [policy, currentUserLogin]); + const shouldShowPolicy = shouldShowPolicyUtil(policy, false, currentUserLogin); let hasAccessToPolicyFeature: boolean | undefined; if (policyFeature) { hasAccessToPolicyFeature = canMemberRead(policy, currentUserLogin ?? '', policyFeature); @@ -174,29 +174,18 @@ function WorkspacePageWithSections({ } const hasWorkspaceBeenDeleted = deletedPolicyID !== undefined && deletedPolicyID === policyID; - const shouldShow = useMemo(() => { - // Keep the not-found view suppressed after the workspace is deleted so it doesn't flash during the exit animation. - if (hasWorkspaceBeenDeleted) { - return false; - } - - // Don't show the not-found view when the user is outside the workspace flow (e.g. on another tab). - if (!isWorkspacesTabFocused) { - return false; - } - - // If the policy object doesn't exist or contains only error data, we shouldn't display it. - if (((isEmptyObject(policy) || (Object.keys(policy).length === 1 && !isEmptyObject(policy.errors))) && isEmptyObject(policyDraft)) || shouldShowNotFoundPage) { - return true; - } - + let shouldShow; + // Suppress the not-found view during the deletion exit animation and while outside the workspace flow. + if (hasWorkspaceBeenDeleted || !isWorkspacesTabFocused) { + shouldShow = false; + } else if (((isEmptyObject(policy) || (Object.keys(policy).length === 1 && !isEmptyObject(policy.errors))) && isEmptyObject(policyDraft)) || shouldShowNotFoundPage) { + shouldShow = true; + } else { // We check isPendingDelete and prevIsPendingDelete to prevent the NotFound view from showing right after we delete the workspace const canShowPage = hasAccessToPolicyFeature ?? (canEditWorkspaceSettings(policy, currentUserLogin) || shouldShowNonAdmin); - const shouldShowPolicyOrFeature = hasAccessToPolicyFeature ?? shouldShowPolicy; - return (!isEmptyObject(policy) && !canShowPage) || (!shouldShowPolicyOrFeature && !(isPendingDelete && !prevIsPendingDelete)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentUserLogin, hasAccessToPolicyFeature, hasWorkspaceBeenDeleted, isWorkspacesTabFocused, policy, shouldShowNonAdmin, shouldShowPolicy]); + shouldShow = (!isEmptyObject(policy) && !canShowPage) || (!shouldShowPolicyOrFeature && !(isPendingDelete && !prevIsPendingDelete)); + } const handleOnBackButtonPress = () => { if (shouldShow) { diff --git a/src/pages/workspace/accounting/AccountingContext/index.tsx b/src/pages/workspace/accounting/AccountingContext/index.tsx index 90c22534e904..3a4f840c4802 100644 --- a/src/pages/workspace/accounting/AccountingContext/index.tsx +++ b/src/pages/workspace/accounting/AccountingContext/index.tsx @@ -26,7 +26,7 @@ import type {ComponentRef, RefObject} from 'react'; import type {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import type {ActiveIntegration, ActiveIntegrationState} from './types'; @@ -81,7 +81,7 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider [], ); - const closeConfirmationModal = useCallback(() => { + const closeConfirmationModal = () => { setActiveIntegration((prev) => { if (prev) { return { @@ -92,7 +92,7 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider } return undefined; }); - }, []); + }; const accountingIcons = useMemoizedLazyExpensifyIcons([ 'IntacctSquare', @@ -117,163 +117,138 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider const [cardFeeds] = useCardFeeds(policyID); const [cardLists] = useCardsLists(); - const startIntegrationFlow = useCallback( - (newActiveIntegration: ActiveIntegration) => { - if (!policyID) { - return; - } + const startIntegrationFlow = (newActiveIntegration: ActiveIntegration) => { + if (!policyID) { + return; + } - if ( - tryNavigateToSubmitWorkspaceUpgrade( - policy, - true, - CONST.UPGRADE_FEATURE_INTRO_MAPPING.accounting.alias, - isUnifiedConnectionsBetaEnabled ? ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID) : undefined, - ) - ) { - return; - } + if ( + tryNavigateToSubmitWorkspaceUpgrade( + policy, + true, + CONST.UPGRADE_FEATURE_INTRO_MAPPING.accounting.alias, + isUnifiedConnectionsBetaEnabled ? ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID) : undefined, + ) + ) { + return; + } - const accountingIntegrationData = getAccountingIntegrationData( - newActiveIntegration.name, - policyID, - translate, - { - sageIntacct: hasReusablePoliciesConnectedToSageIntacct, - qbd: hasReusablePoliciesConnectedToQBD, - certinia: hasReusablePoliciesConnectedToCertinia, - rillet: hasReusablePoliciesConnectedToRillet, - dualEntry: hasReusablePoliciesConnectedToDualEntry, - campfire: hasReusablePoliciesConnectedToCampfire, - }, - { - integrationToDisconnect: newActiveIntegration.integrationToDisconnect, - shouldDisconnectIntegrationBeforeConnecting: newActiveIntegration.shouldDisconnectIntegrationBeforeConnecting, - expensifyIcons: accountingIcons, - cardFeeds, - cardList: cardLists, - isIntuitEnterpriseSuiteOverride: newActiveIntegration.isIntuitEnterpriseSuite, - isUnifiedConnectionsBetaEnabled, - }, + const accountingIntegrationData = getAccountingIntegrationData( + newActiveIntegration.name, + policyID, + translate, + { + sageIntacct: hasReusablePoliciesConnectedToSageIntacct, + qbd: hasReusablePoliciesConnectedToQBD, + certinia: hasReusablePoliciesConnectedToCertinia, + rillet: hasReusablePoliciesConnectedToRillet, + dualEntry: hasReusablePoliciesConnectedToDualEntry, + campfire: hasReusablePoliciesConnectedToCampfire, + }, + { + integrationToDisconnect: newActiveIntegration.integrationToDisconnect, + shouldDisconnectIntegrationBeforeConnecting: newActiveIntegration.shouldDisconnectIntegrationBeforeConnecting, + expensifyIcons: accountingIcons, + cardFeeds, + cardList: cardLists, + isIntuitEnterpriseSuiteOverride: newActiveIntegration.isIntuitEnterpriseSuite, + isUnifiedConnectionsBetaEnabled, + }, + ); + + // Connections has no feature toggle, so turn it on here. The Control upgrade only enables the integration's own feature. + const shouldEnableConnections = isUnifiedConnectionsBetaEnabled && !isPolicyFeatureEnabled(policy, CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED); + if (shouldEnableConnections) { + enablePolicyFeatureForConnection(policy, CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED); + } + + const workspaceUpgradeNavigationDetails = accountingIntegrationData?.workspaceUpgradeNavigationDetails; + if (workspaceUpgradeNavigationDetails && !isControlPolicy(policy)) { + Navigation.navigate( + ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, workspaceUpgradeNavigationDetails.integrationAlias, workspaceUpgradeNavigationDetails.backToAfterWorkspaceUpgradeRoute), ); + return; + } + const activeIntegrationToStart = { + ...newActiveIntegration, + key: Math.random(), + }; + // The setup pages redirect to More features while Connections is off, so they open a frame later, once the policy shows it on + if (shouldEnableConnections) { + requestAnimationFrame(() => setActiveIntegration(activeIntegrationToStart)); + } else { + setActiveIntegration(activeIntegrationToStart); + } - // Connections has no feature toggle, so turn it on here. The Control upgrade only enables the integration's own feature. - const shouldEnableConnections = isUnifiedConnectionsBetaEnabled && !isPolicyFeatureEnabled(policy, CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED); - if (shouldEnableConnections) { - enablePolicyFeatureForConnection(policy, CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED); - } + const integrationToDisconnect = newActiveIntegration.integrationToDisconnect; + if (!newActiveIntegration.shouldDisconnectIntegrationBeforeConnecting || !integrationToDisconnect) { + return; + } - const workspaceUpgradeNavigationDetails = accountingIntegrationData?.workspaceUpgradeNavigationDetails; - if (workspaceUpgradeNavigationDetails && !isControlPolicy(policy)) { - Navigation.navigate( - ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, workspaceUpgradeNavigationDetails.integrationAlias, workspaceUpgradeNavigationDetails.backToAfterWorkspaceUpgradeRoute), - ); + // 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); + const modalCopy = isUnifiedConnectionsBetaEnabled + ? { + title: translate('workspace.connections.replaceConnectionTitle'), + prompt: translate('workspace.connections.replaceConnectionPrompt', getAccountingIntegrationDisplayName(policy, integrationToDisconnect, translate)), + confirmText: translate('common.replace'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + } + : { + title: translate('workspace.accounting.connectTitle', connectionName), + prompt: translate('workspace.accounting.connectPrompt', connectionName), + confirmText: translate('workspace.accounting.setup'), + buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, + }; + + isDisconnectConfirmationPendingRef.current = true; + + showConfirmModal({ + // `startIntegrationFlow` can run more than once for the same flow (the `useFocusEffect` in + // `PolicyAccountingPage` and `WorkspaceConnectionsPage` 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, + ...modalCopy, + cancelText: translate('common.cancel'), + }).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; } - const activeIntegrationToStart = { - ...newActiveIntegration, - key: Math.random(), - }; - // The setup pages redirect to More features while Connections is off, so they open a frame later, once the policy shows it on - if (shouldEnableConnections) { - requestAnimationFrame(() => setActiveIntegration(activeIntegrationToStart)); - } else { - setActiveIntegration(activeIntegrationToStart); + isDisconnectConfirmationPendingRef.current = false; + + if (result.action !== ModalActions.CONFIRM) { + setActiveIntegration(undefined); + return; } - const integrationToDisconnect = newActiveIntegration.integrationToDisconnect; - if (!newActiveIntegration.shouldDisconnectIntegrationBeforeConnecting || !integrationToDisconnect) { + 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; } - // 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); - const modalCopy = isUnifiedConnectionsBetaEnabled - ? { - title: translate('workspace.connections.replaceConnectionTitle'), - prompt: translate('workspace.connections.replaceConnectionPrompt', getAccountingIntegrationDisplayName(policy, integrationToDisconnect, translate)), - confirmText: translate('common.replace'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - } - : { - title: translate('workspace.accounting.connectTitle', connectionName), - prompt: translate('workspace.accounting.connectPrompt', connectionName), - confirmText: translate('workspace.accounting.setup'), - buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, - }; - - isDisconnectConfirmationPendingRef.current = true; - - showConfirmModal({ - // `startIntegrationFlow` can run more than once for the same flow (the `useFocusEffect` in - // `PolicyAccountingPage` and `WorkspaceConnectionsPage` 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, - ...modalCopy, - cancelText: translate('common.cancel'), - }).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, - hasReusablePoliciesConnectedToCertinia, - hasReusablePoliciesConnectedToRillet, - hasReusablePoliciesConnectedToDualEntry, - hasReusablePoliciesConnectedToCampfire, - accountingIcons, - cardFeeds, - cardLists, - isUnifiedConnectionsBetaEnabled, - ], - ); + removePolicyConnection(currentPolicy, integrationToDisconnect); - const stateValue = useMemo( - () => ({ - activeIntegration, - popoverAnchorRefs, - }), - [activeIntegration], - ); + closeConfirmationModal(); + }); + }; - const actionsValue = useMemo( - () => ({ - startIntegrationFlow, - }), - [startIntegrationFlow], - ); + const stateValue = { + activeIntegration, + popoverAnchorRefs, + }; + + const actionsValue = { + startIntegrationFlow, + }; const renderActiveIntegration = () => { if (!policyID || !activeIntegration) { diff --git a/src/pages/workspace/accounting/ClaimOfferPage.tsx b/src/pages/workspace/accounting/ClaimOfferPage.tsx index 624d931c4e0b..2560556c6035 100644 --- a/src/pages/workspace/accounting/ClaimOfferPage.tsx +++ b/src/pages/workspace/accounting/ClaimOfferPage.tsx @@ -33,7 +33,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type {PolicyFeatureName} from '@src/types/onyx/Policy'; -import React, {useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import {AccountingContextProvider, useAccountingActions} from './AccountingContext'; @@ -68,15 +68,10 @@ function ClaimOfferPage({route, policy}: ClaimOfferPageProps) { const syncingAccountingIntegration = accountingIntegrations.find((integrationName) => integrationName === connectionSyncProgress?.connectionName); const connectedIntegration = getConnectedIntegration(policy, accountingIntegrations) ?? syncingAccountingIntegration; - const isConnectedToIntegration = useMemo(() => { - if (integration === CONST.POLICY.CONNECTIONS.NAME.XERO) { - return connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.XERO; - } - if (integration === CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER) { - return isUberConnected; - } - return false; - }, [connectedIntegration, integration, isUberConnected]); + const isConnectedToIntegration = + integration === CONST.POLICY.CONNECTIONS.NAME.XERO + ? connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.XERO + : integration === CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER && isUberConnected; const integrationConfig: Record = { xero: { diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index cbf6899f19b8..adda2ede3ba4 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -41,7 +41,7 @@ import ROUTES from '@src/ROUTES'; import type {ConnectionName} from '@src/types/onyx/Policy'; import {useFocusEffect, useRoute} from '@react-navigation/native'; -import React, {useCallback, useMemo, useRef} from 'react'; +import React, {useCallback, useRef} from 'react'; import {View} from 'react-native'; import type {MenuItemData, PolicyAccountingPageProps} from './types'; @@ -325,19 +325,18 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { }; const otherIntegrationsItems = getOtherIntegrationsItems(); - const [chatTextLink, chatReportID] = useMemo(() => { - // If they have an onboarding specialist assigned display the following and link to the #admins room with the account executive. - if (policy?.chatReportIDAdmins) { - return [translate('workspace.accounting.talkYourOnboardingSpecialist'), policy?.chatReportIDAdmins?.toString()]; - } - - // If not, if they have an account manager assigned display the following and link to the DM with their account manager. - if (account?.accountManagerAccountID) { - return [translate('workspace.accounting.talkYourAccountManager'), account?.accountManagerReportID]; - } + let derivedValue; + // If they have an onboarding specialist assigned display the following and link to the #admins room with the account executive. + if (policy?.chatReportIDAdmins) { + derivedValue = [translate('workspace.accounting.talkYourOnboardingSpecialist'), policy?.chatReportIDAdmins?.toString()]; + } else // If not, if they have an account manager assigned display the following and link to the DM with their account manager. + if (account?.accountManagerAccountID) { + derivedValue = [translate('workspace.accounting.talkYourAccountManager'), account?.accountManagerReportID]; + } else { // Else, display the following and link to their Concierge DM. - return [translate('workspace.accounting.talkToConcierge'), conciergeReportID]; - }, [account?.accountManagerAccountID, account?.accountManagerReportID, conciergeReportID, policy?.chatReportIDAdmins, translate]); + derivedValue = [translate('workspace.accounting.talkToConcierge'), conciergeReportID]; + } + const [chatTextLink, chatReportID] = derivedValue; return ( ; @@ -44,49 +44,46 @@ function DynamicSageIntacctPrerequisitesPage({route}: DynamicSageIntacctPrerequi const navigateToEnterCredentials = () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTER_CREDENTIALS.getRoute(policyID)); - const menuItems = useMemo( - () => [ - { - title: translate('workspace.intacct.downloadExpensifyPackage'), - key: 'workspace.intacct.downloadExpensifyPackage', - icon: icons.Download, - iconRight: icons.NewWindow, - shouldShowRightIcon: true, - onPress: () => { - fileDownload(translate, CONST.EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT, CONST.EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT_FILE_NAME, '', true); - }, - onSecondaryInteraction: (event: GestureResponderEvent | MouseEvent) => - showContextMenu({ - type: CONST.CONTEXT_MENU_TYPES.LINK, - event, - selection: CONST.EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT, - contextMenuAnchor: popoverAnchor.current, - }), - shouldShowContextMenuHint: true, - numberOfLinesTitle: 2, + const menuItems = [ + { + title: translate('workspace.intacct.downloadExpensifyPackage'), + key: 'workspace.intacct.downloadExpensifyPackage', + icon: icons.Download, + iconRight: icons.NewWindow, + shouldShowRightIcon: true, + onPress: () => { + fileDownload(translate, CONST.EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT, CONST.EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT_FILE_NAME, '', true); }, - { - title: translate('workspace.intacct.followSteps'), - key: 'workspace.intacct.followSteps', - icon: icons.Task, - iconRight: icons.NewWindow, - shouldShowRightIcon: true, - onPress: () => { - openExternalLink(CONST.HOW_TO_CONNECT_TO_SAGE_INTACCT); - }, - onSecondaryInteraction: (event: GestureResponderEvent | MouseEvent) => - showContextMenu({ - type: CONST.CONTEXT_MENU_TYPES.LINK, - event, - selection: CONST.HOW_TO_CONNECT_TO_SAGE_INTACCT, - contextMenuAnchor: popoverAnchor.current, - }), - shouldShowContextMenuHint: true, - numberOfLinesTitle: 3, + onSecondaryInteraction: (event: GestureResponderEvent | MouseEvent) => + showContextMenu({ + type: CONST.CONTEXT_MENU_TYPES.LINK, + event, + selection: CONST.EXPENSIFY_PACKAGE_FOR_SAGE_INTACCT, + contextMenuAnchor: popoverAnchor.current, + }), + shouldShowContextMenuHint: true, + numberOfLinesTitle: 2, + }, + { + title: translate('workspace.intacct.followSteps'), + key: 'workspace.intacct.followSteps', + icon: icons.Task, + iconRight: icons.NewWindow, + shouldShowRightIcon: true, + onPress: () => { + openExternalLink(CONST.HOW_TO_CONNECT_TO_SAGE_INTACCT); }, - ], - [icons.Download, icons.NewWindow, translate, icons.Task], - ); + onSecondaryInteraction: (event: GestureResponderEvent | MouseEvent) => + showContextMenu({ + type: CONST.CONTEXT_MENU_TYPES.LINK, + event, + selection: CONST.HOW_TO_CONNECT_TO_SAGE_INTACCT, + contextMenuAnchor: popoverAnchor.current, + }), + shouldShowContextMenuHint: true, + numberOfLinesTitle: 3, + }, + ]; return ( ; @@ -32,29 +32,23 @@ function EnterSageIntacctCredentialsPage({route}: SageIntacctPrerequisitesPagePr const {inputCallbackRef} = useAutoFocusInput(); const policyID: string = route.params.policyID; - const confirmCredentials = useCallback( - (values: FormOnyxValues) => { - connectToSageIntacct(policyID, values); - Navigation.dismissModal(); - }, - [policyID], - ); + const confirmCredentials = (values: FormOnyxValues) => { + connectToSageIntacct(policyID, values); + Navigation.dismissModal(); + }; const formItems = Object.values(INPUT_IDS); - const validate = useCallback( - (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; - for (const formItem of formItems) { - if (values[formItem]) { - continue; - } - addErrorMessage(errors, formItem, translate('common.error.fieldRequired')); + for (const formItem of formItems) { + if (values[formItem]) { + continue; } - return errors; - }, - [formItems, translate], - ); + addErrorMessage(errors, formItem, translate('common.error.fieldRequired')); + } + return errors; + }; return ( ( - - {translate('workspace.sageIntacct.accountingMethods.description')} - - ), - [translate, styles.pb5, styles.ph5], + const headerContent = ( + + {translate('workspace.sageIntacct.accountingMethods.description')} + ); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); + }; - const selectExpenseReportApprovalLevel = useCallback( - (row: MenuListItem) => { - if (row.value !== accountingMethod) { - updateSageIntacctAccountingMethod(policyID, row.value, accountingMethod); - } - goBack(); - }, - [accountingMethod, policyID, goBack], - ); + const selectExpenseReportApprovalLevel = (row: MenuListItem) => { + if (row.value !== accountingMethod) { + updateSageIntacctAccountingMethod(policyID, row.value, accountingMethod); + } + goBack(); + }; return ( bankAccount.id === reimbursementAccountID)?.name ?? reimbursementAccountID; @@ -55,50 +55,47 @@ function SageIntacctAdvancedPage({policy}: WithPolicyProps) { const {isAccordionExpanded, shouldAnimateAccordionSection} = useAccordionAnimation(!!sync?.syncReimbursedReports); - const toggleSections = useMemo( - () => [ - { - label: translate('workspace.sageIntacct.inviteEmployees'), - description: translate('workspace.sageIntacct.inviteEmployeesDescription'), - isActive: !!importEmployees, - onToggle: (enabled: boolean) => { - if (!policyID) { - return; - } - updateSageIntacctImportEmployees(policyID, enabled); - updateSageIntacctApprovalMode(policyID, enabled); - }, - subscribedSettings: [CONST.SAGE_INTACCT_CONFIG.IMPORT_EMPLOYEES, CONST.SAGE_INTACCT_CONFIG.APPROVAL_MODE], - error: getLatestErrorField(config ?? {}, CONST.SAGE_INTACCT_CONFIG.IMPORT_EMPLOYEES) ?? getLatestErrorField(config ?? {}, CONST.SAGE_INTACCT_CONFIG.APPROVAL_MODE), - onCloseError: () => { - clearSageIntacctErrorField(policyID, CONST.SAGE_INTACCT_CONFIG.IMPORT_EMPLOYEES); - clearSageIntacctErrorField(policyID, CONST.SAGE_INTACCT_CONFIG.APPROVAL_MODE); - }, + const toggleSections = [ + { + label: translate('workspace.sageIntacct.inviteEmployees'), + description: translate('workspace.sageIntacct.inviteEmployeesDescription'), + isActive: !!importEmployees, + onToggle: (enabled: boolean) => { + if (!policyID) { + return; + } + updateSageIntacctImportEmployees(policyID, enabled); + updateSageIntacctApprovalMode(policyID, enabled); }, - { - label: translate('workspace.sageIntacct.syncReimbursedReports'), - description: translate('workspace.sageIntacct.syncReimbursedReportsDescription'), - isActive: !!sync?.syncReimbursedReports, - onToggle: (enabled: boolean) => { - if (!policyID) { - return; - } - updateSageIntacctSyncReimbursedReports(policyID, enabled); + subscribedSettings: [CONST.SAGE_INTACCT_CONFIG.IMPORT_EMPLOYEES, CONST.SAGE_INTACCT_CONFIG.APPROVAL_MODE], + error: getLatestErrorField(config ?? {}, CONST.SAGE_INTACCT_CONFIG.IMPORT_EMPLOYEES) ?? getLatestErrorField(config ?? {}, CONST.SAGE_INTACCT_CONFIG.APPROVAL_MODE), + onCloseError: () => { + clearSageIntacctErrorField(policyID, CONST.SAGE_INTACCT_CONFIG.IMPORT_EMPLOYEES); + clearSageIntacctErrorField(policyID, CONST.SAGE_INTACCT_CONFIG.APPROVAL_MODE); + }, + }, + { + label: translate('workspace.sageIntacct.syncReimbursedReports'), + description: translate('workspace.sageIntacct.syncReimbursedReportsDescription'), + isActive: !!sync?.syncReimbursedReports, + onToggle: (enabled: boolean) => { + if (!policyID) { + return; + } + updateSageIntacctSyncReimbursedReports(policyID, enabled); - if (enabled && !sync?.reimbursementAccountID) { - const reimbursementAccountID = data?.bankAccounts[0]?.id; - updateSageIntacctSyncReimbursementAccountID(policyID, reimbursementAccountID); - } - }, - subscribedSettings: [CONST.SAGE_INTACCT_CONFIG.SYNC_REIMBURSED_REPORTS], - error: getLatestErrorField(config ?? {}, CONST.SAGE_INTACCT_CONFIG.SYNC_REIMBURSED_REPORTS), - onCloseError: () => { - clearSageIntacctErrorField(policyID, CONST.SAGE_INTACCT_CONFIG.SYNC_REIMBURSED_REPORTS); - }, + if (enabled && !sync?.reimbursementAccountID) { + const reimbursementAccountID = data?.bankAccounts[0]?.id; + updateSageIntacctSyncReimbursementAccountID(policyID, reimbursementAccountID); + } }, - ], - [translate, config, importEmployees, sync?.syncReimbursedReports, sync?.reimbursementAccountID, policyID, data?.bankAccounts], - ); + subscribedSettings: [CONST.SAGE_INTACCT_CONFIG.SYNC_REIMBURSED_REPORTS], + error: getLatestErrorField(config ?? {}, CONST.SAGE_INTACCT_CONFIG.SYNC_REIMBURSED_REPORTS), + onCloseError: () => { + clearSageIntacctErrorField(policyID, CONST.SAGE_INTACCT_CONFIG.SYNC_REIMBURSED_REPORTS); + }, + }, + ]; return ( ; @@ -41,9 +41,9 @@ function SageIntacctAutoSyncPageBase({policy, navigateBackTo}: SageIntacctAutoSy const accountingMethod = config?.export?.accountingMethod ?? COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH; const pendingAction = settingsPendingAction([CONST.SAGE_INTACCT_CONFIG.AUTO_SYNC], pendingFields) ?? settingsPendingAction([CONST.SAGE_INTACCT_CONFIG.ACCOUNTING_METHOD], pendingFields); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(navigateBackTo ?? ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ADVANCED.getRoute(policyID)); - }, [navigateBackTo, policyID]); + }; return ( (() => getSageIntacctBankAccounts(policy, config?.sync?.reimbursementAccountID), [policy, config?.sync?.reimbursementAccountID]); + const vendorSelectorOptions: SelectorType[] = getSageIntacctBankAccounts(policy, config?.sync?.reimbursementAccountID); - const updateDefaultVendor = useCallback( - ({value}: SelectorType) => { - if (value !== config?.sync?.reimbursementAccountID) { - updateSageIntacctSyncReimbursementAccountID(policyID, value, config?.sync?.reimbursementAccountID); - } - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ADVANCED.getRoute(policyID)); - }, - [policyID, config?.sync?.reimbursementAccountID], - ); + const updateDefaultVendor = ({value}: SelectorType) => { + if (value !== config?.sync?.reimbursementAccountID) { + updateSageIntacctSyncReimbursementAccountID(policyID, value, config?.sync?.reimbursementAccountID); + } + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ADVANCED.getRoute(policyID)); + }; - const listEmptyContent = useMemo( - () => ( - - ), - [illustrations.Telescope, translate], + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx index a9a0a87a538c..f03dad6ff608 100644 --- a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx +++ b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctDefaultVendorPage.tsx @@ -27,7 +27,7 @@ import type SCREENS from '@src/SCREENS'; import type {Connections} from '@src/types/onyx/Policy'; import {useRoute} from '@react-navigation/native'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; // Sage Intacct's default-vendor fields hold a raw vendor ID; sending this empty string clears the default. @@ -48,9 +48,9 @@ function DynamicSageIntacctDefaultVendorPage() { const reimbursableMode = route.params.reimbursable ?? (backPath.includes('/reimbursable') ? CONST.SAGE_INTACCT_CONFIG.REIMBURSABLE : CONST.SAGE_INTACCT_CONFIG.NON_REIMBURSABLE.toLowerCase()); const isReimbursable = reimbursableMode === CONST.SAGE_INTACCT_CONFIG.REIMBURSABLE; - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); + }; let defaultVendor; let settingName: keyof Connections['intacct']['config']['export']; @@ -70,57 +70,42 @@ function DynamicSageIntacctDefaultVendorPage() { // 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 vendorOptions: SelectorType[] = getSageIntacctVendors(policy, defaultVendor, localeCompare); + const clearOption: SelectorType = { + value: CLEAR_DEFAULT_VENDOR, + text: translate('common.none'), + keyForList: CLEAR_DEFAULT_VENDOR, + isSelected: !defaultVendor, + }; const shouldShowClearOption = canClear && (!!defaultVendor || vendorOptions.length > 0); - const vendorSelectorOptions = useMemo( - () => (shouldShowClearOption ? [clearOption, ...vendorOptions] : vendorOptions), - [shouldShowClearOption, clearOption, vendorOptions], - ); - - const listHeaderComponent = useMemo( - () => ( - - - {isReimbursable ? translate('workspace.sageIntacct.defaultVendorDescription', true) : translate('workspace.accounting.defaultVendorSelectHeader')} - - - ), - [translate, styles.pb2, styles.ph5, styles.pb5, styles.textNormal, isReimbursable], + const vendorSelectorOptions: SelectorType[] = shouldShowClearOption ? [clearOption, ...vendorOptions] : vendorOptions; + + const listHeaderComponent = ( + + + {isReimbursable ? translate('workspace.sageIntacct.defaultVendorDescription', true) : translate('workspace.accounting.defaultVendorSelectHeader')} + + ); - const updateDefaultVendor = useCallback( - ({value}: SelectorType) => { - const isAlreadySelected = value === defaultVendor || (!value && !defaultVendor); - if (isAlreadySelected) { - return; - } - updateSageIntacctDefaultVendor(policyID, settingName, value, defaultVendor); - goBack(); - }, - [defaultVendor, policyID, settingName, goBack], - ); - - const listEmptyContent = useMemo( - () => ( - - ), - [translate, styles.pb10, illustrations.Telescope], + const updateDefaultVendor = ({value}: SelectorType) => { + const isAlreadySelected = value === defaultVendor || (!value && !defaultVendor); + if (isAlreadySelected) { + return; + } + updateSageIntacctDefaultVendor(policyID, settingName, value, defaultVendor); + goBack(); + }; + + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctNonReimbursableCreditCardAccountPage.tsx b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctNonReimbursableCreditCardAccountPage.tsx index d71ffc05fd26..5e2a2462b6fc 100644 --- a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctNonReimbursableCreditCardAccountPage.tsx +++ b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctNonReimbursableCreditCardAccountPage.tsx @@ -22,7 +22,7 @@ import {updateSageIntacctNonreimbursableExpensesExportAccount} from '@userAction import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; function DynamicSageIntacctNonReimbursableCreditCardAccountPage({policy}: WithPolicyConnectionsProps) { const styles = useThemeStyles(); @@ -34,34 +34,28 @@ function DynamicSageIntacctNonReimbursableCreditCardAccountPage({policy}: WithPo const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT.path); const illustrations = useMemoizedLazyIllustrations(['Telescope']); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); + }; - const creditCardSelectorOptions = useMemo(() => getSageIntacctCreditCards(policy, exportConfig?.nonReimbursableAccount), [exportConfig?.nonReimbursableAccount, policy]); + const creditCardSelectorOptions: SelectorType[] = getSageIntacctCreditCards(policy, exportConfig?.nonReimbursableAccount); - const updateCreditCardAccount = useCallback( - ({value}: SelectorType) => { - if (value !== exportConfig?.nonReimbursableAccount && policyID) { - updateSageIntacctNonreimbursableExpensesExportAccount(policyID, value, exportConfig?.nonReimbursableAccount); - } - goBack(); - }, - [policyID, exportConfig?.nonReimbursableAccount, goBack], - ); + const updateCreditCardAccount = ({value}: SelectorType) => { + if (value !== exportConfig?.nonReimbursableAccount && policyID) { + updateSageIntacctNonreimbursableExpensesExportAccount(policyID, value, exportConfig?.nonReimbursableAccount); + } + goBack(); + }; - const listEmptyContent = useMemo( - () => ( - - ), - [illustrations.Telescope, translate, styles.pb10], + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctNonReimbursableExpensesDestinationPage.tsx b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctNonReimbursableExpensesDestinationPage.tsx index 7ddee698e62e..d33afc1a6def 100644 --- a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctNonReimbursableExpensesDestinationPage.tsx +++ b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctNonReimbursableExpensesDestinationPage.tsx @@ -22,7 +22,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {ValueOf} from 'type-fest'; -import React, {useCallback} from 'react'; +import React from 'react'; type MenuListItem = ListItem & { value: ValueOf; @@ -42,19 +42,16 @@ function DynamicSageIntacctNonReimbursableExpensesDestinationPage({policy}: With isSelected: config?.export.nonReimbursable === expenseType, })); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); + }; - const selectDestination = useCallback( - (row: MenuListItem) => { - if (row.value !== config?.export.nonReimbursable && policyID) { - updateSageIntacctNonreimbursableExpensesExportDestination(policyID, row.value, config?.export.nonReimbursable); - } - goBack(); - }, - [config?.export.nonReimbursable, policyID, goBack], - ); + const selectDestination = (row: MenuListItem) => { + if (row.value !== config?.export.nonReimbursable && policyID) { + updateSageIntacctNonreimbursableExpensesExportDestination(policyID, row.value, config?.export.nonReimbursable); + } + goBack(); + }; return ( >(); const backTo = route.params?.backTo; - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backTo ?? (policyID && createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID)))); - }, [policyID, backTo]); + }; - const headerContent = useMemo( - () => ( - - {translate('workspace.sageIntacct.exportDate.description')} - - ), - [translate, styles.pb5, styles.ph5], + const headerContent = ( + + {translate('workspace.sageIntacct.exportDate.description')} + ); - const selectExportDate = useCallback( - (row: MenuListItem) => { - if (row.value !== exportConfig?.exportDate && policyID) { - updateSageIntacctExportDate(policyID, row.value, exportConfig?.exportDate); - } - goBack(); - }, - [exportConfig?.exportDate, policyID, goBack], - ); + const selectExportDate = (row: MenuListItem) => { + if (row.value !== exportConfig?.exportDate && policyID) { + updateSageIntacctExportDate(policyID, row.value, exportConfig?.exportDate); + } + goBack(); + }; return ( >(); const backTo = route.params?.backTo; - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backTo ?? (policyID && createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID)))); - }, [policyID, backTo]); - - const data: CardListItem[] = useMemo(() => { - if (!isEmpty(policyOwner) && isEmpty(exporters)) { - return [ - { - value: policyOwner, - text: policyOwner, - keyForList: policyOwner, - isSelected: exportConfiguration?.exporter === policyOwner, - }, - ]; + }; + + // Don't show guides if the current user is not a guide themselves or an Expensify employee + const data: CardListItem[] = + !isEmpty(policyOwner) && isEmpty(exporters) + ? [ + { + value: policyOwner, + text: policyOwner, + keyForList: policyOwner, + isSelected: exportConfiguration?.exporter === policyOwner, + }, + ] + : exporters?.reduce((options, exporter) => { + if (!exporter.email) { + return options; + } + if (isExpensifyTeam(exporter.email) && !isExpensifyTeam(policyOwner) && !isExpensifyTeam(currentUserLogin)) { + return options; + } + options.push({ + value: exporter.email, + text: exporter.email, + keyForList: exporter.email, + isSelected: exportConfiguration?.exporter === exporter.email, + }); + return options; + }, []); + + const selectExporter = (row: CardListItem) => { + if (row.value !== exportConfiguration?.exporter && policyID) { + updateSageIntacctExporter(policyID, row.value, exportConfiguration?.exporter); } - - return exporters?.reduce((options, exporter) => { - if (!exporter.email) { - return options; - } - - // Don't show guides if the current user is not a guide themselves or an Expensify employee - if (isExpensifyTeam(exporter.email) && !isExpensifyTeam(policyOwner) && !isExpensifyTeam(currentUserLogin)) { - return options; - } - - options.push({ - value: exporter.email, - text: exporter.email, - keyForList: exporter.email, - isSelected: exportConfiguration?.exporter === exporter.email, - }); - return options; - }, []); - }, [exportConfiguration?.exporter, exporters, policyOwner, currentUserLogin]); - - const selectExporter = useCallback( - (row: CardListItem) => { - if (row.value !== exportConfiguration?.exporter && policyID) { - updateSageIntacctExporter(policyID, row.value, exportConfiguration?.exporter); - } - goBack(); - }, - [policyID, exportConfiguration?.exporter, goBack], - ); - - const headerContent = useMemo( - () => ( - - {translate('workspace.sageIntacct.exportPreferredExporterNote')} - {translate('workspace.sageIntacct.exportPreferredExporterSubNote')} - - ), - [translate, styles.pb2, styles.ph5, styles.pb5, styles.textNormal], + goBack(); + }; + + const headerContent = ( + + {translate('workspace.sageIntacct.exportPreferredExporterNote')} + {translate('workspace.sageIntacct.exportPreferredExporterSubNote')} + ); return ( diff --git a/src/pages/workspace/accounting/intacct/export/SageIntacctReimbursableExpensesDestinationPage.tsx b/src/pages/workspace/accounting/intacct/export/SageIntacctReimbursableExpensesDestinationPage.tsx index 013e25b94fff..dc1fd5ad6a2f 100644 --- a/src/pages/workspace/accounting/intacct/export/SageIntacctReimbursableExpensesDestinationPage.tsx +++ b/src/pages/workspace/accounting/intacct/export/SageIntacctReimbursableExpensesDestinationPage.tsx @@ -25,7 +25,7 @@ import type SCREENS from '@src/SCREENS'; import type {ValueOf} from 'type-fest'; import {useRoute} from '@react-navigation/native'; -import React, {useCallback} from 'react'; +import React from 'react'; type MenuListItem = ListItem & { value: ValueOf; @@ -46,23 +46,20 @@ function SageIntacctReimbursableExpensesDestinationPage({policy}: WithPolicyConn isSelected: config?.export.reimbursable === expenseType, })); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backTo ?? (policyID && ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_REIMBURSABLE_EXPENSES.getRoute(policyID))); - }, [backTo, policyID]); + }; - const selectDestination = useCallback( - (row: MenuListItem) => { - if (row.value !== config?.export.reimbursable && policyID) { - updateSageIntacctReimbursableExpensesExportDestination(policyID, row.value, config?.export.reimbursable); - if (row.value === CONST.SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL) { - // Employee default mapping value is not allowed when expense type is VENDOR_BILL, so we have to change mapping value to Tag - changeMappingsValueFromDefaultToTag(policyID, config?.mappings); - } + const selectDestination = (row: MenuListItem) => { + if (row.value !== config?.export.reimbursable && policyID) { + updateSageIntacctReimbursableExpensesExportDestination(policyID, row.value, config?.export.reimbursable); + if (row.value === CONST.SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL) { + // Employee default mapping value is not allowed when expense type is VENDOR_BILL, so we have to change mapping value to Tag + changeMappingsValueFromDefaultToTag(policyID, config?.mappings); } - goBack(); - }, - [config?.export.reimbursable, config?.mappings, policyID, goBack], - ); + } + goBack(); + }; return ( ) => { - const errors: FormInputErrors = {}; + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; - if (!values[INPUT_IDS.INTEGRATION_NAME]) { - ErrorUtils.addErrorMessage(errors, INPUT_IDS.INTEGRATION_NAME, translate('common.error.fieldRequired')); - } + if (!values[INPUT_IDS.INTEGRATION_NAME]) { + ErrorUtils.addErrorMessage(errors, INPUT_IDS.INTEGRATION_NAME, translate('common.error.fieldRequired')); + } - if (userDimensions?.some((userDimension) => userDimension.dimension === values[INPUT_IDS.INTEGRATION_NAME])) { - ErrorUtils.addErrorMessage(errors, INPUT_IDS.INTEGRATION_NAME, translate('workspace.intacct.dimensionExists')); - } + if (userDimensions?.some((userDimension) => userDimension.dimension === values[INPUT_IDS.INTEGRATION_NAME])) { + ErrorUtils.addErrorMessage(errors, INPUT_IDS.INTEGRATION_NAME, translate('workspace.intacct.dimensionExists')); + } - if (!values[INPUT_IDS.DIMENSION_TYPE]) { - ErrorUtils.addErrorMessage(errors, INPUT_IDS.DIMENSION_TYPE, translate('common.error.fieldRequired')); - } - return errors; - }, - [translate, userDimensions], - ); + if (!values[INPUT_IDS.DIMENSION_TYPE]) { + ErrorUtils.addErrorMessage(errors, INPUT_IDS.DIMENSION_TYPE, translate('common.error.fieldRequired')); + } + return errors; + }; return ( userDimension.dimension === editedUserDimensionName); - const validate = useCallback( - (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; - if (!values[INPUT_IDS.INTEGRATION_NAME]) { - addErrorMessage(errors, INPUT_IDS.INTEGRATION_NAME, translate('common.error.fieldRequired')); - } + if (!values[INPUT_IDS.INTEGRATION_NAME]) { + addErrorMessage(errors, INPUT_IDS.INTEGRATION_NAME, translate('common.error.fieldRequired')); + } - if (userDimensions?.some((userDimension) => userDimension.dimension === values[INPUT_IDS.INTEGRATION_NAME] && editedUserDimensionName !== values[INPUT_IDS.INTEGRATION_NAME])) { - addErrorMessage(errors, INPUT_IDS.INTEGRATION_NAME, translate('workspace.intacct.dimensionExists')); - } + if (userDimensions?.some((userDimension) => userDimension.dimension === values[INPUT_IDS.INTEGRATION_NAME] && editedUserDimensionName !== values[INPUT_IDS.INTEGRATION_NAME])) { + addErrorMessage(errors, INPUT_IDS.INTEGRATION_NAME, translate('workspace.intacct.dimensionExists')); + } - if (!values[INPUT_IDS.DIMENSION_TYPE]) { - addErrorMessage(errors, INPUT_IDS.DIMENSION_TYPE, translate('common.error.fieldRequired')); - } - return errors; - }, - [editedUserDimensionName, translate, userDimensions], - ); + if (!values[INPUT_IDS.DIMENSION_TYPE]) { + addErrorMessage(errors, INPUT_IDS.DIMENSION_TYPE, translate('common.error.fieldRequired')); + } + return errors; + }; const icons = useMemoizedLazyExpensifyIcons(['Trashcan']); return ( diff --git a/src/pages/workspace/accounting/intacct/import/SageIntacctImportPage.tsx b/src/pages/workspace/accounting/intacct/import/SageIntacctImportPage.tsx index 4c99a0f0a93e..52a22f8cd896 100644 --- a/src/pages/workspace/accounting/intacct/import/SageIntacctImportPage.tsx +++ b/src/pages/workspace/accounting/intacct/import/SageIntacctImportPage.tsx @@ -23,7 +23,7 @@ import ROUTES from '@src/ROUTES'; import type {SageIntacctConnectionsConfig, SageIntacctMappingValue} from '@src/types/onyx/Policy'; import {Str} from 'expensify-common'; -import React, {useMemo} from 'react'; +import React from 'react'; function getDisplayTypeTranslationKey(displayType?: SageIntacctMappingValue): TranslationPaths | undefined { switch (displayType) { @@ -57,19 +57,15 @@ function SageIntacctImportPage({policy}: WithPolicyProps) { const sageIntacctConfig = policy?.connections?.intacct?.config; const sageIntacctData = policy?.connections?.intacct?.data; - const mappingItems = useMemo( - () => - Object.values(CONST.SAGE_INTACCT_CONFIG.MAPPINGS).map((mapping) => { - const menuItemTitleKey = getDisplayTypeTranslationKey(sageIntacctConfig?.mappings?.[mapping]); - return { - description: Str.recapitalize(translate('workspace.intacct.mappingTitle', mapping)), - action: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_TOGGLE_MAPPINGS.getRoute(policyID, mapping)), - title: menuItemTitleKey ? translate(menuItemTitleKey) : undefined, - subscribedSettings: [mapping], - }; - }), - [policyID, sageIntacctConfig?.mappings, translate], - ); + const mappingItems = Object.values(CONST.SAGE_INTACCT_CONFIG.MAPPINGS).map((mapping) => { + const menuItemTitleKey = getDisplayTypeTranslationKey(sageIntacctConfig?.mappings?.[mapping]); + return { + description: Str.recapitalize(translate('workspace.intacct.mappingTitle', mapping)), + action: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_TOGGLE_MAPPINGS.getRoute(policyID, mapping)), + title: menuItemTitleKey ? translate(menuItemTitleKey) : undefined, + subscribedSettings: [mapping], + }; + }); const isExpenseType = sageIntacctConfig?.export.reimbursable === CONST.SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE.EXPENSE_REPORT; diff --git a/src/pages/workspace/accounting/intacct/import/SageIntacctImportTaxMappingPage.tsx b/src/pages/workspace/accounting/intacct/import/SageIntacctImportTaxMappingPage.tsx index e5ecc13635a5..02fc0a10fd18 100644 --- a/src/pages/workspace/accounting/intacct/import/SageIntacctImportTaxMappingPage.tsx +++ b/src/pages/workspace/accounting/intacct/import/SageIntacctImportTaxMappingPage.tsx @@ -17,7 +17,7 @@ import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; type SageIntacctMappingsTypePageProps = PlatformStackScreenProps; @@ -33,28 +33,22 @@ function SageIntacctImportTaxMappingPage({route}: SageIntacctMappingsTypePagePro const sageIntacctConfigTaxSolutionID = sageIntacctConfig?.tax?.taxSolutionID; const sageIntacctData = policy?.connections?.intacct?.data; - const selectionOptions = useMemo(() => { - const mappingOptions: SelectorType[] = []; - const sageIntacctTaxSolutionIDs = sageIntacctData?.taxSolutionIDs ?? []; - for (const taxSolutionID of sageIntacctTaxSolutionIDs) { - mappingOptions.push({ - value: taxSolutionID, - text: taxSolutionID, - keyForList: taxSolutionID, - isSelected: sageIntacctConfigTaxSolutionID === taxSolutionID, - }); - } + const mappingOptions: SelectorType[] = []; + const sageIntacctTaxSolutionIDs = sageIntacctData?.taxSolutionIDs ?? []; + for (const taxSolutionID of sageIntacctTaxSolutionIDs) { + mappingOptions.push({ + value: taxSolutionID, + text: taxSolutionID, + keyForList: taxSolutionID, + isSelected: sageIntacctConfigTaxSolutionID === taxSolutionID, + }); + } + const selectionOptions: SelectorType[] = mappingOptions; - return mappingOptions; - }, [sageIntacctConfigTaxSolutionID, sageIntacctData?.taxSolutionIDs]); - - const updateMapping = useCallback( - ({value}: SelectorType) => { - UpdateSageIntacctTaxSolutionID(policyID, value); - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_IMPORT_TAX.getRoute(policyID)); - }, - [policyID], - ); + const updateMapping = ({value}: SelectorType) => { + UpdateSageIntacctTaxSolutionID(policyID, value); + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_IMPORT_TAX.getRoute(policyID)); + }; return ( ; @@ -34,49 +34,43 @@ function SageIntacctMappingsTypePage({route}: SageIntacctMappingsTypePageProps) const {config} = policy?.connections?.intacct ?? {}; const {mappings, pendingFields, export: exportConfig} = config ?? {}; - const selectionOptions = useMemo(() => { - const mappingOptions: SelectorType[] = []; - if ( - !([CONST.SAGE_INTACCT_CONFIG.MAPPINGS.CUSTOMERS, CONST.SAGE_INTACCT_CONFIG.MAPPINGS.PROJECTS] as string[]).includes(mappingName) && - exportConfig?.reimbursable !== CONST.SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL - ) { - mappingOptions.push({ - value: CONST.SAGE_INTACCT_MAPPING_VALUE.DEFAULT, - text: translate('workspace.intacct.employeeDefault'), - alternateText: translate('workspace.common.appliedOnExport'), - keyForList: CONST.SAGE_INTACCT_MAPPING_VALUE.DEFAULT, - isSelected: mappings?.[mappingName] === CONST.SAGE_INTACCT_MAPPING_VALUE.DEFAULT, - }); - } - mappingOptions.push( - ...[ - { - value: CONST.SAGE_INTACCT_MAPPING_VALUE.TAG, - text: translate('workspace.common.tags'), - alternateText: translate('workspace.common.lineItemLevel'), - keyForList: CONST.SAGE_INTACCT_MAPPING_VALUE.TAG, - isSelected: mappings?.[mappingName] === CONST.SAGE_INTACCT_MAPPING_VALUE.TAG, - }, - { - value: CONST.SAGE_INTACCT_MAPPING_VALUE.REPORT_FIELD, - text: translate('workspace.common.reportFields'), - alternateText: translate('workspace.common.reportLevel'), - keyForList: CONST.SAGE_INTACCT_MAPPING_VALUE.REPORT_FIELD, - isSelected: mappings?.[mappingName] === CONST.SAGE_INTACCT_MAPPING_VALUE.REPORT_FIELD, - }, - ], - ); - - return mappingOptions; - }, [exportConfig?.reimbursable, mappingName, mappings, translate]); - - const updateMapping = useCallback( - ({value}: SelectorType) => { - updateSageIntacctMappingValue(policyID, mappingName, value as SageIntacctMappingValue, mappings?.[mappingName]); - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_TOGGLE_MAPPINGS.getRoute(policyID, mappingName)); - }, - [mappingName, policyID, mappings], + const mappingOptions: SelectorType[] = []; + if ( + !([CONST.SAGE_INTACCT_CONFIG.MAPPINGS.CUSTOMERS, CONST.SAGE_INTACCT_CONFIG.MAPPINGS.PROJECTS] as string[]).includes(mappingName) && + exportConfig?.reimbursable !== CONST.SAGE_INTACCT_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL + ) { + mappingOptions.push({ + value: CONST.SAGE_INTACCT_MAPPING_VALUE.DEFAULT, + text: translate('workspace.intacct.employeeDefault'), + alternateText: translate('workspace.common.appliedOnExport'), + keyForList: CONST.SAGE_INTACCT_MAPPING_VALUE.DEFAULT, + isSelected: mappings?.[mappingName] === CONST.SAGE_INTACCT_MAPPING_VALUE.DEFAULT, + }); + } + mappingOptions.push( + ...[ + { + value: CONST.SAGE_INTACCT_MAPPING_VALUE.TAG, + text: translate('workspace.common.tags'), + alternateText: translate('workspace.common.lineItemLevel'), + keyForList: CONST.SAGE_INTACCT_MAPPING_VALUE.TAG, + isSelected: mappings?.[mappingName] === CONST.SAGE_INTACCT_MAPPING_VALUE.TAG, + }, + { + value: CONST.SAGE_INTACCT_MAPPING_VALUE.REPORT_FIELD, + text: translate('workspace.common.reportFields'), + alternateText: translate('workspace.common.reportLevel'), + keyForList: CONST.SAGE_INTACCT_MAPPING_VALUE.REPORT_FIELD, + isSelected: mappings?.[mappingName] === CONST.SAGE_INTACCT_MAPPING_VALUE.REPORT_FIELD, + }, + ], ); + const selectionOptions: SelectorType[] = mappingOptions; + + const updateMapping = ({value}: SelectorType) => { + updateSageIntacctMappingValue(policyID, mappingName, value as SageIntacctMappingValue, mappings?.[mappingName]); + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_TOGGLE_MAPPINGS.getRoute(policyID, mappingName)); + }; return ( ( - - ), - [translate, styles.pb10, illustrations.Telescope], + const listEmptyContent = ( + ); - const listHeaderComponent = useMemo( - () => ( - - {translate('workspace.netsuite.subsidiarySelectDescription')} - - ), - [styles.pb2, styles.ph5, styles.textNormal, translate], + const listHeaderComponent = ( + + {translate('workspace.netsuite.subsidiarySelectDescription')} + ); return ( diff --git a/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/subPages/NetSuiteTokenInputForm.tsx b/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/subPages/NetSuiteTokenInputForm.tsx index 5defe8dd0e86..65457116589b 100644 --- a/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/subPages/NetSuiteTokenInputForm.tsx +++ b/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/subPages/NetSuiteTokenInputForm.tsx @@ -24,7 +24,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/NetSuiteTokenInputForm'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; import connectToNetSuiteOAuthSetup from './connectToNetSuiteOAuthSetup'; @@ -38,42 +38,36 @@ function NetSuiteTokenInputForm({onNext, policyID, isOAuthFlow, isMigration, sho const formInputs = isOAuthFlow ? [INPUT_IDS.NETSUITE_ACCOUNT_ID] : Object.values(INPUT_IDS); - const validate = useCallback( - (formValues: FormOnyxValues) => { - const errors: FormInputErrors = {}; - const inputs = isOAuthFlow ? [INPUT_IDS.NETSUITE_ACCOUNT_ID] : Object.values(INPUT_IDS); + const validate = (formValues: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const inputs = isOAuthFlow ? [INPUT_IDS.NETSUITE_ACCOUNT_ID] : Object.values(INPUT_IDS); - for (const formInput of inputs) { - if (formValues[formInput]) { - continue; - } - addErrorMessage(errors, formInput, translate('common.error.fieldRequired')); + for (const formInput of inputs) { + if (formValues[formInput]) { + continue; } - return errors; - }, - [isOAuthFlow, translate], - ); + addErrorMessage(errors, formInput, translate('common.error.fieldRequired')); + } + return errors; + }; - const connectPolicy = useCallback( - (formValues: FormOnyxValues) => { - if (!policyID) { - return; - } + const connectPolicy = (formValues: FormOnyxValues) => { + if (!policyID) { + return; + } - if (isOAuthFlow) { - connectToNetSuiteOAuthSetup(policyID, formValues[INPUT_IDS.NETSUITE_ACCOUNT_ID], environmentURL, isMigration); - return; - } + if (isOAuthFlow) { + connectToNetSuiteOAuthSetup(policyID, formValues[INPUT_IDS.NETSUITE_ACCOUNT_ID], environmentURL, isMigration); + return; + } - if (shouldUseUpdateNetSuiteTokens(policy)) { - updateNetSuiteTokens(policyID, formValues); - } else { - connectPolicyToNetSuite(policyID, formValues); - } - onNext(); - }, - [onNext, policyID, policy, isOAuthFlow, environmentURL, isMigration], - ); + if (shouldUseUpdateNetSuiteTokens(policy)) { + updateNetSuiteTokens(policyID, formValues); + } else { + connectPolicyToNetSuite(policyID, formValues); + } + onNext(); + }; return ( <> diff --git a/src/pages/workspace/accounting/netsuite/advanced/DynamicNetSuiteAccountingMethodPage.tsx b/src/pages/workspace/accounting/netsuite/advanced/DynamicNetSuiteAccountingMethodPage.tsx index ed75d662efb9..ae2e2a35b9ee 100644 --- a/src/pages/workspace/accounting/netsuite/advanced/DynamicNetSuiteAccountingMethodPage.tsx +++ b/src/pages/workspace/accounting/netsuite/advanced/DynamicNetSuiteAccountingMethodPage.tsx @@ -22,7 +22,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {ValueOf} from 'type-fest'; import {CONST as COMMON_CONST} from 'expensify-common'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type MenuListItem = ListItem & { @@ -48,24 +48,18 @@ function DynamicNetSuiteAccountingMethodPage({policy}: WithPolicyConnectionsProp const pendingAction = settingsPendingAction([CONST.NETSUITE_CONFIG.AUTO_SYNC], autoSyncConfig?.pendingFields) ?? settingsPendingAction([CONST.NETSUITE_CONFIG.ACCOUNTING_METHOD], config?.pendingFields); - const headerContent = useMemo( - () => ( - - {translate('workspace.netsuite.advancedConfig.accountingMethods.description')} - - ), - [translate, styles.pb5, styles.ph5], + const headerContent = ( + + {translate('workspace.netsuite.advancedConfig.accountingMethods.description')} + ); - const selectExpenseReportApprovalLevel = useCallback( - (row: MenuListItem) => { - if (row.value !== config?.accountingMethod) { - updateNetSuiteAccountingMethod(policyID, row.value, config?.accountingMethod ?? COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH); - } - Navigation.goBack(backPath); - }, - [backPath, config?.accountingMethod, policyID], - ); + const selectExpenseReportApprovalLevel = (row: MenuListItem) => { + if (row.value !== config?.accountingMethod) { + updateNetSuiteAccountingMethod(policyID, row.value, config?.accountingMethod ?? COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH); + } + Navigation.goBack(backPath); + }; return ( { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); + }; return ( getFilteredReimbursableAccountOptions(payableList).find(({id}) => id === config?.reimbursementAccountID), - [payableList, config?.reimbursementAccountID], - ); - const selectedCollectionAccount = useMemo( - () => getFilteredCollectionAccountOptions(payableList).find(({id}) => id === config?.collectionAccount), - [payableList, config?.collectionAccount], - ); - const selectedFxExpenseAccount = useMemo(() => expenseAccounts?.find(({id}) => id === config?.fxExpenseAccount), [expenseAccounts, config?.fxExpenseAccount]); + const selectedReimbursementAccount = getFilteredReimbursableAccountOptions(payableList).find(({id}) => id === config?.reimbursementAccountID); + const selectedCollectionAccount = getFilteredCollectionAccountOptions(payableList).find(({id}) => id === config?.collectionAccount); + const selectedFxExpenseAccount = expenseAccounts?.find(({id}) => id === config?.fxExpenseAccount); const approvalAccount = config?.approvalAccount; - const selectedApprovalAccount = useMemo(() => { - // NetSuite uses a synthesized "default approval account" when nothing is explicitly set. - if (!approvalAccount || approvalAccount === CONST.NETSUITE_APPROVAL_ACCOUNT_DEFAULT) { - return { - id: CONST.NETSUITE_APPROVAL_ACCOUNT_DEFAULT, - name: translate('workspace.netsuite.advancedConfig.defaultApprovalAccount'), - }; - } - return getFilteredApprovalAccountOptions(payableList).find(({id}) => id === approvalAccount); - }, [approvalAccount, payableList, translate]); + // NetSuite uses a synthesized "default approval account" when nothing is explicitly set. + const selectedApprovalAccount = + !approvalAccount || approvalAccount === CONST.NETSUITE_APPROVAL_ACCOUNT_DEFAULT + ? { + id: CONST.NETSUITE_APPROVAL_ACCOUNT_DEFAULT, + name: translate('workspace.netsuite.advancedConfig.defaultApprovalAccount'), + } + : getFilteredApprovalAccountOptions(payableList).find(({id}) => id === approvalAccount); const renderDefaultMenuItem = (item: MenuItemToRender) => { return ( diff --git a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteApprovalAccountSelectPage.tsx b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteApprovalAccountSelectPage.tsx index 74c6745480bb..a214ea26e6e5 100644 --- a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteApprovalAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteApprovalAccountSelectPage.tsx @@ -23,7 +23,7 @@ import {clearNetSuiteErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; function NetSuiteApprovalAccountSelectPage({policy}: WithPolicyConnectionsProps) { @@ -34,46 +34,32 @@ function NetSuiteApprovalAccountSelectPage({policy}: WithPolicyConnectionsProps) const policyID = policy?.id; const config = policy?.connections?.netsuite?.options.config; - const netsuiteApprovalAccountOptions = useMemo( - () => getNetSuiteApprovalAccountOptions(policy ?? undefined, config?.approvalAccount, translate), - // The default option will be language dependent, so we need to recompute the options when the language changes - - [config?.approvalAccount, policy, translate], - ); - - const initiallyFocusedOptionKey = useMemo(() => netsuiteApprovalAccountOptions?.find((mode) => mode.isSelected)?.keyForList, [netsuiteApprovalAccountOptions]); - - const updateCollectionAccount = useCallback( - ({value}: SelectorType) => { - if (config?.approvalAccount !== value && policyID) { - updateNetSuiteApprovalAccount(policyID, value, config?.approvalAccount ?? CONST.NETSUITE_APPROVAL_ACCOUNT_DEFAULT); - } - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_ADVANCED.getRoute(policyID)); - }, - [policyID, config?.approvalAccount], - ); - - const listEmptyContent = useMemo( - () => ( - - ), - [illustrations.Telescope, translate, styles.pb10], + const netsuiteApprovalAccountOptions: SelectorType[] = getNetSuiteApprovalAccountOptions(policy ?? undefined, config?.approvalAccount, translate); + + const initiallyFocusedOptionKey = netsuiteApprovalAccountOptions?.find((mode) => mode.isSelected)?.keyForList; + + const updateCollectionAccount = ({value}: SelectorType) => { + if (config?.approvalAccount !== value && policyID) { + updateNetSuiteApprovalAccount(policyID, value, config?.approvalAccount ?? CONST.NETSUITE_APPROVAL_ACCOUNT_DEFAULT); + } + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_ADVANCED.getRoute(policyID)); + }; + + const listEmptyContent = ( + ); - const headerContent = useMemo( - () => ( - - {translate('workspace.netsuite.advancedConfig.approvalAccountDescription')} - - ), - [translate, styles.pb5, styles.ph5], + const headerContent = ( + + {translate('workspace.netsuite.advancedConfig.approvalAccountDescription')} + ); return ( diff --git a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteCollectionAccountSelectPage.tsx b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteCollectionAccountSelectPage.tsx index 29a77826d070..1a58d039a778 100644 --- a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteCollectionAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteCollectionAccountSelectPage.tsx @@ -23,7 +23,7 @@ import {clearNetSuiteErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; function NetSuiteCollectionAccountSelectPage({policy}: WithPolicyConnectionsProps) { @@ -34,44 +34,32 @@ function NetSuiteCollectionAccountSelectPage({policy}: WithPolicyConnectionsProp const illustrations = useMemoizedLazyIllustrations(['Telescope']); const config = policy?.connections?.netsuite?.options.config; - const netsuiteCollectionAccountOptions = useMemo( - () => getNetSuiteCollectionAccountOptions(policy ?? undefined, config?.collectionAccount), - [config?.collectionAccount, policy], - ); - - const initiallyFocusedOptionKey = useMemo(() => netsuiteCollectionAccountOptions?.find((mode) => mode.isSelected)?.keyForList, [netsuiteCollectionAccountOptions]); - - const updateCollectionAccount = useCallback( - ({value}: SelectorType) => { - if (config?.collectionAccount !== value && policyID) { - updateNetSuiteCollectionAccount(policyID, value, config?.collectionAccount); - } - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_ADVANCED.getRoute(policyID)); - }, - [policyID, config?.collectionAccount], - ); - - const listEmptyContent = useMemo( - () => ( - - ), - [illustrations.Telescope, translate, styles.pb10], + const netsuiteCollectionAccountOptions: SelectorType[] = getNetSuiteCollectionAccountOptions(policy ?? undefined, config?.collectionAccount); + + const initiallyFocusedOptionKey = netsuiteCollectionAccountOptions?.find((mode) => mode.isSelected)?.keyForList; + + const updateCollectionAccount = ({value}: SelectorType) => { + if (config?.collectionAccount !== value && policyID) { + updateNetSuiteCollectionAccount(policyID, value, config?.collectionAccount); + } + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_ADVANCED.getRoute(policyID)); + }; + + const listEmptyContent = ( + ); - const headerContent = useMemo( - () => ( - - {translate('workspace.netsuite.advancedConfig.collectionsAccountDescription')} - - ), - [translate, styles.pb5, styles.ph5], + const headerContent = ( + + {translate('workspace.netsuite.advancedConfig.collectionsAccountDescription')} + ); return ( diff --git a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteCustomFormIDPage.tsx b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteCustomFormIDPage.tsx index 2650f99d12f3..7162022d3eb1 100644 --- a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteCustomFormIDPage.tsx +++ b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteCustomFormIDPage.tsx @@ -26,7 +26,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {useRoute} from '@react-navigation/native'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; function NetSuiteCustomFormIDPage({policy}: WithPolicyConnectionsProps) { @@ -45,28 +45,22 @@ function NetSuiteCustomFormIDPage({policy}: WithPolicyConnectionsProps) { (isReimbursable ? config?.reimbursableExpensesExportDestination : config?.nonreimbursableExpensesExportDestination) ?? CONST.NETSUITE_EXPORT_DESTINATION.EXPENSE_REPORT; const customFormIDKey = isReimbursable ? CONST.NETSUITE_CONFIG.CUSTOM_FORM_ID_TYPE.REIMBURSABLE : CONST.NETSUITE_CONFIG.CUSTOM_FORM_ID_TYPE.NON_REIMBURSABLE; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; - if (values[params.expenseType] && !isNumeric(values[params.expenseType])) { - addErrorMessage(errors, params.expenseType, translate('workspace.netsuite.advancedConfig.error.customFormID')); - } + if (values[params.expenseType] && !isNumeric(values[params.expenseType])) { + addErrorMessage(errors, params.expenseType, translate('workspace.netsuite.advancedConfig.error.customFormID')); + } - return errors; - }, - [params.expenseType, translate], - ); + return errors; + }; - const updateCustomFormID = useCallback( - (formValues: FormOnyxValues) => { - if (config?.customFormIDOptions?.[customFormIDKey]?.[CONST.NETSUITE_MAP_EXPORT_DESTINATION[exportDestination]] !== formValues[params.expenseType]) { - updateNetSuiteCustomFormIDOptions(policyID, formValues[params.expenseType], isReimbursable, exportDestination, config?.customFormIDOptions); - } - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_ADVANCED.getRoute(policyID)); - }, - [config?.customFormIDOptions, customFormIDKey, exportDestination, isReimbursable, params.expenseType, policyID], - ); + const updateCustomFormID = (formValues: FormOnyxValues) => { + if (config?.customFormIDOptions?.[customFormIDKey]?.[CONST.NETSUITE_MAP_EXPORT_DESTINATION[exportDestination]] !== formValues[params.expenseType]) { + updateNetSuiteCustomFormIDOptions(policyID, formValues[params.expenseType], isReimbursable, exportDestination, config?.customFormIDOptions); + } + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_ADVANCED.getRoute(policyID)); + }; return ( ( - - {translate('workspace.netsuite.advancedConfig.exportReportsTo.description')} - - ), - [translate, styles.pb5, styles.ph5], + const headerContent = ( + + {translate('workspace.netsuite.advancedConfig.exportReportsTo.description')} + ); - const selectExpenseReportApprovalLevel = useCallback( - (row: MenuListItem) => { - if (row.value !== config?.syncOptions.exportReportsTo && policyID) { - updateNetSuiteExportReportsTo(policyID, row.value, config?.syncOptions.exportReportsTo ?? CONST.NETSUITE_REPORTS_APPROVAL_LEVEL.REPORTS_APPROVED_NONE); - } - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_ADVANCED.getRoute(policyID)); - }, - [config?.syncOptions.exportReportsTo, policyID], - ); + const selectExpenseReportApprovalLevel = (row: MenuListItem) => { + if (row.value !== config?.syncOptions.exportReportsTo && policyID) { + updateNetSuiteExportReportsTo(policyID, row.value, config?.syncOptions.exportReportsTo ?? CONST.NETSUITE_REPORTS_APPROVAL_LEVEL.REPORTS_APPROVED_NONE); + } + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_ADVANCED.getRoute(policyID)); + }; return ( ( - - {translate('workspace.netsuite.advancedConfig.exportJournalsTo.description')} - - ), - [translate, styles.pb5, styles.ph5], + const headerContent = ( + + {translate('workspace.netsuite.advancedConfig.exportJournalsTo.description')} + ); - const selectJournalApprovalLevel = useCallback( - (row: MenuListItem) => { - if (row.value !== config?.syncOptions.exportJournalsTo && policyID) { - updateNetSuiteExportJournalsTo(policyID, row.value, config?.syncOptions.exportJournalsTo ?? CONST.NETSUITE_JOURNALS_APPROVAL_LEVEL.JOURNALS_APPROVED_NONE); - } - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_ADVANCED.getRoute(policyID)); - }, - [config?.syncOptions.exportJournalsTo, policyID], - ); + const selectJournalApprovalLevel = (row: MenuListItem) => { + if (row.value !== config?.syncOptions.exportJournalsTo && policyID) { + updateNetSuiteExportJournalsTo(policyID, row.value, config?.syncOptions.exportJournalsTo ?? CONST.NETSUITE_JOURNALS_APPROVAL_LEVEL.JOURNALS_APPROVED_NONE); + } + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_ADVANCED.getRoute(policyID)); + }; return ( ( - () => getNetSuiteReimbursableAccountOptions(policy ?? undefined, config?.reimbursementAccountID), - [config?.reimbursementAccountID, policy], - ); - - const initiallyFocusedOptionKey = useMemo(() => netsuiteReimbursableAccountOptions?.find((mode) => mode.isSelected)?.keyForList, [netsuiteReimbursableAccountOptions]); - - const updateReimbursementAccount = useCallback( - ({value}: SelectorType) => { - if (config?.reimbursementAccountID !== value && policyID) { - updateNetSuiteReimbursementAccountID(policyID, value, config?.reimbursementAccountID); - } - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_ADVANCED.getRoute(policyID)); - }, - [policyID, config?.reimbursementAccountID], - ); - - const listEmptyContent = useMemo( - () => ( - - ), - [illustrations.Telescope, translate, styles.pb10], + const netsuiteReimbursableAccountOptions: SelectorType[] = getNetSuiteReimbursableAccountOptions(policy ?? undefined, config?.reimbursementAccountID); + + const initiallyFocusedOptionKey = netsuiteReimbursableAccountOptions?.find((mode) => mode.isSelected)?.keyForList; + + const updateReimbursementAccount = ({value}: SelectorType) => { + if (config?.reimbursementAccountID !== value && policyID) { + updateNetSuiteReimbursementAccountID(policyID, value, config?.reimbursementAccountID); + } + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_ADVANCED.getRoute(policyID)); + }; + + const listEmptyContent = ( + ); - const headerContent = useMemo( - () => ( - - {translate('workspace.netsuite.advancedConfig.reimbursementsAccountDescription')} - - ), - [translate, styles.pb5, styles.ph5], + const headerContent = ( + + {translate('workspace.netsuite.advancedConfig.reimbursementsAccountDescription')} + ); return ( diff --git a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteVendorBillApprovalLevelSelectPage.tsx b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteVendorBillApprovalLevelSelectPage.tsx index 554116def927..c734be366243 100644 --- a/src/pages/workspace/accounting/netsuite/advanced/NetSuiteVendorBillApprovalLevelSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/advanced/NetSuiteVendorBillApprovalLevelSelectPage.tsx @@ -22,7 +22,7 @@ import ROUTES from '@src/ROUTES'; import type {ValueOf} from 'type-fest'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type MenuListItem = ListItem & { @@ -41,24 +41,18 @@ function NetSuiteVendorBillApprovalLevelSelectPage({policy}: WithPolicyConnectio isSelected: config?.syncOptions.exportVendorBillsTo === approvalType, })); - const headerContent = useMemo( - () => ( - - {translate('workspace.netsuite.advancedConfig.exportVendorBillsTo.description')} - - ), - [translate, styles.pb5, styles.ph5], + const headerContent = ( + + {translate('workspace.netsuite.advancedConfig.exportVendorBillsTo.description')} + ); - const selectVendorBillApprovalLevel = useCallback( - (row: MenuListItem) => { - if (row.value !== config?.syncOptions.exportVendorBillsTo && policyID) { - updateNetSuiteExportVendorBillsTo(policyID, row.value, config?.syncOptions.exportVendorBillsTo ?? CONST.NETSUITE_VENDOR_BILLS_APPROVAL_LEVEL.VENDOR_BILLS_APPROVED_NONE); - } - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_ADVANCED.getRoute(policyID)); - }, - [config?.syncOptions.exportVendorBillsTo, policyID], - ); + const selectVendorBillApprovalLevel = (row: MenuListItem) => { + if (row.value !== config?.syncOptions.exportVendorBillsTo && policyID) { + updateNetSuiteExportVendorBillsTo(policyID, row.value, config?.syncOptions.exportVendorBillsTo ?? CONST.NETSUITE_VENDOR_BILLS_APPROVAL_LEVEL.VENDOR_BILLS_APPROVED_NONE); + } + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_ADVANCED.getRoute(policyID)); + }; return ( ( - - {translate('workspace.netsuite.exportDate.description')} - - ), - [translate, styles.pb5, styles.ph5], + const headerContent = ( + + {translate('workspace.netsuite.exportDate.description')} + ); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); + }; - const selectExportDate = useCallback( - (row: MenuListItem) => { - if (row.value !== config?.exportDate && policyID) { - updateNetSuiteExportDate(policyID, row.value, config?.exportDate); - } - goBack(); - }, - [config?.exportDate, policyID, goBack], - ); + const selectExportDate = (row: MenuListItem) => { + if (row.value !== config?.exportDate && policyID) { + updateNetSuiteExportDate(policyID, row.value, config?.exportDate); + } + goBack(); + }; return ( ; @@ -57,49 +57,43 @@ function DynamicNetSuiteExportExpensesDestinationSelectPage({policy}: WithPolicy isSelected: currentDestination === dateType, })); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); - - const applyDestination = useCallback( - (value: ValueOf) => { - if (!policyID) { - return; - } - if (isReimbursable) { - updateNetSuiteReimbursableExpensesExportDestination(policyID, value, currentDestination ?? CONST.NETSUITE_EXPORT_DESTINATION.EXPENSE_REPORT); - } else { - updateNetSuiteNonReimbursableExpensesExportDestination(policyID, value, currentDestination ?? CONST.NETSUITE_EXPORT_DESTINATION.VENDOR_BILL); - } + }; + + const applyDestination = (value: ValueOf) => { + if (!policyID) { + return; + } + if (isReimbursable) { + updateNetSuiteReimbursableExpensesExportDestination(policyID, value, currentDestination ?? CONST.NETSUITE_EXPORT_DESTINATION.EXPENSE_REPORT); + } else { + updateNetSuiteNonReimbursableExpensesExportDestination(policyID, value, currentDestination ?? CONST.NETSUITE_EXPORT_DESTINATION.VENDOR_BILL); + } + goBack(); + }; + + const selectDestination = async (row: MenuListItem) => { + if (row.value === currentDestination || !policyID) { goBack(); - }, - [currentDestination, isReimbursable, policyID, goBack], - ); - - const selectDestination = useCallback( - async (row: MenuListItem) => { - if (row.value === currentDestination || !policyID) { - goBack(); - return; - } - - if (!isReimbursable && row.value === CONST.NETSUITE_EXPORT_DESTINATION.EXPENSE_REPORT && policy?.areCompanyCardsEnabled) { - const {action} = await showConfirmModal({ - title: translate('common.areYouSure'), - prompt: translate('workspace.netsuite.exportDestination.expenseReportDestinationConfirmDescription'), - confirmText: translate('common.confirm'), - cancelText: translate('common.cancel'), - }); - if (action === ModalActions.CONFIRM) { - applyDestination(row.value); - } - return; + return; + } + + if (!isReimbursable && row.value === CONST.NETSUITE_EXPORT_DESTINATION.EXPENSE_REPORT && policy?.areCompanyCardsEnabled) { + const {action} = await showConfirmModal({ + title: translate('common.areYouSure'), + prompt: translate('workspace.netsuite.exportDestination.expenseReportDestinationConfirmDescription'), + confirmText: translate('common.confirm'), + cancelText: translate('common.cancel'), + }); + if (action === ModalActions.CONFIRM) { + applyDestination(row.value); } + return; + } - applyDestination(row.value); - }, - [currentDestination, isReimbursable, policyID, goBack, policy?.areCompanyCardsEnabled, showConfirmModal, translate, applyDestination], - ); + applyDestination(row.value); + }; return ( ; @@ -54,19 +54,16 @@ function DynamicNetSuiteExportExpensesJournalPostingPreferenceSelectPage({policy isSelected: selectedValue === postingPreference, })); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); - - const selectPostingPreference = useCallback( - (row: MenuListItem) => { - if (row.value !== config?.journalPostingPreference && policyID) { - updateNetSuiteJournalPostingPreference(policyID, row.value, config?.journalPostingPreference); - } - goBack(); - }, - [config?.journalPostingPreference, goBack, policyID], - ); + }; + + const selectPostingPreference = (row: MenuListItem) => { + if (row.value !== config?.journalPostingPreference && policyID) { + updateNetSuiteJournalPostingPreference(policyID, row.value, config?.journalPostingPreference); + } + goBack(); + }; return ( vendors?.find(({id}) => id === config?.defaultVendor), [vendors, config?.defaultVendor]); + const defaultVendor = vendors?.find(({id}) => id === config?.defaultVendor); - const selectedPayableAccount = useMemo(() => payableList?.find(({id}) => id === config?.payableAcct), [payableList, config?.payableAcct]); + const selectedPayableAccount = payableList?.find(({id}) => id === config?.payableAcct); - const selectedReimbursablePayableAccount = useMemo(() => payableList?.find(({id}) => id === config?.reimbursablePayableAccount), [payableList, config?.reimbursablePayableAccount]); + const selectedReimbursablePayableAccount = payableList?.find(({id}) => id === config?.reimbursablePayableAccount); const navigateToStep = (path: Parameters[0]) => { if (!policyID) { diff --git a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesPayableAccountSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesPayableAccountSelectPage.tsx index 60f7d3c03634..857f10751b87 100644 --- a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesPayableAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesPayableAccountSelectPage.tsx @@ -27,7 +27,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {useRoute} from '@react-navigation/native'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; function DynamicNetSuiteExportExpensesPayableAccountSelectPage({policy}: WithPolicyConnectionsProps) { const styles = useThemeStyles(); @@ -44,40 +44,34 @@ function DynamicNetSuiteExportExpensesPayableAccountSelectPage({policy}: WithPol const config = policy?.connections?.netsuite?.options.config; const currentSettingName = isReimbursable ? CONST.NETSUITE_CONFIG.REIMBURSABLE_PAYABLE_ACCOUNT : CONST.NETSUITE_CONFIG.PAYABLE_ACCT; const currentPayableAccountID = config?.[currentSettingName] ?? CONST.NETSUITE_PAYABLE_ACCOUNT_DEFAULT_VALUE; - const netsuitePayableAccountOptions = useMemo(() => getNetSuitePayableAccountOptions(policy ?? undefined, currentPayableAccountID), [currentPayableAccountID, policy]); + const netsuitePayableAccountOptions: SelectorType[] = getNetSuitePayableAccountOptions(policy ?? undefined, currentPayableAccountID); - const initiallyFocusedOptionKey = useMemo(() => netsuitePayableAccountOptions?.find((mode) => mode.isSelected)?.keyForList, [netsuitePayableAccountOptions]); + const initiallyFocusedOptionKey = netsuitePayableAccountOptions?.find((mode) => mode.isSelected)?.keyForList; - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); - - const updatePayableAccount = useCallback( - ({value}: SelectorType) => { - if (currentPayableAccountID !== value && policyID) { - if (isReimbursable) { - updateNetSuiteReimbursablePayableAccount(policyID, value, currentPayableAccountID); - } else { - updateNetSuitePayableAcct(policyID, value, currentPayableAccountID); - } + }; + + const updatePayableAccount = ({value}: SelectorType) => { + if (currentPayableAccountID !== value && policyID) { + if (isReimbursable) { + updateNetSuiteReimbursablePayableAccount(policyID, value, currentPayableAccountID); + } else { + updateNetSuitePayableAcct(policyID, value, currentPayableAccountID); } - goBack(); - }, - [currentPayableAccountID, policyID, goBack, isReimbursable], - ); - - const listEmptyContent = useMemo( - () => ( - - ), - [translate, styles.pb10, illustrations.Telescope], + } + goBack(); + }; + + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx index d807fcb8f66d..7597f316ccb0 100644 --- a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteExportExpensesVendorSelectPage.tsx @@ -27,7 +27,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {useRoute} from '@react-navigation/native'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; function DynamicNetSuiteExportExpensesVendorSelectPage({policy}: WithPolicyConnectionsProps) { const styles = useThemeStyles(); @@ -42,54 +42,42 @@ 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 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 vendorOptions: SelectorType[] = getNetSuiteVendorOptions(policy ?? undefined, config?.defaultVendor); + const clearOption: SelectorType = { + value: '', + text: translate('common.none'), + keyForList: '', + isSelected: !config?.defaultVendor, + }; const shouldShowClearOption = !!config?.defaultVendor || vendorOptions.length > 0; - const netsuiteVendorOptions = useMemo( - () => (shouldShowClearOption ? [clearOption, ...vendorOptions] : vendorOptions), - [shouldShowClearOption, clearOption, vendorOptions], - ); + const netsuiteVendorOptions: SelectorType[] = shouldShowClearOption ? [clearOption, ...vendorOptions] : vendorOptions; - const initiallyFocusedOptionKey = useMemo(() => netsuiteVendorOptions?.find((mode) => mode.isSelected)?.keyForList, [netsuiteVendorOptions]); + const initiallyFocusedOptionKey = netsuiteVendorOptions?.find((mode) => mode.isSelected)?.keyForList; - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); - - const updateDefaultVendor = useCallback( - ({value}: SelectorType) => { - const isAlreadySelected = value === config?.defaultVendor || (!value && !config?.defaultVendor); - if (isAlreadySelected) { - return; - } - if (policyID) { - updateNetSuiteDefaultVendor(policyID, value, config?.defaultVendor); - } - goBack(); - }, - [config?.defaultVendor, policyID, goBack], - ); - - const listEmptyContent = useMemo( - () => ( - - ), - [translate, styles.pb10, illustrations.Telescope], + }; + + const updateDefaultVendor = ({value}: SelectorType) => { + const isAlreadySelected = value === config?.defaultVendor || (!value && !config?.defaultVendor); + if (isAlreadySelected) { + return; + } + if (policyID) { + updateNetSuiteDefaultVendor(policyID, value, config?.defaultVendor); + } + goBack(); + }; + + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteInvoiceItemPreferenceSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteInvoiceItemPreferenceSelectPage.tsx index c1780fc766b3..d9904af86978 100644 --- a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteInvoiceItemPreferenceSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteInvoiceItemPreferenceSelectPage.tsx @@ -28,7 +28,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {ValueOf} from 'type-fest'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; type MenuListItem = ListItem & { value: ValueOf; @@ -43,29 +43,25 @@ function DynamicNetSuiteInvoiceItemPreferenceSelectPage({policy}: WithPolicyConn const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_INVOICE_ITEM_PREFERENCE_SELECT.path); const {items} = policy?.connections?.netsuite?.options.data ?? {}; - const selectedItem = useMemo(() => items?.find(({id}) => id === config?.invoiceItem), [items, config?.invoiceItem]); + const selectedItem = items?.find(({id}) => id === config?.invoiceItem); const selectedValue = Object.values(CONST.NETSUITE_INVOICE_ITEM_PREFERENCE).find((value) => value === config?.invoiceItemPreference) ?? CONST.NETSUITE_INVOICE_ITEM_PREFERENCE.CREATE; const [draftPreference, setDraftPreference] = useState>(); const currentPreference = draftPreference ?? selectedValue; - const options: MenuListItem[] = useMemo( - () => - Object.values(CONST.NETSUITE_INVOICE_ITEM_PREFERENCE).map((postingPreference) => ({ - value: postingPreference, - text: translate(`workspace.netsuite.invoiceItem.values.${postingPreference}.label`), - keyForList: postingPreference, - isSelected: currentPreference === postingPreference, - })), - [currentPreference, translate], - ); + const options: MenuListItem[] = Object.values(CONST.NETSUITE_INVOICE_ITEM_PREFERENCE).map((postingPreference) => ({ + value: postingPreference, + text: translate(`workspace.netsuite.invoiceItem.values.${postingPreference}.label`), + keyForList: postingPreference, + isSelected: currentPreference === postingPreference, + })); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); + }; - const savePreference = useCallback(() => { + const savePreference = () => { if (currentPreference !== config?.invoiceItemPreference && policyID) { updateNetSuiteInvoiceItemPreference(policyID, currentPreference, config?.invoiceItemPreference); } @@ -73,14 +69,14 @@ function DynamicNetSuiteInvoiceItemPreferenceSelectPage({policy}: WithPolicyConn if (currentPreference === CONST.NETSUITE_INVOICE_ITEM_PREFERENCE.CREATE) { goBack(); } - }, [currentPreference, config?.invoiceItemPreference, policyID, goBack]); + }; // Rendered as the list footer so the invoice-item sub-menu sits directly under the options and the Save button stays pinned to the bottom of the screen. - const invoiceItemFooterContent = useMemo(() => { - if (config?.invoiceItemPreference !== CONST.NETSUITE_INVOICE_ITEM_PREFERENCE.SELECT) { - return null; - } - return ( + let invoiceItemFooterContent; + if (config?.invoiceItemPreference !== CONST.NETSUITE_INVOICE_ITEM_PREFERENCE.SELECT) { + invoiceItemFooterContent = null; + } else { + invoiceItemFooterContent = ( ); - }, [config?.invoiceItemPreference, config?.pendingFields, config?.errorFields, policyID, selectedItem, translate]); + } // Update focused index when the current preference changes (after an error reverts the selection) useEffect(() => { diff --git a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteInvoiceItemSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteInvoiceItemSelectPage.tsx index c1b1a8545041..9f29552be265 100644 --- a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteInvoiceItemSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteInvoiceItemSelectPage.tsx @@ -23,7 +23,7 @@ import {clearNetSuiteErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; function DynamicNetSuiteInvoiceItemSelectPage({policy}: WithPolicyConnectionsProps) { const styles = useThemeStyles(); @@ -33,36 +33,30 @@ function DynamicNetSuiteInvoiceItemSelectPage({policy}: WithPolicyConnectionsPro const policyID = policy?.id; const config = policy?.connections?.netsuite?.options.config; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_INVOICE_ITEM_SELECT.path); - const netsuiteInvoiceItemOptions = useMemo(() => getNetSuiteInvoiceItemOptions(policy ?? undefined, config?.invoiceItem), [config?.invoiceItem, policy]); + const netsuiteInvoiceItemOptions: SelectorType[] = getNetSuiteInvoiceItemOptions(policy ?? undefined, config?.invoiceItem); - const initiallyFocusedOptionKey = useMemo(() => netsuiteInvoiceItemOptions?.find((mode) => mode.isSelected)?.keyForList, [netsuiteInvoiceItemOptions]); + const initiallyFocusedOptionKey = netsuiteInvoiceItemOptions?.find((mode) => mode.isSelected)?.keyForList; - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); + }; - const updateInvoiceItem = useCallback( - ({value}: SelectorType) => { - if (config?.invoiceItem !== value && policyID) { - updateNetSuiteInvoiceItem(policyID, value, config?.invoiceItem); - } - goBack(); - }, - [policyID, config?.invoiceItem, goBack], - ); + const updateInvoiceItem = ({value}: SelectorType) => { + if (config?.invoiceItem !== value && policyID) { + updateNetSuiteInvoiceItem(policyID, value, config?.invoiceItem); + } + goBack(); + }; - const listEmptyContent = useMemo( - () => ( - - ), - [illustrations.Telescope, translate, styles.pb10], + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuitePreferredExporterSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuitePreferredExporterSelectPage.tsx index aec73477101b..69564d46d2ab 100644 --- a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuitePreferredExporterSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuitePreferredExporterSelectPage.tsx @@ -21,7 +21,7 @@ import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import isEmpty from 'lodash/isEmpty'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type CardListItem = ListItem & { @@ -37,60 +37,49 @@ function DynamicNetSuitePreferredExporterSelectPage({policy}: WithPolicyConnecti const {login: currentUserLogin} = useCurrentUserPersonalDetails(); const policyID = policy?.id; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_PREFERRED_EXPORTER_SELECT.path); - const data: CardListItem[] = useMemo(() => { - if (!isEmpty(policyOwner) && isEmpty(exporters)) { - return [ - { - value: policyOwner, - text: policyOwner, - keyForList: policyOwner, - isSelected: true, - }, - ]; - } - - return exporters?.reduce((options, exporter) => { - if (!exporter.email) { - return options; - } - - // Don't show guides if the current user is not a guide themselves or an Expensify employee - if (isExpensifyTeam(exporter.email) && !isExpensifyTeam(policyOwner) && !isExpensifyTeam(currentUserLogin)) { - return options; - } - - options.push({ - value: exporter.email, - text: exporter.email, - keyForList: exporter.email, - isSelected: (config?.exporter ?? policyOwner) === exporter.email, - }); - return options; - }, []); - }, [config?.exporter, exporters, policyOwner, currentUserLogin]); - - const goBack = useCallback(() => { + // Don't show guides if the current user is not a guide themselves or an Expensify employee + const data: CardListItem[] = + !isEmpty(policyOwner) && isEmpty(exporters) + ? [ + { + value: policyOwner, + text: policyOwner, + keyForList: policyOwner, + isSelected: true, + }, + ] + : exporters?.reduce((options, exporter) => { + if (!exporter.email) { + return options; + } + if (isExpensifyTeam(exporter.email) && !isExpensifyTeam(policyOwner) && !isExpensifyTeam(currentUserLogin)) { + return options; + } + options.push({ + value: exporter.email, + text: exporter.email, + keyForList: exporter.email, + isSelected: (config?.exporter ?? policyOwner) === exporter.email, + }); + return options; + }, []); + + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); - - const selectExporter = useCallback( - (row: CardListItem) => { - if (row.value !== config?.exporter && policyID) { - updateNetSuiteExporter(policyID, row.value, config?.exporter ?? ''); - } - goBack(); - }, - [config?.exporter, policyID, goBack], - ); + }; - const headerContent = useMemo( - () => ( - - {translate('workspace.accounting.exportPreferredExporterNote')} - {translate('workspace.accounting.exportPreferredExporterSubNote')} - - ), - [translate, styles.pb2, styles.ph5, styles.pb5, styles.textNormal], + const selectExporter = (row: CardListItem) => { + if (row.value !== config?.exporter && policyID) { + updateNetSuiteExporter(policyID, row.value, config?.exporter ?? ''); + } + goBack(); + }; + + const headerContent = ( + + {translate('workspace.accounting.exportPreferredExporterNote')} + {translate('workspace.accounting.exportPreferredExporterSubNote')} + ); return ( diff --git a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteReceivableAccountSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteReceivableAccountSelectPage.tsx index 825b5d30afb5..2406752d2d23 100644 --- a/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteReceivableAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/DynamicNetSuiteReceivableAccountSelectPage.tsx @@ -22,7 +22,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; function DynamicNetSuiteReceivableAccountSelectPage({policy}: WithPolicyConnectionsProps) { const styles = useThemeStyles(); @@ -32,39 +32,30 @@ function DynamicNetSuiteReceivableAccountSelectPage({policy}: WithPolicyConnecti const policyID = policy?.id; const config = policy?.connections?.netsuite?.options.config; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_RECEIVABLE_ACCOUNT_SELECT.path); - const netsuiteReceivableAccountOptions = useMemo( - () => getNetSuiteReceivableAccountOptions(policy ?? undefined, config?.receivableAccount), - [config?.receivableAccount, policy], - ); + const netsuiteReceivableAccountOptions: SelectorType[] = getNetSuiteReceivableAccountOptions(policy ?? undefined, config?.receivableAccount); - const initiallyFocusedOptionKey = useMemo(() => netsuiteReceivableAccountOptions?.find((mode) => mode.isSelected)?.keyForList, [netsuiteReceivableAccountOptions]); + const initiallyFocusedOptionKey = netsuiteReceivableAccountOptions?.find((mode) => mode.isSelected)?.keyForList; - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); + }; - const updateReceivableAccount = useCallback( - ({value}: SelectorType) => { - if (config?.receivableAccount !== value && policyID) { - updateNetSuiteReceivableAccount(policyID, value, config?.receivableAccount); - } - goBack(); - }, - [policyID, config?.receivableAccount, goBack], - ); + const updateReceivableAccount = ({value}: SelectorType) => { + if (config?.receivableAccount !== value && policyID) { + updateNetSuiteReceivableAccount(policyID, value, config?.receivableAccount); + } + goBack(); + }; - const listEmptyContent = useMemo( - () => ( - - ), - [translate, styles.pb10, illustrations.Telescope], + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/accounting/netsuite/export/NetSuiteProvincialTaxPostingAccountSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/NetSuiteProvincialTaxPostingAccountSelectPage.tsx index 2767f7120590..ecd5f968ebc9 100644 --- a/src/pages/workspace/accounting/netsuite/export/NetSuiteProvincialTaxPostingAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/NetSuiteProvincialTaxPostingAccountSelectPage.tsx @@ -25,7 +25,7 @@ import {clearNetSuiteErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; function NetSuiteProvincialTaxPostingAccountSelectPage({policy}: WithPolicyConnectionsProps) { const styles = useThemeStyles(); @@ -40,37 +40,28 @@ function NetSuiteProvincialTaxPostingAccountSelectPage({policy}: WithPolicyConne const config = policy?.connections?.netsuite?.options.config; const {subsidiaryList} = policy?.connections?.netsuite?.options?.data ?? {}; - const selectedSubsidiary = useMemo(() => (subsidiaryList ?? []).find((subsidiary) => subsidiary.internalID === config?.subsidiaryID), [subsidiaryList, config?.subsidiaryID]); - - const netsuiteTaxAccountOptions = useMemo( - () => getNetSuiteTaxAccountOptions(policy ?? undefined, selectedSubsidiary?.country, config?.provincialTaxPostingAccount), - [config?.provincialTaxPostingAccount, policy, selectedSubsidiary?.country], - ); - - const initiallyFocusedOptionKey = useMemo(() => netsuiteTaxAccountOptions?.find((mode) => mode.isSelected)?.keyForList, [netsuiteTaxAccountOptions]); - - const updateTaxAccount = useCallback( - ({value}: SelectorType) => { - if (config?.provincialTaxPostingAccount !== value && policyID) { - updateNetSuiteProvincialTaxPostingAccount(policyID, value, config?.provincialTaxPostingAccount); - } - Navigation.goBack(netSuiteExportBackPath); - }, - [netSuiteExportBackPath, policyID, config?.provincialTaxPostingAccount], - ); - - const listEmptyContent = useMemo( - () => ( - - ), - [illustrations.Telescope, translate, styles.pb10], + const selectedSubsidiary = (subsidiaryList ?? []).find((subsidiary) => subsidiary.internalID === config?.subsidiaryID); + + const netsuiteTaxAccountOptions: SelectorType[] = getNetSuiteTaxAccountOptions(policy ?? undefined, selectedSubsidiary?.country, config?.provincialTaxPostingAccount); + + const initiallyFocusedOptionKey = netsuiteTaxAccountOptions?.find((mode) => mode.isSelected)?.keyForList; + + const updateTaxAccount = ({value}: SelectorType) => { + if (config?.provincialTaxPostingAccount !== value && policyID) { + updateNetSuiteProvincialTaxPostingAccount(policyID, value, config?.provincialTaxPostingAccount); + } + Navigation.goBack(netSuiteExportBackPath); + }; + + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage.tsx index 999c690799d6..2c0526571cba 100644 --- a/src/pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage.tsx @@ -26,7 +26,7 @@ import {clearNetSuiteErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; function NetSuiteTaxPostingAccountSelectPage({policy}: WithPolicyConnectionsProps) { const styles = useThemeStyles(); @@ -42,37 +42,28 @@ function NetSuiteTaxPostingAccountSelectPage({policy}: WithPolicyConnectionsProp const config = policy?.connections?.netsuite?.options.config; const {subsidiaryList} = policy?.connections?.netsuite?.options?.data ?? {}; - const selectedSubsidiary = useMemo(() => (subsidiaryList ?? []).find((subsidiary) => subsidiary.internalID === config?.subsidiaryID), [subsidiaryList, config?.subsidiaryID]); - - const netsuiteTaxAccountOptions = useMemo( - () => getNetSuiteTaxAccountOptions(policy ?? undefined, selectedSubsidiary?.country, config?.taxPostingAccount), - [config?.taxPostingAccount, policy, selectedSubsidiary?.country], - ); - - const initiallyFocusedOptionKey = useMemo(() => netsuiteTaxAccountOptions?.find((mode) => mode.isSelected)?.keyForList, [netsuiteTaxAccountOptions]); - - const updateTaxAccount = useCallback( - ({value}: SelectorType) => { - if (config?.taxPostingAccount !== value && policyID) { - updateNetSuiteTaxPostingAccount(policyID, value, config?.taxPostingAccount); - } - Navigation.goBack(netSuiteExportBackPath); - }, - [netSuiteExportBackPath, policyID, config?.taxPostingAccount], - ); - - const listEmptyContent = useMemo( - () => ( - - ), - [illustrations.Telescope, translate, styles.pb10], + const selectedSubsidiary = (subsidiaryList ?? []).find((subsidiary) => subsidiary.internalID === config?.subsidiaryID); + + const netsuiteTaxAccountOptions: SelectorType[] = getNetSuiteTaxAccountOptions(policy ?? undefined, selectedSubsidiary?.country, config?.taxPostingAccount); + + const initiallyFocusedOptionKey = netsuiteTaxAccountOptions?.find((mode) => mode.isSelected)?.keyForList; + + const updateTaxAccount = ({value}: SelectorType) => { + if (config?.taxPostingAccount !== value && policyID) { + updateNetSuiteTaxPostingAccount(policyID, value, config?.taxPostingAccount); + } + Navigation.goBack(netSuiteExportBackPath); + }; + + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldEdit.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldEdit.tsx index 43d506424ab9..cd3809871c0a 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldEdit.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldEdit.tsx @@ -25,7 +25,7 @@ import type {NetSuiteCustomList, NetSuiteCustomSegment} from '@src/types/onyx/Po import type {ValueOf} from 'type-fest'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import NetSuiteCustomFieldMappingPicker from './NetSuiteImportCustomFieldNew/NetSuiteCustomFieldMappingPicker'; @@ -56,113 +56,99 @@ function NetSuiteImportCustomFieldEdit({ const {inputCallbackRef} = useAutoFocusInput(); const config = policy?.connections?.netsuite?.options?.config; - const allRecords = useMemo(() => config?.syncOptions?.[importCustomField] ?? [], [config?.syncOptions, importCustomField]); + const allRecords = config?.syncOptions?.[importCustomField] ?? []; const customField: CustomField | undefined = allRecords[valueIndex]; const fieldValue = customField?.[fieldName as keyof CustomField] ?? ''; - const updateRecord = useCallback( - (formValues: Partial>) => { - const newValue = formValues[fieldName as keyof typeof formValues]; - - if (customField) { - const updatedRecords = allRecords.map((record, index) => { - if (index === Number(valueIndex)) { - return { - ...record, - [fieldName]: newValue, - }; - } - return record; - }); + const updateRecord = (formValues: Partial>) => { + const newValue = formValues[fieldName as keyof typeof formValues]; - if (isNetSuiteCustomSegmentRecord(customField)) { - updateNetSuiteCustomSegments( - policyID, - updatedRecords as NetSuiteCustomSegment[], - allRecords as NetSuiteCustomSegment[], - `${importCustomField}_${valueIndex}`, - CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - ); - } else { - updateNetSuiteCustomLists( - policyID, - updatedRecords as NetSuiteCustomList[], - allRecords as NetSuiteCustomList[], - `${importCustomField}_${valueIndex}`, - CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - ); + if (customField) { + const updatedRecords = allRecords.map((record, index) => { + if (index === Number(valueIndex)) { + return { + ...record, + [fieldName]: newValue, + }; } + return record; + }); + + if (isNetSuiteCustomSegmentRecord(customField)) { + updateNetSuiteCustomSegments( + policyID, + updatedRecords as NetSuiteCustomSegment[], + allRecords as NetSuiteCustomSegment[], + `${importCustomField}_${valueIndex}`, + CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + ); + } else { + updateNetSuiteCustomLists( + policyID, + updatedRecords as NetSuiteCustomList[], + allRecords as NetSuiteCustomList[], + `${importCustomField}_${valueIndex}`, + CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + ); } + } - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOM_FIELD_VIEW.getRoute(policyID, importCustomField, valueIndex)); - }, - [allRecords, customField, fieldName, importCustomField, policyID, valueIndex], - ); - - const validate = useCallback( - (formValues: FormOnyxValues) => { - const errors: FormInputErrors = {}; - - const key = fieldName as keyof typeof formValues; - const fieldLabel = translate(`workspace.netsuite.import.importCustomFields.${importCustomField}.fields.${fieldName}` as TranslationPaths); - if (!formValues[key]) { - addErrorMessage(errors, fieldName, translate('workspace.netsuite.import.importCustomFields.requiredFieldError', fieldLabel)); - } else if ( - policy?.connections?.netsuite?.options?.config?.syncOptions?.customSegments?.find( - (customSegment) => customSegment?.[fieldName as keyof typeof customSegment]?.toLowerCase() === formValues[key].toLowerCase(), - ) - ) { - addErrorMessage(errors, fieldName, translate('workspace.netsuite.import.importCustomFields.customSegments.errors.uniqueFieldError', fieldLabel)); - } + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOM_FIELD_VIEW.getRoute(policyID, importCustomField, valueIndex)); + }; - return errors; - }, - [fieldName, importCustomField, policy?.connections?.netsuite?.options?.config?.syncOptions?.customSegments, translate], - ); + const validate = (formValues: FormOnyxValues) => { + const errors: FormInputErrors = {}; + + const key = fieldName as keyof typeof formValues; + const fieldLabel = translate(`workspace.netsuite.import.importCustomFields.${importCustomField}.fields.${fieldName}` as TranslationPaths); + if (!formValues[key]) { + addErrorMessage(errors, fieldName, translate('workspace.netsuite.import.importCustomFields.requiredFieldError', fieldLabel)); + } else if ( + policy?.connections?.netsuite?.options?.config?.syncOptions?.customSegments?.find( + (customSegment) => customSegment?.[fieldName as keyof typeof customSegment]?.toLowerCase() === formValues[key].toLowerCase(), + ) + ) { + addErrorMessage(errors, fieldName, translate('workspace.netsuite.import.importCustomFields.customSegments.errors.uniqueFieldError', fieldLabel)); + } + + return errors; + }; - const renderForm = useMemo( - () => - !!customField && ( - - - - ), - [config?.pendingFields, customField, fieldName, fieldValue, importCustomField, inputCallbackRef, styles.flexGrow1, styles.ph5, translate, updateRecord, validate, valueIndex], + const renderForm = !!customField && ( + + + ); - const renderSelection = useMemo( - () => - !!customField && ( - { - updateRecord({ - [fieldName]: value, - }); - }} - value={fieldValue} - /> - ), - [customField, fieldName, fieldValue, updateRecord], + const renderSelection = !!customField && ( + { + updateRecord({ + [fieldName]: value, + }); + }} + value={fieldValue} + /> ); const renderMap: Record = { diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListSelectorPage.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListSelectorPage.tsx index aa2e29e76add..bb210af2b320 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListSelectorPage.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListSelectorPage.tsx @@ -25,7 +25,7 @@ import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/NetSuiteCustomFieldForm'; import {Str} from 'expensify-common'; -import React, {useMemo} from 'react'; +import React from 'react'; type NetSuiteCustomListSelectorPageProps = PlatformStackScreenProps; @@ -44,38 +44,31 @@ function NetSuiteCustomListSelectorPage({ const rawCustomLists = policy?.connections?.netsuite?.options?.data?.customLists; - const {options, showTextInput} = useMemo(() => { - const customLists = rawCustomLists ?? []; - const customListData = customLists.map((customListRecord) => ({ - text: customListRecord.name, - value: customListRecord.name, - isSelected: customListRecord.name === currentCustomListValue, - keyForList: customListRecord.name, - id: customListRecord.id, - })); - - // Pin the frozen pre-selected list to the top of the full list before search filtering, so it stays pinned while searching. - const orderedCustomListData = moveInitialSelectionToTop(customListData, initialCustomListValue ? [initialCustomListValue] : []); - - const searchRegex = new RegExp(Str.escapeForRegExp(debouncedSearchValue.trim()), 'i'); - const filteredCustomLists = orderedCustomListData.filter((customListRecord) => searchRegex.test(customListRecord.text ?? '')); - const isEmpty = debouncedSearchValue.trim() && !filteredCustomLists.length; - - return { - options: isEmpty ? [] : filteredCustomLists, - showTextInput: customListData.length >= CONST.STANDARD_LIST_ITEM_LIMIT, - }; - }, [debouncedSearchValue, rawCustomLists, currentCustomListValue, initialCustomListValue]); - - const textInputOptions = useMemo( - () => ({ - value: searchValue, - label: showTextInput ? translate('common.search') : undefined, - onChangeText: setSearchValue, - headerMessage: debouncedSearchValue.trim() && options.length === 0 ? translate('common.noResultsFound') : '', - }), - [searchValue, showTextInput, translate, setSearchValue, debouncedSearchValue, options.length], - ); + const customLists = rawCustomLists ?? []; + const customListData = customLists.map((customListRecord) => ({ + text: customListRecord.name, + value: customListRecord.name, + isSelected: customListRecord.name === currentCustomListValue, + keyForList: customListRecord.name, + id: customListRecord.id, + })); + + // Pin the frozen pre-selected list to the top of the full list before search filtering, so it stays pinned while searching. + const orderedCustomListData = moveInitialSelectionToTop(customListData, initialCustomListValue ? [initialCustomListValue] : []); + const searchRegex = new RegExp(Str.escapeForRegExp(debouncedSearchValue.trim()), 'i'); + const filteredCustomLists = orderedCustomListData.filter((customListRecord) => searchRegex.test(customListRecord.text ?? '')); + const isEmpty = debouncedSearchValue.trim() && !filteredCustomLists.length; + const {options, showTextInput} = { + options: isEmpty ? [] : filteredCustomLists, + showTextInput: customListData.length >= CONST.STANDARD_LIST_ITEM_LIMIT, + }; + + const textInputOptions = { + value: searchValue, + label: showTextInput ? translate('common.search') : undefined, + onChangeText: setSearchValue, + headerMessage: debouncedSearchValue.trim() && options.length === 0 ? translate('common.noResultsFound') : '', + }; const label = translate('workspace.netsuite.import.importCustomFields.customLists.fields.listName'); diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomListContent.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomListContent.tsx index 05d946e87fc0..1525f54f0372 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomListContent.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomListContent.tsx @@ -24,7 +24,7 @@ import type {Policy} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useCallback, useMemo, useRef} from 'react'; +import React, {useRef} from 'react'; import {View} from 'react-native'; import {getCustomListInitialSubstep, getSubstepValues} from './customUtils'; @@ -52,13 +52,13 @@ function NetSuiteImportAddCustomListContent({policy, draftValues, policyIDParam} const {translate} = useLocalize(); const formRef = useRef(null); - const values = useMemo(() => getSubstepValues(draftValues), [draftValues]); - const startFrom = useMemo(() => getCustomListInitialSubstep(values), [values]); + const values = getSubstepValues(draftValues); + const startFrom = getCustomListInitialSubstep(values); const config = policy?.connections?.netsuite?.options?.config; - const customLists = useMemo(() => config?.syncOptions?.customLists ?? [], [config?.syncOptions]); + const customLists = config?.syncOptions?.customLists ?? []; - const handleFinishStep = useCallback(() => { + const handleFinishStep = () => { const updatedCustomLists = customLists.concat([ { listName: values[INPUT_IDS.LIST_NAME], @@ -76,7 +76,7 @@ function NetSuiteImportAddCustomListContent({policy, draftValues, policyIDParam} ); clearDraftValues(ONYXKEYS.FORMS.NETSUITE_CUSTOM_LIST_ADD_FORM); Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOM_FIELD_MAPPING.getRoute(policyID, CONST.NETSUITE_CONFIG.IMPORT_CUSTOM_FIELDS.CUSTOM_LISTS)); - }, [values, customLists, policyID]); + }; const {CurrentPage, isEditing, nextPage, prevPage, pageIndex, moveTo, isRedirecting} = useSubPage({ pages, @@ -105,13 +105,13 @@ function NetSuiteImportAddCustomListContent({policy, draftValues, policyIDParam} prevPage(); }; - const handleNextScreen = useCallback(() => { + const handleNextScreen = () => { if (isEditing) { goBackToConfirmStep(); return; } nextPage(); - }, [isEditing, goBackToConfirmStep, nextPage]); + }; if (isRedirecting) { return ( diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomSegmentContent.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomSegmentContent.tsx index 65dc3ada8a8e..abe2057e7cfc 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomSegmentContent.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomSegmentContent.tsx @@ -26,7 +26,7 @@ import type {Policy} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import React, {useCallback, useMemo, useRef, useState} from 'react'; +import React, {useRef, useState} from 'react'; import {View} from 'react-native'; import {getCustomSegmentInitialSubstep, getSubstepValues} from './customUtils'; @@ -59,11 +59,11 @@ function NetSuiteImportAddCustomSegmentContent({policy, policyIDParam, draftValu const formRef = useRef(null); const config = policy?.connections?.netsuite?.options?.config; - const customSegments = useMemo(() => config?.syncOptions?.customSegments ?? [], [config?.syncOptions]); + const customSegments = config?.syncOptions?.customSegments ?? []; const [customSegmentType, setCustomSegmentType] = useState | undefined>(); - const values = useMemo(() => getSubstepValues(draftValues), [draftValues]); - const startFrom = useMemo(() => getCustomSegmentInitialSubstep(values), [values]); - const handleFinishStep = useCallback(() => { + const values = getSubstepValues(draftValues); + const startFrom = getCustomSegmentInitialSubstep(values); + const handleFinishStep = () => { const updatedCustomSegments = customSegments.concat([ { segmentName: values[INPUT_IDS.SEGMENT_NAME], @@ -82,7 +82,7 @@ function NetSuiteImportAddCustomSegmentContent({policy, policyIDParam, draftValu clearDraftValues(ONYXKEYS.FORMS.NETSUITE_CUSTOM_SEGMENT_ADD_FORM); Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOM_FIELD_MAPPING.getRoute(policyID, CONST.NETSUITE_CONFIG.IMPORT_CUSTOM_FIELDS.CUSTOM_SEGMENTS)); - }, [values, customSegments, policyID]); + }; const {CurrentPage, isEditing, nextPage, prevPage, pageIndex, moveTo, isRedirecting} = useSubPage({ pages, @@ -111,13 +111,13 @@ function NetSuiteImportAddCustomSegmentContent({policy, policyIDParam, draftValu prevPage(); }; - const handleNextScreen = useCallback(() => { + const handleNextScreen = () => { if (isEditing) { goBackToConfirmStep(); return; } nextPage(); - }, [goBackToConfirmStep, isEditing, nextPage]); + }; if (isRedirecting) { return ( diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ChooseCustomListStep.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ChooseCustomListStep.tsx index 45999b04e139..50560f24a033 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ChooseCustomListStep.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ChooseCustomListStep.tsx @@ -15,7 +15,7 @@ import type {CustomFieldSubPageWithPolicy} from '@pages/workspace/accounting/net import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/NetSuiteCustomFieldForm'; -import React, {useCallback} from 'react'; +import React from 'react'; const STEP_FIELDS = [INPUT_IDS.LIST_NAME, INPUT_IDS.INTERNAL_ID]; @@ -23,12 +23,9 @@ function ChooseCustomListStep({policyIDParam, onNext, isEditing, netSuiteCustomF const styles = useThemeStyles(); const {translate} = useLocalize(); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - return getFieldRequiredErrors(values, [INPUT_IDS.LIST_NAME], translate); - }, - [translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => { + return getFieldRequiredErrors(values, [INPUT_IDS.LIST_NAME], translate); + }; const handleSubmit = useNetSuiteImportAddCustomListFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ChooseSegmentTypeStep.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ChooseSegmentTypeStep.tsx index 7e0358e5f8d0..6038c4e044e4 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ChooseSegmentTypeStep.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ChooseSegmentTypeStep.tsx @@ -18,7 +18,7 @@ import INPUT_IDS from '@src/types/form/NetSuiteCustomFieldForm'; import type {ValueOf} from 'type-fest'; -import React, {useCallback} from 'react'; +import React from 'react'; const STEP_FIELDS = [INPUT_IDS.SEGMENT_TYPE]; @@ -26,18 +26,15 @@ function ChooseSegmentTypeStep({onNext, setCustomSegmentType, isEditing, netSuit const styles = useThemeStyles(); const {translate} = useLocalize(); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; - if (!isRequiredFulfilled(values[INPUT_IDS.SEGMENT_TYPE])) { - errors[INPUT_IDS.SEGMENT_TYPE] = translate('common.error.pleaseSelectOne'); - } + if (!isRequiredFulfilled(values[INPUT_IDS.SEGMENT_TYPE])) { + errors[INPUT_IDS.SEGMENT_TYPE] = translate('common.error.pleaseSelectOne'); + } - return errors; - }, - [translate], - ); + return errors; + }; const handleSubmit = useNetSuiteImportAddCustomSegmentFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/CustomListMappingStep.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/CustomListMappingStep.tsx index 497a2f3de137..dcd819e6d572 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/CustomListMappingStep.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/CustomListMappingStep.tsx @@ -17,7 +17,7 @@ import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/NetSuiteCustomFieldForm'; -import React, {useCallback} from 'react'; +import React from 'react'; const STEP_FIELDS = [INPUT_IDS.MAPPING]; @@ -25,18 +25,15 @@ function CustomListMappingStep({importCustomField, customSegmentType, onNext, is const styles = useThemeStyles(); const {translate} = useLocalize(); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; - if (!isRequiredFulfilled(values[INPUT_IDS.MAPPING])) { - errors[INPUT_IDS.MAPPING] = translate('common.error.pleaseSelectOne'); - } + if (!isRequiredFulfilled(values[INPUT_IDS.MAPPING])) { + errors[INPUT_IDS.MAPPING] = translate('common.error.pleaseSelectOne'); + } - return errors; - }, - [translate], - ); + return errors; + }; const handleSubmit = useNetSuiteImportAddCustomListFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/CustomSegmentInternalIdStep.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/CustomSegmentInternalIdStep.tsx index 6792c93eed78..1e80a0c06b5c 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/CustomSegmentInternalIdStep.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/CustomSegmentInternalIdStep.tsx @@ -19,7 +19,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/NetSuiteCustomFieldForm'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; const STEP_FIELDS = [INPUT_IDS.INTERNAL_ID]; @@ -39,19 +39,16 @@ function CustomSegmentInternalIdStep({customSegmentType, onNext, isEditing, netS shouldSaveDraft: true, }); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; - if (!isRequiredFulfilled(values[INPUT_IDS.INTERNAL_ID])) { - errors[INPUT_IDS.INTERNAL_ID] = translate('workspace.netsuite.import.importCustomFields.requiredFieldError', fieldLabel); - } else if (customSegments?.find((customSegment) => customSegment.internalID.toLowerCase() === values[INPUT_IDS.INTERNAL_ID].toLowerCase())) { - errors[INPUT_IDS.INTERNAL_ID] = translate('workspace.netsuite.import.importCustomFields.customSegments.errors.uniqueFieldError', fieldLabel); - } - return errors; - }, - [customSegments, translate, fieldLabel], - ); + if (!isRequiredFulfilled(values[INPUT_IDS.INTERNAL_ID])) { + errors[INPUT_IDS.INTERNAL_ID] = translate('workspace.netsuite.import.importCustomFields.requiredFieldError', fieldLabel); + } else if (customSegments?.find((customSegment) => customSegment.internalID.toLowerCase() === values[INPUT_IDS.INTERNAL_ID].toLowerCase())) { + errors[INPUT_IDS.INTERNAL_ID] = translate('workspace.netsuite.import.importCustomFields.customSegments.errors.uniqueFieldError', fieldLabel); + } + return errors; + }; return ( ): FormInputErrors => { - const errors: FormInputErrors = {}; - if (!isRequiredFulfilled(values[INPUT_IDS.MAPPING])) { - errors[INPUT_IDS.MAPPING] = translate('common.error.pleaseSelectOne'); - } - return errors; - }, - [translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + if (!isRequiredFulfilled(values[INPUT_IDS.MAPPING])) { + errors[INPUT_IDS.MAPPING] = translate('common.error.pleaseSelectOne'); + } + return errors; + }; const handleSubmit = useNetSuiteImportAddCustomSegmentFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/CustomSegmentNameStep.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/CustomSegmentNameStep.tsx index 4454dcf4f72f..b4ca6c63bb03 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/CustomSegmentNameStep.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/CustomSegmentNameStep.tsx @@ -19,7 +19,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/NetSuiteCustomFieldForm'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; const STEP_FIELDS = [INPUT_IDS.SEGMENT_NAME]; @@ -38,22 +38,19 @@ function CustomSegmentNameStep({customSegmentType, onNext, isEditing, customSegm shouldSaveDraft: true, }); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; - if (!isRequiredFulfilled(values[INPUT_IDS.SEGMENT_NAME])) { - errors[INPUT_IDS.SEGMENT_NAME] = translate( - 'workspace.netsuite.import.importCustomFields.requiredFieldError', - translate(`workspace.netsuite.import.importCustomFields.customSegments.addForm.${customSegmentRecordType}Name`), - ); - } else if (customSegments?.find((customSegment) => customSegment.segmentName.toLowerCase() === values[INPUT_IDS.SEGMENT_NAME].toLowerCase())) { - errors[INPUT_IDS.SEGMENT_NAME] = translate('workspace.netsuite.import.importCustomFields.customSegments.errors.uniqueFieldError', fieldLabel); - } - return errors; - }, - [customSegments, translate, customSegmentRecordType, fieldLabel], - ); + if (!isRequiredFulfilled(values[INPUT_IDS.SEGMENT_NAME])) { + errors[INPUT_IDS.SEGMENT_NAME] = translate( + 'workspace.netsuite.import.importCustomFields.requiredFieldError', + translate(`workspace.netsuite.import.importCustomFields.customSegments.addForm.${customSegmentRecordType}Name`), + ); + } else if (customSegments?.find((customSegment) => customSegment.segmentName.toLowerCase() === values[INPUT_IDS.SEGMENT_NAME].toLowerCase())) { + errors[INPUT_IDS.SEGMENT_NAME] = translate('workspace.netsuite.import.importCustomFields.customSegments.errors.uniqueFieldError', fieldLabel); + } + return errors; + }; return ( ): FormInputErrors => { - const errors: FormInputErrors = {}; + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; - if (!isRequiredFulfilled(values[INPUT_IDS.SCRIPT_ID])) { - errors[INPUT_IDS.SCRIPT_ID] = translate('workspace.netsuite.import.importCustomFields.requiredFieldError', fieldLabel); - } else if (customSegments?.find((customSegment) => customSegment.scriptID.toLowerCase() === values[INPUT_IDS.SCRIPT_ID].toLowerCase())) { - errors[INPUT_IDS.SCRIPT_ID] = translate('workspace.netsuite.import.importCustomFields.customSegments.errors.uniqueFieldError', fieldLabel); - } - return errors; - }, - [customSegments, translate, fieldLabel], - ); + if (!isRequiredFulfilled(values[INPUT_IDS.SCRIPT_ID])) { + errors[INPUT_IDS.SCRIPT_ID] = translate('workspace.netsuite.import.importCustomFields.requiredFieldError', fieldLabel); + } else if (customSegments?.find((customSegment) => customSegment.scriptID.toLowerCase() === values[INPUT_IDS.SCRIPT_ID].toLowerCase())) { + errors[INPUT_IDS.SCRIPT_ID] = translate('workspace.netsuite.import.importCustomFields.customSegments.errors.uniqueFieldError', fieldLabel); + } + return errors; + }; const handleSubmit = useNetSuiteImportAddCustomSegmentFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/TransactionFieldIDStep.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/TransactionFieldIDStep.tsx index cef01544b967..7935e97e49bc 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/TransactionFieldIDStep.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/TransactionFieldIDStep.tsx @@ -19,7 +19,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/NetSuiteCustomFieldForm'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; const STEP_FIELDS = [INPUT_IDS.TRANSACTION_FIELD_ID]; @@ -37,18 +37,15 @@ function TransactionFieldIDStep({onNext, isEditing, netSuiteCustomFieldFormValue shouldSaveDraft: true, }); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; - if (!isRequiredFulfilled(values[INPUT_IDS.TRANSACTION_FIELD_ID])) { - errors[INPUT_IDS.TRANSACTION_FIELD_ID] = translate('workspace.netsuite.import.importCustomFields.requiredFieldError', fieldLabel); - } else if (customLists?.find((customList) => customList.transactionFieldID.toLowerCase() === values[INPUT_IDS.TRANSACTION_FIELD_ID].toLowerCase())) { - errors[INPUT_IDS.TRANSACTION_FIELD_ID] = translate('workspace.netsuite.import.importCustomFields.customLists.errors.uniqueTransactionFieldIDError'); - } - return errors; - }, - [customLists, translate, fieldLabel], - ); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + if (!isRequiredFulfilled(values[INPUT_IDS.TRANSACTION_FIELD_ID])) { + errors[INPUT_IDS.TRANSACTION_FIELD_ID] = translate('workspace.netsuite.import.importCustomFields.requiredFieldError', fieldLabel); + } else if (customLists?.find((customList) => customList.transactionFieldID.toLowerCase() === values[INPUT_IDS.TRANSACTION_FIELD_ID].toLowerCase())) { + errors[INPUT_IDS.TRANSACTION_FIELD_ID] = translate('workspace.netsuite.import.importCustomFields.customLists.errors.uniqueTransactionFieldIDError'); + } + return errors; + }; return ( ; @@ -82,38 +82,32 @@ function NetSuiteImportCustomFieldPage({ const config = policy?.connections?.netsuite?.options?.config; const data = config?.syncOptions?.[importCustomField] ?? []; - const listEmptyComponent = useMemo( - () => ( - - } - containerStyle={[styles.flex1, styles.justifyContentCenter]} - /> - ), - [importCustomField, styles, translate, illustrations.FolderWithPapers], - ); - - const listHeaderComponent = useMemo( - () => ( - + const listEmptyComponent = ( + - - ), - [styles, importCustomField, translate], + } + containerStyle={[styles.flex1, styles.justifyContentCenter]} + /> + ); + + const listHeaderComponent = ( + + + ); return ( diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldView.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldView.tsx index 32cdfc1ce987..b56b34f0a5f0 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldView.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldView.tsx @@ -27,7 +27,7 @@ import type {NetSuiteCustomList, NetSuiteCustomSegment} from '@src/types/onyx/Po import type {ValueOf} from 'type-fest'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; type CustomField = NetSuiteCustomList | NetSuiteCustomSegment; type ImportCustomFieldsKeys = ValueOf; @@ -55,14 +55,14 @@ function NetSuiteImportCustomFieldView({ const {showConfirmModal} = useConfirmModal(); const config = policy?.connections?.netsuite?.options?.config; - const allRecords = useMemo(() => config?.syncOptions?.[importCustomField] ?? [], [config?.syncOptions, importCustomField]); + const allRecords = config?.syncOptions?.[importCustomField] ?? []; const icons = useMemoizedLazyExpensifyIcons(['Trashcan']); const customField: CustomField | undefined = allRecords[valueIndex]; const fieldList = customField && isNetSuiteCustomSegmentRecord(customField) ? CONST.NETSUITE_CONFIG.CUSTOM_SEGMENT_FIELDS : [INPUT_IDS.LIST_NAME, INPUT_IDS.TRANSACTION_FIELD_ID, INPUT_IDS.MAPPING]; - const removeRecord = useCallback(() => { + const removeRecord = () => { if (!policyID) { return; } @@ -89,7 +89,7 @@ function NetSuiteImportCustomFieldView({ } } Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOM_FIELD_MAPPING.getRoute(policyID, importCustomField)); - }, [allRecords, customField, importCustomField, policyID, valueIndex]); + }; return ( { - if (value !== importedValue) { - updateNetSuiteCustomersJobsMapping( - policyID, - { - customersMapping: importCustomer !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT ? value : CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT, - jobsMapping: importJobs !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT ? value : CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT, - }, - { - customersMapping: importMappings?.customers, - jobsMapping: importMappings?.jobs, - }, - ); - } - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS.getRoute(policyID)); - }, - [importCustomer, importJobs, importMappings?.customers, importMappings?.jobs, importedValue, policyID], - ); + const updateImportMapping = ({value}: ImportListItem) => { + if (value !== importedValue) { + updateNetSuiteCustomersJobsMapping( + policyID, + { + customersMapping: importCustomer !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT ? value : CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT, + jobsMapping: importJobs !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT ? value : CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT, + }, + { + customersMapping: importMappings?.customers, + jobsMapping: importMappings?.jobs, + }, + ); + } + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOMERS_OR_PROJECTS.getRoute(policyID)); + }; return ( { - let newValue; - if (!isEnabled) { - // if the import is off, then we send default as the value for mapping - newValue = CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT; + const updateMapping = (importField: ImportField, isEnabled: boolean) => { + let newValue; + if (!isEnabled) { + // if the import is off, then we send default as the value for mapping + newValue = CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT; + } else { + // when we enable any field, and if the other one already has a value set, we should set that, + const otherFieldValue = importField === CONST.NETSUITE_CONFIG.SYNC_OPTIONS.CUSTOMER_MAPPINGS.JOBS ? importCustomer : importJobs; + if (otherFieldValue === CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT) { + // fallback to Tag + newValue = CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG; } else { - // when we enable any field, and if the other one already has a value set, we should set that, - const otherFieldValue = importField === CONST.NETSUITE_CONFIG.SYNC_OPTIONS.CUSTOMER_MAPPINGS.JOBS ? importCustomer : importJobs; - if (otherFieldValue === CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT) { - // fallback to Tag - newValue = CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG; - } else { - newValue = otherFieldValue; - } + newValue = otherFieldValue; } - if (newValue) { - updateNetSuiteImportMapping(policyID, importField, newValue, config?.syncOptions?.mapping?.[importField] ?? null); - } - }, - [config?.syncOptions?.mapping, policyID, importCustomer, importJobs], - ); + } + if (newValue) { + updateNetSuiteImportMapping(policyID, importField, newValue, config?.syncOptions?.mapping?.[importField] ?? null); + } + }; return ( ; @@ -56,24 +56,18 @@ function NetSuiteImportMappingPage({ const importValue = importMappings?.[importField] ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT; - const listFooterContent = useMemo( - () => ( - - {translate(`workspace.netsuite.import.importTypes.${importValue}.footerContent`, importField)} - - ), - [importField, importValue, styles.mb4, styles.mt3, styles.ph5, translate], + const listFooterContent = ( + + {translate(`workspace.netsuite.import.importTypes.${importValue}.footerContent`, importField)} + ); - const listHeaderComponent = useMemo( - () => ( - - - ${Parser.replace(translate(`workspace.netsuite.import.importFields.${importField}.subtitle` as TranslationPaths))}`} /> - + const listHeaderComponent = ( + + + ${Parser.replace(translate(`workspace.netsuite.import.importFields.${importField}.subtitle` as TranslationPaths))}`} /> - ), - [styles.ph5, styles.pb5, styles.flexRow, translate, importField], + ); const inputOptions = [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT, CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD]; @@ -88,16 +82,13 @@ function NetSuiteImportMappingPage({ const titleKey = `workspace.netsuite.import.importFields.${importField}.title` as TranslationPaths; - const updateImportMapping = useCallback( - ({value}: ImportListItem) => { - if (value !== importValue) { - updateNetSuiteImportMapping(policyID, importField as keyof typeof importMappings, value, importValue); - } + const updateImportMapping = ({value}: ImportListItem) => { + if (value !== importValue) { + updateNetSuiteImportMapping(policyID, importField as keyof typeof importMappings, value, importValue); + } - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT.getRoute(policyID)); - }, - [importField, importValue, policyID], - ); + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT.getRoute(policyID)); + }; return ( (subsidiaryList ?? []).find((subsidiary) => subsidiary.internalID === config?.subsidiaryID), [subsidiaryList, config?.subsidiaryID]); + const selectedSubsidiary = (subsidiaryList ?? []).find((subsidiary) => subsidiary.internalID === config?.subsidiaryID); return ( ; @@ -46,7 +46,7 @@ function RequireQuickBooksDesktopModal({route}: RequireQuickBooksDesktopModalPro const [codatSetupLink, setCodatSetupLink] = useState(''); const hasResultOfFetchingSetupLink = !!codatSetupLink || hasError; - const fetchSetupLink = useCallback(() => { + const fetchSetupLink = () => { setHasError(false); getQuickbooksDesktopCodatSetupLink(policyID).then((response) => { @@ -61,7 +61,7 @@ function RequireQuickBooksDesktopModal({route}: RequireQuickBooksDesktopModalPro setHasError(true); } }); - }, [policyID, translate]); + }; useEffect(() => { // Since QBD doesn't support Taxes, we should disable them from the LHN when connecting to QBD diff --git a/src/pages/workspace/accounting/qbd/QuickbooksDesktopExistingConnectionsPage.tsx b/src/pages/workspace/accounting/qbd/QuickbooksDesktopExistingConnectionsPage.tsx index 353a5a20b33b..df5fbd085b54 100644 --- a/src/pages/workspace/accounting/qbd/QuickbooksDesktopExistingConnectionsPage.tsx +++ b/src/pages/workspace/accounting/qbd/QuickbooksDesktopExistingConnectionsPage.tsx @@ -21,7 +21,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; -import React, {useMemo} from 'react'; +import React from 'react'; import getQuickbooksDesktopSetupEntryRoute from './utils'; @@ -35,30 +35,24 @@ function QuickbooksDesktopExistingConnectionsPage({route}: QuickbooksDesktopExis const {reusablePoliciesConnectedTo: reusablePoliciesConnectedToQBD} = useReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.QBD, policyID); const [connectionSyncProgressCollection] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS); - const menuItems = useMemo( - () => - reusablePoliciesConnectedToQBD.map((policy) => { - const syncProgress = connectionSyncProgressCollection?.[`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy.id}`]; - const lastSuccessfulSyncDate = getIntegrationLastSuccessfulDate(getLocalDateFromDatetime, policy.connections?.quickbooksDesktop, syncProgress); - const date = lastSuccessfulSyncDate ? datetimeToRelative(lastSuccessfulSyncDate) : undefined; - return { - title: policy.name, - key: policy.id, - avatarID: policy.id, - icon: policy.avatarURL, - iconType: CONST.ICON_TYPE_WORKSPACE, - shouldShowRightIcon: true, - description: date - ? translate('workspace.common.lastSyncDate', CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.quickbooksDesktop, date) - : translate('workspace.accounting.qbd'), - onPress: () => { - copyExistingPolicyConnection(policy.id, policyID, CONST.POLICY.CONNECTIONS.NAME.QBD); - Navigation.dismissModal(); - }, - }; - }), - [reusablePoliciesConnectedToQBD, connectionSyncProgressCollection, policyID, translate, datetimeToRelative, getLocalDateFromDatetime], - ); + const menuItems = reusablePoliciesConnectedToQBD.map((policy) => { + const syncProgress = connectionSyncProgressCollection?.[`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy.id}`]; + const lastSuccessfulSyncDate = getIntegrationLastSuccessfulDate(getLocalDateFromDatetime, policy.connections?.quickbooksDesktop, syncProgress); + const date = lastSuccessfulSyncDate ? datetimeToRelative(lastSuccessfulSyncDate) : undefined; + return { + title: policy.name, + key: policy.id, + avatarID: policy.id, + icon: policy.avatarURL, + iconType: CONST.ICON_TYPE_WORKSPACE, + shouldShowRightIcon: true, + description: date ? translate('workspace.common.lastSyncDate', CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.quickbooksDesktop, date) : translate('workspace.accounting.qbd'), + onPress: () => { + copyExistingPolicyConnection(policy.id, policyID, CONST.POLICY.CONNECTIONS.NAME.QBD); + Navigation.dismissModal(); + }, + }; + }); return ( ( - - {translate('workspace.qbd.accountingMethods.description')} - - ), - [translate, styles.pb5, styles.ph5], + const headerContent = ( + + {translate('workspace.qbd.accountingMethods.description')} + ); - const selectAccountingMethod = useCallback( - (row: MenuListItem) => { - if (row.value !== accountingMethod) { - if (!policyID) { - return; - } - updateQuickbooksDesktopAccountingMethod(policyID, row.value, accountingMethod); + const selectAccountingMethod = (row: MenuListItem) => { + if (row.value !== accountingMethod) { + if (!policyID) { + return; } - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_AUTO_SYNC.getRoute(policyID)); - }, - [accountingMethod, policyID], - ); + updateQuickbooksDesktopAccountingMethod(policyID, row.value, accountingMethod); + } + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_AUTO_SYNC.getRoute(policyID)); + }; return ( ; @@ -44,14 +44,14 @@ function QuickbooksDesktopAutoSyncPageBase({policy, navigateBackTo}: QuickbooksD const pendingAction = settingsPendingAction([CONST.QUICKBOOKS_DESKTOP_CONFIG.AUTO_SYNC], pendingFields) ?? settingsPendingAction([CONST.QUICKBOOKS_DESKTOP_CONFIG.ACCOUNTING_METHOD], pendingFields); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack( navigateBackTo ?? (policyID ? createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_DESKTOP_ADVANCED.path, getAccountingSettingsRoute(isUnifiedConnectionsBetaEnabled, policyID)) : undefined), ); - }, [isUnifiedConnectionsBetaEnabled, policyID, navigateBackTo]); + }; return ( { - const qbdReimbursableAccounts = getQBDReimbursableAccounts(policy?.connections?.quickbooksDesktop, nonReimbursable); - return qbdReimbursableAccounts.find(({id}) => nonReimbursableAccount === id)?.name; - }, [policy?.connections?.quickbooksDesktop, nonReimbursable, nonReimbursableAccount]); + const qbdReimbursableAccounts = getQBDReimbursableAccounts(policy?.connections?.quickbooksDesktop, nonReimbursable); + const accountName = qbdReimbursableAccounts.find(({id}) => nonReimbursableAccount === id)?.name; const {isAccordionExpanded, shouldAnimateAccordionSection} = useAccordionAnimation(!!qbdConfig?.shouldAutoCreateVendor); diff --git a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopCompanyCardExpenseAccountSelectCardPage.tsx b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopCompanyCardExpenseAccountSelectCardPage.tsx index 48cdb626674d..a0c36f9c1fc2 100644 --- a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopCompanyCardExpenseAccountSelectCardPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopCompanyCardExpenseAccountSelectCardPage.tsx @@ -21,7 +21,7 @@ import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {QBDNonReimbursableExportAccountType} from '@src/types/onyx/Policy'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; type MenuItem = ListItem & { value: QBDNonReimbursableExportAccountType; @@ -37,56 +37,50 @@ function DynamicQuickbooksDesktopCompanyCardExpenseAccountSelectCardPage({policy const nonReimbursableBillDefaultVendor = qbdConfig?.export?.nonReimbursableBillDefaultVendor; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_CARD_SELECT.path); - const data: MenuItem[] = useMemo( - () => [ - { - text: translate(`workspace.qbd.accounts.${CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}`), - value: CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, - keyForList: CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, - isSelected: CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD === nonReimbursable, - }, - { - text: translate(`workspace.qbd.accounts.${CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CHECK}`), - value: CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CHECK, - keyForList: CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CHECK, - isSelected: CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CHECK === nonReimbursable, - }, - { - text: translate(`workspace.qbd.accounts.${CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL}`), - value: CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL, - keyForList: CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL, - isSelected: CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL === nonReimbursable, - }, - ], - [translate, nonReimbursable], - ); + const data: MenuItem[] = [ + { + text: translate(`workspace.qbd.accounts.${CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}`), + value: CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, + keyForList: CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, + isSelected: CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD === nonReimbursable, + }, + { + text: translate(`workspace.qbd.accounts.${CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CHECK}`), + value: CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CHECK, + keyForList: CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CHECK, + isSelected: CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CHECK === nonReimbursable, + }, + { + text: translate(`workspace.qbd.accounts.${CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL}`), + value: CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL, + keyForList: CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL, + isSelected: CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL === nonReimbursable, + }, + ]; - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath, {compareParams: false}); - }, [backPath]); + }; - const selectExportCompanyCard = useCallback( - (row: MenuItem) => { - if (row.value !== nonReimbursable && policyID) { - // Switching export type invalidates the previously selected account/vendor because the list of available accounts differs per export type. Clear them so the admin is required to choose on the next screens. - updateQuickbooksCompanyCardExpenseAccount( - policyID, - { - [CONST.QUICKBOOKS_DESKTOP_CONFIG.NON_REIMBURSABLE]: row.value, - [CONST.QUICKBOOKS_DESKTOP_CONFIG.NON_REIMBURSABLE_ACCOUNT]: '', - [CONST.QUICKBOOKS_DESKTOP_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE as string, - }, - { - [CONST.QUICKBOOKS_DESKTOP_CONFIG.NON_REIMBURSABLE]: nonReimbursable, - [CONST.QUICKBOOKS_DESKTOP_CONFIG.NON_REIMBURSABLE_ACCOUNT]: nonReimbursableAccount, - [CONST.QUICKBOOKS_DESKTOP_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: nonReimbursableBillDefaultVendor, - }, - ); - } - goBack(); - }, - [nonReimbursable, nonReimbursableAccount, nonReimbursableBillDefaultVendor, policyID, goBack], - ); + const selectExportCompanyCard = (row: MenuItem) => { + if (row.value !== nonReimbursable && policyID) { + // Switching export type invalidates the previously selected account/vendor because the list of available accounts differs per export type. Clear them so the admin is required to choose on the next screens. + updateQuickbooksCompanyCardExpenseAccount( + policyID, + { + [CONST.QUICKBOOKS_DESKTOP_CONFIG.NON_REIMBURSABLE]: row.value, + [CONST.QUICKBOOKS_DESKTOP_CONFIG.NON_REIMBURSABLE_ACCOUNT]: '', + [CONST.QUICKBOOKS_DESKTOP_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE as string, + }, + { + [CONST.QUICKBOOKS_DESKTOP_CONFIG.NON_REIMBURSABLE]: nonReimbursable, + [CONST.QUICKBOOKS_DESKTOP_CONFIG.NON_REIMBURSABLE_ACCOUNT]: nonReimbursableAccount, + [CONST.QUICKBOOKS_DESKTOP_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: nonReimbursableBillDefaultVendor, + }, + ); + } + goBack(); + }; return ( { - const accounts = getQBDReimbursableAccounts(policy?.connections?.quickbooksDesktop, nonReimbursable); - return accounts.map((card) => ({ - value: card, - text: card.name, - keyForList: card.name, - isSelected: card.id === nonReimbursableAccount, - })); - }, [policy?.connections?.quickbooksDesktop, nonReimbursable, nonReimbursableAccount]); + const accounts = getQBDReimbursableAccounts(policy?.connections?.quickbooksDesktop, nonReimbursable); + const data: CardListItem[] = accounts.map((card) => ({ + value: card, + text: card.name, + keyForList: card.name, + isSelected: card.id === nonReimbursableAccount, + })); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath, {compareParams: false}); - }, [backPath]); + }; - const selectExportAccount = useCallback( - (row: CardListItem) => { - if (row.value.id !== nonReimbursableAccount && policyID) { - updateQuickbooksDesktopNonReimbursableExpensesAccount(policyID, row.value.id, nonReimbursableAccount); - } - goBack(); - }, - [nonReimbursableAccount, policyID, goBack], - ); + const selectExportAccount = (row: CardListItem) => { + if (row.value.id !== nonReimbursableAccount && policyID) { + updateQuickbooksDesktopNonReimbursableExpensesAccount(policyID, row.value.id, nonReimbursableAccount); + } + goBack(); + }; - const listEmptyContent = useMemo( - () => ( - - ), - [translate, styles.pb10, illustrations.Telescope], + const listEmptyContent = ( + ); return ( ; @@ -36,34 +36,27 @@ function DynamicQuickbooksDesktopExportDateSelectPage({policy}: WithPolicyConnec const exportDate = qbdConfig?.export?.exportDate; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT_DATE_SELECT.path); - const data: CardListItem[] = useMemo( - () => - Object.values(CONST.QUICKBOOKS_EXPORT_DATE).map((dateType) => ({ - value: dateType, - text: translate(`workspace.qbd.exportDate.values.${dateType}.label`), - alternateText: translate(`workspace.qbd.exportDate.values.${dateType}.description`), - keyForList: dateType, - isSelected: exportDate === dateType, - })), - [exportDate, translate], - ); + const data: CardListItem[] = Object.values(CONST.QUICKBOOKS_EXPORT_DATE).map((dateType) => ({ + value: dateType, + text: translate(`workspace.qbd.exportDate.values.${dateType}.label`), + alternateText: translate(`workspace.qbd.exportDate.values.${dateType}.description`), + keyForList: dateType, + isSelected: exportDate === dateType, + })); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); + }; - const selectExportDate = useCallback( - (row: CardListItem) => { - if (!policyID) { - return; - } - if (row.value !== exportDate) { - updateQuickbooksDesktopExportDate(policyID, row.value, exportDate); - } - goBack(); - }, - [policyID, exportDate, goBack], - ); + const selectExportDate = (row: CardListItem) => { + if (!policyID) { + return; + } + if (row.value !== exportDate) { + updateQuickbooksDesktopExportDate(policyID, row.value, exportDate); + } + goBack(); + }; return ( qbdConfig?.export?.nonReimbursable === CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL, - [qbdConfig?.export?.nonReimbursable], - ); + const shouldShowVendorMenuItems = qbdConfig?.export?.nonReimbursable === CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL; const {payableAccounts} = policy?.connections?.quickbooksDesktop?.data ?? {}; const travelPayableAccount = payableAccounts?.find((account) => account.id === qbdConfig?.export?.travelInvoicingPayableAccountID); @@ -48,14 +45,11 @@ function DynamicQuickbooksDesktopExportPage({policy}: WithPolicyConnectionsProps const travelSettings = getCardSettings(cardSettings, CONST.TRAVEL.PROGRAM_TRAVEL_US); const isTravelBillingEnabled = getIsTravelBillingEnabled(travelSettings); - const shouldGoBackToSpecificRoute = useMemo( - () => qbdConfig?.export?.nonReimbursable === CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CHECK || shouldShowVendorMenuItems, - [qbdConfig?.export?.nonReimbursable, shouldShowVendorMenuItems], - ); + const shouldGoBackToSpecificRoute = qbdConfig?.export?.nonReimbursable === CONST.QUICKBOOKS_DESKTOP_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CHECK || shouldShowVendorMenuItems; - const goBack = useCallback(() => { + const goBack = () => { return goBackFromExportConnection(shouldGoBackToSpecificRoute, dynamicBackPath); - }, [dynamicBackPath, shouldGoBackToSpecificRoute]); + }; const menuItems = [ { diff --git a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseAccountSelectPage.tsx b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseAccountSelectPage.tsx index 0c00235b18b4..ca67ef41baac 100644 --- a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseAccountSelectPage.tsx @@ -27,7 +27,7 @@ import type {TranslationPaths} from '@src/languages/types'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Account} from '@src/types/onyx/Policy'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; type CardListItem = ListItem & { value: Account; @@ -40,68 +40,57 @@ function DynamicQuickbooksDesktopOutOfPocketExpenseAccountSelectPage({policy}: W const qbdConfig = policy?.connections?.quickbooksDesktop?.config; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_OUT_OF_POCKET_EXPENSE_ACCOUNT_SELECT.path); - const [title, description] = useMemo<[TranslationPaths | undefined, string | undefined]>(() => { - let titleText: TranslationPaths | undefined; - let descriptionText: string | undefined; - switch (qbdConfig?.export.reimbursable) { - case CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK: - titleText = 'workspace.qbd.bankAccount'; - descriptionText = translate('workspace.qbd.bankAccountDescription'); - break; - case CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY: - titleText = 'workspace.qbd.account'; - descriptionText = translate('workspace.qbd.accountDescription'); - break; - case CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL: - titleText = 'workspace.qbd.accountsPayable'; - descriptionText = translate('workspace.qbd.accountsPayableDescription'); - break; - default: - titleText = 'workspace.qbd.account'; - break; - } - - return [titleText, descriptionText]; - }, [qbdConfig?.export.reimbursable, translate]); - - const data: CardListItem[] = useMemo(() => { - const accounts = getQBDReimbursableAccounts(policy?.connections?.quickbooksDesktop); - return accounts.map((account) => ({ - value: account, - text: account.name, - keyForList: account.name, - isSelected: account.id === qbdConfig?.export?.reimbursableAccount, - })); - }, [policy?.connections?.quickbooksDesktop, qbdConfig?.export?.reimbursableAccount]); + let titleText: TranslationPaths | undefined; + let descriptionText: string | undefined; + switch (qbdConfig?.export.reimbursable) { + case CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK: + titleText = 'workspace.qbd.bankAccount'; + descriptionText = translate('workspace.qbd.bankAccountDescription'); + break; + case CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY: + titleText = 'workspace.qbd.account'; + descriptionText = translate('workspace.qbd.accountDescription'); + break; + case CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL: + titleText = 'workspace.qbd.accountsPayable'; + descriptionText = translate('workspace.qbd.accountsPayableDescription'); + break; + default: + titleText = 'workspace.qbd.account'; + break; + } + const [title, description]: [TranslationPaths | undefined, string | undefined] = [titleText, descriptionText]; + + const accounts = getQBDReimbursableAccounts(policy?.connections?.quickbooksDesktop); + const data: CardListItem[] = accounts.map((account) => ({ + value: account, + text: account.name, + keyForList: account.name, + isSelected: account.id === qbdConfig?.export?.reimbursableAccount, + })); const policyID = policy?.id; - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); - - const selectExportAccount = useCallback( - (row: CardListItem) => { - if (row.value.id !== qbdConfig?.export?.reimbursableAccount && policyID) { - updateQuickbooksDesktopReimbursableExpensesAccount(policyID, row.value.id, qbdConfig?.export?.reimbursableAccount); - } - goBack(); - }, - [qbdConfig?.export?.reimbursableAccount, policyID, goBack], - ); + }; - const listEmptyContent = useMemo( - () => ( - - ), - [illustrations.Telescope, translate, styles.pb10], + const selectExportAccount = (row: CardListItem) => { + if (row.value.id !== qbdConfig?.export?.reimbursableAccount && policyID) { + updateQuickbooksDesktopReimbursableExpensesAccount(policyID, row.value.id, qbdConfig?.export?.reimbursableAccount); + } + goBack(); + }; + + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseConfigurationPage.tsx b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseConfigurationPage.tsx index 7572551e0eb9..a66d4005c875 100644 --- a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseConfigurationPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseConfigurationPage.tsx @@ -28,7 +28,7 @@ import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type {ValueOf} from 'type-fest'; -import React, {useMemo} from 'react'; +import React from 'react'; type QBDSectionType = { title?: string; @@ -52,29 +52,26 @@ function DynamicQuickbooksDesktopOutOfPocketExpenseConfigurationPage({policy}: W const reimbursable = qbdConfig?.export.reimbursable; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT_OUT_OF_POCKET_EXPENSES.path); - const [exportHintText, accountDescription, accountsList] = useMemo(() => { - let hintText: string | undefined; - let description: string | undefined; - const accounts = getQBDReimbursableAccounts(policy?.connections?.quickbooksDesktop); - switch (reimbursable) { - case CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK: - hintText = translate('workspace.qbd.exportCheckDescription'); - description = translate('workspace.qbd.bankAccount'); - break; - case CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY: - hintText = translate('workspace.qbd.exportJournalEntryDescription'); - description = translate('workspace.qbd.account'); - break; - case CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL: - hintText = translate('workspace.qbd.exportVendorBillDescription'); - description = translate('workspace.qbd.accountsPayable'); - break; - default: - break; - } - - return [hintText, description, accounts]; - }, [policy?.connections?.quickbooksDesktop, reimbursable, translate]); + let hintText: string | undefined; + let description: string | undefined; + const accounts = getQBDReimbursableAccounts(policy?.connections?.quickbooksDesktop); + switch (reimbursable) { + case CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK: + hintText = translate('workspace.qbd.exportCheckDescription'); + description = translate('workspace.qbd.bankAccount'); + break; + case CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY: + hintText = translate('workspace.qbd.exportJournalEntryDescription'); + description = translate('workspace.qbd.account'); + break; + case CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL: + hintText = translate('workspace.qbd.exportVendorBillDescription'); + description = translate('workspace.qbd.accountsPayable'); + break; + default: + break; + } + const [exportHintText, accountDescription, accountsList] = [hintText, description, accounts]; const sections: QBDSectionType[] = [ { diff --git a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseEntitySelectPage.tsx b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseEntitySelectPage.tsx index 041d89d821d4..adff9ef41c5c 100644 --- a/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseEntitySelectPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseEntitySelectPage.tsx @@ -22,7 +22,7 @@ import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Account, QBDReimbursableExportAccountType} from '@src/types/onyx/Policy'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; type MenuItem = ListItem & { value: QBDReimbursableExportAccountType; @@ -39,62 +39,56 @@ function DynamicQuickbooksDesktopOutOfPocketExpenseEntitySelectPage({policy}: Wi const policyID = policy?.id; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_OUT_OF_POCKET_EXPENSE_ENTITY_SELECT.path); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); + }; - const data: MenuItem[] = useMemo( - () => [ - { - value: CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK, - text: translate(`workspace.qbd.accounts.${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK}`), - keyForList: CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK, - isSelected: reimbursable === CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK, - isShown: true, - accounts: getQBDReimbursableAccounts(policy?.connections?.quickbooksDesktop, CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK), - }, - { - value: CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY, - text: translate(`workspace.qbd.accounts.${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY}`), - keyForList: CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY, - isSelected: reimbursable === CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY, - isShown: true, - accounts: getQBDReimbursableAccounts(policy?.connections?.quickbooksDesktop, CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY), - }, - { - value: CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, - text: translate(`workspace.qbd.accounts.${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}`), - keyForList: CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, - isSelected: reimbursable === CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, - isShown: true, - accounts: getQBDReimbursableAccounts(policy?.connections?.quickbooksDesktop, CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL), - }, - ], - [translate, reimbursable, policy?.connections?.quickbooksDesktop], - ); + const data: MenuItem[] = [ + { + value: CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK, + text: translate(`workspace.qbd.accounts.${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK}`), + keyForList: CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK, + isSelected: reimbursable === CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK, + isShown: true, + accounts: getQBDReimbursableAccounts(policy?.connections?.quickbooksDesktop, CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.CHECK), + }, + { + value: CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY, + text: translate(`workspace.qbd.accounts.${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY}`), + keyForList: CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY, + isSelected: reimbursable === CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY, + isShown: true, + accounts: getQBDReimbursableAccounts(policy?.connections?.quickbooksDesktop, CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY), + }, + { + value: CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, + text: translate(`workspace.qbd.accounts.${CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL}`), + keyForList: CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, + isSelected: reimbursable === CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, + isShown: true, + accounts: getQBDReimbursableAccounts(policy?.connections?.quickbooksDesktop, CONST.QUICKBOOKS_DESKTOP_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL), + }, + ]; - const filteredData = useMemo(() => data.filter((item) => item.isShown), [data]); + const filteredData = data.filter((item) => item.isShown); - const selectExportEntity = useCallback( - (row: MenuItem) => { - const account = row?.accounts?.at(0)?.id; - if (row.value !== reimbursable && policyID) { - updateQuickbooksDesktopExpensesExportDestination( - policyID, - { - [CONST.QUICKBOOKS_DESKTOP_CONFIG.REIMBURSABLE]: row.value, - [CONST.QUICKBOOKS_DESKTOP_CONFIG.REIMBURSABLE_ACCOUNT]: account, - }, - { - [CONST.QUICKBOOKS_DESKTOP_CONFIG.REIMBURSABLE]: reimbursable, - [CONST.QUICKBOOKS_DESKTOP_CONFIG.REIMBURSABLE_ACCOUNT]: qbdConfig?.export?.reimbursableAccount, - }, - ); - } - goBack(); - }, - [reimbursable, policyID, qbdConfig?.export?.reimbursableAccount, goBack], - ); + const selectExportEntity = (row: MenuItem) => { + const account = row?.accounts?.at(0)?.id; + if (row.value !== reimbursable && policyID) { + updateQuickbooksDesktopExpensesExportDestination( + policyID, + { + [CONST.QUICKBOOKS_DESKTOP_CONFIG.REIMBURSABLE]: row.value, + [CONST.QUICKBOOKS_DESKTOP_CONFIG.REIMBURSABLE_ACCOUNT]: account, + }, + { + [CONST.QUICKBOOKS_DESKTOP_CONFIG.REIMBURSABLE]: reimbursable, + [CONST.QUICKBOOKS_DESKTOP_CONFIG.REIMBURSABLE_ACCOUNT]: qbdConfig?.export?.reimbursableAccount, + }, + ); + } + goBack(); + }; return ( { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); - - const data: CardListItem[] = useMemo( - () => - exporters?.reduce((options, exporter) => { - if (!exporter.email) { - return options; - } - - // Don't show guides if the current user is not a guide themselves or an Expensify employee - if (isExpensifyTeam(exporter.email) && !isExpensifyTeam(policy?.owner) && !isExpensifyTeam(currentUserLogin)) { - return options; - } - options.push({ - value: exporter.email, - text: exporter.email, - keyForList: exporter.email, - // We use the logical OR (||) here instead of ?? because `exporter` could be an empty string - isSelected: (currentExporter || policy?.owner) === exporter.email, - }); - return options; - }, []), - [exporters, policy?.owner, currentUserLogin, currentExporter], - ); - - const selectExporter = useCallback( - (row: CardListItem) => { - if (!policyID) { - return; - } - if (row.value !== currentExporter) { - updateQuickbooksDesktopPreferredExporter(policyID, row.value, currentExporter); - } - goBack(); - }, - [currentExporter, goBack, policyID], - ); - - const headerContent = useMemo( - () => ( - <> - {translate('workspace.accounting.exportPreferredExporterNote')} - {translate('workspace.accounting.exportPreferredExporterSubNote')} - - ), - [translate, styles.ph5, styles.pb5], + }; + + const data: CardListItem[] = exporters?.reduce((options, exporter) => { + if (!exporter.email) { + return options; + } + + // Don't show guides if the current user is not a guide themselves or an Expensify employee + if (isExpensifyTeam(exporter.email) && !isExpensifyTeam(policy?.owner) && !isExpensifyTeam(currentUserLogin)) { + return options; + } + options.push({ + value: exporter.email, + text: exporter.email, + keyForList: exporter.email, + // We use the logical OR (||) here instead of ?? because `exporter` could be an empty string + isSelected: (currentExporter || policy?.owner) === exporter.email, + }); + return options; + }, []); + + const selectExporter = (row: CardListItem) => { + if (!policyID) { + return; + } + if (row.value !== currentExporter) { + updateQuickbooksDesktopPreferredExporter(policyID, row.value, currentExporter); + } + goBack(); + }; + + const headerContent = ( + <> + {translate('workspace.accounting.exportPreferredExporterNote')} + {translate('workspace.accounting.exportPreferredExporterSubNote')} + ); return ( diff --git a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx index bf952cc7e288..e34a7adea262 100644 --- a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopNonReimbursableDefaultVendorSelectPage.tsx @@ -21,7 +21,7 @@ import {clearQBDErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; type CardListItem = ListItem & { value: string; @@ -36,54 +36,42 @@ function QuickbooksDesktopNonReimbursableDefaultVendorSelectPage({policy}: WithP const nonReimbursableBillDefaultVendor = qbdConfig?.export?.nonReimbursableBillDefaultVendor; const policyID = policy?.id ?? CONST.DEFAULT_NUMBER_ID.toString(); - const vendorOptions: CardListItem[] = useMemo( - () => - vendors?.map((vendor) => ({ - value: vendor.id, - text: vendor.name, - keyForList: vendor.name, - isSelected: vendor.id === nonReimbursableBillDefaultVendor, - })) ?? [], - [nonReimbursableBillDefaultVendor, vendors], - ); + const vendorOptions: CardListItem[] = + vendors?.map((vendor) => ({ + value: vendor.id, + text: vendor.name, + keyForList: vendor.name, + isSelected: vendor.id === nonReimbursableBillDefaultVendor, + })) ?? []; 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 clearOption: CardListItem = { + value: CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, + text: translate('common.none'), + keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, + isSelected: isNonReimbursableBillDefaultVendorNone, + }; const shouldShowClearOption = !!nonReimbursableBillDefaultVendor || vendorOptions.length > 0; - const data: CardListItem[] = useMemo(() => (shouldShowClearOption ? [clearOption, ...vendorOptions] : vendorOptions), [shouldShowClearOption, clearOption, vendorOptions]); - - const selectVendor = useCallback( - (row: CardListItem) => { - 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, isNonReimbursableBillDefaultVendorNone, policyID], - ); - - const listEmptyContent = useMemo( - () => ( - - ), - [illustrations.Telescope, translate, styles.pb10], + const data: CardListItem[] = shouldShowClearOption ? [clearOption, ...vendorOptions] : vendorOptions; + + const selectVendor = (row: CardListItem) => { + const isAlreadySelected = row.value === nonReimbursableBillDefaultVendor || (row.value === CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE && isNonReimbursableBillDefaultVendorNone); + if (isAlreadySelected) { + return; + } + updateQuickbooksDesktopNonReimbursableBillDefaultVendor(policyID, row.value, nonReimbursableBillDefaultVendor); + Navigation.goBack(); + }; + + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopClassesDisplayedAsPage.tsx b/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopClassesDisplayedAsPage.tsx index a6dad547c3fd..cc6fb93faee9 100644 --- a/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopClassesDisplayedAsPage.tsx +++ b/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopClassesDisplayedAsPage.tsx @@ -18,7 +18,7 @@ import {clearQBDErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import React, {useCallback} from 'react'; +import React from 'react'; type CardListItem = ListItem & { value: keyof typeof CONST.INTEGRATION_ENTITY_MAP_TYPES; @@ -47,15 +47,12 @@ function QuickbooksDesktopClassesDisplayedAsPage({policy}: WithPolicyConnections }, ]; - const selectDisplayedAs = useCallback( - (row: CardListItem) => { - if (row.value !== qbdConfig?.mappings?.classes) { - updateQuickbooksDesktopSyncClasses(policyID, row.value, qbdConfig?.mappings?.classes); - } - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CLASSES.getRoute(policyID)); - }, - [qbdConfig?.mappings?.classes, policyID], - ); + const selectDisplayedAs = (row: CardListItem) => { + if (row.value !== qbdConfig?.mappings?.classes) { + updateQuickbooksDesktopSyncClasses(policyID, row.value, qbdConfig?.mappings?.classes); + } + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CLASSES.getRoute(policyID)); + }; return ( { - if (row.value !== qbdConfig?.mappings?.customers) { - updateQuickbooksDesktopSyncCustomers(policyID, row.value, qbdConfig?.mappings?.customers); - } - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CUSTOMERS.getRoute(policyID)); - }, - [qbdConfig?.mappings?.customers, policyID], - ); + const selectDisplayedAs = (row: CardListItem) => { + if (row.value !== qbdConfig?.mappings?.customers) { + updateQuickbooksDesktopSyncCustomers(policyID, row.value, qbdConfig?.mappings?.customers); + } + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_CUSTOMERS.getRoute(policyID)); + }; return ( ( - - {translate('workspace.qbo.accountingMethods.description')} - - ), - [translate, styles.pb5, styles.ph5], + const headerContent = ( + + {translate('workspace.qbo.accountingMethods.description')} + ); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); + }; - const selectExpenseReportApprovalLevel = useCallback( - (row: MenuListItem) => { - if (row.value !== config?.accountingMethod) { - updateQuickbooksOnlineAccountingMethod(policyID, row.value, config?.accountingMethod ?? COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH); - } - goBack(); - }, - [config?.accountingMethod, policyID, goBack], - ); + const selectExpenseReportApprovalLevel = (row: MenuListItem) => { + if (row.value !== config?.accountingMethod) { + updateQuickbooksOnlineAccountingMethod(policyID, row.value, config?.accountingMethod ?? COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH); + } + goBack(); + }; return ( { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); + }; return ( [...(bankAccounts ?? []), ...(creditCards ?? [])], [bankAccounts, creditCards]); - - const qboOnlineSelectorOptions = useMemo( - () => - accountOptions?.map(({id, name}) => ({ - value: id, - text: name, - keyForList: id, - isSelected: qboConfig?.reimbursementAccountID === id, - })), - [qboConfig?.reimbursementAccountID, accountOptions], + const accountOptions = [...(bankAccounts ?? []), ...(creditCards ?? [])]; + + const qboOnlineSelectorOptions: SelectorType[] = accountOptions?.map(({id, name}) => ({ + value: id, + text: name, + keyForList: id, + isSelected: qboConfig?.reimbursementAccountID === id, + })); + const listHeaderComponent = ( + + {translate('workspace.qbo.advancedConfig.accountSelectDescription', integrationName)} + ); - const listHeaderComponent = useMemo( - () => ( - - {translate('workspace.qbo.advancedConfig.accountSelectDescription', integrationName)} - - ), - [translate, styles.pb2, styles.ph5, styles.pb5, styles.textNormal, integrationName], - ); - - const initiallyFocusedOptionKey = useMemo(() => qboOnlineSelectorOptions?.find((mode) => mode.isSelected)?.keyForList, [qboOnlineSelectorOptions]); - const saveSelection = useCallback( - ({value}: SelectorType) => { - updateQuickbooksOnlineReimbursementAccountID(policyID, value, qboConfig?.reimbursementAccountID); - Navigation.goBack(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED.getRoute(policyID)); - }, - [policyID, qboConfig?.reimbursementAccountID], - ); - - const listEmptyContent = useMemo( - () => ( - - ), - [illustrations.Telescope, translate, styles.pb10, integrationName], + const initiallyFocusedOptionKey = qboOnlineSelectorOptions?.find((mode) => mode.isSelected)?.keyForList; + + const saveSelection = ({value}: SelectorType) => { + updateQuickbooksOnlineReimbursementAccountID(policyID, value, qboConfig?.reimbursementAccountID); + Navigation.goBack(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED.getRoute(policyID)); + }; + + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/accounting/qbo/advanced/QuickbooksAdvancedPage.tsx b/src/pages/workspace/accounting/qbo/advanced/QuickbooksAdvancedPage.tsx index 8782f26fca63..7cfce5c1fc8d 100644 --- a/src/pages/workspace/accounting/qbo/advanced/QuickbooksAdvancedPage.tsx +++ b/src/pages/workspace/accounting/qbo/advanced/QuickbooksAdvancedPage.tsx @@ -31,7 +31,7 @@ import type {TranslationPaths} from '@src/languages/types'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import {CONST as COMMON_CONST} from 'expensify-common'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; const reimbursementAccountIDs = [CONST.QUICKBOOKS_CONFIG.REIMBURSEMENT_ACCOUNT_ID]; @@ -52,25 +52,19 @@ function QuickbooksAdvancedPage({policy}: WithPolicyConnectionsProps) { const {bankAccounts, creditCards, expenseAccounts, otherCurrentAssetAccounts, vendors} = policy?.connections?.quickbooksOnline?.data ?? {}; const nonReimbursableBillDefaultVendorObject = vendors?.find((vendor) => vendor.id === qboConfig?.nonReimbursableBillDefaultVendor); - const qboAccountOptions = useMemo(() => [...(bankAccounts ?? []), ...(creditCards ?? [])], [bankAccounts, creditCards]); - const invoiceAccountCollectionOptions = useMemo(() => [...(bankAccounts ?? []), ...(otherCurrentAssetAccounts ?? [])], [bankAccounts, otherCurrentAssetAccounts]); + const qboAccountOptions = [...(bankAccounts ?? []), ...(creditCards ?? [])]; + const invoiceAccountCollectionOptions = [...(bankAccounts ?? []), ...(otherCurrentAssetAccounts ?? [])]; - const isSyncReimbursedSwitchOn = useMemo( - () => !!qboConfig?.collectionAccountID || !!qboConfig?.reimbursementAccountID, - [qboConfig?.collectionAccountID, qboConfig?.reimbursementAccountID], - ); + const isSyncReimbursedSwitchOn = !!qboConfig?.collectionAccountID || !!qboConfig?.reimbursementAccountID; const reimbursementAccountID = qboConfig?.reimbursementAccountID; - const selectedQboAccountName = useMemo(() => qboAccountOptions?.find(({id}) => id === reimbursementAccountID)?.name, [qboAccountOptions, reimbursementAccountID]); + const selectedQboAccountName = qboAccountOptions?.find(({id}) => id === reimbursementAccountID)?.name; const collectionAccountID = qboConfig?.collectionAccountID; - const selectedInvoiceCollectionAccountName = useMemo( - () => invoiceAccountCollectionOptions?.find(({id}) => id === collectionAccountID)?.name, - [invoiceAccountCollectionOptions, collectionAccountID], - ); + const selectedInvoiceCollectionAccountName = invoiceAccountCollectionOptions?.find(({id}) => id === collectionAccountID)?.name; const fxExpenseAccount = qboConfig?.fxExpenseAccount; - const selectedFxExpenseAccountName = useMemo(() => expenseAccounts?.find(({id}) => id === fxExpenseAccount)?.name, [expenseAccounts, fxExpenseAccount]); + const selectedFxExpenseAccountName = expenseAccounts?.find(({id}) => id === fxExpenseAccount)?.name; const autoCreateVendorConst = CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR; const defaultVendorConst = CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR; diff --git a/src/pages/workspace/accounting/qbo/advanced/QuickbooksInvoiceAccountSelectPage.tsx b/src/pages/workspace/accounting/qbo/advanced/QuickbooksInvoiceAccountSelectPage.tsx index da0a897d595f..b10e8b54d54a 100644 --- a/src/pages/workspace/accounting/qbo/advanced/QuickbooksInvoiceAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/advanced/QuickbooksInvoiceAccountSelectPage.tsx @@ -23,7 +23,7 @@ import {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type SelectorType = ListItem & { @@ -38,51 +38,38 @@ function QuickbooksInvoiceAccountSelectPage({policy}: WithPolicyConnectionsProps const policyID = policy?.id ?? CONST.DEFAULT_NUMBER_ID.toString(); const {bankAccounts, otherCurrentAssetAccounts} = policy?.connections?.quickbooksOnline?.data ?? {}; - const accountOptions = useMemo(() => [...(bankAccounts ?? []), ...(otherCurrentAssetAccounts ?? [])], [bankAccounts, otherCurrentAssetAccounts]); + const accountOptions = [...(bankAccounts ?? []), ...(otherCurrentAssetAccounts ?? [])]; const qboConfig = policy?.connections?.quickbooksOnline?.config; - const qboOnlineSelectorOptions = useMemo( - () => - accountOptions?.map(({id, name}) => ({ - value: id, - text: name, - keyForList: id, - isSelected: qboConfig?.collectionAccountID === id, - })), - [qboConfig?.collectionAccountID, accountOptions], + const qboOnlineSelectorOptions: SelectorType[] = accountOptions?.map(({id, name}) => ({ + value: id, + text: name, + keyForList: id, + isSelected: qboConfig?.collectionAccountID === id, + })); + + const listHeaderComponent = ( + + {translate('workspace.qbo.advancedConfig.invoiceAccountSelectorDescription', integrationName)} + ); - const listHeaderComponent = useMemo( - () => ( - - {translate('workspace.qbo.advancedConfig.invoiceAccountSelectorDescription', integrationName)} - - ), - [translate, styles.pb2, styles.ph5, styles.pb5, styles.textNormal, integrationName], - ); - - const initiallyFocusedOptionKey = useMemo(() => qboOnlineSelectorOptions?.find((mode) => mode.isSelected)?.keyForList, [qboOnlineSelectorOptions]); - - const updateAccount = useCallback( - ({value}: SelectorType) => { - updateQuickbooksOnlineCollectionAccountID(policyID, value, qboConfig?.collectionAccountID); - Navigation.goBack(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED.getRoute(policyID)); - }, - [policyID, qboConfig?.collectionAccountID], - ); - - const listEmptyContent = useMemo( - () => ( - - ), - [illustrations.Telescope, translate, styles.pb10, integrationName], + const initiallyFocusedOptionKey = qboOnlineSelectorOptions?.find((mode) => mode.isSelected)?.keyForList; + + const updateAccount = ({value}: SelectorType) => { + updateQuickbooksOnlineCollectionAccountID(policyID, value, qboConfig?.collectionAccountID); + Navigation.goBack(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED.getRoute(policyID)); + }; + + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksCompanyCardExpenseAccountSelectCardPage.tsx b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksCompanyCardExpenseAccountSelectCardPage.tsx index da6d378b0836..33da094b489a 100644 --- a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksCompanyCardExpenseAccountSelectCardPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksCompanyCardExpenseAccountSelectCardPage.tsx @@ -24,7 +24,7 @@ import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Account, QBONonReimbursableExportAccountType} from '@src/types/onyx/Policy'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; type MenuItem = ListItem & { value: QBONonReimbursableExportAccountType; @@ -42,64 +42,59 @@ function DynamicQuickbooksCompanyCardExpenseAccountSelectCardPage({policy}: With const canUseVendorBill = canUseVendorBillForCompanyCardExport(qboConfig); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_CARD_SELECT.path); - const data: MenuItem[] = useMemo(() => { - const options: MenuItem[] = [ - { - text: translate(`workspace.qbo.accounts.credit_card`), - value: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, - keyForList: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, - isSelected: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD === qboConfig?.nonReimbursableExpensesExportDestination, - accounts: creditCards ?? [], - defaultVendor: CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, - }, - { - text: translate(`workspace.qbo.accounts.debit_card`), - value: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD, - keyForList: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD, - isSelected: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD === qboConfig?.nonReimbursableExpensesExportDestination, - accounts: bankAccounts ?? [], - defaultVendor: CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, - }, - ]; - if (canUseVendorBill) { - options.push({ - text: translate(`workspace.qbo.accounts.bill`), - value: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL, - keyForList: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL, - isSelected: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL === qboConfig?.nonReimbursableExpensesExportDestination, - accounts: accountPayable ?? [], - defaultVendor: vendors?.[0]?.id ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, - }); - } - return options; - }, [translate, qboConfig?.nonReimbursableExpensesExportDestination, canUseVendorBill, accountPayable, bankAccounts, creditCards, vendors]); + const options: MenuItem[] = [ + { + text: translate(`workspace.qbo.accounts.credit_card`), + value: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, + keyForList: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, + isSelected: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD === qboConfig?.nonReimbursableExpensesExportDestination, + accounts: creditCards ?? [], + defaultVendor: CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, + }, + { + text: translate(`workspace.qbo.accounts.debit_card`), + value: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD, + keyForList: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD, + isSelected: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD === qboConfig?.nonReimbursableExpensesExportDestination, + accounts: bankAccounts ?? [], + defaultVendor: CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, + }, + ]; + if (canUseVendorBill) { + options.push({ + text: translate(`workspace.qbo.accounts.bill`), + value: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL, + keyForList: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL, + isSelected: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL === qboConfig?.nonReimbursableExpensesExportDestination, + accounts: accountPayable ?? [], + defaultVendor: vendors?.[0]?.id ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE, + }); + } + const data: MenuItem[] = options; - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); + }; - const selectExportCompanyCard = useCallback( - (row: MenuItem) => { - if (row.value !== qboConfig?.nonReimbursableExpensesExportDestination) { - updateManyPolicyConnectionConfigs( - policyID, - CONST.POLICY.CONNECTIONS.NAME.QBO, - { - [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: row.value, - [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_ACCOUNT]: row.accounts.at(0), - [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: row.defaultVendor, - }, - { - [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: qboConfig?.nonReimbursableExpensesExportDestination, - [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_ACCOUNT]: qboConfig?.nonReimbursableExpensesAccount, - [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: qboConfig?.nonReimbursableBillDefaultVendor, - }, - ); - } - goBack(); - }, - [qboConfig?.nonReimbursableExpensesExportDestination, policyID, qboConfig?.nonReimbursableExpensesAccount, qboConfig?.nonReimbursableBillDefaultVendor, goBack], - ); + const selectExportCompanyCard = (row: MenuItem) => { + if (row.value !== qboConfig?.nonReimbursableExpensesExportDestination) { + updateManyPolicyConnectionConfigs( + policyID, + CONST.POLICY.CONNECTIONS.NAME.QBO, + { + [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: row.value, + [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_ACCOUNT]: row.accounts.at(0), + [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: row.defaultVendor, + }, + { + [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: qboConfig?.nonReimbursableExpensesExportDestination, + [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_ACCOUNT]: qboConfig?.nonReimbursableExpensesAccount, + [CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: qboConfig?.nonReimbursableBillDefaultVendor, + }, + ); + } + goBack(); + }; return ( { - let accounts: Account[]; - switch (qboConfig?.nonReimbursableExpensesExportDestination) { - case CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD: - accounts = creditCards ?? []; - break; - case CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD: - accounts = bankAccounts ?? []; - break; - case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL: - accounts = accountPayable ?? []; - break; - default: - accounts = []; - } - - return accounts.map((card) => ({ - value: card, - text: card.name, - keyForList: card.name, - isSelected: card.name === qboConfig?.nonReimbursableExpensesAccount?.name, - })); - }, [qboConfig?.nonReimbursableExpensesAccount, creditCards, bankAccounts, qboConfig?.nonReimbursableExpensesExportDestination, accountPayable]); - - const goBack = useCallback(() => { + let accounts: Account[]; + switch (qboConfig?.nonReimbursableExpensesExportDestination) { + case CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD: + accounts = creditCards ?? []; + break; + case CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD: + accounts = bankAccounts ?? []; + break; + case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL: + accounts = accountPayable ?? []; + break; + default: + accounts = []; + } + const data: CardListItem[] = accounts.map((card) => ({ + value: card, + text: card.name, + keyForList: card.name, + isSelected: card.name === qboConfig?.nonReimbursableExpensesAccount?.name, + })); + + const goBack = () => { Navigation.goBack( backPath || (policyID && `${ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT.path}`), ); - }, [policyID, backPath]); - - const selectExportAccount = useCallback( - (row: CardListItem) => { - if (row.value.id !== qboConfig?.nonReimbursableExpensesAccount?.id && policyID) { - updateQuickbooksOnlineNonReimbursableExpensesAccount(policyID, row.value, qboConfig?.nonReimbursableExpensesAccount); - } - goBack(); - }, - [qboConfig?.nonReimbursableExpensesAccount, policyID, goBack], - ); + }; - const listEmptyContent = useMemo( - () => ( - - ), - [translate, styles.pb10, illustrations.Telescope, integrationName], + const selectExportAccount = (row: CardListItem) => { + if (row.value.id !== qboConfig?.nonReimbursableExpensesAccount?.id && policyID) { + updateQuickbooksOnlineNonReimbursableExpensesAccount(policyID, row.value, qboConfig?.nonReimbursableExpensesAccount); + } + goBack(); + }; + + const listEmptyContent = ( + ); return ( qboConfig?.nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL, - [qboConfig?.nonReimbursableExpensesExportDestination], - ); + const shouldShowVendorMenuItems = qboConfig?.nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL; - const shouldShowCreditCardVendorMenuItem = useMemo( - () => - qboConfig?.nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD || - qboConfig?.nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD, - [qboConfig?.nonReimbursableExpensesExportDestination], - ); + const shouldShowCreditCardVendorMenuItem = + qboConfig?.nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD || + qboConfig?.nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD; const dynamicBackPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT.path); const goBack = () => goBackFromExportConnection(shouldShowVendorMenuItems, dynamicBackPath); diff --git a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksExportDateSelectPage.tsx b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksExportDateSelectPage.tsx index c921525b5962..4b24f50aeb82 100644 --- a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksExportDateSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksExportDateSelectPage.tsx @@ -23,7 +23,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {ValueOf} from 'type-fest'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; type CardListItem = ListItem & { value: ValueOf; @@ -48,21 +48,18 @@ function DynamicQuickbooksExportDateSelectPage({policy}: WithPolicyConnectionsPr isSelected: qboConfig?.exportDate === dateType, })); - const exportDate = useMemo(() => qboConfig?.exportDate, [qboConfig?.exportDate]); + const exportDate = qboConfig?.exportDate; - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); - - const selectExportDate = useCallback( - (row: CardListItem) => { - if (row.value !== exportDate && policyID) { - updateQuickbooksOnlineExportDate(policyID, row.value, exportDate); - } - goBack(); - }, - [policyID, exportDate, goBack], - ); + }; + + const selectExportDate = (row: CardListItem) => { + if (row.value !== exportDate && policyID) { + updateQuickbooksOnlineExportDate(policyID, row.value, exportDate); + } + goBack(); + }; return ( - accountsReceivable?.map((account) => ({ - value: account, - text: account.name, - keyForList: account.name, - isSelected: account.id === qboConfig?.receivableAccount?.id, - })) ?? [], - [qboConfig?.receivableAccount, accountsReceivable], - ); - - const goBack = useCallback(() => { + const data: CardListItem[] = + accountsReceivable?.map((account) => ({ + value: account, + text: account.name, + keyForList: account.name, + isSelected: account.id === qboConfig?.receivableAccount?.id, + })) ?? []; + + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); - - const selectExportInvoice = useCallback( - (row: CardListItem) => { - if (row.value.id !== qboConfig?.receivableAccount?.id) { - updateQuickbooksOnlineReceivableAccount(policyID, row.value, qboConfig?.receivableAccount); - } - goBack(); - }, - [qboConfig?.receivableAccount, policyID, goBack], - ); - - const listEmptyContent = useMemo( - () => ( - - ), - [illustrations.Telescope, translate, styles.pb10, integrationName], + }; + + const selectExportInvoice = (row: CardListItem) => { + if (row.value.id !== qboConfig?.receivableAccount?.id) { + updateQuickbooksOnlineReceivableAccount(policyID, row.value, qboConfig?.receivableAccount); + } + goBack(); + }; + + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseAccountSelectPage.tsx b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseAccountSelectPage.tsx index 5422a8047ec1..bee4a908bd39 100644 --- a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseAccountSelectPage.tsx @@ -27,7 +27,7 @@ import type {TranslationPaths} from '@src/languages/types'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Account} from '@src/types/onyx/Policy'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; type CardListItem = ListItem & { value: Account; @@ -42,82 +42,70 @@ function DynamicQuickbooksOutOfPocketExpenseAccountSelectPage({policy}: WithPoli const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT.path); const illustrations = useMemoizedLazyIllustrations(['Telescope']); - const [title, description] = useMemo(() => { - let titleText: TranslationPaths | undefined; - let descriptionText: string | undefined; - switch (qboConfig?.reimbursableExpensesExportDestination) { - case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK: - titleText = 'workspace.qbo.bankAccount'; - descriptionText = translate('workspace.qbo.bankAccountDescription'); - break; - case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY: - titleText = 'workspace.qbo.account'; - descriptionText = translate('workspace.qbo.accountDescription'); - break; - case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL: - titleText = 'workspace.qbo.accountsPayable'; - descriptionText = translate('workspace.qbo.accountsPayableDescription'); - break; - default: - titleText = 'workspace.qbo.account'; - break; - } - - return [titleText, descriptionText]; - }, [qboConfig?.reimbursableExpensesExportDestination, translate]); - - const data: CardListItem[] = useMemo(() => { - let accounts: Account[]; - switch (qboConfig?.reimbursableExpensesExportDestination) { - case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK: - accounts = bankAccounts ?? []; - break; - case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY: - accounts = journalEntryAccounts ?? []; - break; - case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL: - accounts = accountPayable ?? []; - break; - default: - accounts = []; - } - - return accounts.map((account) => ({ - value: account, - text: account.name, - keyForList: account.name, - isSelected: account.id === qboConfig?.reimbursableExpensesAccount?.id, - })); - }, [qboConfig?.reimbursableExpensesExportDestination, qboConfig?.reimbursableExpensesAccount?.id, bankAccounts, journalEntryAccounts, accountPayable]); + let titleText: TranslationPaths | undefined; + let descriptionText: string | undefined; + switch (qboConfig?.reimbursableExpensesExportDestination) { + case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK: + titleText = 'workspace.qbo.bankAccount'; + descriptionText = translate('workspace.qbo.bankAccountDescription'); + break; + case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY: + titleText = 'workspace.qbo.account'; + descriptionText = translate('workspace.qbo.accountDescription'); + break; + case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL: + titleText = 'workspace.qbo.accountsPayable'; + descriptionText = translate('workspace.qbo.accountsPayableDescription'); + break; + default: + titleText = 'workspace.qbo.account'; + break; + } + const [title, description] = [titleText, descriptionText]; + + let accounts: Account[]; + switch (qboConfig?.reimbursableExpensesExportDestination) { + case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK: + accounts = bankAccounts ?? []; + break; + case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY: + accounts = journalEntryAccounts ?? []; + break; + case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL: + accounts = accountPayable ?? []; + break; + default: + accounts = []; + } + const data: CardListItem[] = accounts.map((account) => ({ + value: account, + text: account.name, + keyForList: account.name, + isSelected: account.id === qboConfig?.reimbursableExpensesAccount?.id, + })); const policyID = policy?.id; - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); - - const selectExportAccount = useCallback( - (row: CardListItem) => { - if (row.value.id !== qboConfig?.reimbursableExpensesAccount?.id && policyID) { - updateQuickbooksOnlineReimbursableExpensesAccount(policyID, row.value, qboConfig?.reimbursableExpensesAccount); - } - goBack(); - }, - [qboConfig?.reimbursableExpensesAccount, policyID, goBack], - ); + }; - const listEmptyContent = useMemo( - () => ( - - ), - [translate, styles.pb10, illustrations.Telescope, integrationName], + const selectExportAccount = (row: CardListItem) => { + if (row.value.id !== qboConfig?.reimbursableExpensesAccount?.id && policyID) { + updateQuickbooksOnlineReimbursableExpensesAccount(policyID, row.value, qboConfig?.reimbursableExpensesAccount); + } + goBack(); + }; + + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseConfigurationPage.tsx b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseConfigurationPage.tsx index aac90ab953ad..a3cf72586fe0 100644 --- a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseConfigurationPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseConfigurationPage.tsx @@ -24,7 +24,7 @@ import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {ValueOf} from 'type-fest'; -import React, {useMemo} from 'react'; +import React from 'react'; type QBOSectionType = { title?: string; @@ -51,29 +51,26 @@ function DynamicQuickbooksOutOfPocketExpenseConfigurationPage({policy}: WithPoli const isLocationEnabled = !!(qboConfig?.syncLocations && qboConfig?.syncLocations !== CONST.INTEGRATION_ENTITY_MAP_TYPES.NONE); const isTaxesEnabled = !!qboConfig?.syncTax; - const [exportHintText, accountDescription] = useMemo(() => { - let hintText: string | undefined; - let description: string | undefined; - switch (qboConfig?.reimbursableExpensesExportDestination) { - case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK: - hintText = isLocationEnabled ? undefined : translate('workspace.qbo.exportCheckDescription'); - description = translate('workspace.qbo.bankAccount'); - break; - case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY: - hintText = isTaxesEnabled ? undefined : translate('workspace.qbo.exportJournalEntryDescription'); - description = translate('workspace.qbo.account'); - break; - case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL: - hintText = isLocationEnabled ? undefined : translate('workspace.qbo.exportVendorBillDescription'); - description = translate('workspace.qbo.accountsPayable'); - break; - default: - description = translate('workspace.qbo.account'); - break; - } - - return [hintText, description]; - }, [translate, qboConfig?.reimbursableExpensesExportDestination, isLocationEnabled, isTaxesEnabled]); + let hintText: string | undefined; + let description: string | undefined; + switch (qboConfig?.reimbursableExpensesExportDestination) { + case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK: + hintText = isLocationEnabled ? undefined : translate('workspace.qbo.exportCheckDescription'); + description = translate('workspace.qbo.bankAccount'); + break; + case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY: + hintText = isTaxesEnabled ? undefined : translate('workspace.qbo.exportJournalEntryDescription'); + description = translate('workspace.qbo.account'); + break; + case CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL: + hintText = isLocationEnabled ? undefined : translate('workspace.qbo.exportVendorBillDescription'); + description = translate('workspace.qbo.accountsPayable'); + break; + default: + description = translate('workspace.qbo.account'); + break; + } + const [exportHintText, accountDescription] = [hintText, description]; const sections: QBOSectionType[] = [ { diff --git a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseEntitySelectPage.tsx b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseEntitySelectPage.tsx index 231d3e49fefd..220aa74a8ad7 100644 --- a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseEntitySelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksOutOfPocketExpenseEntitySelectPage.tsx @@ -24,7 +24,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import type {Account, QBOReimbursableExportAccountType} from '@src/types/onyx/Policy'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; function Footer({isTaxEnabled, integrationName}: {isTaxEnabled: boolean; integrationName: string}) { @@ -61,70 +61,64 @@ function DynamicQuickbooksOutOfPocketExpenseEntitySelectPage({policy}: WithPolic const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT.path); const [selectedExportDestinationError, setSelectedExportDestinationError] = useState(null); - const data: MenuItem[] = useMemo( - () => [ - { - value: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK, - text: translate('workspace.qbo.accounts.check'), - keyForList: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK, - isSelected: qboConfig?.reimbursableExpensesExportDestination === CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK, - isShown: qboConfig?.syncLocations !== CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, - accounts: bankAccounts ?? [], - }, - { - value: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY, - text: translate('workspace.qbo.accounts.journal_entry'), - keyForList: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY, - isSelected: qboConfig?.reimbursableExpensesExportDestination === CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY, - isShown: !isTaxesEnabled, - accounts: journalEntryAccounts ?? [], - }, - { - value: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, - text: translate('workspace.qbo.accounts.bill'), - keyForList: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, - isSelected: qboConfig?.reimbursableExpensesExportDestination === CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, - isShown: qboConfig?.syncLocations !== CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, - accounts: accountPayable ?? [], - }, - ], - [qboConfig?.reimbursableExpensesExportDestination, qboConfig?.syncLocations, translate, bankAccounts, accountPayable, journalEntryAccounts, isTaxesEnabled], - ); + const data: MenuItem[] = [ + { + value: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK, + text: translate('workspace.qbo.accounts.check'), + keyForList: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK, + isSelected: qboConfig?.reimbursableExpensesExportDestination === CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.CHECK, + isShown: qboConfig?.syncLocations !== CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, + accounts: bankAccounts ?? [], + }, + { + value: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY, + text: translate('workspace.qbo.accounts.journal_entry'), + keyForList: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY, + isSelected: qboConfig?.reimbursableExpensesExportDestination === CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.JOURNAL_ENTRY, + isShown: !isTaxesEnabled, + accounts: journalEntryAccounts ?? [], + }, + { + value: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, + text: translate('workspace.qbo.accounts.bill'), + keyForList: CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, + isSelected: qboConfig?.reimbursableExpensesExportDestination === CONST.QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE.VENDOR_BILL, + isShown: qboConfig?.syncLocations !== CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, + accounts: accountPayable ?? [], + }, + ]; - const filteredData = useMemo(() => data.filter((item) => item.isShown), [data]); + const filteredData = data.filter((item) => item.isShown); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); - - const selectExportEntity = useCallback( - (row: MenuItem) => { - if (!row.accounts.at(0)) { - setSelectedExportDestinationError({ - [CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: translate(`workspace.qbo.exportDestinationSetupAccountsInfo.${row.value}`, integrationName), - }); - return; - } - - if (row.value !== qboConfig?.reimbursableExpensesExportDestination) { - setSelectedExportDestinationError(null); - updateManyPolicyConnectionConfigs( - policyID, - CONST.POLICY.CONNECTIONS.NAME.QBO, - { - [CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: row.value, - [CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_ACCOUNT]: row.accounts.at(0), - }, - { - [CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: qboConfig?.reimbursableExpensesExportDestination, - [CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_ACCOUNT]: qboConfig?.reimbursableExpensesAccount, - }, - ); - } - goBack(); - }, - [qboConfig?.reimbursableExpensesExportDestination, policyID, qboConfig?.reimbursableExpensesAccount, goBack, translate, integrationName], - ); + }; + + const selectExportEntity = (row: MenuItem) => { + if (!row.accounts.at(0)) { + setSelectedExportDestinationError({ + [CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: translate(`workspace.qbo.exportDestinationSetupAccountsInfo.${row.value}`, integrationName), + }); + return; + } + + if (row.value !== qboConfig?.reimbursableExpensesExportDestination) { + setSelectedExportDestinationError(null); + updateManyPolicyConnectionConfigs( + policyID, + CONST.POLICY.CONNECTIONS.NAME.QBO, + { + [CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: row.value, + [CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_ACCOUNT]: row.accounts.at(0), + }, + { + [CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_EXPORT_DESTINATION]: qboConfig?.reimbursableExpensesExportDestination, + [CONST.QUICKBOOKS_CONFIG.REIMBURSABLE_EXPENSES_ACCOUNT]: qboConfig?.reimbursableExpensesAccount, + }, + ); + } + goBack(); + }; const errors = hasErrors && qboConfig?.reimbursableExpensesExportDestination diff --git a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksPreferredExporterConfigurationPage.tsx b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksPreferredExporterConfigurationPage.tsx index 066b85d06cc0..eb5f97a38be6 100644 --- a/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksPreferredExporterConfigurationPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/DynamicQuickbooksPreferredExporterConfigurationPage.tsx @@ -21,7 +21,7 @@ import {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; type CardListItem = ListItem & { value: string; @@ -36,50 +36,40 @@ function DynamicQuickbooksPreferredExporterConfigurationPage({policy}: WithPolic const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_PREFERRED_EXPORTER.path); const policyID = policy?.id; - const data: CardListItem[] = useMemo( - () => - exporters?.reduce((options, exporter) => { - if (!exporter.email) { - return options; - } - - // Don't show guides if the current user is not a guide themselves or an Expensify employee - if (isExpensifyTeam(exporter.email) && !isExpensifyTeam(policy?.owner) && !isExpensifyTeam(currentUserLogin)) { - return options; - } - options.push({ - value: exporter.email, - text: exporter.email, - keyForList: exporter.email, - isSelected: (qboConfig?.export?.exporter ?? policy?.owner) === exporter.email, - }); - return options; - }, []), - [exporters, policy?.owner, currentUserLogin, qboConfig?.export?.exporter], - ); - - const goBack = useCallback(() => { + const data: CardListItem[] = exporters?.reduce((options, exporter) => { + if (!exporter.email) { + return options; + } + + // Don't show guides if the current user is not a guide themselves or an Expensify employee + if (isExpensifyTeam(exporter.email) && !isExpensifyTeam(policy?.owner) && !isExpensifyTeam(currentUserLogin)) { + return options; + } + options.push({ + value: exporter.email, + text: exporter.email, + keyForList: exporter.email, + isSelected: (qboConfig?.export?.exporter ?? policy?.owner) === exporter.email, + }); + return options; + }, []); + + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); - - const selectExporter = useCallback( - (row: CardListItem) => { - if (row.value !== qboConfig?.export?.exporter) { - updateQuickbooksOnlinePreferredExporter(policyID, {exporter: row.value}, {exporter: qboConfig?.export?.exporter ?? ''}); - } - goBack(); - }, - [qboConfig?.export, policyID, goBack], - ); - - const headerContent = useMemo( - () => ( - <> - {translate('workspace.accounting.exportPreferredExporterNote')} - {translate('workspace.accounting.exportPreferredExporterSubNote')} - - ), - [translate, styles.ph5, styles.pb5], + }; + + const selectExporter = (row: CardListItem) => { + if (row.value !== qboConfig?.export?.exporter) { + updateQuickbooksOnlinePreferredExporter(policyID, {exporter: row.value}, {exporter: qboConfig?.export?.exporter ?? ''}); + } + goBack(); + }; + + const headerContent = ( + <> + {translate('workspace.accounting.exportPreferredExporterNote')} + {translate('workspace.accounting.exportPreferredExporterSubNote')} + ); return ( diff --git a/src/pages/workspace/accounting/qbo/import/QuickbooksClassesDisplayedAsPage.tsx b/src/pages/workspace/accounting/qbo/import/QuickbooksClassesDisplayedAsPage.tsx index d9d7c187298f..90ec3fa3e915 100644 --- a/src/pages/workspace/accounting/qbo/import/QuickbooksClassesDisplayedAsPage.tsx +++ b/src/pages/workspace/accounting/qbo/import/QuickbooksClassesDisplayedAsPage.tsx @@ -17,7 +17,7 @@ import {clearQBOErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; type CardListItem = ListItem & { value: keyof typeof CONST.INTEGRATION_ENTITY_MAP_TYPES; @@ -29,39 +29,33 @@ function QuickbooksClassesDisplayedAsPage({policy}: WithPolicyProps) { const policyID = policy?.id; const qboConfig = policy?.connections?.quickbooksOnline?.config; - const data: CardListItem[] = useMemo( - () => [ - { - value: CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, - text: translate('workspace.common.tags'), - keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, - isSelected: qboConfig?.syncClasses === CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, - }, - { - value: CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD, - text: translate('workspace.common.reportFields'), - keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD, - isSelected: qboConfig?.syncClasses === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD, - }, - ], - [qboConfig?.syncClasses, translate], - ); + const data: CardListItem[] = [ + { + value: CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, + text: translate('workspace.common.tags'), + keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, + isSelected: qboConfig?.syncClasses === CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, + }, + { + value: CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD, + text: translate('workspace.common.reportFields'), + keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD, + isSelected: qboConfig?.syncClasses === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD, + }, + ]; - const selectDisplayedAs = useCallback( - (row: CardListItem) => { - if (row.value !== qboConfig?.syncClasses) { - if (row.value === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD && !isControlPolicy(policy)) { - Navigation.navigate( - ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.REPORT_FIELDS_FEATURE.qbo.classes, ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CLASSES.getRoute(policyID)), - ); - return; - } - updateQuickbooksOnlineSyncClasses(policyID, row.value, qboConfig?.syncClasses); + const selectDisplayedAs = (row: CardListItem) => { + if (row.value !== qboConfig?.syncClasses) { + if (row.value === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD && !isControlPolicy(policy)) { + Navigation.navigate( + ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.REPORT_FIELDS_FEATURE.qbo.classes, ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CLASSES.getRoute(policyID)), + ); + return; } - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CLASSES.getRoute(policyID)); - }, - [qboConfig?.syncClasses, policyID, policy], - ); + updateQuickbooksOnlineSyncClasses(policyID, row.value, qboConfig?.syncClasses); + } + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CLASSES.getRoute(policyID)); + }; return ( [ - { - value: CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, - text: translate('workspace.common.tags'), - keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, - isSelected: qboConfig?.syncCustomers === CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, - }, - { - value: CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD, - text: translate('workspace.common.reportFields'), - keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD, - isSelected: qboConfig?.syncCustomers === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD, - }, - ], - [qboConfig?.syncCustomers, translate], - ); + const data: CardListItem[] = [ + { + value: CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, + text: translate('workspace.common.tags'), + keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, + isSelected: qboConfig?.syncCustomers === CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, + }, + { + value: CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD, + text: translate('workspace.common.reportFields'), + keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD, + isSelected: qboConfig?.syncCustomers === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD, + }, + ]; - const selectDisplayedAs = useCallback( - (row: CardListItem) => { - if (row.value !== qboConfig?.syncCustomers) { - if (row.value === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD && !isControlPolicy(policy)) { - Navigation.navigate( - ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.REPORT_FIELDS_FEATURE.qbo.customers, ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CUSTOMERS.getRoute(policyID)), - ); - return; - } - updateQuickbooksOnlineSyncCustomers(policyID, row.value, qboConfig?.syncCustomers); + const selectDisplayedAs = (row: CardListItem) => { + if (row.value !== qboConfig?.syncCustomers) { + if (row.value === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD && !isControlPolicy(policy)) { + Navigation.navigate( + ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.REPORT_FIELDS_FEATURE.qbo.customers, ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CUSTOMERS.getRoute(policyID)), + ); + return; } - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CUSTOMERS.getRoute(policyID)); - }, - [qboConfig?.syncCustomers, policyID, policy], - ); + updateQuickbooksOnlineSyncCustomers(policyID, row.value, qboConfig?.syncCustomers); + } + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CUSTOMERS.getRoute(policyID)); + }; return ( { - const items: CardListItem[] = []; - - if (canUseTagsForLocations) { - items.push({ - value: CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, - text: translate('workspace.common.tags'), - keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, - isSelected: qboConfig?.syncLocations === CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, - }); - } - + const items: CardListItem[] = []; + if (canUseTagsForLocations) { items.push({ - value: CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD, - text: translate('workspace.common.reportFields'), - keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD, - isSelected: qboConfig?.syncLocations === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD, + value: CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, + text: translate('workspace.common.tags'), + keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, + isSelected: qboConfig?.syncLocations === CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, }); + } + items.push({ + value: CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD, + text: translate('workspace.common.reportFields'), + keyForList: CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD, + isSelected: qboConfig?.syncLocations === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD, + }); + const data: CardListItem[] = items; - return items; - }, [canUseTagsForLocations, qboConfig?.syncLocations, translate]); - - const selectDisplayedAs = useCallback( - (row: CardListItem) => { - if (row.value !== qboConfig?.syncLocations) { - if (row.value === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD && !isControlPolicy(policy)) { - Navigation.navigate( - ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.REPORT_FIELDS_FEATURE.qbo.locations, ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_LOCATIONS.getRoute(policyID)), - ); - return; - } - updateQuickbooksOnlineSyncLocations(policyID, row.value, qboConfig?.syncLocations); + const selectDisplayedAs = (row: CardListItem) => { + if (row.value !== qboConfig?.syncLocations) { + if (row.value === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD && !isControlPolicy(policy)) { + Navigation.navigate( + ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.REPORT_FIELDS_FEATURE.qbo.locations, ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_LOCATIONS.getRoute(policyID)), + ); + return; } - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_LOCATIONS.getRoute(policyID)); - }, - [qboConfig?.syncLocations, policyID, policy], - ); + updateQuickbooksOnlineSyncLocations(policyID, row.value, qboConfig?.syncLocations); + } + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_LOCATIONS.getRoute(policyID)); + }; return ( { - if (settingValue === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD && !isControlPolicy(policy)) { - Navigation.navigate( - ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.REPORT_FIELDS_FEATURE.qbo.locations, ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_LOCATIONS.getRoute(policyID)), - ); - return; - } - updateQuickbooksOnlineSyncLocationsAction(policyID, settingValue, qboConfig?.syncLocations); - }, - [policy, policyID, qboConfig?.syncLocations], - ); + const updateQuickbooksOnlineSyncLocations = (settingValue: IntegrationEntityMap) => { + if (settingValue === CONST.INTEGRATION_ENTITY_MAP_TYPES.REPORT_FIELD && !isControlPolicy(policy)) { + Navigation.navigate( + ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.REPORT_FIELDS_FEATURE.qbo.locations, ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_LOCATIONS.getRoute(policyID)), + ); + return; + } + updateQuickbooksOnlineSyncLocationsAction(policyID, settingValue, qboConfig?.syncLocations); + }; // If we previously selected tags but now we have the line items restriction, we need to switch to report fields useEffect(() => { if (!shouldSwitchLocationsToReportFields(qboConfig)) { diff --git a/src/pages/workspace/accounting/reconciliation/CardReconciliationPage.tsx b/src/pages/workspace/accounting/reconciliation/CardReconciliationPage.tsx index e6cb49ff4fb3..0418a9aae314 100644 --- a/src/pages/workspace/accounting/reconciliation/CardReconciliationPage.tsx +++ b/src/pages/workspace/accounting/reconciliation/CardReconciliationPage.tsx @@ -41,7 +41,7 @@ import type SCREENS from '@src/SCREENS'; import type {TupleToUnion} from 'type-fest'; import {isExpensifyCardContinuousReconciliationEnabledSelector} from '@selectors/Card'; -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; type CardReconciliationPageProps = WithPolicyConnectionsProps & PlatformStackScreenProps; @@ -101,31 +101,35 @@ function CardReconciliationPage({policy, route}: CardReconciliationPageProps) { } }; - const accountingAdvancedSettingsLink = useMemo(() => { - if (!policyID) { - return ''; - } - + let accountingAdvancedSettingsLink; + if (!policyID) { + accountingAdvancedSettingsLink = ''; + } else { switch (connection) { case CONST.POLICY.CONNECTIONS.ROUTE.QBO: - return `${environmentURL}/${ROUTES.WORKSPACE_ACCOUNTING_CARD_RECONCILIATION.getRoute(policyID, connection)}/${DYNAMIC_ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_AUTO_SYNC.path}`; + accountingAdvancedSettingsLink = `${environmentURL}/${ROUTES.WORKSPACE_ACCOUNTING_CARD_RECONCILIATION.getRoute(policyID, connection)}/${DYNAMIC_ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_AUTO_SYNC.path}`; + break; case CONST.POLICY.CONNECTIONS.ROUTE.XERO: - return `${environmentURL}/${ROUTES.WORKSPACE_ACCOUNTING_CARD_RECONCILIATION.getRoute(policyID, connection)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_AUTO_SYNC.path}`; + accountingAdvancedSettingsLink = `${environmentURL}/${ROUTES.WORKSPACE_ACCOUNTING_CARD_RECONCILIATION.getRoute(policyID, connection)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_AUTO_SYNC.path}`; + break; case CONST.POLICY.CONNECTIONS.ROUTE.NETSUITE: - return `${environmentURL}/${ROUTES.WORKSPACE_ACCOUNTING_CARD_RECONCILIATION.getRoute(policyID, connection)}/${DYNAMIC_ROUTES.NETSUITE_AUTO_SYNC.path}`; + accountingAdvancedSettingsLink = `${environmentURL}/${ROUTES.WORKSPACE_ACCOUNTING_CARD_RECONCILIATION.getRoute(policyID, connection)}/${DYNAMIC_ROUTES.NETSUITE_AUTO_SYNC.path}`; + break; case CONST.POLICY.CONNECTIONS.ROUTE.SAGE_INTACCT: - return `${environmentURL}/${ROUTES.POLICY_ACCOUNTING_CARD_RECONCILIATION_SAGE_INTACCT_AUTO_SYNC.getRoute(policyID)}`; + accountingAdvancedSettingsLink = `${environmentURL}/${ROUTES.POLICY_ACCOUNTING_CARD_RECONCILIATION_SAGE_INTACCT_AUTO_SYNC.getRoute(policyID)}`; + break; default: - return ''; + accountingAdvancedSettingsLink = ''; + break; } - }, [connection, policyID, environmentURL]); + } - const fetchPolicyAccountingData = useCallback(() => { + const fetchPolicyAccountingData = () => { if (!policyID) { return; } openPolicyAccountingPage(policyID); - }, [policyID]); + }; useEffect(() => { if (continuousReconciliation !== undefined) { diff --git a/src/pages/workspace/accounting/reconciliation/DynamicReconciliationAccountSettingsPage.tsx b/src/pages/workspace/accounting/reconciliation/DynamicReconciliationAccountSettingsPage.tsx index c1eee331bacd..9719b7e9066e 100644 --- a/src/pages/workspace/accounting/reconciliation/DynamicReconciliationAccountSettingsPage.tsx +++ b/src/pages/workspace/accounting/reconciliation/DynamicReconciliationAccountSettingsPage.tsx @@ -36,7 +36,7 @@ import type {ConnectionName} from '@src/types/onyx/Policy'; import type {OnyxEntry} from 'react-native-onyx'; import {useNavigationState} from '@react-navigation/native'; -import React, {useCallback, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import RECONCILIATION_ACCOUNT_SETTINGS_TYPE from './constants'; @@ -236,9 +236,9 @@ function DynamicReconciliationAccountSettingsPage({route}: DynamicReconciliation const connectionBankAccounts = getConnectionBankAccountsForReconciliation(connections, connectionName); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); + }; if (reconciliationAccountSettingsType === RECONCILIATION_ACCOUNT_SETTINGS_TYPE.TRAVEL_BILLING) { return ( diff --git a/src/pages/workspace/accounting/xero/XeroImportPage.tsx b/src/pages/workspace/accounting/xero/XeroImportPage.tsx index 2480cf38eb6b..1706302e3fea 100644 --- a/src/pages/workspace/accounting/xero/XeroImportPage.tsx +++ b/src/pages/workspace/accounting/xero/XeroImportPage.tsx @@ -21,7 +21,7 @@ import {getTrackingCategories} from '@userActions/connections/Xero'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import React, {useMemo} from 'react'; +import React from 'react'; function XeroImportPage({policy}: WithPolicyProps) { const {translate} = useLocalize(); @@ -31,42 +31,39 @@ function XeroImportPage({policy}: WithPolicyProps) { const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); const {importCustomers, importTaxRates, importTrackingCategories, pendingFields, errorFields} = policy?.connections?.xero?.config ?? {}; - const currentXeroOrganizationName = useMemo(() => getCurrentXeroOrganizationName(policy ?? undefined), [policy]); + const currentXeroOrganizationName = getCurrentXeroOrganizationName(policy ?? undefined); - const sections = useMemo( - () => [ - { - description: translate('workspace.accounting.accounts'), - action: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_CHART_OF_ACCOUNTS.getRoute(policyID)), - title: translate('workspace.accounting.importAsCategory'), - subscribedSettings: [CONST.XERO_CONFIG.ENABLE_NEW_CATEGORIES], + const sections = [ + { + description: translate('workspace.accounting.accounts'), + action: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_CHART_OF_ACCOUNTS.getRoute(policyID)), + title: translate('workspace.accounting.importAsCategory'), + subscribedSettings: [CONST.XERO_CONFIG.ENABLE_NEW_CATEGORIES], + }, + { + description: translate('workspace.xero.trackingCategories'), + action: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES.getRoute(policyID)), + title: importTrackingCategories ? translate('workspace.accounting.imported') : translate('workspace.xero.notImported'), + subscribedSettings: [ + CONST.XERO_CONFIG.IMPORT_TRACKING_CATEGORIES, + ...getTrackingCategories(policy).map((category) => `${CONST.XERO_CONFIG.TRACKING_CATEGORY_PREFIX}${category.id}`), + ], + }, + { + description: translate('workspace.xero.customers'), + action: () => { + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_CUSTOMER.getRoute(policyID)); }, - { - description: translate('workspace.xero.trackingCategories'), - action: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES.getRoute(policyID)), - title: importTrackingCategories ? translate('workspace.accounting.imported') : translate('workspace.xero.notImported'), - subscribedSettings: [ - CONST.XERO_CONFIG.IMPORT_TRACKING_CATEGORIES, - ...getTrackingCategories(policy).map((category) => `${CONST.XERO_CONFIG.TRACKING_CATEGORY_PREFIX}${category.id}`), - ], - }, - { - description: translate('workspace.xero.customers'), - action: () => { - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_CUSTOMER.getRoute(policyID)); - }, - title: importCustomers ? translate('workspace.accounting.importTypes.TAG') : translate('workspace.xero.notImported'), - subscribedSettings: [CONST.XERO_CONFIG.IMPORT_CUSTOMERS], - }, - { - description: translate('workspace.accounting.taxes'), - action: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_TAXES.getRoute(policyID)), - title: importTaxRates ? translate('workspace.accounting.imported') : translate('workspace.xero.notImported'), - subscribedSettings: [CONST.XERO_CONFIG.IMPORT_TAX_RATES], - }, - ], - [translate, policy, importTrackingCategories, importCustomers, importTaxRates, policyID], - ); + title: importCustomers ? translate('workspace.accounting.importTypes.TAG') : translate('workspace.xero.notImported'), + subscribedSettings: [CONST.XERO_CONFIG.IMPORT_CUSTOMERS], + }, + { + description: translate('workspace.accounting.taxes'), + action: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_TAXES.getRoute(policyID)), + title: importTaxRates ? translate('workspace.accounting.imported') : translate('workspace.xero.notImported'), + subscribedSettings: [CONST.XERO_CONFIG.IMPORT_TAX_RATES], + }, + ]; return ( key.startsWith(CONST.XERO_CONFIG.TRACKING_CATEGORY_PREFIX) && value === CONST.XERO_CONFIG.TRACKING_CATEGORY_OPTIONS.REPORT_FIELD, ); - const optionsList = useMemo( - () => - Object.values(CONST.XERO_CONFIG.TRACKING_CATEGORY_OPTIONS).map((option) => ({ - value: option, - text: translate(`workspace.xero.trackingCategoriesOptions.${option.toUpperCase()}` as TranslationPaths), - keyForList: option, - isSelected: option === currentTrackingCategoryValue, - })), - [translate, currentTrackingCategoryValue], - ); + const optionsList = Object.values(CONST.XERO_CONFIG.TRACKING_CATEGORY_OPTIONS).map((option) => ({ + value: option, + text: translate(`workspace.xero.trackingCategoriesOptions.${option.toUpperCase()}` as TranslationPaths), + keyForList: option, + isSelected: option === currentTrackingCategoryValue, + })); - const listHeaderComponent = useMemo( - () => ( - - {translate('workspace.xero.mapTrackingCategoryToDescription', decodedCategoryName)} - - ), - [translate, styles.pb2, styles.ph5, styles.pb5, styles.textNormal, decodedCategoryName], + const listHeaderComponent = ( + + {translate('workspace.xero.mapTrackingCategoryToDescription', decodedCategoryName)} + ); - const updateMapping = useCallback( - (option: {value: string}) => { - if (option.value !== currentTrackingCategoryValue) { - if (option.value === CONST.XERO_CONFIG.TRACKING_CATEGORY_OPTIONS.REPORT_FIELD && !isControlPolicy(policy)) { - const backToRoute = ROUTES.WORKSPACE_UPGRADE.getRoute( - policyID, - `${CONST.REPORT_FIELDS_FEATURE.xero.mapping}`, - ROUTES.POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES.getRoute(policyID), - ); - Navigation.navigate(`${backToRoute}&categoryId=${categoryId}`); - return; - } - if (!policyID) { - return; - } - updateXeroMappings( + const updateMapping = (option: {value: string}) => { + if (option.value !== currentTrackingCategoryValue) { + if (option.value === CONST.XERO_CONFIG.TRACKING_CATEGORY_OPTIONS.REPORT_FIELD && !isControlPolicy(policy)) { + const backToRoute = ROUTES.WORKSPACE_UPGRADE.getRoute( policyID, - categoryId ? {[`${CONST.XERO_CONFIG.TRACKING_CATEGORY_PREFIX}${categoryId}`]: option.value} : {}, - categoryId ? {[`${CONST.XERO_CONFIG.TRACKING_CATEGORY_PREFIX}${categoryId}`]: currentTrackingCategoryValue} : {}, + `${CONST.REPORT_FIELDS_FEATURE.xero.mapping}`, + ROUTES.POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES.getRoute(policyID), ); - if (!reportFieldTrackingCategories.length && option.value === CONST.XERO_CONFIG.TRACKING_CATEGORY_OPTIONS.REPORT_FIELD) { - enablePolicyReportFields(policyID, true); - } - if (reportFieldTrackingCategories.length === 1 && currentTrackingCategoryValue === CONST.XERO_CONFIG.TRACKING_CATEGORY_OPTIONS.REPORT_FIELD) { - enablePolicyReportFields(policyID, false); - } + Navigation.navigate(`${backToRoute}&categoryId=${categoryId}`); + return; } - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES.getRoute(policyID)); - }, - [categoryId, currentTrackingCategoryValue, reportFieldTrackingCategories.length, policy, policyID], - ); + if (!policyID) { + return; + } + updateXeroMappings( + policyID, + categoryId ? {[`${CONST.XERO_CONFIG.TRACKING_CATEGORY_PREFIX}${categoryId}`]: option.value} : {}, + categoryId ? {[`${CONST.XERO_CONFIG.TRACKING_CATEGORY_PREFIX}${categoryId}`]: currentTrackingCategoryValue} : {}, + ); + if (!reportFieldTrackingCategories.length && option.value === CONST.XERO_CONFIG.TRACKING_CATEGORY_OPTIONS.REPORT_FIELD) { + enablePolicyReportFields(policyID, true); + } + if (reportFieldTrackingCategories.length === 1 && currentTrackingCategoryValue === CONST.XERO_CONFIG.TRACKING_CATEGORY_OPTIONS.REPORT_FIELD) { + enablePolicyReportFields(policyID, false); + } + } + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES.getRoute(policyID)); + }; return ( ; @@ -36,7 +36,7 @@ function XeroOrganizationConfigurationPage({ }: XeroOrganizationConfigurationPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const tenants = useMemo(() => getXeroTenants(policy ?? undefined), [policy]); + const tenants = getXeroTenants(policy ?? undefined); const xeroConfig = policy?.connections?.xero?.config; const currentXeroOrganization = findCurrentXeroOrganization(tenants, xeroConfig?.tenantID); @@ -51,13 +51,10 @@ function XeroOrganizationConfigurationPage({ isSelected: tenant.id === organizationID, })) ?? []; - const listHeaderComponent = useMemo( - () => ( - - {translate('workspace.xero.organizationDescription')} - - ), - [translate, styles.pb2, styles.ph5, styles.pb5, styles.textNormal], + const listHeaderComponent = ( + + {translate('workspace.xero.organizationDescription')} + ); const saveSelection = ({keyForList}: ListItem) => { @@ -69,18 +66,15 @@ function XeroOrganizationConfigurationPage({ Navigation.goBack(); }; - const listEmptyContent = useMemo( - () => ( - - ), - [illustrations.Telescope, translate, styles.pb10], + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/accounting/xero/XeroTrackingCategoryConfigurationPage.tsx b/src/pages/workspace/accounting/xero/XeroTrackingCategoryConfigurationPage.tsx index 1d3160ad187d..13d083ef97ba 100644 --- a/src/pages/workspace/accounting/xero/XeroTrackingCategoryConfigurationPage.tsx +++ b/src/pages/workspace/accounting/xero/XeroTrackingCategoryConfigurationPage.tsx @@ -27,7 +27,7 @@ import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; import type {XeroTrackingCategory} from '@src/types/onyx/Policy'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; function XeroTrackingCategoryConfigurationPage({policy}: WithPolicyProps) { @@ -39,22 +39,24 @@ function XeroTrackingCategoryConfigurationPage({policy}: WithPolicyProps) { const {isAccordionExpanded, shouldAnimateAccordionSection} = useAccordionAnimation(!!xeroConfig?.importTrackingCategories); - const menuItems = useMemo(() => { - const trackingCategories = getTrackingCategories(policy); - return trackingCategories.map((category: XeroTrackingCategory & {value: string}) => { + const trackingCategories = getTrackingCategories(policy); + const menuItems = trackingCategories.map( + ( + category: XeroTrackingCategory & { + value: string; + }, + ) => { const decodedCategoryName = getDecodedCategoryName(category.name); return { id: category.id, description: translate('workspace.xero.mapTrackingCategoryTo', decodedCategoryName) as TranslationPaths, onPress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_TRACKING_CATEGORIES_MAP.getRoute(policyID, category.id, category.name)), title: translate( - `workspace.xero.trackingCategoriesOptions.${ - !StringUtils.isEmptyString(category.value) ? category.value.toUpperCase() : CONST.XERO_CONFIG.TRACKING_CATEGORY_OPTIONS.DEFAULT - }` as TranslationPaths, + `workspace.xero.trackingCategoriesOptions.${!StringUtils.isEmptyString(category.value) ? category.value.toUpperCase() : CONST.XERO_CONFIG.TRACKING_CATEGORY_OPTIONS.DEFAULT}` as TranslationPaths, ), }; - }); - }, [translate, policy, policyID]); + }, + ); return ( ( - - {translate('workspace.xero.accountingMethods.description')} - - ), - [translate, styles.pb5, styles.ph5], + const headerContent = ( + + {translate('workspace.xero.accountingMethods.description')} + ); - const selectExpenseReportApprovalLevel = useCallback( - (row: MenuListItem) => { - if (row.value !== accountingMethod) { - updateXeroAccountingMethod(policyID, row.value, accountingMethod); - } - Navigation.goBack(backPath); - }, - [accountingMethod, policyID, backPath], - ); + const selectExpenseReportApprovalLevel = (row: MenuListItem) => { + if (row.value !== accountingMethod) { + updateXeroAccountingMethod(policyID, row.value, accountingMethod); + } + Navigation.goBack(backPath); + }; return ( { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); + }; return ( (accountID: string | undefined) => { - if (!accountID) { - return; - } + const getSelectedAccountName = (accountID: string | undefined) => { + if (!accountID) { + return; + } - const selectedAccount = (bankAccounts ?? []).find((bank) => bank.id === accountID); - return selectedAccount?.name; - }, - [bankAccounts], - ); + const selectedAccount = (bankAccounts ?? []).find((bank) => bank.id === accountID); + return selectedAccount?.name; + }; const selectedBankAccountName = getSelectedAccountName(invoiceCollectionsAccountID); const selectedBillPaymentAccountName = getSelectedAccountName(reimbursementAccountID); const selectedFxExpenseAccountName = (expenseAccounts ?? []).find((account) => account.id === xeroConfig?.fxExpenseAccount)?.name; - const currentXeroOrganizationName = useMemo(() => getCurrentXeroOrganizationName(policy ?? undefined), [policy]); + const currentXeroOrganizationName = getCurrentXeroOrganizationName(policy ?? undefined); const {isAccordionExpanded, shouldAnimateAccordionSection} = useAccordionAnimation(!!sync?.syncReimbursedReports); diff --git a/src/pages/workspace/accounting/xero/advanced/XeroBillPaymentAccountSelectorPage.tsx b/src/pages/workspace/accounting/xero/advanced/XeroBillPaymentAccountSelectorPage.tsx index 58a7b959bf54..0c682f4b4e5d 100644 --- a/src/pages/workspace/accounting/xero/advanced/XeroBillPaymentAccountSelectorPage.tsx +++ b/src/pages/workspace/accounting/xero/advanced/XeroBillPaymentAccountSelectorPage.tsx @@ -22,7 +22,7 @@ import {clearXeroErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; function XeroBillPaymentAccountSelectorPage({policy}: WithPolicyConnectionsProps) { @@ -33,39 +33,30 @@ function XeroBillPaymentAccountSelectorPage({policy}: WithPolicyConnectionsProps const illustrations = useMemoizedLazyIllustrations(['Telescope']); const {config} = policy?.connections?.xero ?? {}; const {reimbursementAccountID, syncReimbursedReports} = policy?.connections?.xero?.config.sync ?? {}; - const xeroSelectorOptions = useMemo(() => getXeroBankAccounts(policy ?? undefined, reimbursementAccountID), [reimbursementAccountID, policy]); + const xeroSelectorOptions: SelectorType[] = getXeroBankAccounts(policy ?? undefined, reimbursementAccountID); - const listHeaderComponent = useMemo( - () => ( - - {translate('workspace.xero.advancedConfig.xeroBillPaymentAccountDescription')} - - ), - [translate, styles.pb2, styles.ph5, styles.pb5, styles.textNormal], + const listHeaderComponent = ( + + {translate('workspace.xero.advancedConfig.xeroBillPaymentAccountDescription')} + ); - const initiallyFocusedOptionKey = useMemo(() => xeroSelectorOptions?.find((mode) => mode.isSelected)?.keyForList, [xeroSelectorOptions]); + const initiallyFocusedOptionKey = xeroSelectorOptions?.find((mode) => mode.isSelected)?.keyForList; - const updateAccount = useCallback( - ({value}: SelectorType) => { - updateXeroSyncReimbursementAccountID(policyID, value, reimbursementAccountID); - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_XERO_ADVANCED.getRoute(policyID)); - }, - [policyID, reimbursementAccountID], - ); + const updateAccount = ({value}: SelectorType) => { + updateXeroSyncReimbursementAccountID(policyID, value, reimbursementAccountID); + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_XERO_ADVANCED.getRoute(policyID)); + }; - const listEmptyContent = useMemo( - () => ( - - ), - [translate, styles.pb10, illustrations.Telescope], + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/accounting/xero/advanced/XeroInvoiceAccountSelectorPage.tsx b/src/pages/workspace/accounting/xero/advanced/XeroInvoiceAccountSelectorPage.tsx index bf66240e05ab..2e21a4e845cc 100644 --- a/src/pages/workspace/accounting/xero/advanced/XeroInvoiceAccountSelectorPage.tsx +++ b/src/pages/workspace/accounting/xero/advanced/XeroInvoiceAccountSelectorPage.tsx @@ -22,7 +22,7 @@ import {clearXeroErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; function XeroInvoiceAccountSelectorPage({policy}: WithPolicyConnectionsProps) { @@ -33,39 +33,30 @@ function XeroInvoiceAccountSelectorPage({policy}: WithPolicyConnectionsProps) { const illustrations = useMemoizedLazyIllustrations(['Telescope']); const {config} = policy?.connections?.xero ?? {}; const {invoiceCollectionsAccountID, syncReimbursedReports} = policy?.connections?.xero?.config.sync ?? {}; - const xeroSelectorOptions = useMemo(() => getXeroBankAccounts(policy ?? undefined, invoiceCollectionsAccountID), [invoiceCollectionsAccountID, policy]); + const xeroSelectorOptions: SelectorType[] = getXeroBankAccounts(policy ?? undefined, invoiceCollectionsAccountID); - const listHeaderComponent = useMemo( - () => ( - - {translate('workspace.xero.advancedConfig.invoiceAccountSelectorDescription')} - - ), - [translate, styles.pb2, styles.ph5, styles.pb5, styles.textNormal], + const listHeaderComponent = ( + + {translate('workspace.xero.advancedConfig.invoiceAccountSelectorDescription')} + ); - const initiallyFocusedOptionKey = useMemo(() => xeroSelectorOptions?.find((mode) => mode.isSelected)?.keyForList, [xeroSelectorOptions]); + const initiallyFocusedOptionKey = xeroSelectorOptions?.find((mode) => mode.isSelected)?.keyForList; - const updateAccount = useCallback( - ({value}: SelectorType) => { - updateXeroSyncInvoiceCollectionsAccountID(policyID, value, invoiceCollectionsAccountID); - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_XERO_ADVANCED.getRoute(policyID)); - }, - [policyID, invoiceCollectionsAccountID], - ); + const updateAccount = ({value}: SelectorType) => { + updateXeroSyncInvoiceCollectionsAccountID(policyID, value, invoiceCollectionsAccountID); + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_XERO_ADVANCED.getRoute(policyID)); + }; - const listEmptyContent = useMemo( - () => ( - - ), - [translate, styles.pb10, illustrations.Telescope], + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroBankAccountSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroBankAccountSelectPage.tsx index f90d18ff8d0e..bf71115a3129 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroBankAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroBankAccountSelectPage.tsx @@ -23,7 +23,7 @@ import {updateXeroExportNonReimbursableAccount} from '@userActions/connections/X import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; function DynamicXeroBankAccountSelectPage({policy}: WithPolicyConnectionsProps) { @@ -33,50 +33,38 @@ function DynamicXeroBankAccountSelectPage({policy}: WithPolicyConnectionsProps) const policyID = policy?.id; const {config} = policy?.connections?.xero ?? {}; - const xeroSelectorOptions = useMemo( - () => getXeroBankAccounts(policy ?? undefined, config?.export?.nonReimbursableAccount), - [config?.export?.nonReimbursableAccount, policy], - ); + const xeroSelectorOptions: SelectorType[] = getXeroBankAccounts(policy ?? undefined, config?.export?.nonReimbursableAccount); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT_BANK_ACCOUNT_SELECT.path); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); - - const listHeaderComponent = useMemo( - () => ( - - {translate('workspace.xero.xeroBankAccountDescription')} - - ), - [translate, styles.pb2, styles.ph5, styles.pb5, styles.textNormal], - ); + }; - const initiallyFocusedOptionKey = useMemo(() => xeroSelectorOptions?.find((mode) => mode.isSelected)?.keyForList, [xeroSelectorOptions]); - - const updateBankAccount = useCallback( - ({value}: SelectorType) => { - if (initiallyFocusedOptionKey !== value && policyID) { - updateXeroExportNonReimbursableAccount(policyID, value, config?.export?.nonReimbursableAccount); - } - goBack(); - }, - [initiallyFocusedOptionKey, policyID, config?.export?.nonReimbursableAccount, goBack], + const listHeaderComponent = ( + + {translate('workspace.xero.xeroBankAccountDescription')} + ); - const listEmptyContent = useMemo( - () => ( - - ), - [translate, styles.pb10, illustrations.Telescope], + const initiallyFocusedOptionKey = xeroSelectorOptions?.find((mode) => mode.isSelected)?.keyForList; + + const updateBankAccount = ({value}: SelectorType) => { + if (initiallyFocusedOptionKey !== value && policyID) { + updateXeroExportNonReimbursableAccount(policyID, value, config?.export?.nonReimbursableAccount); + } + goBack(); + }; + + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx index 8b1ffe92f97b..4df986bb2b14 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx @@ -20,7 +20,7 @@ import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import React, {useMemo} from 'react'; +import React from 'react'; function DynamicXeroExportConfigurationPage({policy}: WithPolicyConnectionsProps) { const {translate} = useLocalize(); @@ -46,12 +46,10 @@ function DynamicXeroExportConfigurationPage({policy}: WithPolicyConnectionsProps const travelSettings = getCardSettings(cardSettings, CONST.TRAVEL.PROGRAM_TRAVEL_US); const isTravelBillingEnabled = getIsTravelBillingEnabled(travelSettings); - const selectedBankAccountName = useMemo(() => { - const selectedAccount = (bankAccounts ?? []).find((bank) => bank.id === exportConfiguration?.nonReimbursableAccount); - return selectedAccount?.name ?? ''; - }, [bankAccounts, exportConfiguration?.nonReimbursableAccount]); + const selectedAccount = (bankAccounts ?? []).find((bank) => bank.id === exportConfiguration?.nonReimbursableAccount); + const selectedBankAccountName = selectedAccount?.name ?? ''; - const currentXeroOrganizationName = useMemo(() => getCurrentXeroOrganizationName(policy ?? undefined), [policy]); + const currentXeroOrganizationName = getCurrentXeroOrganizationName(policy ?? undefined); const menuItems = [ { diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx index 50497207b12e..36384f1beb7a 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx @@ -25,7 +25,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; // Empty string persisted to defaultVendor when the admin wants to disable the fallback @@ -50,32 +50,25 @@ function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicy // cannot persist a defaultVendor from that tenant. const isFeatureAvailable = isXeroVendorMatchingActive(policy); - const suppliers = useMemo(() => getXeroSuppliers(policy), [policy]); + const suppliers = getXeroSuppliers(policy); const sortedSuppliers = sortVendors(suppliers, localeCompare); const [searchText, setSearchText] = useState(''); // Prepend a "None" row so an admin can persist an empty default — without it the picker has // no way to remove a stale or deleted supplier and the optional setting becomes sticky. - const clearOption: SelectorType = useMemo( - () => ({ - value: CLEAR_DEFAULT_VENDOR_VALUE, - text: translate('common.none'), - keyForList: CLEAR_DEFAULT_VENDOR_VALUE, - isSelected: !currentContactID, - }), - [translate, currentContactID], - ); - - const supplierOptions: SelectorType[] = useMemo( - () => - sortedSuppliers.map((supplier) => ({ - value: supplier.id, - text: supplier.name, - keyForList: supplier.id, - isSelected: supplier.id === currentContactID, - })), - [sortedSuppliers, currentContactID], - ); + const clearOption: SelectorType = { + value: CLEAR_DEFAULT_VENDOR_VALUE, + text: translate('common.none'), + keyForList: CLEAR_DEFAULT_VENDOR_VALUE, + isSelected: !currentContactID, + }; + + const supplierOptions: SelectorType[] = sortedSuppliers.map((supplier) => ({ + value: supplier.id, + text: supplier.name, + keyForList: supplier.id, + isSelected: supplier.id === currentContactID, + })); // Match the threshold the Company Cards export picker uses for its search input — Xero // tenants with hundreds or thousands of contacts need filtering, but a 5-supplier workspace @@ -101,59 +94,50 @@ function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicy const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT.path); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); - - const selectSupplier = useCallback( - ({value}: SelectorType) => { - // If the screen render race-ended with isFeatureAvailable still truthy and the user - // managed to tap, refuse to persist the update. - if (!isFeatureAvailable) { - goBack(); - return; - } - - // 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) { - return; - } - if (policyID) { - updateManyPolicyConnectionConfigs( - policyID, - CONST.POLICY.CONNECTIONS.NAME.XERO, - {[CONST.XERO_CONFIG.DEFAULT_VENDOR]: value}, - {[CONST.XERO_CONFIG.DEFAULT_VENDOR]: currentContactID}, - ); - } - goBack(); - }, - [currentContactID, policyID, isFeatureAvailable, goBack], - ); + }; - const listHeaderContent = useMemo( - () => ( - - {translate('workspace.accounting.defaultVendorSelectHeader')} - - ), - [translate, styles.pb2, styles.ph5, styles.pb5, styles.textNormal], + const selectSupplier = ({value}: SelectorType) => { + // If the screen render race-ended with isFeatureAvailable still truthy and the user + // managed to tap, refuse to persist the update. + if (!isFeatureAvailable) { + goBack(); + return; + } + + // 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) { + return; + } + if (policyID) { + updateManyPolicyConnectionConfigs( + policyID, + CONST.POLICY.CONNECTIONS.NAME.XERO, + {[CONST.XERO_CONFIG.DEFAULT_VENDOR]: value}, + {[CONST.XERO_CONFIG.DEFAULT_VENDOR]: currentContactID}, + ); + } + goBack(); + }; + + const listHeaderContent = ( + + {translate('workspace.accounting.defaultVendorSelectHeader')} + ); - const listEmptyContent = useMemo( - () => ( - - ), - [translate, styles.pb10, illustrations.Telescope], + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroPreferredExporterSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroPreferredExporterSelectPage.tsx index 2cc45f201673..4f9d01d0e291 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroPreferredExporterSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroPreferredExporterSelectPage.tsx @@ -22,7 +22,7 @@ import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import isEmpty from 'lodash/isEmpty'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type CardListItem = ListItem & { @@ -39,60 +39,49 @@ function DynamicXeroPreferredExporterSelectPage({policy}: WithPolicyConnectionsP const policyID = policy?.id; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_PREFERRED_EXPORTER_SELECT.path); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); - - const data: CardListItem[] = useMemo(() => { - if (!isEmpty(policyOwner) && isEmpty(exporters)) { - return [ - { - value: policyOwner, - text: policyOwner, - keyForList: policyOwner, - isSelected: true, - }, - ]; + }; + + // Don't show guides if the current user is not a guide themselves or an Expensify employee + const data: CardListItem[] = + !isEmpty(policyOwner) && isEmpty(exporters) + ? [ + { + value: policyOwner, + text: policyOwner, + keyForList: policyOwner, + isSelected: true, + }, + ] + : exporters?.reduce((options, exporter) => { + if (!exporter.email) { + return options; + } + if (isExpensifyTeam(exporter.email) && !isExpensifyTeam(policyOwner) && !isExpensifyTeam(currentUserLogin)) { + return options; + } + options.push({ + value: exporter.email, + text: exporter.email, + keyForList: exporter.email, + isSelected: (config?.export?.exporter ?? policyOwner) === exporter.email, + }); + return options; + }, []); + + const selectExporter = (row: CardListItem) => { + if (row.value !== config?.export?.exporter && policyID) { + updateXeroExportExporter(policyID, row.value, config?.export?.exporter); } - - return exporters?.reduce((options, exporter) => { - if (!exporter.email) { - return options; - } - - // Don't show guides if the current user is not a guide themselves or an Expensify employee - if (isExpensifyTeam(exporter.email) && !isExpensifyTeam(policyOwner) && !isExpensifyTeam(currentUserLogin)) { - return options; - } - - options.push({ - value: exporter.email, - text: exporter.email, - keyForList: exporter.email, - isSelected: (config?.export?.exporter ?? policyOwner) === exporter.email, - }); - return options; - }, []); - }, [policyOwner, exporters, currentUserLogin, config?.export?.exporter]); - - const selectExporter = useCallback( - (row: CardListItem) => { - if (row.value !== config?.export?.exporter && policyID) { - updateXeroExportExporter(policyID, row.value, config?.export?.exporter); - } - goBack(); - }, - [policyID, config?.export?.exporter, goBack], - ); - - const headerContent = useMemo( - () => ( - - {translate('workspace.accounting.exportPreferredExporterNote')} - {translate('workspace.accounting.exportPreferredExporterSubNote')} - - ), - [translate, styles.pb2, styles.ph5, styles.pb5, styles.textNormal], + goBack(); + }; + + const headerContent = ( + + {translate('workspace.accounting.exportPreferredExporterNote')} + {translate('workspace.accounting.exportPreferredExporterSubNote')} + ); return ( diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillDateSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillDateSelectPage.tsx index 036381960fb0..47c19e773d01 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillDateSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillDateSelectPage.tsx @@ -23,7 +23,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {ValueOf} from 'type-fest'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type MenuListItem = ListItem & { @@ -45,29 +45,23 @@ function DynamicXeroPurchaseBillDateSelectPage({policy}: WithPolicyConnectionsPr const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT_PURCHASE_BILL_DATE_SELECT.path); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); - - const headerContent = useMemo( - () => ( - - {translate('workspace.xero.exportDate.description')} - - ), - [translate, styles.pb5, styles.ph5], - ); + }; - const selectExportDate = useCallback( - (row: MenuListItem) => { - if (row.value !== config?.export?.billDate && policyID) { - updateXeroExportBillDate(policyID, row.value, config?.export?.billDate); - } - goBack(); - }, - [config?.export?.billDate, policyID, goBack], + const headerContent = ( + + {translate('workspace.xero.exportDate.description')} + ); + const selectExportDate = (row: MenuListItem) => { + if (row.value !== config?.export?.billDate && policyID) { + updateXeroExportBillDate(policyID, row.value, config?.export?.billDate); + } + goBack(); + }; + return ( { + const goBack = () => { Navigation.goBack(backPath); - }, [backPath]); + }; const data: MenuListItem[] = Object.values(CONST.XERO_CONFIG.INVOICE_STATUS).map((status) => ({ value: status, @@ -51,34 +51,28 @@ function DynamicXeroPurchaseBillStatusSelectorPage({policy}: WithPolicyConnectio isSelected: invoiceStatus === status, })); - const headerContent = useMemo( - () => ( - - {translate('workspace.xero.invoiceStatus.description')} - - ), - [translate, styles.pb5, styles.ph5], + const headerContent = ( + + {translate('workspace.xero.invoiceStatus.description')} + ); - const selectPurchaseBillStatus = useCallback( - (row: MenuListItem) => { - if (isEmpty(config?.export?.billStatus)) { - return; - } - if (row.value !== invoiceStatus && policyID) { - updateXeroExportBillStatus( - policyID, - { - ...config?.export?.billStatus, - purchase: row.value, - }, - config?.export?.billStatus, - ); - } - goBack(); - }, - [config?.export?.billStatus, invoiceStatus, policyID, goBack], - ); + const selectPurchaseBillStatus = (row: MenuListItem) => { + if (isEmpty(config?.export?.billStatus)) { + return; + } + if (row.value !== invoiceStatus && policyID) { + updateXeroExportBillStatus( + policyID, + { + ...config?.export?.billStatus, + purchase: row.value, + }, + config?.export?.billStatus, + ); + } + goBack(); + }; return ( { + const hideArchiveErrorModal = () => { dismissWorkspaceError(policyID, policy?.pendingAction); - }, [policyID, policy?.pendingAction]); + }; - const dismissArchiveFlow = useCallback(() => { + const dismissArchiveFlow = () => { hideArchiveErrorModal(); onDismiss(); - }, [hideArchiveErrorModal, onDismiss]); + }; - const showGenericArchiveErrorModal = useCallback( - (errorMessage: string) => { - if (!isFocused) { - dismissArchiveFlow(); - return; - } + const showGenericArchiveErrorModal = (errorMessage: string) => { + if (!isFocused) { + dismissArchiveFlow(); + return; + } - const prompt = CONST.HTML_TAG_REGEX.test(errorMessage) ? ( - - { - closeModal(); - dismissArchiveFlow(); - }} - /> - - ) : ( - errorMessage - ); - - showConfirmModal({ - title: translate('workspace.common.archive'), - prompt, - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - shouldHandleNavigationBack: false, - }).then(() => { - dismissArchiveFlow(); - }); - }, - [closeModal, dismissArchiveFlow, isFocused, showConfirmModal, styles.flexRow, styles.renderHTML, translate], - ); + const prompt = CONST.HTML_TAG_REGEX.test(errorMessage) ? ( + + { + closeModal(); + dismissArchiveFlow(); + }} + /> + + ) : ( + errorMessage + ); + + showConfirmModal({ + title: translate('workspace.common.archive'), + prompt, + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + shouldHandleNavigationBack: false, + }).then(() => { + dismissArchiveFlow(); + }); + }; const getArchiveConfirmationPrompt = () => { if (hasExpensifyCardsEnabledOnWorkspace) { diff --git a/src/pages/workspace/categories/CategoryForm.tsx b/src/pages/workspace/categories/CategoryForm.tsx index 5dacb68f9d5a..4acb70e9926c 100644 --- a/src/pages/workspace/categories/CategoryForm.tsx +++ b/src/pages/workspace/categories/CategoryForm.tsx @@ -17,7 +17,7 @@ import type {PolicyCategories} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useCallback} from 'react'; +import React from 'react'; import {Keyboard} from 'react-native'; type CategoryFormProps = { @@ -37,27 +37,21 @@ function CategoryForm({onSubmit, policyCategories, categoryName, validateEdit, a const decodedCategoryName = getDecodedCategoryName(categoryName ?? ''); - const validate = useCallback( - (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; - const nameError = getCategoryNameError(policyCategories, values.categoryName); + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const nameError = getCategoryNameError(policyCategories, values.categoryName); - if (nameError) { - addErrorMessage(errors, 'categoryName', getCategoryNameErrorMessage(translate, nameError, values.categoryName)); - } + if (nameError) { + addErrorMessage(errors, 'categoryName', getCategoryNameErrorMessage(translate, nameError, values.categoryName)); + } - return errors; - }, - [policyCategories, translate], - ); + return errors; + }; - const submit = useCallback( - (values: FormOnyxValues) => { - Keyboard.dismiss(); - onSubmit(values); - }, - [onSubmit], - ); + const submit = (values: FormOnyxValues) => { + Keyboard.dismiss(); + onSubmit(values); + }; return ( @@ -46,34 +46,28 @@ function CategoryGLCodePage({route}: EditCategoryPageProps) { const isQuickSettingsFlow = route.name === SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORY_GL_CODE; const workspaceBackPath = useDynamicBackPath(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_GL_CODE.path); - const validate = useCallback( - (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; - const value = values[INPUT_IDS.GL_CODE]; - - if (value.length > CONST.MAX_LENGTH_256) { - errors[INPUT_IDS.GL_CODE] = translate('common.error.characterLimitExceedCounter', value.length, CONST.MAX_LENGTH_256); - } - - return errors; - }, - [translate], - ); - - const editGLCode = useCallback( - (values: FormOnyxValues) => { - const newGLCode = values.glCode.trim(); - if (newGLCode !== glCode) { - setPolicyCategoryGLCode(policyID, categoryName, newGLCode, policyCategories); - } - Navigation.goBack( - isQuickSettingsFlow - ? createDynamicRoute(DYNAMIC_ROUTES.SETTINGS_CATEGORY_SETTINGS.getRoute(categoryName), ROUTES.SETTINGS_CATEGORIES_ROOT.getRoute(policyID, backTo)) - : workspaceBackPath, - ); - }, - [glCode, isQuickSettingsFlow, policyID, categoryName, backTo, policyCategories, workspaceBackPath], - ); + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const value = values[INPUT_IDS.GL_CODE]; + + if (value.length > CONST.MAX_LENGTH_256) { + errors[INPUT_IDS.GL_CODE] = translate('common.error.characterLimitExceedCounter', value.length, CONST.MAX_LENGTH_256); + } + + return errors; + }; + + const editGLCode = (values: FormOnyxValues) => { + const newGLCode = values.glCode.trim(); + if (newGLCode !== glCode) { + setPolicyCategoryGLCode(policyID, categoryName, newGLCode, policyCategories); + } + Navigation.goBack( + isQuickSettingsFlow + ? createDynamicRoute(DYNAMIC_ROUTES.SETTINGS_CATEGORY_SETTINGS.getRoute(categoryName), ROUTES.SETTINGS_CATEGORIES_ROOT.getRoute(policyID, backTo)) + : workspaceBackPath, + ); + }; return ( @@ -46,34 +46,28 @@ function CategoryPayrollCodePage({route}: EditCategoryPageProps) { const isQuickSettingsFlow = route.name === SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORY_PAYROLL_CODE; const workspaceBackPath = useDynamicBackPath(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_PAYROLL_CODE.path); - const editPayrollCode = useCallback( - (values: FormOnyxValues) => { - const newPayrollCode = values.payrollCode.trim(); - if (newPayrollCode !== payrollCode) { - setPolicyCategoryPayrollCode(policyID, categoryName, newPayrollCode, policyCategories); - } - Navigation.goBack( - isQuickSettingsFlow - ? createDynamicRoute(DYNAMIC_ROUTES.SETTINGS_CATEGORY_SETTINGS.getRoute(categoryName), ROUTES.SETTINGS_CATEGORIES_ROOT.getRoute(policyID, backTo)) - : workspaceBackPath, - ); - }, - [payrollCode, isQuickSettingsFlow, policyID, categoryName, backTo, policyCategories, workspaceBackPath], - ); - - const validate = useCallback( - (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; - const value = values[INPUT_IDS.PAYROLL_CODE]; - - if (value.length > CONST.MAX_LENGTH_256) { - errors[INPUT_IDS.PAYROLL_CODE] = translate('common.error.characterLimitExceedCounter', value.length, CONST.MAX_LENGTH_256); - } - - return errors; - }, - [translate], - ); + const editPayrollCode = (values: FormOnyxValues) => { + const newPayrollCode = values.payrollCode.trim(); + if (newPayrollCode !== payrollCode) { + setPolicyCategoryPayrollCode(policyID, categoryName, newPayrollCode, policyCategories); + } + Navigation.goBack( + isQuickSettingsFlow + ? createDynamicRoute(DYNAMIC_ROUTES.SETTINGS_CATEGORY_SETTINGS.getRoute(categoryName), ROUTES.SETTINGS_CATEGORIES_ROOT.getRoute(policyID, backTo)) + : workspaceBackPath, + ); + }; + + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const value = values[INPUT_IDS.PAYROLL_CODE]; + + if (value.length > CONST.MAX_LENGTH_256) { + errors[INPUT_IDS.PAYROLL_CODE] = translate('common.error.characterLimitExceedCounter', value.length, CONST.MAX_LENGTH_256); + } + + return errors; + }; return ( ; @@ -51,13 +51,13 @@ function DynamicCategoryDefaultTaxRatePage({ const hasChanges = !!selectedTaxRate && selectedTaxRate !== persistedTaxRate; const initialSelectedTaxRate = useInitialSelection(selectedTaxRate, {isVisible: !!persistedTaxRate, resetOnFocus: true}); - const textForDefault = useCallback((taxID: string, taxRate: TaxRate) => formatDefaultTaxRateText(translate, taxID, taxRate, policy?.taxRates), [policy?.taxRates, translate]); + const textForDefault = (taxID: string, taxRate: TaxRate) => formatDefaultTaxRateText(translate, taxID, taxRate, policy?.taxRates); - const taxesList = useMemo(() => { - if (!policy) { - return []; - } - return Object.entries(policy.taxRates?.taxes ?? {}) + let taxesList: TaxRateListItem[]; + if (!policy) { + taxesList = []; + } else { + taxesList = Object.entries(policy.taxRates?.taxes ?? {}) .map(([key, value]) => ({ text: textForDefault(key, value), value: key, @@ -67,7 +67,7 @@ function DynamicCategoryDefaultTaxRatePage({ pendingAction: value.pendingAction ?? (Object.keys(value.pendingFields ?? {}).length > 0 ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : null), })) .sort((a, b) => localeCompare(a.text ?? a.keyForList ?? '', b.text ?? b.keyForList ?? '')); - }, [policy, selectedTaxRate, textForDefault, localeCompare]); + } const orderedTaxesList = moveInitialSelectionToTop(taxesList, initialSelectedTaxRate ? [initialSelectedTaxRate] : []); const saveAndGoBack = () => { diff --git a/src/pages/workspace/categories/DynamicCreateCategoryPage.tsx b/src/pages/workspace/categories/DynamicCreateCategoryPage.tsx index e0cca901a721..36fc8cf7065b 100644 --- a/src/pages/workspace/categories/DynamicCreateCategoryPage.tsx +++ b/src/pages/workspace/categories/DynamicCreateCategoryPage.tsx @@ -25,7 +25,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import React, {useCallback} from 'react'; +import React from 'react'; import CategoryForm from './CategoryForm'; @@ -60,35 +60,14 @@ function DynamicCreateCategoryPage({route}: DynamicCreateCategoryPageProps) { const policyHasTags = hasTags(policyTags); const getReviewWorkspaceSettingsTaskCompletion = useReviewWorkspaceSettingsTaskCompletion(); - const createCategory = useCallback( - (values: FormOnyxValues) => { - createPolicyCategory({ - policyID: route.params.policyID, - categoryName: StringUtils.sanitizeName(values.categoryName), - isSetupCategoriesTaskParentReportArchived: isSetupCategoryTaskParentReportArchived, - setupCategoryTaskReport, - setupCategoryTaskParentReport, - currentUserAccountID: currentUserPersonalDetails.accountID, - hasOutstandingChildTask, - parentReportAction, - setupCategoriesAndTagsTaskReport, - setupCategoriesAndTagsTaskParentReport, - isSetupCategoriesAndTagsTaskParentReportArchived, - setupCategoriesAndTagsHasOutstandingChildTask, - setupCategoriesAndTagsParentReportAction, - policyHasTags, - reviewWorkspaceSettingsTaskData: getReviewWorkspaceSettingsTaskCompletion(), - }); - Navigation.goBack(isQuickSettingsFlow ? backPath : undefined); - }, - [ - route.params.policyID, - isSetupCategoryTaskParentReportArchived, + const createCategory = (values: FormOnyxValues) => { + createPolicyCategory({ + policyID: route.params.policyID, + categoryName: StringUtils.sanitizeName(values.categoryName), + isSetupCategoriesTaskParentReportArchived: isSetupCategoryTaskParentReportArchived, setupCategoryTaskReport, setupCategoryTaskParentReport, - currentUserPersonalDetails.accountID, - isQuickSettingsFlow, - backPath, + currentUserAccountID: currentUserPersonalDetails.accountID, hasOutstandingChildTask, parentReportAction, setupCategoriesAndTagsTaskReport, @@ -97,9 +76,10 @@ function DynamicCreateCategoryPage({route}: DynamicCreateCategoryPageProps) { setupCategoriesAndTagsHasOutstandingChildTask, setupCategoriesAndTagsParentReportAction, policyHasTags, - getReviewWorkspaceSettingsTaskCompletion, - ], - ); + reviewWorkspaceSettingsTaskData: getReviewWorkspaceSettingsTaskCompletion(), + }); + Navigation.goBack(isQuickSettingsFlow ? backPath : undefined); + }; return ( ) => { - const errors: FormInputErrors = {}; - const nameError = getCategoryNameError(policyCategories, values.categoryName, decodedCategoryName); - - if (nameError) { - errors.categoryName = getCategoryNameErrorMessage(translate, nameError, values.categoryName); - } - return errors; - }, - [policyCategories, decodedCategoryName, translate], - ); - - const editCategory = useCallback( - (values: FormOnyxValues) => { - const newCategoryName = StringUtils.sanitizeName(values.categoryName); - // Do not call the API if the edited category name is the same as the current category name - if (decodedCategoryName !== newCategoryName) { - renamePolicyCategory(policyData, {oldName: currentCategoryName, newName: newCategoryName}, isVendorMatchingBetaEnabled, getReviewWorkspaceSettingsTaskCompletion()); - } - - // Ensure Onyx.update is executed before navigation to prevent UI blinking issues, affecting the category name and rate. - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.goBack(isQuickSettingsFlow ? settingsBackPath : workspaceBackPath, {compareParams: false}); - }); - }, - [ - decodedCategoryName, - currentCategoryName, - policyData, - isQuickSettingsFlow, - settingsBackPath, - workspaceBackPath, - isVendorMatchingBetaEnabled, - getReviewWorkspaceSettingsTaskCompletion, - ], - ); + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const nameError = getCategoryNameError(policyCategories, values.categoryName, decodedCategoryName); + + if (nameError) { + errors.categoryName = getCategoryNameErrorMessage(translate, nameError, values.categoryName); + } + return errors; + }; + + const editCategory = (values: FormOnyxValues) => { + const newCategoryName = StringUtils.sanitizeName(values.categoryName); + // Do not call the API if the edited category name is the same as the current category name + if (decodedCategoryName !== newCategoryName) { + renamePolicyCategory(policyData, {oldName: currentCategoryName, newName: newCategoryName}, isVendorMatchingBetaEnabled, getReviewWorkspaceSettingsTaskCompletion()); + } + + // Ensure Onyx.update is executed before navigation to prevent UI blinking issues, affecting the category name and rate. + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.goBack(isQuickSettingsFlow ? settingsBackPath : workspaceBackPath, {compareParams: false}); + }); + }; return ( ; @@ -84,7 +84,7 @@ function ImportedCategoriesPage({route}: ImportedCategoriesPageProps) { const requiredColumns = columnRoles.filter((role) => role.isRequired).map((role) => role); - const validate = useCallback(() => { + const validate = () => { const columns = Object.values(spreadsheet?.columns ?? {}); let errors: Errors = {}; @@ -108,7 +108,7 @@ function ImportedCategoriesPage({route}: ImportedCategoriesPageProps) { } } return errors; - }, [spreadsheet?.columns, spreadsheet?.data, requiredColumns, translate, columnRoles, containsHeader]); + }; const closeImportPageAndModal = () => { setIsClosing(true); diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 9941750005f5..3ba5c374895a 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -69,7 +69,7 @@ import SCREENS from '@src/SCREENS'; import type {PolicyCategories} from '@src/types/onyx'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; type WorkspaceCategoriesPageProps = @@ -132,9 +132,9 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const policyHasTags = hasTags(policyTags); const getReviewWorkspaceSettingsTaskCompletion = useReviewWorkspaceSettingsTaskCompletion(); - const fetchCategories = useCallback(() => { + const fetchCategories = () => { openPolicyCategoriesPage(policyId); - }, [policyId]); + }; const {isOffline} = useNetwork({onReconnect: fetchCategories}); @@ -143,9 +143,9 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const clearTableSelection = useCallback(() => { + const clearTableSelection = () => { setSelectedCategoryKeys((prevSelectedCategoryKeys) => (prevSelectedCategoryKeys.length > 0 ? [] : prevSelectedCategoryKeys)); - }, []); + }; useCleanupSelectedOptions(clearTableSelection); @@ -183,47 +183,28 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }); // 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, categoryName: string) => { - if (!canWriteCategories) { - showReadOnlyModal(); - return; - } - setWorkspaceCategoryEnabled({ - isVendorMatchingBetaEnabled, - policyData, - categoriesToUpdate: {[categoryName]: {name: categoryName, enabled: value}}, - isSetupCategoriesTaskParentReportArchived: isSetupCategoryTaskParentReportArchived, - setupCategoryTaskReport, - setupCategoryTaskParentReport, - currentUserAccountID: currentUserPersonalDetails.accountID, - hasOutstandingChildTask, - parentReportAction, - setupCategoriesAndTagsTaskReport, - setupCategoriesAndTagsTaskParentReport, - isSetupCategoriesAndTagsTaskParentReportArchived, - setupCategoriesAndTagsHasOutstandingChildTask, - setupCategoriesAndTagsParentReportAction, - policyHasTags, - reviewWorkspaceSettingsTaskData: getReviewWorkspaceSettingsTaskCompletion(), - }); - }, - [ + const updateWorkspaceCategoryEnabled = (value: boolean, categoryName: string) => { + if (!canWriteCategories) { + showReadOnlyModal(); + return; + } + setWorkspaceCategoryEnabled({ isVendorMatchingBetaEnabled, policyData, - isSetupCategoryTaskParentReportArchived, + categoriesToUpdate: {[categoryName]: {name: categoryName, enabled: value}}, + isSetupCategoriesTaskParentReportArchived: isSetupCategoryTaskParentReportArchived, setupCategoryTaskReport, setupCategoryTaskParentReport, - currentUserPersonalDetails.accountID, + currentUserAccountID: currentUserPersonalDetails.accountID, hasOutstandingChildTask, parentReportAction, setupCategoriesAndTagsTaskReport, @@ -232,114 +213,82 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { setupCategoriesAndTagsHasOutstandingChildTask, setupCategoriesAndTagsParentReportAction, policyHasTags, - canWriteCategories, - showReadOnlyModal, - getReviewWorkspaceSettingsTaskCompletion, - ], - ); + reviewWorkspaceSettingsTaskData: getReviewWorkspaceSettingsTaskCompletion(), + }); + }; - const navigateToCategory = useCallback( - (category: PolicyCategories[string]) => { - const path = isQuickSettingsFlow - ? buildDynamicRoute(DYNAMIC_ROUTES.SETTINGS_CATEGORY_SETTINGS.getRoute(category.name)) - : buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(category.name)); + const navigateToCategory = (category: PolicyCategories[string]) => { + const path = isQuickSettingsFlow + ? buildDynamicRoute(DYNAMIC_ROUTES.SETTINGS_CATEGORY_SETTINGS.getRoute(category.name)) + : buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(category.name)); - Navigation.navigate(path); - }, - [buildDynamicRoute, isQuickSettingsFlow], - ); + Navigation.navigate(path); + }; - const handleCategoryToggle = useCallback( - (enabled: boolean, category: PolicyCategories[string]) => { - if (!canWriteCategories) { - showReadOnlyModal(); - return; - } + const handleCategoryToggle = (enabled: boolean, category: PolicyCategories[string]) => { + if (!canWriteCategories) { + showReadOnlyModal(); + return; + } - if (isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [category])) { - showCannotDeleteOrDisableLastCategoryModal(); - return; - } + if (isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [category])) { + showCannotDeleteOrDisableLastCategoryModal(); + return; + } - updateWorkspaceCategoryEnabled(enabled, category.name); - }, - [canWriteCategories, policy, policyCategories, showCannotDeleteOrDisableLastCategoryModal, showReadOnlyModal, updateWorkspaceCategoryEnabled], - ); + updateWorkspaceCategoryEnabled(enabled, category.name); + }; const categories = Object.values(policyCategories ?? {}); - const categoryApproverEmails = useMemo(() => { - const approverEmails: Record = {}; - - for (const category of categories) { - const approverEmail = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], category.name)?.approver; - - if (approverEmail) { - approverEmails[category.name] = approverEmail; - } + const approverEmails: Record = {}; + for (const category of categories) { + const approverEmail = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], category.name)?.approver; + if (approverEmail) { + approverEmails[category.name] = approverEmail; } - return approverEmails; - }, [categories, policy?.rules?.approvalRules]); + } + const categoryApproverEmails = approverEmails; const shouldShowGLCodeColumn = Object.values(policyCategories ?? {}).some((category) => !!category['GL Code']) && isControlPolicyWithWideLayout; const shouldShowApproverColumn = isControlPolicyWithWideLayout && arePolicyRulesEnabled(policy, policyCategories) && Object.keys(categoryApproverEmails).length > 0; const isSelectionModeActive = selectedCategoryKeys.length > 0 || isMobileSelectionModeEnabled; - const categoryRows = useMemo(() => { - return categories.reduce((acc, value) => { - const isDisabled = value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + const categoryRows: WorkspaceCategoryTableRowData[] = categories.reduce((acc, value) => { + const isDisabled = value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - if (!isOffline && isDisabled) { - return acc; - } + if (!isOffline && isDisabled) { + return acc; + } - const approverEmail = shouldShowApproverColumn ? categoryApproverEmails[value.name] : undefined; - const approverPersonalDetail = employeePersonalDetails[approverEmail ?? '']; - const {avatar: approverAvatar, displayName = approverEmail, accountID: approverAccountID} = approverPersonalDetail ?? {}; - const approverDisplayName = displayName ? formatPhoneNumber(displayName) : ''; - - acc.push({ - keyForList: value.name, - name: getDecodedCategoryName(value.name), - glCode: value['GL Code'], - disabled: isDisabled, - approverAvatar, - approverAccountID, - approverDisplayName, - enabled: value.enabled, - errors: value.errors ?? undefined, - pendingAction: value.pendingAction, - isLocked: isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [value]) || !canWriteCategories || isDisabled, - canEditName: canWriteCategories && !isDisabled && !isSelectionModeActive, - action: () => navigateToCategory(value), - onToggleEnabled: (enabled: boolean) => handleCategoryToggle(enabled, value), - onRenameName: (newName: string) => - showInlineEditError(renameCategoryInline(translate, policyData, value.name, newName, isVendorMatchingBetaEnabled, getReviewWorkspaceSettingsTaskCompletion())), - dismissError: () => clearCategoryErrors(policyId, value.name, policyCategories), - }); + const approverEmail = shouldShowApproverColumn ? categoryApproverEmails[value.name] : undefined; + const approverPersonalDetail = employeePersonalDetails[approverEmail ?? '']; + const {avatar: approverAvatar, displayName = approverEmail, accountID: approverAccountID} = approverPersonalDetail ?? {}; + const approverDisplayName = displayName ? formatPhoneNumber(displayName) : ''; + + acc.push({ + keyForList: value.name, + name: getDecodedCategoryName(value.name), + glCode: value['GL Code'], + disabled: isDisabled, + approverAvatar, + approverAccountID, + approverDisplayName, + enabled: value.enabled, + errors: value.errors ?? undefined, + pendingAction: value.pendingAction, + isLocked: isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [value]) || !canWriteCategories || isDisabled, + canEditName: canWriteCategories && !isDisabled && !isSelectionModeActive, + action: () => navigateToCategory(value), + onToggleEnabled: (enabled: boolean) => handleCategoryToggle(enabled, value), + onRenameName: (newName: string) => + showInlineEditError(renameCategoryInline(translate, policyData, value.name, newName, isVendorMatchingBetaEnabled, getReviewWorkspaceSettingsTaskCompletion())), + dismissError: () => clearCategoryErrors(policyId, value.name, policyCategories), + }); - return acc; - }, []); - }, [ - categories, - isOffline, - shouldShowApproverColumn, - categoryApproverEmails, - canWriteCategories, - isSelectionModeActive, - policyData, - policy, - policyCategories, - navigateToCategory, - translate, - handleCategoryToggle, - policyId, - employeePersonalDetails, - formatPhoneNumber, - isVendorMatchingBetaEnabled, - getReviewWorkspaceSettingsTaskCompletion, - ]); + return acc; + }, []); const navigateToCreateCategoryPage = () => { Navigation.navigate(buildDynamicRoute(isQuickSettingsFlow ? DYNAMIC_ROUTES.SETTINGS_CATEGORY_CREATE.path : DYNAMIC_ROUTES.WORKSPACE_CATEGORY_CREATE.path)); @@ -369,7 +318,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const policyHasAccountingConnections = hasAccountingConnections(policy); - const showOfflineModal = useCallback(() => { + const showOfflineModal = () => { close(() => { showConfirmModal({ title: translate('common.youAppearToBeOffline'), @@ -378,9 +327,9 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { shouldShowCancelButton: false, }); }); - }, [showConfirmModal, translate]); + }; - const navigateToImportSpreadsheet = useCallback(() => { + const navigateToImportSpreadsheet = () => { if (isOffline) { showOfflineModal(); return; @@ -390,107 +339,80 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { ? ROUTES.SETTINGS_CATEGORIES_IMPORT.getRoute(policyId, ROUTES.SETTINGS_CATEGORIES_ROOT.getRoute(policyId, backTo)) : buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORIES_IMPORT.path), ); - }, [backTo, buildDynamicRoute, isOffline, isQuickSettingsFlow, policyId, showOfflineModal]); - - const secondaryActions = useMemo(() => { - const menuItems = []; - // The former Settings page's toggles are surfaced directly in this menu. Selecting a toggle row (click or Enter) - // flips it; the Switch is a display-only indicator and the menu stays open on select. - if (canWriteCategories) { - menuItems.push({ - text: translate('workspace.categories.autoCategorizeNewExpenses'), - value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, - shouldCloseModalOnSelect: false, - onSelected: () => setPolicyAutoCategorizeNewExpenses(policyId, !(policy?.autoCategorizeNewExpenses ?? true)), - numberOfLinesTitle: 0, - shouldIgnoreCompactStyle: true, - innerContainerStyle: styles.alignItemsCenter, - titleStyle: [styles.textLabel, styles.fontWeightNormal], - pendingAction: policy?.pendingFields?.autoCategorizeNewExpenses, - errors: policy?.errorFields?.autoCategorizeNewExpenses, - onCloseError: () => clearPolicyErrorField(policyId, 'autoCategorizeNewExpenses'), - switchProps: { - isOn: policy?.autoCategorizeNewExpenses ?? true, - accessibilityLabel: translate('workspace.categories.autoCategorizeNewExpenses'), - onToggle: (value: boolean) => setPolicyAutoCategorizeNewExpenses(policyId, value), - disabled: !policy?.areCategoriesEnabled || !hasEnabledCategories, - }, - }); - } - if (canWriteCategories && !!policy?.glCodes) { - menuItems.push({ - text: translate('workspace.categories.showCategoryGLCodes'), - value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, - shouldCloseModalOnSelect: false, - onSelected: () => setPolicyShowCategoryGLCodes(policyId, !(policy?.showCategoryGLCodes ?? false)), - numberOfLinesTitle: 0, - shouldIgnoreCompactStyle: true, - innerContainerStyle: styles.alignItemsCenter, - titleStyle: [styles.textLabel, styles.fontWeightNormal], - pendingAction: policy?.pendingFields?.showCategoryGLCodes, - errors: policy?.errorFields?.showCategoryGLCodes, - onCloseError: () => clearPolicyErrorField(policyId, 'showCategoryGLCodes'), - switchProps: { - isOn: policy?.showCategoryGLCodes ?? false, - accessibilityLabel: translate('workspace.categories.showCategoryGLCodes'), - onToggle: (value: boolean) => setPolicyShowCategoryGLCodes(policyId, value), - disabled: !policy?.areCategoriesEnabled, - }, - }); - } - if (canWriteCategories && !policyHasAccountingConnections) { - menuItems.push({ - icon: icons.Table, - text: translate('spreadsheet.importSpreadsheet'), - onSelected: navigateToImportSpreadsheet, - value: CONST.POLICY.SECONDARY_ACTIONS.IMPORT_SPREADSHEET, - // Group the toggle rows apart from the spreadsheet actions. - addSeparatorBefore: true, - }); - } - if (hasVisibleCategories) { - menuItems.push({ - icon: icons.Download, - text: translate('spreadsheet.downloadCSV'), - onSelected: () => { - if (isOffline) { - showOfflineModal(); - return; - } - close(() => { - downloadCategoriesCSV(policyId, showDownloadErrorModal, translate); - }); - }, - value: CONST.POLICY.SECONDARY_ACTIONS.DOWNLOAD_CSV, - }); - } + }; - return menuItems; - }, [ - showOfflineModal, - showDownloadErrorModal, - icons.Download, - icons.Table, - translate, - canWriteCategories, - policy?.glCodes, - policy?.showCategoryGLCodes, - policy?.autoCategorizeNewExpenses, - policy?.areCategoriesEnabled, - policy?.pendingFields?.showCategoryGLCodes, - policy?.errorFields?.showCategoryGLCodes, - policy?.pendingFields?.autoCategorizeNewExpenses, - policy?.errorFields?.autoCategorizeNewExpenses, - hasEnabledCategories, - policyHasAccountingConnections, - hasVisibleCategories, - navigateToImportSpreadsheet, - isOffline, - policyId, - styles.alignItemsCenter, - styles.textLabel, - styles.fontWeightNormal, - ]); + const menuItems = []; + // The former Settings page's toggles are surfaced directly in this menu. Selecting a toggle row (click or Enter) + // flips it; the Switch is a display-only indicator and the menu stays open on select. + if (canWriteCategories) { + menuItems.push({ + text: translate('workspace.categories.autoCategorizeNewExpenses'), + value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, + shouldCloseModalOnSelect: false, + onSelected: () => setPolicyAutoCategorizeNewExpenses(policyId, !(policy?.autoCategorizeNewExpenses ?? true)), + numberOfLinesTitle: 0, + shouldIgnoreCompactStyle: true, + innerContainerStyle: styles.alignItemsCenter, + titleStyle: [styles.textLabel, styles.fontWeightNormal], + pendingAction: policy?.pendingFields?.autoCategorizeNewExpenses, + errors: policy?.errorFields?.autoCategorizeNewExpenses, + onCloseError: () => clearPolicyErrorField(policyId, 'autoCategorizeNewExpenses'), + switchProps: { + isOn: policy?.autoCategorizeNewExpenses ?? true, + accessibilityLabel: translate('workspace.categories.autoCategorizeNewExpenses'), + onToggle: (value: boolean) => setPolicyAutoCategorizeNewExpenses(policyId, value), + disabled: !policy?.areCategoriesEnabled || !hasEnabledCategories, + }, + }); + } + if (canWriteCategories && !!policy?.glCodes) { + menuItems.push({ + text: translate('workspace.categories.showCategoryGLCodes'), + value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, + shouldCloseModalOnSelect: false, + onSelected: () => setPolicyShowCategoryGLCodes(policyId, !(policy?.showCategoryGLCodes ?? false)), + numberOfLinesTitle: 0, + shouldIgnoreCompactStyle: true, + innerContainerStyle: styles.alignItemsCenter, + titleStyle: [styles.textLabel, styles.fontWeightNormal], + pendingAction: policy?.pendingFields?.showCategoryGLCodes, + errors: policy?.errorFields?.showCategoryGLCodes, + onCloseError: () => clearPolicyErrorField(policyId, 'showCategoryGLCodes'), + switchProps: { + isOn: policy?.showCategoryGLCodes ?? false, + accessibilityLabel: translate('workspace.categories.showCategoryGLCodes'), + onToggle: (value: boolean) => setPolicyShowCategoryGLCodes(policyId, value), + disabled: !policy?.areCategoriesEnabled, + }, + }); + } + if (canWriteCategories && !policyHasAccountingConnections) { + menuItems.push({ + icon: icons.Table, + text: translate('spreadsheet.importSpreadsheet'), + onSelected: navigateToImportSpreadsheet, + value: CONST.POLICY.SECONDARY_ACTIONS.IMPORT_SPREADSHEET, + // Group the toggle rows apart from the spreadsheet actions. + addSeparatorBefore: true, + }); + } + if (hasVisibleCategories) { + menuItems.push({ + icon: icons.Download, + text: translate('spreadsheet.downloadCSV'), + onSelected: () => { + if (isOffline) { + showOfflineModal(); + return; + } + close(() => { + downloadCategoriesCSV(policyId, showDownloadErrorModal, translate); + }); + }, + value: CONST.POLICY.SECONDARY_ACTIONS.DOWNLOAD_CSV, + }); + } + const secondaryActions = menuItems; const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); @@ -677,29 +599,15 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { ); - const subtitleText = useMemo(() => { - if (!policyHasAccountingConnections) { - return {translate('workspace.categories.emptyCategories.subtitle')}; - } - return ( - - - - ); - }, [ - policyHasAccountingConnections, - styles.renderHTML, - styles.textAlignCenter, - styles.alignItemsCenter, - styles.textSupporting, - styles.textNormal, - translate, - environmentURL, - policyId, - canWriteCategories, - ]); + const subtitleText = !policyHasAccountingConnections ? ( + {translate('workspace.categories.emptyCategories.subtitle')} + ) : ( + + + + ); const emptyStateButtons: EmptyStateButton[] | undefined = !policyHasAccountingConnections ? [ diff --git a/src/pages/workspace/companyCards/BankConnection/index.native.tsx b/src/pages/workspace/companyCards/BankConnection/index.native.tsx index 43a6d5f3da22..93f784fac185 100644 --- a/src/pages/workspace/companyCards/BankConnection/index.native.tsx +++ b/src/pages/workspace/companyCards/BankConnection/index.native.tsx @@ -34,7 +34,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {WebViewNavigation} from 'react-native-webview'; -import React, {useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; import {WebView} from 'react-native-webview'; @@ -65,10 +65,7 @@ function BankConnection({policyID, feed, title}: BankConnectionProps) { const [isConnectionCompleted, setConnectionCompleted] = useState(false); const prevFeedsData = usePrevious(cardFeeds); const isFeedExpired = feed ? isSelectedFeedExpired(cardFeeds?.[feed]) : false; - const {isNewFeedConnected, newFeed} = useMemo( - () => checkIfNewFeedConnected(prevFeedsData ?? {}, cardFeeds ?? {}, addNewCard?.data?.plaidConnectedFeed), - [addNewCard?.data?.plaidConnectedFeed, cardFeeds, prevFeedsData], - ); + const {isNewFeedConnected, newFeed} = checkIfNewFeedConnected(prevFeedsData ?? {}, cardFeeds ?? {}, addNewCard?.data?.plaidConnectedFeed); const headerTitleAddCards = translate('workspace.companyCards.addCards'); const headerTitle = feed ? translate('workspace.companyCards.assignCard') : headerTitleAddCards; const onImportPlaidAccounts = useImportPlaidAccounts(policyID); diff --git a/src/pages/workspace/companyCards/BankConnection/index.tsx b/src/pages/workspace/companyCards/BankConnection/index.tsx index 0d649ed6cc9c..2b05ca7f0dc6 100644 --- a/src/pages/workspace/companyCards/BankConnection/index.tsx +++ b/src/pages/workspace/companyCards/BankConnection/index.tsx @@ -35,7 +35,7 @@ import ROUTES from '@src/ROUTES'; import type {CompanyCardFeedWithDomainID} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import openBankConnection from './openBankConnection'; @@ -62,10 +62,7 @@ function BankConnection({policyID, feed, title}: BankConnectionProps) { const [shouldBlockWindowOpen, setShouldBlockWindowOpen] = useState(false); const selectedBank = addNewCard?.data?.selectedBank; const bankName = feed ? getBankName(getCompanyCardFeed(feed)) : (addNewCard?.data?.plaidConnectedFeed ?? selectedBank); - const {isNewFeedConnected, newFeed} = useMemo( - () => checkIfNewFeedConnected(prevFeedsData ?? {}, cardFeeds ?? {}, addNewCard?.data?.plaidConnectedFeed), - [addNewCard?.data?.plaidConnectedFeed, cardFeeds, prevFeedsData], - ); + const {isNewFeedConnected, newFeed} = checkIfNewFeedConnected(prevFeedsData ?? {}, cardFeeds ?? {}, addNewCard?.data?.plaidConnectedFeed); const {isOffline} = useNetwork(); const plaidToken = addNewCard?.data?.publicToken ?? assignCard?.cardToAssign?.plaidAccessToken; const {updateBrokenConnection, isFeedConnectionBroken} = useUpdateFeedBrokenConnection({policyID, feed}); @@ -82,12 +79,12 @@ function BankConnection({policyID, feed, title}: BankConnectionProps) { const {isBlockedToAddNewFeeds, isAllFeedsResultLoading} = useIsBlockedToAddFeed(policyID); const {checkForDuplicateFeed} = useDuplicateFeedDetection({policyID, isPlaid}); - const onOpenBankConnectionFlow = useCallback(() => { + const onOpenBankConnectionFlow = () => { if (!url) { return; } customWindow = openBankConnection(url); - }, [url]); + }; useEffect(() => { if (!policyID || !isBlockedToAddNewFeeds || feed) { diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardExpensifyCardPromotionBanner.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardExpensifyCardPromotionBanner.tsx index fadd22590255..03137ad95e44 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardExpensifyCardPromotionBanner.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardExpensifyCardPromotionBanner.tsx @@ -18,7 +18,7 @@ import type {Policy} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type WorkspaceCompanyCardExpensifyCardPromotionBannerProps = { @@ -40,7 +40,7 @@ function WorkspaceCompanyCardExpensifyCardPromotionBanner({policy, canWriteCompa const illustrations = useMemoizedLazyIllustrations(['CreditCardsNewGreen']); - const handleLearnMore = useCallback(() => { + const handleLearnMore = () => { if (!policyID) { return; } @@ -51,29 +51,27 @@ function WorkspaceCompanyCardExpensifyCardPromotionBanner({policy, canWriteCompa } enableExpensifyCard(policyID, true, true); - }, [policyID, areExpensifyCardsEnabled]); + }; - const rightComponent = useMemo(() => { - const shouldDisplayActionInSeparateLine = shouldUseNarrowLayout && !isInLandscapeMode; - const smallScreenStyle = shouldDisplayActionInSeparateLine ? [styles.flex0, styles.mnw100, styles.maxWidth100Percentage, styles.justifyContentCenter] : []; - return ( - + - - ); - }, [styles, shouldUseNarrowLayout, isInLandscapeMode, translate, canUseLearnMore, handleLearnMore, onReadOnlyAction]); + {translate('workspace.moreFeatures.companyCards.expensifyCardBannerLearnMoreButton')} + + + ); return ( diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardStatementCloseDatePage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardStatementCloseDatePage.tsx index b9b006401b9a..46374e2b3981 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardStatementCloseDatePage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardStatementCloseDatePage.tsx @@ -20,7 +20,7 @@ import type SCREENS from '@src/SCREENS'; import type {StatementPeriodEnd, StatementPeriodEndDay} from '@src/types/onyx/CardFeeds'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import WorkspaceCompanyCardStatementCloseDateSelectionList from './WorkspaceCompanyCardStatementCloseDateSelectionList'; @@ -42,41 +42,29 @@ function WorkspaceCompanyCardStatementCloseDatePage({ const domainOrWorkspaceAccountID = getDomainOrWorkspaceAccountID(workspaceAccountID, selectedFeedData); const statementPeriodEndDay = selectedFeedData?.statementPeriodEndDay; - const [defaultStatementPeriodEnd, defaultStatementPeriodEndDay] = useMemo(() => { - if (!statementPeriodEndDay) { - return [undefined, undefined]; - } + const defaultStatementPeriodEnd = statementPeriodEndDay && typeof statementPeriodEndDay !== 'number' ? statementPeriodEndDay : undefined; + const defaultStatementPeriodEndDay = statementPeriodEndDay && typeof statementPeriodEndDay === 'number' ? statementPeriodEndDay : undefined; - if (typeof statementPeriodEndDay === 'number') { - return [undefined, statementPeriodEndDay]; + const submit = (newStatementPeriodEnd: StatementPeriodEnd | undefined, newStatementPeriodEndDay: StatementPeriodEndDay | undefined) => { + const isChangedValue = (newStatementPeriodEndDay ?? newStatementPeriodEnd) !== statementPeriodEndDay; + if (feed && isChangedValue) { + setFeedStatementPeriodEndDay(policyID, feed, domainOrWorkspaceAccountID, newStatementPeriodEnd, newStatementPeriodEndDay, statementPeriodEndDay); } - return [statementPeriodEndDay, undefined]; - }, [statementPeriodEndDay]); - - const submit = useCallback( - (newStatementPeriodEnd: StatementPeriodEnd | undefined, newStatementPeriodEndDay: StatementPeriodEndDay | undefined) => { - const isChangedValue = (newStatementPeriodEndDay ?? newStatementPeriodEnd) !== statementPeriodEndDay; - if (feed && isChangedValue) { - setFeedStatementPeriodEndDay(policyID, feed, domainOrWorkspaceAccountID, newStatementPeriodEnd, newStatementPeriodEndDay, statementPeriodEndDay); - } - - Navigation.goBack(ROUTES.WORKSPACE_COMPANY_CARDS_SETTINGS.getRoute(policyID)); - }, - [policyID, feed, statementPeriodEndDay, domainOrWorkspaceAccountID], - ); + Navigation.goBack(ROUTES.WORKSPACE_COMPANY_CARDS_SETTINGS.getRoute(policyID)); + }; - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(ROUTES.WORKSPACE_COMPANY_CARDS_SETTINGS.getRoute(policyID)); - }, [policyID]); + }; - const clearError = useCallback(() => { + const clearError = () => { if (!feed) { return; } clearErrorField(feed, domainOrWorkspaceAccountID, 'statementPeriodEndDay'); - }, [feed, domainOrWorkspaceAccountID]); + }; if (isLoadingOnyxValue(cardFeedsResult) || isLoadingOnyxValue(lastSelectedFeedResult)) { return ; diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardStatementCloseDateSelectionList/CustomCloseDateSelectionList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardStatementCloseDateSelectionList/CustomCloseDateSelectionList.tsx index 7e5847d26ae4..88dd31532610 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardStatementCloseDateSelectionList/CustomCloseDateSelectionList.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardStatementCloseDateSelectionList/CustomCloseDateSelectionList.tsx @@ -12,7 +12,7 @@ import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import CONST from '@src/CONST'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; type CustomCloseDateListItem = ListItem & { value: number; @@ -33,57 +33,49 @@ function CustomCloseDateSelectionList({initiallySelectedDay, onConfirmSelectedDa // Freeze the day that was selected when this list opened so the pre-selected day stays pinned to the top for the whole open/focus cycle, even as the live selection changes. const initialSelectedDay = useInitialSelection(selectedDay, {resetOnFocus: true}); - const data = useMemo(() => { - const allDays = CONST.DATE.MONTH_DAYS.map((dayValue) => ({ - value: dayValue, - text: dayValue.toString(), - keyForList: dayValue.toString(), - isSelected: dayValue === selectedDay, - })); - - // Pin the frozen initial day to the top of the full list before search filtering, so it stays pinned while searching (search filters the already-pinned list rather than reordering it). - const orderedDays = moveInitialSelectionToTop(allDays, initialSelectedDay ? [initialSelectedDay.toString()] : []); - - if (!debouncedSearchValue) { - return orderedDays; - } - - return orderedDays.filter((day) => day?.text?.includes(debouncedSearchValue)); - }, [selectedDay, debouncedSearchValue, initialSelectedDay]); - - const selectDayAndClearError = useCallback((item: CustomCloseDateListItem) => { + let data; + const allDays = CONST.DATE.MONTH_DAYS.map((dayValue) => ({ + value: dayValue, + text: dayValue.toString(), + keyForList: dayValue.toString(), + isSelected: dayValue === selectedDay, + })); + + // Pin the frozen initial day to the top of the full list before search filtering, so it stays pinned while searching (search filters the already-pinned list rather than reordering it). + const orderedDays = moveInitialSelectionToTop(allDays, initialSelectedDay ? [initialSelectedDay.toString()] : []); + if (!debouncedSearchValue) { + data = orderedDays; + } else { + data = orderedDays.filter((day) => day?.text?.includes(debouncedSearchValue)); + } + + const selectDayAndClearError = (item: CustomCloseDateListItem) => { setSelectedDay(item.value); setError(undefined); - }, []); + }; - const confirmSelectedDay = useCallback(() => { + const confirmSelectedDay = () => { if (!selectedDay) { setError(translate('workspace.moreFeatures.companyCards.error.statementCloseDateRequired')); return; } onConfirmSelectedDay(selectedDay); - }, [selectedDay, onConfirmSelectedDay, translate]); - - const textInputOptions = useMemo( - () => ({ - label: translate('common.search'), - value: searchValue, - onChangeText: setSearchValue, - headerMessage: data.length === 0 ? translate('common.noResultsFound') : undefined, - }), - [translate, searchValue, data.length, setSearchValue], - ); - - const confirmButtonOptions = useMemo( - () => ({ - showButton: true, - text: translate('common.save'), - onConfirm: confirmSelectedDay, - style: styles.mt3, - }), - [translate, confirmSelectedDay, styles.mt3], - ); + }; + + const textInputOptions = { + label: translate('common.search'), + value: searchValue, + onChangeText: setSearchValue, + headerMessage: data.length === 0 ? translate('common.noResultsFound') : undefined, + }; + + const confirmButtonOptions = { + showButton: true, + text: translate('common.save'), + onConfirm: confirmSelectedDay, + style: styles.mt3, + }; return ( (undefined); const {isLoading, startWithLoading} = usePressLoading(); - const title = useMemo( - () => (isChoosingCustomDate ? translate('workspace.companyCards.customCloseDate') : translate('workspace.moreFeatures.companyCards.statementCloseDateTitle')), - [translate, isChoosingCustomDate], - ); + const title = isChoosingCustomDate ? translate('workspace.companyCards.customCloseDate') : translate('workspace.moreFeatures.companyCards.statementCloseDateTitle'); - const goBack = useCallback(() => { + const goBack = () => { if (isChoosingCustomDate) { setIsChoosingCustomDate(false); return; } onBackButtonPress(); - }, [isChoosingCustomDate, onBackButtonPress]); + }; - const selectDateAndClearError = useCallback((item: StatementCloseDateListItem) => { + const selectDateAndClearError = (item: StatementCloseDateListItem) => { setSelectedDate(item.value); setError(undefined); - }, []); - - const selectCustomDateAndClearError = useCallback( - (day: number) => { - setSelectedCustomDate(day); - setError(undefined); - goBack(); - }, - [goBack], - ); + }; + + const selectCustomDateAndClearError = (day: number) => { + setSelectedCustomDate(day); + setError(undefined); + goBack(); + }; - const submit = useCallback(() => { + const submit = () => { if (!selectedDate) { setError(translate('workspace.moreFeatures.companyCards.error.statementCloseDateRequired')); return; @@ -115,7 +109,7 @@ function WorkspaceCompanyCardStatementCloseDateSelectionList({ onSubmit(selectedDate, undefined); }); - }, [selectedDate, selectedCustomDate, onSubmit, translate, startWithLoading]); + }; return ( ; @@ -121,13 +121,13 @@ function WorkspaceCompanyCardsPage({route}: WorkspaceCompanyCardsPageProps) { loadPolicyCompanyCardsPageEvent(); }, []); - const loadPolicyCompanyCardsFeed = useCallback(() => { + const loadPolicyCompanyCardsFeed = () => { if (isLoading || !bankName || isFeedPending || isOffline) { return; } openPolicyCompanyCardsFeed(domainOrWorkspaceAccountID, policyID, bankName, translate); - }, [bankName, domainOrWorkspaceAccountID, isFeedPending, isLoading, policyID, translate, isOffline]); + }; useEffect(() => { loadPolicyCompanyCardsFeed(); diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsFeedNamePage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsFeedNamePage.tsx index 256d33c7a5c5..7b9802896631 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsFeedNamePage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsFeedNamePage.tsx @@ -33,7 +33,7 @@ import type {WorkspaceCompanyCardFeedName} from '@src/types/form/WorkspaceCompan import INPUT_IDS from '@src/types/form/WorkspaceTaxCustomName'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; type WorkspaceCompanyCardsSettingsFeedNamePageProps = PlatformStackScreenProps; @@ -56,21 +56,18 @@ function WorkspaceCompanyCardsSettingsFeedNamePage({ const feedName = selectedFeed ? getCustomOrFormattedFeedName(translate, feed, cardFeeds?.[selectedFeed]?.customFeedName) : undefined; const domainOrWorkspaceAccountID = getDomainOrWorkspaceAccountID(workspaceAccountID, selectedFeed ? companyFeeds[selectedFeed] : undefined); - const validate = useCallback( - (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; - const value = values[INPUT_IDS.NAME]; + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const value = values[INPUT_IDS.NAME]; - if (!isRequiredFulfilled(value)) { - errors.name = translate('workspace.moreFeatures.companyCards.error.feedNameRequired'); - } else if (value.length > CONST.NAME.MAX_LENGTH) { - errors.name = translate('common.error.characterLimitExceedCounter', value.length, CONST.NAME.MAX_LENGTH); - } + if (!isRequiredFulfilled(value)) { + errors.name = translate('workspace.moreFeatures.companyCards.error.feedNameRequired'); + } else if (value.length > CONST.NAME.MAX_LENGTH) { + errors.name = translate('common.error.characterLimitExceedCounter', value.length, CONST.NAME.MAX_LENGTH); + } - return errors; - }, - [translate], - ); + return errors; + }; const submit = ({name}: WorkspaceCompanyCardFeedName) => { if (feed) { diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx index 61dd90efe489..c62a165f4fd2 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx @@ -36,7 +36,7 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {CompanyCardFeedWithDomainID} from '@src/types/onyx'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type WorkspaceCompanyCardsSettingsPageProps = PlatformStackScreenProps; @@ -56,7 +56,7 @@ function WorkspaceCompanyCardsSettingsPage({ const [countryByIp] = useOnyx(ONYXKEYS.COUNTRY); const {currencyList} = useCurrencyListState(); - const selectedFeed = useMemo(() => getSelectedFeed(lastSelectedFeed, cardFeeds), [cardFeeds, lastSelectedFeed]); + const selectedFeed = getSelectedFeed(lastSelectedFeed, cardFeeds); const feed = selectedFeed ? getCompanyCardFeed(selectedFeed) : undefined; const {showConfirmModal} = useConfirmModal(); @@ -72,17 +72,14 @@ function WorkspaceCompanyCardsSettingsPage({ const isDirectFeedType = isDirectFeed(feed); const isCsvFeed = isCSVUploadFeed(feed); - const statementCloseDate = useMemo(() => { - if (!selectedFeedData?.statementPeriodEndDay) { - return undefined; - } - - if (typeof selectedFeedData?.statementPeriodEndDay === 'number') { - return selectedFeedData.statementPeriodEndDay; - } - - return translate(`workspace.companyCards.statementCloseDate.${selectedFeedData.statementPeriodEndDay}`); - }, [translate, selectedFeedData?.statementPeriodEndDay]); + let statementCloseDate; + if (!selectedFeedData?.statementPeriodEndDay) { + statementCloseDate = undefined; + } else if (typeof selectedFeedData?.statementPeriodEndDay === 'number') { + statementCloseDate = selectedFeedData.statementPeriodEndDay; + } else { + statementCloseDate = translate(`workspace.companyCards.statementCloseDate.${selectedFeedData.statementPeriodEndDay}`); + } const navigateToChangeFeedName = () => { Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_SETTINGS_FEED_NAME.getRoute(policyID)); diff --git a/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx b/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx index b2f0f9e7ac20..3cf1c34ddf1a 100644 --- a/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx +++ b/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx @@ -29,7 +29,7 @@ import type {CardFeedProvider, CombinedCardFeeds} from '@src/types/onyx/CardFeed import type {StyleProp, ViewStyle} from 'react-native'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; type CardTypeSelection = CardFeedProvider | typeof CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK; @@ -148,7 +148,7 @@ function CardTypeStep({policyID, cardFeeds, workspaceAccountID}: CardTypeStepPro const isNewCardTypeSelected = typeSelected !== feedType; const doesCountrySupportPlaid = isPlaidSupportedCountry(addNewCard?.data?.selectedCountry); - const submit = useCallback(() => { + const submit = () => { if (!typeSelected || (typeSelected === CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK && !shouldShowMockFeed)) { setIsError(true); } else if (typeSelected === CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK) { @@ -164,7 +164,7 @@ function CardTypeStep({policyID, cardFeeds, workspaceAccountID}: CardTypeStepPro isEditing: false, }); } - }, [bankName, cardFeeds, isNewCardTypeSelected, isOtherBankSelected, lastSelectedFeed, policyID, shouldShowMockFeed, typeSelected, workspaceAccountID]); + }; const handleBackButtonPress = () => { if (isOtherBankSelected) { @@ -178,14 +178,11 @@ function CardTypeStep({policyID, cardFeeds, workspaceAccountID}: CardTypeStepPro setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_FEED_TYPE}); }; - const confirmButtonOptions = useMemo( - () => ({ - showButton: true, - text: translate('common.next'), - onConfirm: submit, - }), - [submit, translate], - ); + const confirmButtonOptions = { + showButton: true, + text: translate('common.next'), + onConfirm: submit, + }; return ( ; @@ -74,10 +74,7 @@ function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) { const showImportSpreadsheetConfirmModal = useImportSpreadsheetConfirmModal(); const layoutName = addNewCard?.data?.companyCardLayoutName ?? ''; const prefilledLayoutType = addNewCard?.data?.layoutType; - const generatedLayoutType = useMemo( - () => prefilledLayoutType ?? getCSVFeedType(workspaceCardFeeds?.settings?.companyCards), - [prefilledLayoutType, workspaceCardFeeds?.settings?.companyCards], - ); + const generatedLayoutType = prefilledLayoutType ?? getCSVFeedType(workspaceCardFeeds?.settings?.companyCards); const layoutType = prefilledLayoutType ?? generatedLayoutType; const columnNames = generateColumnNames(spreadsheet?.data?.length ?? 0); diff --git a/src/pages/workspace/companyCards/addNew/PlaidConnectionStep.tsx b/src/pages/workspace/companyCards/addNew/PlaidConnectionStep.tsx index f03ab425e087..a8ebe45b1aa5 100644 --- a/src/pages/workspace/companyCards/addNew/PlaidConnectionStep.tsx +++ b/src/pages/workspace/companyCards/addNew/PlaidConnectionStep.tsx @@ -29,7 +29,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {CompanyCardFeedWithDomainID} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; type PlaidConnectionStepProps = { @@ -112,9 +112,9 @@ function PlaidConnectionStep({feed, policyID, onExit, title}: PlaidConnectionSte } setAddNewCompanyCardStepAndData({step: isUSCountry ? CONST.COMPANY_CARDS.STEP.SELECT_BANK : CONST.COMPANY_CARDS.STEP.SELECT_FEED_TYPE}); }; - const handlePlaidLinkError = useCallback((error: ErrorEvent | null) => { + const handlePlaidLinkError = (error: ErrorEvent | null) => { Log.hmmm('[PlaidLink] Error: ', error?.message); - }, []); + }; const renderPlaidLink = () => { if (plaidLinkToken) { diff --git a/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx b/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx index 7f89c22376dc..09433500235e 100644 --- a/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx +++ b/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx @@ -24,7 +24,7 @@ import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; function SelectBankStep() { @@ -39,7 +39,7 @@ function SelectBankStep() { const [hasError, setHasError] = useState(false); const isOtherBankSelected = bankSelected === CONST.COMPANY_CARDS.BANKS.OTHER; - const submit = useCallback(() => { + const submit = () => { if (!bankSelected) { setHasError(true); } else { @@ -53,7 +53,7 @@ function SelectBankStep() { isEditing: false, }); } - }, [bankSelected, isOtherBankSelected]); + }; const handleBackButtonPress = () => { setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_FEED_TYPE, data: {selectedBank: null}}); @@ -89,16 +89,13 @@ function SelectBankStep() { }; }); - const confirmButtonOptions = useMemo( - () => ({ - showButton: true, - text: translate('common.next'), - onConfirm: submit, - isDisabled: isOffline, - style: !hasError && styles.mt5, - }), - [hasError, isOffline, styles.mt5, submit, translate], - ); + const confirmButtonOptions = { + showButton: true, + text: translate('common.next'), + onConfirm: submit, + isDisabled: isOffline, + style: !hasError && styles.mt5, + }; return ( { + const submit = () => { if (!typeSelected) { setHasError(true); return; @@ -62,7 +62,7 @@ function SelectFeedType() { step, data: {selectedFeedType: typeSelected}, }); - }, [isUSCountry, typeSelected]); + }; const handleBackButtonPress = () => { setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_COUNTRY}); @@ -94,14 +94,11 @@ function SelectFeedType() { const finalData = doesCountrySupportPlaid ? [directFeedItem, commercialFeedItem, fileImportItem] : [commercialFeedItem, fileImportItem]; - const confirmButtonOptions = useMemo( - () => ({ - showButton: true, - text: translate('common.next'), - onConfirm: submit, - }), - [submit, translate], - ); + const confirmButtonOptions = { + showButton: true, + text: translate('common.next'), + onConfirm: submit, + }; return ( ; @@ -92,11 +92,7 @@ function AssigneeStep({route}: AssigneeStepProps) { excludedUsers[login] = true; } - const { - assignedGuideEmail, - accountManagerLogin, - exclusions: softExclusions, - } = useMemo(() => getGuideAndAccountManagerInfo(policy, account?.accountManagerAccountID, personalDetails), [policy, account?.accountManagerAccountID, personalDetails]); + const {assignedGuideEmail, accountManagerLogin, exclusions: softExclusions} = getGuideAndAccountManagerInfo(policy, account?.accountManagerAccountID, personalDetails); const {searchTerm, setSearchTerm, debouncedSearchTerm, availableOptions, areOptionsInitialized} = usePersonalDetailSearchSelector({ selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, diff --git a/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx b/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx index cecac584a11a..ed84494db29a 100644 --- a/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx @@ -39,7 +39,7 @@ import type SCREENS from '@src/SCREENS'; import type {UnassignedCard} from '@src/types/onyx/Card'; import {Str} from 'expensify-common'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; type CardSelectionStepProps = PlatformStackScreenProps; @@ -134,22 +134,17 @@ function CardSelectionStep({route}: CardSelectionStepProps) { }); }; - const searchedListOptions = useMemo(() => { - return tokenizedSearch(cardListOptions, searchText, (option) => [option.text]); - }, [searchText, cardListOptions]); + const searchedListOptions = tokenizedSearch(cardListOptions, searchText, (option) => [option.text]); const safeAreaPaddingBottomStyle = useBottomSafeSafeAreaPaddingStyle(); - const textInputOptions = useMemo( - () => ({ - headerMessage: searchedListOptions.length ? undefined : translate('common.noResultsFound'), - label: cardListOptions.length > CONST.COMPANY_CARDS.CARD_LIST_THRESHOLD ? translate('common.search') : undefined, - value: searchText, - onChangeText: setSearchText, - shouldBeInsideList: true, - }), - [cardListOptions.length, searchText, searchedListOptions.length, translate], - ); + const textInputOptions = { + headerMessage: searchedListOptions.length ? undefined : translate('common.noResultsFound'), + label: cardListOptions.length > CONST.COMPANY_CARDS.CARD_LIST_THRESHOLD ? translate('common.search') : undefined, + value: searchText, + onChangeText: setSearchText, + shouldBeInsideList: true, + }; const customListHeader = ( diff --git a/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx b/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx index 56d55924238b..1a95804e1b22 100644 --- a/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx +++ b/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx @@ -34,7 +34,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; type DeleteWorkspaceFlowProps = { @@ -126,16 +126,16 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork const shouldCalculateBillNewDot = !!canDowngrade && ownedPaidPoliciesCounts?.total === 1; const {shouldBlockDeletion} = useOutstandingBalanceGuard(ownedPaidPoliciesCounts?.active ?? 0, onDismiss); - const hideDeleteWorkspaceErrorModal = useCallback(() => { + const hideDeleteWorkspaceErrorModal = () => { dismissWorkspaceError(policyID, policy?.pendingAction); - }, [policyID, policy?.pendingAction]); + }; - const dismissDeleteWorkspaceFlow = useCallback(() => { + const dismissDeleteWorkspaceFlow = () => { hideDeleteWorkspaceErrorModal(); onDismiss(); - }, [hideDeleteWorkspaceErrorModal, onDismiss]); + }; - const showDeleteWorkspaceErrorModal = useCallback(() => { + const showDeleteWorkspaceErrorModal = () => { if (!isFocused) { dismissDeleteWorkspaceFlow(); return; @@ -161,41 +161,38 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork }).then(() => { dismissDeleteWorkspaceFlow(); }); - }, [closeModal, dismissDeleteWorkspaceFlow, isBlockedByExpensifyCards, isFocused, showConfirmModal, styles.flexRow, styles.renderHTML, translate]); + }; - const showGenericDeleteWorkspaceErrorModal = useCallback( - (errorMessage: string) => { - if (!isFocused) { - dismissDeleteWorkspaceFlow(); - return; - } + const showGenericDeleteWorkspaceErrorModal = (errorMessage: string) => { + if (!isFocused) { + dismissDeleteWorkspaceFlow(); + return; + } - const prompt = CONST.HTML_TAG_REGEX.test(errorMessage) ? ( - - { - closeModal(); - dismissDeleteWorkspaceFlow(); - }} - /> - - ) : ( - errorMessage - ); - - showConfirmModal({ - title: translate('workspace.common.delete'), - prompt, - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - shouldHandleNavigationBack: false, - }).then(() => { - dismissDeleteWorkspaceFlow(); - }); - }, - [closeModal, dismissDeleteWorkspaceFlow, isFocused, showConfirmModal, styles.flexRow, styles.renderHTML, translate], - ); + const prompt = CONST.HTML_TAG_REGEX.test(errorMessage) ? ( + + { + closeModal(); + dismissDeleteWorkspaceFlow(); + }} + /> + + ) : ( + errorMessage + ); + + showConfirmModal({ + title: translate('workspace.common.delete'), + prompt, + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + shouldHandleNavigationBack: false, + }).then(() => { + dismissDeleteWorkspaceFlow(); + }); + }; // Always invoked after a re-render (from the start effect below for normal deletes, or from usePayAndDowngrade for billed deletes), // so the workspace being deleted and its derived data are read from the latest state. diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx index dc7982fae63d..e6656fe9642c 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx @@ -40,7 +40,7 @@ import type {Rate, RateAttributes} from '@src/types/onyx/Policy'; import type {OnyxCollection} from 'react-native-onyx'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type PolicyDistanceRateDetailsPageProps = PlatformStackScreenProps; @@ -53,42 +53,36 @@ function PolicyDistanceRateDetailsPage({route}: PolicyDistanceRateDetailsPagePro const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`); const {canWrite: canWriteDistanceRates, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.DISTANCE_RATES); const rateID = route.params.rateID; - const customUnit = useMemo(() => getDistanceRateCustomUnit(policy), [policy]); + const customUnit = getDistanceRateCustomUnit(policy); const rate = customUnit?.rates[rateID]; - const policyReportsSelector = useCallback( - (reports: OnyxCollection) => { - const reportIDs: Record = {}; - for (const report of Object.values(reports ?? {})) { - if (report?.policyID === policyID) { - reportIDs[report.reportID] = true; - } + const policyReportsSelector = (reports: OnyxCollection) => { + const reportIDs: Record = {}; + for (const report of Object.values(reports ?? {})) { + if (report?.policyID === policyID) { + reportIDs[report.reportID] = true; } - return reportIDs; - }, - [policyID], - ); + } + return reportIDs; + }; const [policyReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { selector: policyReportsSelector, }); - const transactionsSelector = useCallback( - (transactions: OnyxCollection) => { - return Object.values(transactions ?? {}).reduce((transactionIDs, transaction) => { - if ( - transaction?.reportID && - policyReports?.[transaction.reportID] && - transaction?.comment?.customUnit?.customUnitRateID && - transaction?.comment?.customUnit?.customUnitRateID === rateID - ) { - transactionIDs.push(transaction?.transactionID); - } - return transactionIDs; - }, []); - }, - [rateID, policyReports], - ); + const transactionsSelector = (transactions: OnyxCollection) => { + return Object.values(transactions ?? {}).reduce((transactionIDs, transaction) => { + if ( + transaction?.reportID && + policyReports?.[transaction.reportID] && + transaction?.comment?.customUnit?.customUnitRateID && + transaction?.comment?.customUnit?.customUnitRateID === rateID + ) { + transactionIDs.push(transaction?.transactionID); + } + return transactionIDs; + }, []); + }; const [eligibleTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { selector: transactionsSelector, diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateEditPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateEditPage.tsx index 551ac9b3fecd..460810c14c5f 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateEditPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateEditPage.tsx @@ -27,7 +27,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/PolicyDistanceRateEditForm'; -import React, {useCallback} from 'react'; +import React from 'react'; import {Keyboard} from 'react-native'; type PolicyDistanceRateEditPageProps = PlatformStackScreenProps; @@ -58,10 +58,7 @@ function PolicyDistanceRateEditPage({route}: PolicyDistanceRateEditPageProps) { Navigation.goBack(); }; - const validate = useCallback( - (values: FormOnyxValues) => validateRateValue(values, toLocaleDigit, translate), - [toLocaleDigit, translate], - ); + const validate = (values: FormOnyxValues) => validateRateValue(values, toLocaleDigit, translate); if (!rate) { return ; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateNameEditPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateNameEditPage.tsx index a9cb1158d80d..9f0f7d3337cc 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateNameEditPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateNameEditPage.tsx @@ -29,7 +29,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/PolicyDistanceRateNameEditForm'; -import React, {useCallback} from 'react'; +import React from 'react'; import {Keyboard} from 'react-native'; type PolicyDistanceRateNameEditPageProps = PlatformStackScreenProps; @@ -46,39 +46,33 @@ function PolicyDistanceRateNameEditPage({route}: PolicyDistanceRateNameEditPageP const rate = customUnit?.rates[rateID]; const currentRateName = rate?.name; - const validate = useCallback( - (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; - const existingRateNames = Object.values(customUnit?.rates ?? {}).map((existingRate) => existingRate.name ?? ''); - // The input starts from the name without the hidden date suffix, so check the full name that will be saved - const nameToSave = rate ? DistanceRequestUtils.getRateNameWithDateSuffix(rate, StringUtils.sanitizeName(values.rateName)) : values.rateName; - const nameError = getDistanceRateNameError(existingRateNames, nameToSave, currentRateName); - - if (nameError) { - errors.rateName = getDistanceRateNameErrorMessage(translate, nameError, nameToSave); - } - - return errors; - }, - [customUnit?.rates, currentRateName, rate, translate], - ); - - const submit = useCallback( - (values: FormOnyxValues) => { - if (!customUnit || !rate) { - return; - } - const nameToSave = DistanceRequestUtils.getRateNameWithDateSuffix(rate, StringUtils.sanitizeName(values.rateName)); - if (currentRateName === nameToSave) { - Navigation.goBack(); - return; - } - updatePolicyDistanceRateName(policyID, customUnit, [{...rate, name: nameToSave}]); - Keyboard.dismiss(); + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const existingRateNames = Object.values(customUnit?.rates ?? {}).map((existingRate) => existingRate.name ?? ''); + // The input starts from the name without the hidden date suffix, so check the full name that will be saved + const nameToSave = rate ? DistanceRequestUtils.getRateNameWithDateSuffix(rate, StringUtils.sanitizeName(values.rateName)) : values.rateName; + const nameError = getDistanceRateNameError(existingRateNames, nameToSave, currentRateName); + + if (nameError) { + errors.rateName = getDistanceRateNameErrorMessage(translate, nameError, nameToSave); + } + + return errors; + }; + + const submit = (values: FormOnyxValues) => { + if (!customUnit || !rate) { + return; + } + const nameToSave = DistanceRequestUtils.getRateNameWithDateSuffix(rate, StringUtils.sanitizeName(values.rateName)); + if (currentRateName === nameToSave) { Navigation.goBack(); - }, - [currentRateName, customUnit, rate, policyID], - ); + return; + } + updatePolicyDistanceRateName(policyID, customUnit, [{...rate, name: nameToSave}]); + Keyboard.dismiss(); + Navigation.goBack(); + }; if (!rate) { return ; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateTaxReclaimableEditPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateTaxReclaimableEditPage.tsx index 937aa2a821a6..88dfe1dbf6f5 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateTaxReclaimableEditPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateTaxReclaimableEditPage.tsx @@ -27,7 +27,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/PolicyDistanceRateTaxReclaimableOnEditForm'; -import React, {useCallback} from 'react'; +import React from 'react'; type PolicyDistanceRateTaxReclaimableEditPageProps = WithPolicyOnyxProps & PlatformStackScreenProps; @@ -65,10 +65,7 @@ function PolicyDistanceRateTaxReclaimableEditPage({route, policy}: PolicyDistanc Navigation.goBack(); }; - const validate = useCallback( - (values: FormOnyxValues) => validateTaxClaimableValue(values, rate, translate), - [rate, translate], - ); + const validate = (values: FormOnyxValues) => validateTaxClaimableValue(values, rate, translate); return ( ; @@ -76,65 +76,55 @@ function PolicyDistanceRatesPage({ const isMobileSelectionModeEnabled = useMobileSelectionMode(); const {canWrite: canWriteDistanceRates, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.DISTANCE_RATES); - const customUnit = useMemo(() => getDistanceRateCustomUnit(policy), [policy]); + const customUnit = getDistanceRateCustomUnit(policy); const customUnitID = customUnit?.customUnitID; - const customUnitRates: Record = useMemo(() => customUnit?.rates ?? {}, [customUnit?.rates]); - - const selectableRates = useMemo( - () => - Object.values(customUnitRates).reduce>((acc, rate) => { - acc[rate.customUnitRateID] = rate; - return acc; - }, {}), - [customUnitRates], - ); + const customUnitRates: Record = customUnit?.rates ?? {}; - const rateIDs = useMemo(() => new Set(Object.keys(selectableRates)), [selectableRates]); + const selectableRates = Object.values(customUnitRates).reduce>((acc, rate) => { + acc[rate.customUnitRateID] = rate; + return acc; + }, {}); - const policyReportsSelector = useCallback( - (reports: OnyxCollection) => { - const reportIDs: Record = {}; - for (const report of Object.values(reports ?? {})) { - if (report?.policyID === policyID) { - reportIDs[report.reportID] = true; - } + const rateIDs = new Set(Object.keys(selectableRates)); + + const policyReportsSelector = (reports: OnyxCollection) => { + const reportIDs: Record = {}; + for (const report of Object.values(reports ?? {})) { + if (report?.policyID === policyID) { + reportIDs[report.reportID] = true; } - return reportIDs; - }, - [policyID], - ); + } + return reportIDs; + }; const [policyReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { selector: policyReportsSelector, }); - const transactionsSelector = useCallback( - (transactions: OnyxCollection) => { - if (!customUnit?.customUnitID || rateIDs.size === 0) { - return undefined; - } - return Object.values(transactions ?? {}).reduce( - (transactionsData, transaction) => { - if ( - transaction?.reportID && - policyReports?.[transaction.reportID] && - transaction?.comment?.customUnit?.customUnitRateID && - rateIDs.has(transaction?.comment?.customUnit?.customUnitRateID) - ) { - transactionsData.transactionIDs.push(transaction.transactionID); - if (!transactionsData.rateIDToTransactionIDsMap[transaction?.comment?.customUnit?.customUnitRateID]) { - // eslint-disable-next-line no-param-reassign - transactionsData.rateIDToTransactionIDsMap[transaction?.comment?.customUnit?.customUnitRateID] = []; - } - transactionsData.rateIDToTransactionIDsMap[transaction?.comment?.customUnit?.customUnitRateID]?.push(transaction?.transactionID); + const transactionsSelector = (transactions: OnyxCollection) => { + if (!customUnit?.customUnitID || rateIDs.size === 0) { + return undefined; + } + return Object.values(transactions ?? {}).reduce( + (transactionsData, transaction) => { + if ( + transaction?.reportID && + policyReports?.[transaction.reportID] && + transaction?.comment?.customUnit?.customUnitRateID && + rateIDs.has(transaction?.comment?.customUnit?.customUnitRateID) + ) { + transactionsData.transactionIDs.push(transaction.transactionID); + if (!transactionsData.rateIDToTransactionIDsMap[transaction?.comment?.customUnit?.customUnitRateID]) { + // eslint-disable-next-line no-param-reassign + transactionsData.rateIDToTransactionIDsMap[transaction?.comment?.customUnit?.customUnitRateID] = []; } - return transactionsData; - }, - {transactionIDs: [] as string[], rateIDToTransactionIDsMap: {} as Record}, - ); - }, - [customUnit?.customUnitID, rateIDs, policyReports], - ); + transactionsData.rateIDToTransactionIDsMap[transaction?.comment?.customUnit?.customUnitRateID]?.push(transaction?.transactionID); + } + return transactionsData; + }, + {transactionIDs: [] as string[], rateIDToTransactionIDsMap: {} as Record}, + ); + }; const [eligibleTransactionsData] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { selector: transactionsSelector, @@ -144,32 +134,26 @@ function PolicyDistanceRatesPage({ const transactionViolations = useTransactionViolation(eligibleTransactionIDs); - const filterRateSelection = useCallback( - (rate?: Rate) => !!rate && !!customUnitRates?.[rate.customUnitRateID] && customUnitRates?.[rate.customUnitRateID]?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - [customUnitRates], - ); + const filterRateSelection = (rate?: Rate) => + !!rate && !!customUnitRates?.[rate.customUnitRateID] && customUnitRates?.[rate.customUnitRateID]?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const [selectedDistanceRates, setSelectedDistanceRates] = useFilteredSelection(selectableRates, filterRateSelection); - const clearTableSelection = useCallback(() => { + const clearTableSelection = () => { setSelectedDistanceRates((prev) => (prev.length > 0 ? [] : prev)); - }, [setSelectedDistanceRates]); + }; useCleanupSelectedOptions(clearTableSelection); const isSelectionModeActive = selectedDistanceRates.length > 0 || isMobileSelectionModeEnabled; - const canDisableOrDeleteSelectedRates = useMemo( - () => - Object.keys(selectableRates) - .filter((rateID) => !selectedDistanceRates.includes(rateID)) - .some((rateID) => selectableRates[rateID].enabled), - [selectableRates, selectedDistanceRates], - ); + const canDisableOrDeleteSelectedRates = Object.keys(selectableRates) + .filter((rateID) => !selectedDistanceRates.includes(rateID)) + .some((rateID) => selectableRates[rateID].enabled); - const fetchDistanceRates = useCallback(() => { + const fetchDistanceRates = () => { openPolicyDistanceRatesPage(policyID); - }, [policyID]); + }; const {isOffline} = useNetwork({onReconnect: fetchDistanceRates}); @@ -183,42 +167,36 @@ function PolicyDistanceRatesPage({ onNavigationCallBack: () => Navigation.goBack(), }); - const canDisableOrDeleteRate = useCallback( - (rateID: string): boolean => { - return Object.values(customUnit?.rates ?? {}).some( - (distanceRate: Rate) => distanceRate?.enabled && rateID !== distanceRate?.customUnitRateID && distanceRate?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - ); - }, - [customUnit?.rates], - ); + const canDisableOrDeleteRate = (rateID: string): boolean => { + return Object.values(customUnit?.rates ?? {}).some( + (distanceRate: Rate) => distanceRate?.enabled && rateID !== distanceRate?.customUnitRateID && distanceRate?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + ); + }; - const showWarningModal = useCallback(() => { + const showWarningModal = () => { showConfirmModal({ title: translate('workspace.distanceRates.oopsNotSoFast'), prompt: translate('workspace.distanceRates.workspaceNeeds'), confirmText: translate('common.buttonConfirm'), shouldShowCancelButton: false, }); - }, [showConfirmModal, translate]); + }; - const updateDistanceRateEnabled = useCallback( - (value: boolean, rateID: string) => { - if (!canWriteDistanceRates) { - showReadOnlyModal(); - return; - } - if (!customUnit) { - return; - } - const rate = customUnit?.rates?.[rateID]; - if (!rate?.enabled || canDisableOrDeleteRate(rateID)) { - setPolicyDistanceRatesEnabled(policyID, customUnit, [{...rate, enabled: value}]); - } else { - showWarningModal(); - } - }, - [canDisableOrDeleteRate, canWriteDistanceRates, customUnit, policyID, showReadOnlyModal, showWarningModal], - ); + const updateDistanceRateEnabled = (value: boolean, rateID: string) => { + if (!canWriteDistanceRates) { + showReadOnlyModal(); + return; + } + if (!customUnit) { + return; + } + const rate = customUnit?.rates?.[rateID]; + if (!rate?.enabled || canDisableOrDeleteRate(rateID)) { + setPolicyDistanceRatesEnabled(policyID, customUnit, [{...rate, enabled: value}]); + } else { + showWarningModal(); + } + }; const unitTranslation = translate(`common.${customUnit?.attributes?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}`); const currentUnit = customUnit?.attributes?.unit; @@ -227,9 +205,9 @@ function PolicyDistanceRatesPage({ Navigation.navigate(ROUTES.WORKSPACE_CREATE_DISTANCE_RATE.getRoute(policyID)); }; - const openSettings = useCallback(() => { + const openSettings = () => { Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATES_SETTINGS.getRoute(policyID)); - }, [policyID]); + }; const disableRates = () => { if (customUnit === undefined) { @@ -274,88 +252,64 @@ function PolicyDistanceRatesPage({ setSelectedDistanceRates([]); }; - const dismissErrorByID = useCallback( - (rateID: string) => { - if (!customUnitID) { - return; - } - if (customUnitRates[rateID]?.errors) { - clearDeleteDistanceRateError(policyID, customUnitID, rateID); - return; - } - clearCreateDistanceRateItemAndError(policyID, customUnitID, rateID); - }, - [customUnitID, customUnitRates, policyID], - ); + const dismissErrorByID = (rateID: string) => { + if (!customUnitID) { + return; + } + if (customUnitRates[rateID]?.errors) { + clearDeleteDistanceRateError(policyID, customUnitID, rateID); + return; + } + clearCreateDistanceRateItemAndError(policyID, customUnitID, rateID); + }; - const openRateDetailsByID = useCallback( - (rateID: string) => { - Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATE_DETAILS.getRoute(policyID, rateID)); - }, - [policyID], - ); + const openRateDetailsByID = (rateID: string) => { + Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATE_DETAILS.getRoute(policyID, rateID)); + }; - const ratesData: DistanceRateTableItemData[] = useMemo( - () => - Object.values(customUnitRates).map((rate) => { - const resolvedPendingAction = - rate.pendingAction ?? - rate.pendingFields?.rate ?? - rate.pendingFields?.enabled ?? - rate.pendingFields?.currency ?? - rate.pendingFields?.taxRateExternalID ?? - rate.pendingFields?.taxClaimablePercentage ?? - rate.pendingFields?.name ?? - customUnit?.pendingFields?.attributes ?? - (policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD ? policy?.pendingAction : undefined); - - const isDeleting = resolvedPendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - - return { - keyForList: rate.customUnitRateID, - rateID: rate.customUnitRateID, - rate, - disabled: isDeleting, - enabled: !!rate.enabled, - isLocked: !canWriteDistanceRates || !canDisableOrDeleteRate(rate.customUnitRateID) || isDeleting, - formattedRate: `${convertAmountToDisplayString(rate.rate, rate.currency ?? CONST.CURRENCY.USD)} / ${unitTranslation}`, - pendingAction: resolvedPendingAction ?? undefined, - errors: rate.errors ?? undefined, - canEditName: canWriteDistanceRates && !isDeleting && !isSelectionModeActive, - canEditRate: canWriteDistanceRates && !isDeleting && !isSelectionModeActive, - action: () => openRateDetailsByID(rate.customUnitRateID), - dismissError: () => dismissErrorByID(rate.customUnitRateID), - onToggleEnabled: (value: boolean) => updateDistanceRateEnabled(value, rate.customUnitRateID), - onRenameName: (newName: string) => { - if (!customUnit) { - return; - } - showInlineEditError(renameDistanceRateInline(translate, policyID, customUnit, rate, newName)); - }, - onChangeRate: (newRate: string) => { - if (!customUnit) { - return; - } - showInlineEditError(updateDistanceRateValueInline(translate, policyID, customUnit, rate, newRate, toLocaleDigit)); - }, - }; - }), - [ - customUnitRates, - unitTranslation, - customUnit, - policy?.pendingAction, - canWriteDistanceRates, - canDisableOrDeleteRate, - isSelectionModeActive, - policyID, - toLocaleDigit, - translate, - openRateDetailsByID, - dismissErrorByID, - updateDistanceRateEnabled, - ], - ); + const ratesData: DistanceRateTableItemData[] = Object.values(customUnitRates).map((rate) => { + const resolvedPendingAction = + rate.pendingAction ?? + rate.pendingFields?.rate ?? + rate.pendingFields?.enabled ?? + rate.pendingFields?.currency ?? + rate.pendingFields?.taxRateExternalID ?? + rate.pendingFields?.taxClaimablePercentage ?? + rate.pendingFields?.name ?? + customUnit?.pendingFields?.attributes ?? + (policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD ? policy?.pendingAction : undefined); + + const isDeleting = resolvedPendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + + return { + keyForList: rate.customUnitRateID, + rateID: rate.customUnitRateID, + rate, + disabled: isDeleting, + enabled: !!rate.enabled, + isLocked: !canWriteDistanceRates || !canDisableOrDeleteRate(rate.customUnitRateID) || isDeleting, + formattedRate: `${convertAmountToDisplayString(rate.rate, rate.currency ?? CONST.CURRENCY.USD)} / ${unitTranslation}`, + pendingAction: resolvedPendingAction ?? undefined, + errors: rate.errors ?? undefined, + canEditName: canWriteDistanceRates && !isDeleting && !isSelectionModeActive, + canEditRate: canWriteDistanceRates && !isDeleting && !isSelectionModeActive, + action: () => openRateDetailsByID(rate.customUnitRateID), + dismissError: () => dismissErrorByID(rate.customUnitRateID), + onToggleEnabled: (value: boolean) => updateDistanceRateEnabled(value, rate.customUnitRateID), + onRenameName: (newName: string) => { + if (!customUnit) { + return; + } + showInlineEditError(renameDistanceRateInline(translate, policyID, customUnit, rate, newName)); + }, + onChangeRate: (newRate: string) => { + if (!customUnit) { + return; + } + showInlineEditError(updateDistanceRateValueInline(translate, policyID, customUnit, rate, newRate, toLocaleDigit)); + }, + }; + }); const getBulkActionsButtonOptions = () => { const options: Array> = [ @@ -409,17 +363,14 @@ function PolicyDistanceRatesPage({ const isLoading = !isOffline && customUnit === undefined; - const secondaryActions = useMemo( - () => [ - { - icon: icons.Gear, - text: translate('common.settings'), - onSelected: openSettings, - value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, - }, - ], - [icons.Gear, openSettings, translate], - ); + const secondaryActions = [ + { + icon: icons.Gear, + text: translate('common.settings'), + onSelected: openSettings, + value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, + }, + ]; const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); diff --git a/src/pages/workspace/downgrade/DowngradeConfirmation.tsx b/src/pages/workspace/downgrade/DowngradeConfirmation.tsx index fc90728acffb..24f441bfc15d 100644 --- a/src/pages/workspace/downgrade/DowngradeConfirmation.tsx +++ b/src/pages/workspace/downgrade/DowngradeConfirmation.tsx @@ -14,7 +14,7 @@ import type {Policy} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; import {activeAdminPoliciesSelector} from '@selectors/Policy'; -import React, {useCallback} from 'react'; +import React from 'react'; type Props = { onConfirmDowngrade: () => void; @@ -25,12 +25,9 @@ function DowngradeConfirmation({onConfirmDowngrade, policyID}: Props) { const {translate} = useLocalize(); const illustrations = useMemoizedLazyIllustrations(['MushroomTopHat']); const {login} = useCurrentUserPersonalDetails(); - const selector = useCallback( - (policies: OnyxCollection) => { - return activeAdminPoliciesSelector(policies, login ?? ''); - }, - [login], - ); + const selector = (policies: OnyxCollection) => { + return activeAdminPoliciesSelector(policies, login ?? ''); + }; const [adminPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector}); const hasOtherControlWorkspaces = hasOtherControlWorkspacesPolicyUtils(adminPolicies, policyID); diff --git a/src/pages/workspace/downgrade/DynamicPayAndDowngradePage.tsx b/src/pages/workspace/downgrade/DynamicPayAndDowngradePage.tsx index 467b0c81d577..9ec6e2aac0b9 100644 --- a/src/pages/workspace/downgrade/DynamicPayAndDowngradePage.tsx +++ b/src/pages/workspace/downgrade/DynamicPayAndDowngradePage.tsx @@ -25,7 +25,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import React, {useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; type BillingItem = { @@ -45,10 +45,10 @@ function DynamicPayAndDowngradePage() { const errorMessage = billingDetails?.errors; - const items: BillingItem[] = useMemo(() => { - if (isEmptyObject(billingDetails)) { - return []; - } + let items: BillingItem[]; + if (isEmptyObject(billingDetails)) { + items = []; + } else { const results = [...billingDetails.receiptsWithoutDiscount, ...billingDetails.discounts].map((item) => { return { key: item.description, @@ -56,14 +56,13 @@ function DynamicPayAndDowngradePage() { isTotal: false, }; }); - results.push({ key: translate('common.total'), value: billingDetails.formattedSubtotal, isTotal: true, }); - return results; - }, [billingDetails, translate]); + items = results; + } useEffect(() => { if (billingDetails?.isLoading || !prevIsLoading || billingDetails?.errors) { diff --git a/src/pages/workspace/downgrade/DynamicWorkspaceDowngradePage.tsx b/src/pages/workspace/downgrade/DynamicWorkspaceDowngradePage.tsx index 5281d5723883..daf42335f8c5 100644 --- a/src/pages/workspace/downgrade/DynamicWorkspaceDowngradePage.tsx +++ b/src/pages/workspace/downgrade/DynamicWorkspaceDowngradePage.tsx @@ -30,7 +30,7 @@ import type {Policy} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; import DowngradeConfirmation from './DowngradeConfirmation'; @@ -45,7 +45,7 @@ function DynamicWorkspaceDowngradePage({route}: DynamicWorkspaceDowngradePagePro const {accountID} = useCurrentUserPersonalDetails(); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); - const ownerPoliciesSelectorWithAccountID = useCallback((policies: OnyxCollection) => ownerPoliciesSelector(policies, accountID), [accountID]); + const ownerPoliciesSelectorWithAccountID = (policies: OnyxCollection) => ownerPoliciesSelector(policies, accountID); const [ownerPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { selector: ownerPoliciesSelectorWithAccountID, }); diff --git a/src/pages/workspace/duplicate/WorkspaceDuplicateForm.tsx b/src/pages/workspace/duplicate/WorkspaceDuplicateForm.tsx index d81cc48aad22..2ee53b993c56 100644 --- a/src/pages/workspace/duplicate/WorkspaceDuplicateForm.tsx +++ b/src/pages/workspace/duplicate/WorkspaceDuplicateForm.tsx @@ -27,7 +27,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/WorkspaceDuplicateForm'; -import React, {useCallback, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; type WorkspaceDuplicateFormProps = { @@ -43,35 +43,29 @@ function WorkspaceDuplicateForm({policyID}: WorkspaceDuplicateFormProps) { const policy = usePolicy(policyID); const defaultWorkspaceName = `${policy?.name} (${translate('workspace.common.duplicateWorkspacePrefix')})`; - const validate = useCallback( - (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; - const name = values.name.trim(); - - if (!isRequiredFulfilled(name)) { - errors.name = translate('workspace.editor.nameIsRequiredError'); - } else if ([...name].length > CONST.TITLE_CHARACTER_LIMIT) { - // Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16 - // code units. - addErrorMessage(errors, 'name', translate('common.error.characterLimitExceedCounter', [...name].length, CONST.TITLE_CHARACTER_LIMIT)); - } - - return errors; - }, - [translate], - ); - - const onSubmit = useCallback( - ({name, avatarFile}: {name?: string; avatarFile?: File | CustomRNImageManipulatorResult}) => { - if (!policyID) { - return; - } - const newPolicyID = generatePolicyID(); - setDuplicateWorkspaceData({policyID: newPolicyID, name, fileURI: avatarFile?.uri}); - Navigation.navigate(ROUTES.WORKSPACE_DUPLICATE_SELECT_FEATURES.getRoute(policyID)); - }, - [policyID], - ); + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const name = values.name.trim(); + + if (!isRequiredFulfilled(name)) { + errors.name = translate('workspace.editor.nameIsRequiredError'); + } else if ([...name].length > CONST.TITLE_CHARACTER_LIMIT) { + // Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16 + // code units. + addErrorMessage(errors, 'name', translate('common.error.characterLimitExceedCounter', [...name].length, CONST.TITLE_CHARACTER_LIMIT)); + } + + return errors; + }; + + const onSubmit = ({name, avatarFile}: {name?: string; avatarFile?: File | CustomRNImageManipulatorResult}) => { + if (!policyID) { + return; + } + const newPolicyID = generatePolicyID(); + setDuplicateWorkspaceData({policyID: newPolicyID, name, fileURI: avatarFile?.uri}); + Navigation.navigate(ROUTES.WORKSPACE_DUPLICATE_SELECT_FEATURES.getRoute(policyID)); + }; const [workspaceNameFirstCharacter, setWorkspaceNameFirstCharacter] = useState(defaultWorkspaceName ?? ''); diff --git a/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitPage.tsx b/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitPage.tsx index 9285c419db19..4590b617f335 100644 --- a/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitPage.tsx +++ b/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitPage.tsx @@ -39,7 +39,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/EditExpensifyCardLimitForm'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; type DynamicExpensifyCardLimitPageProps = PlatformStackScreenProps; @@ -69,9 +69,9 @@ function DynamicExpensifyCardLimitPage({route}: DynamicExpensifyCardLimitPagePro const getNewAvailableSpend = (newLimit: number) => getExpensifyCardNewAvailableSpend(cardRef.current, newLimit); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath, {compareParams: false}); - }, [backPath]); + }; const updateCardLimit = (newLimit: number) => { const latestCard = cardRef.current; @@ -114,21 +114,18 @@ function DynamicExpensifyCardLimitPage({route}: DynamicExpensifyCardLimitPagePro updateCardLimit(newLimit); }; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, [INPUT_IDS.LIMIT], translate); - if (errors.limit) { - return errors; - } - - const limitError = getExpensifyCardLimitError(values.limit); - if (limitError) { - errors.limit = getExpensifyCardLimitErrorMessage(translate, limitError); - } + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, [INPUT_IDS.LIMIT], translate); + if (errors.limit) { return errors; - }, - [translate], - ); + } + + const limitError = getExpensifyCardLimitError(values.limit); + if (limitError) { + errors.limit = getExpensifyCardLimitErrorMessage(translate, limitError); + } + return errors; + }; return ( ; @@ -44,9 +44,9 @@ function DynamicExpensifyCardNamePage({route}: DynamicExpensifyCardNamePageProps const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCardsForWorkspace}); const card = cardsList?.[cardID]; - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(backPath, {compareParams: false}); - }, [backPath]); + }; const submit = (values: FormOnyxValues) => { updateExpensifyCardTitle(defaultFundID, Number(cardID), StringUtils.sanitizeName(values[INPUT_IDS.NAME]), card?.nameValuePairs?.cardTitle); diff --git a/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx b/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx index 877871cb7c0b..245410ef52cd 100644 --- a/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx +++ b/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx @@ -66,7 +66,7 @@ import type {NavigationProp} from '@react-navigation/native'; import {useNavigation} from '@react-navigation/native'; import {cardByIdSelector} from '@selectors/Card'; -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; type DynamicWorkspaceExpensifyCardDetailsPageProps = PlatformStackScreenProps< @@ -166,9 +166,9 @@ function DynamicWorkspaceExpensifyCardDetailsPage({route}: DynamicWorkspaceExpen const isDeactivated = card?.state === CONST.EXPENSIFY_CARD.STATE.STATE_DEACTIVATED; const isCardOpen = card?.state === CONST.EXPENSIFY_CARD.STATE.OPEN; - const fetchCardDetails = useCallback(() => { + const fetchCardDetails = () => { openCardDetailsPage(Number(cardID)); - }, [cardID]); + }; const {isOffline} = useNetwork({onReconnect: fetchCardDetails}); useEffect(() => fetchCardDetails(), [fetchCardDetails]); @@ -257,33 +257,27 @@ function DynamicWorkspaceExpensifyCardDetailsPage({route}: DynamicWorkspaceExpen const navigateToTransactions = () => navigateToCardTransactions(cardID); - const spendRulesTitleComponent = useMemo( - () => ( - - {spendRulesSummary.map((summary) => ( - - {summary} - - ))} - - ), - [spendRulesSummary], + const spendRulesTitleComponent = ( + + {spendRulesSummary.map((summary) => ( + + {summary} + + ))} + ); const canManageCardFreeze = canWriteExpensifyCard && !!card; - const scarfOverlayStyle = useMemo( - () => ({ - top: 0, - left: (variables.cardPreviewWidth - variables.cardScarfOverlayWidth) / 2, - zIndex: variables.cardScarfOverlayZIndex, - width: variables.cardScarfOverlayWidth, - height: variables.cardScarfOverlayHeight, - }), - [], - ); + const scarfOverlayStyle = { + top: 0, + left: (variables.cardPreviewWidth - variables.cardScarfOverlayWidth) / 2, + zIndex: variables.cardScarfOverlayZIndex, + width: variables.cardScarfOverlayWidth, + height: variables.cardScarfOverlayHeight, + }; const workspaceCardImage = ( <> ; @@ -87,12 +87,12 @@ function DynamicWorkspaceSettlementAccountPage({route}: WorkspaceSettlementAccou const domainName = settings?.domainName ?? getDomainNameForPolicy(policyID); const hasActiveAccountingConnection = !!(policy?.connections && Object.keys(policy.connections).length > 0); - const fetchPolicyAccountingData = useCallback(() => { + const fetchPolicyAccountingData = () => { if (!policyID) { return; } openPolicyAccountingPage(policyID); - }, [policyID]); + }; useEffect(() => { if (!cardSettings || !hasActiveAccountingConnection || continuousReconciliation !== undefined || reconciliationConnection !== undefined) { diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index 9834037b5ecb..792fa62049de 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -77,7 +77,7 @@ import type {CardLimitType} from '@src/types/onyx/Card'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import EmptyCardView from './EmptyCardView'; @@ -145,10 +145,8 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp const defaultLimitType = getDefaultExpensifyCardLimitType(policy); const shouldShowEuUkDisclaimer = isCurrencySupportedForECards(settlementCurrency); const employeePersonalDetails = usePersonalDetailsByLogins(Object.keys(policy?.employeeList ?? {})); - const allCards = useMemo(() => { - const policyMembersAccountIDs = Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList, employeePersonalDetails)); - return getCardsByCardholderName(cardsList, policyMembersAccountIDs); - }, [cardsList, policy?.employeeList, employeePersonalDetails]); + const policyMembersAccountIDs = Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList, employeePersonalDetails)); + const allCards = getCardsByCardholderName(cardsList, policyMembersAccountIDs); // Row click handlers close over the card from that render. Read the live list from a ref so // no-op/confirm checks use the same card persist will use, and so confirm still persists @@ -160,148 +158,125 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp const isCardListEmpty = allCards.length === 0; const [selectedCardKeys, setSelectedCardKeys] = useState([]); - const selectableCardKeySet = useMemo(() => new Set(allCards.map((card) => String(card.cardID))), [allCards]); - const validatedSelectedCardKeys = useMemo(() => selectedCardKeys.filter((key) => selectableCardKeySet.has(key)), [selectedCardKeys, selectableCardKeySet]); - const selectedCardIDs = useMemo(() => validatedSelectedCardKeys.map((key) => Number(key)), [validatedSelectedCardKeys]); + const selectableCardKeySet = new Set(allCards.map((card) => String(card.cardID))); + const validatedSelectedCardKeys = selectedCardKeys.filter((key) => selectableCardKeySet.has(key)); + const selectedCardIDs = validatedSelectedCardKeys.map((key) => Number(key)); const isSelectionModeActive = selectedCardKeys.length > 0 || isMobileSelectionModeEnabled; - const clearTableSelection = useCallback(() => { + const clearTableSelection = () => { setSelectedCardKeys((prevSelectedCardKeys) => (prevSelectedCardKeys.length > 0 ? [] : prevSelectedCardKeys)); - }, []); + }; useCleanupSelectedOptions(clearTableSelection); - const changeCardLimitType = useCallback( - (card: Card, newLimitType: CardLimitType) => { - const latestCard = cardsListRef.current?.[String(card.cardID)] ?? card; - if (!canUpdateExpensifyCardLimitTypeInline(latestCard, newLimitType, defaultLimitType)) { - return; - } + const changeCardLimitType = (card: Card, newLimitType: CardLimitType) => { + const latestCard = cardsListRef.current?.[String(card.cardID)] ?? card; + if (!canUpdateExpensifyCardLimitTypeInline(latestCard, newLimitType, defaultLimitType)) { + return; + } - const persistLimitType = () => updateExpensifyCardLimitTypeInline(fundID, cardsListRef.current?.[String(card.cardID)] ?? latestCard, newLimitType, defaultLimitType); + const persistLimitType = () => updateExpensifyCardLimitTypeInline(fundID, cardsListRef.current?.[String(card.cardID)] ?? latestCard, newLimitType, defaultLimitType); - if (!shouldConfirmExpensifyCardLimitTypeChange(latestCard, newLimitType, defaultLimitType)) { - persistLimitType(); + if (!shouldConfirmExpensifyCardLimitTypeChange(latestCard, newLimitType, defaultLimitType)) { + persistLimitType(); + return; + } + + showConfirmModal({ + title: translate('workspace.expensifyCard.changeCardLimitType'), + prompt: translate( + getExpensifyCardLimitTypeChangeWarningKey(latestCard.nameValuePairs?.limitType ?? defaultLimitType), + convertToDisplayString(latestCard.nameValuePairs?.unapprovedExpenseLimit, settlementCurrency), + ), + confirmText: translate('workspace.expensifyCard.changeLimitType'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + shouldEnableNewFocusManagement: true, + }).then(({action}) => { + if (action !== ModalActions.CONFIRM) { return; } + persistLimitType(); + }); + }; - showConfirmModal({ - title: translate('workspace.expensifyCard.changeCardLimitType'), - prompt: translate( - getExpensifyCardLimitTypeChangeWarningKey(latestCard.nameValuePairs?.limitType ?? defaultLimitType), - convertToDisplayString(latestCard.nameValuePairs?.unapprovedExpenseLimit, settlementCurrency), - ), - confirmText: translate('workspace.expensifyCard.changeLimitType'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - shouldEnableNewFocusManagement: true, - }).then(({action}) => { - if (action !== ModalActions.CONFIRM) { - return; - } - persistLimitType(); - }); - }, - [convertToDisplayString, defaultLimitType, fundID, settlementCurrency, showConfirmModal, translate], - ); + const changeCardLimit = (card: Card, newLimit: string): InlineEditSaveResult => { + const latestCard = cardsListRef.current?.[String(card.cardID)] ?? card; + const limitError = getExpensifyCardLimitInlineEditError(translate, newLimit); + if (limitError) { + showInlineEditError(limitError); + return true; + } - const changeCardLimit = useCallback( - (card: Card, newLimit: string): InlineEditSaveResult => { - const latestCard = cardsListRef.current?.[String(card.cardID)] ?? card; - const limitError = getExpensifyCardLimitInlineEditError(translate, newLimit); - if (limitError) { - showInlineEditError(limitError); - return true; - } + const nextLimit = getExpensifyCardLimitInlineUpdate(latestCard, newLimit); + if (nextLimit === undefined) { + return true; + } - const nextLimit = getExpensifyCardLimitInlineUpdate(latestCard, newLimit); - if (nextLimit === undefined) { - return true; - } + const persistLimit = () => updateExpensifyCardLimitInline(fundID, cardsListRef.current?.[String(card.cardID)] ?? latestCard, newLimit); - const persistLimit = () => updateExpensifyCardLimitInline(fundID, cardsListRef.current?.[String(card.cardID)] ?? latestCard, newLimit); + if (getExpensifyCardNewAvailableSpend(latestCard, nextLimit) > 0) { + persistLimit(); + return true; + } - if (getExpensifyCardNewAvailableSpend(latestCard, nextLimit) > 0) { - persistLimit(); - return true; + // Keep the typed amount in the field while the modal is open. It is written only after confirm. + return showConfirmModal({ + title: translate('workspace.expensifyCard.changeCardLimit'), + prompt: translate(getExpensifyCardLimitChangeWarningKey(latestCard.nameValuePairs?.limitType ?? defaultLimitType), convertToDisplayString(nextLimit, settlementCurrency)), + confirmText: translate('workspace.expensifyCard.changeLimit'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + shouldEnableNewFocusManagement: true, + }).then(({action}) => { + if (action !== ModalActions.CONFIRM) { + return false; } + persistLimit(); + return true; + }); + }; - // Keep the typed amount in the field while the modal is open. It is written only after confirm. - return showConfirmModal({ - title: translate('workspace.expensifyCard.changeCardLimit'), - prompt: translate(getExpensifyCardLimitChangeWarningKey(latestCard.nameValuePairs?.limitType ?? defaultLimitType), convertToDisplayString(nextLimit, settlementCurrency)), - confirmText: translate('workspace.expensifyCard.changeLimit'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - shouldEnableNewFocusManagement: true, - }).then(({action}) => { - if (action !== ModalActions.CONFIRM) { - return false; - } - persistLimit(); - return true; - }); - }, - [convertToDisplayString, defaultLimitType, fundID, settlementCurrency, showConfirmModal, translate], - ); - - const cardRows = useMemo( - () => - allCards.map((card) => { - const frozenByDisplayName = card.nameValuePairs?.frozen?.byAccountID - ? temporaryGetDisplayNameOrDefault({ - passedPersonalDetails: personalDetails?.[card.nameValuePairs.frozen.byAccountID], - defaultValue: '', - shouldFallbackToHidden: false, - translate, - formatPhoneNumber, - }) || undefined - : undefined; - const canEditCard = canWriteExpensifyCard && card.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isSelectionModeActive; - - return { - keyForList: String(card.cardID), - cardID: card.cardID, - card, - lastFourPAN: card.lastFourPAN ?? '', - name: card.nameValuePairs?.cardTitle ?? '', - cardholder: personalDetails?.[card.accountID ?? CONST.DEFAULT_NUMBER_ID], - limit: card.nameValuePairs?.unapprovedExpenseLimit ?? 0, - remainingLimit: card.availableSpend ?? 0, - currency: settlementCurrency, - isVirtual: !!card.nameValuePairs?.isVirtual, - limitType: card.nameValuePairs?.limitType, - exportAccountTitle: shouldShowExportAccountColumn ? getCardExportAccountTitle(cardExportSettings, card) : undefined, - frozenByDisplayName, - frozenByAccountID: card.nameValuePairs?.frozen?.byAccountID, - frozenDate: card.nameValuePairs?.frozen?.date, - errors: card.errors, - pendingAction: card.pendingAction, - canEditName: canEditCard, - canEditLimitType: canEditCard, - canEditLimit: canEditCard, - action: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_DETAILS.getRoute(card.cardID.toString()))), - onRenameName: (newName: string) => showInlineEditError(renameExpensifyCardInline(translate, fundID, card.cardID, newName, card.nameValuePairs?.cardTitle ?? '')), - onChangeLimitType: (newLimitType: CardLimitType) => changeCardLimitType(card, newLimitType), - onChangeLimit: (newLimit: string) => changeCardLimit(card, newLimit), - onClose: () => clearDeletePaymentMethodError(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${fundID}_${CONST.EXPENSIFY_CARD.BANK}`, card.cardID), - }; - }), - [ - allCards, - canWriteExpensifyCard, - cardExportSettings, - changeCardLimit, - changeCardLimitType, - formatPhoneNumber, - fundID, - isSelectionModeActive, - personalDetails, - settlementCurrency, - shouldShowExportAccountColumn, - translate, - ], - ); + const cardRows: WorkspaceExpensifyCardTableRowData[] = allCards.map((card) => { + const frozenByDisplayName = card.nameValuePairs?.frozen?.byAccountID + ? temporaryGetDisplayNameOrDefault({ + passedPersonalDetails: personalDetails?.[card.nameValuePairs.frozen.byAccountID], + defaultValue: '', + shouldFallbackToHidden: false, + translate, + formatPhoneNumber, + }) || undefined + : undefined; + const canEditCard = canWriteExpensifyCard && card.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isSelectionModeActive; + + return { + keyForList: String(card.cardID), + cardID: card.cardID, + card, + lastFourPAN: card.lastFourPAN ?? '', + name: card.nameValuePairs?.cardTitle ?? '', + cardholder: personalDetails?.[card.accountID ?? CONST.DEFAULT_NUMBER_ID], + limit: card.nameValuePairs?.unapprovedExpenseLimit ?? 0, + remainingLimit: card.availableSpend ?? 0, + currency: settlementCurrency, + isVirtual: !!card.nameValuePairs?.isVirtual, + limitType: card.nameValuePairs?.limitType, + exportAccountTitle: shouldShowExportAccountColumn ? getCardExportAccountTitle(cardExportSettings, card) : undefined, + frozenByDisplayName, + frozenByAccountID: card.nameValuePairs?.frozen?.byAccountID, + frozenDate: card.nameValuePairs?.frozen?.date, + errors: card.errors, + pendingAction: card.pendingAction, + canEditName: canEditCard, + canEditLimitType: canEditCard, + canEditLimit: canEditCard, + action: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_DETAILS.getRoute(card.cardID.toString()))), + onRenameName: (newName: string) => showInlineEditError(renameExpensifyCardInline(translate, fundID, card.cardID, newName, card.nameValuePairs?.cardTitle ?? '')), + onChangeLimitType: (newLimitType: CardLimitType) => changeCardLimitType(card, newLimitType), + onChangeLimit: (newLimit: string) => changeCardLimit(card, newLimit), + onClose: () => clearDeletePaymentMethodError(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${fundID}_${CONST.EXPENSIFY_CARD.BANK}`, card.cardID), + }; + }); const bulkExportOptions: Array> = [ { diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPage.tsx index 382e3b900ec9..e56fff1ae6fa 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPage.tsx @@ -22,7 +22,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; -import React, {useCallback, useEffect} from 'react'; +import React, {useEffect} from 'react'; import WorkspaceExpensifyCardListPage from './WorkspaceExpensifyCardListPage'; import WorkspaceExpensifyCardPageEmptyState from './WorkspaceExpensifyCardPageEmptyState'; @@ -42,10 +42,10 @@ function WorkspaceExpensifyCardPage({route}: WorkspaceExpensifyCardPageProps) { const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCardsForWorkspace}); const [cardsPageLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_EXPENSIFY_CARD_LOADING_STATE}${policyID}`); - const fetchExpensifyCards = useCallback(() => { + const fetchExpensifyCards = () => { updateSelectedExpensifyCardFeed(defaultFundID, policyID); openPolicyExpensifyCardsPage(policyID, defaultFundID); - }, [policyID, defaultFundID]); + }; const {isOffline} = useNetwork({onReconnect: fetchExpensifyCards}); diff --git a/src/pages/workspace/expensifyCard/WorkspaceSettlementFrequencyPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceSettlementFrequencyPage.tsx index 14985a1513d7..72ed008c5419 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceSettlementFrequencyPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceSettlementFrequencyPage.tsx @@ -26,7 +26,7 @@ import type SCREENS from '@src/SCREENS'; import type {ValueOf} from 'type-fest'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; type WorkspaceSettlementFrequencyPageProps = PlatformStackScreenProps; @@ -47,27 +47,22 @@ function WorkspaceSettlementFrequencyPage({route}: WorkspaceSettlementFrequencyP const [selectedFrequencyDraft, setSelectedFrequencyDraft] = useState>(); const currentFrequency = selectedFrequencyDraft ?? selectedFrequency; - const data = useMemo(() => { - const options = []; - + const options = []; + options.push({ + value: CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY, + text: translate('workspace.expensifyCard.frequency.daily'), + keyForList: CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY, + isSelected: currentFrequency === CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY, + }); + if (shouldShowMonthlyOption) { options.push({ - value: CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY, - text: translate('workspace.expensifyCard.frequency.daily'), - keyForList: CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY, - isSelected: currentFrequency === CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY, + value: CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY, + text: translate('workspace.expensifyCard.frequency.monthly'), + keyForList: CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY, + isSelected: currentFrequency === CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY, }); - - if (shouldShowMonthlyOption) { - options.push({ - value: CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY, - text: translate('workspace.expensifyCard.frequency.monthly'), - keyForList: CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY, - isSelected: currentFrequency === CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY, - }); - } - - return options; - }, [translate, shouldShowMonthlyOption, currentFrequency]); + } + const data = options; const updateSettlementFrequency = (value: ValueOf) => { setSelectedFrequencyDraft(value); diff --git a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx index ea290cdd9838..054eeb280bd8 100644 --- a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx @@ -38,7 +38,7 @@ import type {IssueNewCardData} from '@src/types/onyx/Card'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; type AssigneeListItem = ListItem & { value: string; @@ -77,11 +77,7 @@ function AssigneeStep({policy, stepNames, startStepIndex, route}: AssigneeStepPr excludedUsers[login] = true; } - const { - assignedGuideEmail, - accountManagerLogin, - exclusions: softExclusions, - } = useMemo(() => getGuideAndAccountManagerInfo(policy, account?.accountManagerAccountID, personalDetails), [policy, account?.accountManagerAccountID, personalDetails]); + const {assignedGuideEmail, accountManagerLogin, exclusions: softExclusions} = getGuideAndAccountManagerInfo(policy, account?.accountManagerAccountID, personalDetails); const {searchTerm, setSearchTerm, debouncedSearchTerm, availableOptions, areOptionsInitialized} = usePersonalDetailSearchSelector({ selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, diff --git a/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx b/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx index 77536c33f735..43ae5f83aa7e 100644 --- a/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx @@ -23,7 +23,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/IssueNewExpensifyCardForm'; import KeyboardUtils from '@src/utils/keyboard'; -import React, {useCallback} from 'react'; +import React from 'react'; type CardNameStepProps = { policyID: string | undefined; @@ -62,23 +62,20 @@ function CardNameStep({policyID, stepNames, startStepIndex}: CardNameStepProps) return errors; }; - const submit = useCallback( - (values: FormOnyxValues) => { - KeyboardUtils.dismiss().then(() => { - setIssueNewCardStepAndData({ - step: CONST.EXPENSIFY_CARD.STEP.CONFIRMATION, - data: { - cardTitle: StringUtils.sanitizeName(values.cardTitle), - }, - isEditing: false, - policyID, - }); + const submit = (values: FormOnyxValues) => { + KeyboardUtils.dismiss().then(() => { + setIssueNewCardStepAndData({ + step: CONST.EXPENSIFY_CARD.STEP.CONFIRMATION, + data: { + cardTitle: StringUtils.sanitizeName(values.cardTitle), + }, + isEditing: false, + policyID, }); - }, - [policyID], - ); + }); + }; - const handleBackButtonPress = useCallback(() => { + const handleBackButtonPress = () => { KeyboardUtils.dismiss().then(() => { if (isEditing) { setIssueNewCardStepAndData({step: CONST.EXPENSIFY_CARD.STEP.CONFIRMATION, isEditing: false, policyID}); @@ -91,7 +88,7 @@ function CardNameStep({policyID, stepNames, startStepIndex}: CardNameStepProps) setIssueNewCardStepAndData({step: CONST.EXPENSIFY_CARD.STEP.LIMIT_TYPE, policyID}); }); - }, [isEditing, areSpendRulesAvailable, isVirtualCard, policyID]); + }; return ( { + const handleIssueCard = () => { if (!policyID) { return; } @@ -112,7 +112,7 @@ function ConfirmationStep({policyID, stepNames, startStepIndex}: ConfirmationSte // Navigate to validateCode page Navigation.navigate(buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_CONFIRM_VALIDATE_CODE.path)); } - }, [policyID, data, account, defaultFundID, assigneeTimeZone, buildDynamicRoute]); + }; const errorMessage = getLatestErrorMessage(issueNewCard) || (shouldDisableSubmitButton ? translate('workspace.card.issueNewCard.disabledApprovalForSmartLimitError') : ''); diff --git a/src/pages/workspace/expensifyCard/issueNew/DynamicIssueNewCardPage.tsx b/src/pages/workspace/expensifyCard/issueNew/DynamicIssueNewCardPage.tsx index 3e0d59db314f..16a248575ed5 100644 --- a/src/pages/workspace/expensifyCard/issueNew/DynamicIssueNewCardPage.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/DynamicIssueNewCardPage.tsx @@ -23,7 +23,7 @@ import type {IssueNewCard, IssueNewCardStep} from '@src/types/onyx/Card'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import AssigneeStep from './AssigneeStep'; import CardNameStep from './CardNameStep'; @@ -61,13 +61,8 @@ function DynamicIssueNewCardPage({policy, route}: IssueNewCardPageProps) { const backPath = useDynamicBackPath(DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW.path); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); - const stepNames = useMemo(() => { - if (issueNewCard?.isChangeAssigneeDisabled) { - return CONST.EXPENSIFY_CARD.ASSIGNEE_EXCLUDED_STEP_NAMES; - } - return CONST.EXPENSIFY_CARD.STEP_NAMES; - }, [issueNewCard?.isChangeAssigneeDisabled]); - const startStepIndex = useMemo(() => getStartStepIndex(issueNewCard), [issueNewCard]); + const stepNames = issueNewCard?.isChangeAssigneeDisabled ? CONST.EXPENSIFY_CARD.ASSIGNEE_EXCLUDED_STEP_NAMES : CONST.EXPENSIFY_CARD.STEP_NAMES; + const startStepIndex = getStartStepIndex(issueNewCard); useEffect(() => { startIssueNewCardFlow(policyID); diff --git a/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx b/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx index a62faf0bb971..f7bef1b0e60d 100644 --- a/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx @@ -17,7 +17,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; -import React, {useCallback, useEffect} from 'react'; +import React, {useEffect} from 'react'; type InviteeNewMemberStepProps = Omit & WithCurrentUserPersonalDetailsProps & { @@ -48,7 +48,7 @@ function InviteNewMemberStep({policy, route, currentUserPersonalDetails}: Invite }); }; - const goToNextStep = useCallback(() => { + const goToNextStep = () => { if (isEditing) { setIssueNewCardStepAndData({ step: CONST.EXPENSIFY_CARD.STEP.CONFIRMATION, @@ -64,7 +64,7 @@ function InviteNewMemberStep({policy, route, currentUserPersonalDetails}: Invite data: {...issueNewCard?.data, assigneeEmail: issueNewCard?.data?.invitingMemberEmail ?? '', invitingMemberEmail: undefined}, }); } - }, [isEditing, issueNewCard?.data, policyID]); + }; // If the currently inviting member is already a member of the policy then we should just call goToNextStep // See https://github.com/Expensify/App/issues/74256 for more details diff --git a/src/pages/workspace/expensifyCard/issueNew/IssueNewCardConfirmValidateCodePage.tsx b/src/pages/workspace/expensifyCard/issueNew/IssueNewCardConfirmValidateCodePage.tsx index 83a3207037d4..eea28e650991 100644 --- a/src/pages/workspace/expensifyCard/issueNew/IssueNewCardConfirmValidateCodePage.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/IssueNewCardConfirmValidateCodePage.tsx @@ -23,7 +23,7 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {CONST as COMMON_CONST} from 'expensify-common'; -import React, {useCallback, useEffect} from 'react'; +import React, {useEffect} from 'react'; type IssueNewCardConfirmValidateCodePageProps = PlatformStackScreenProps< SettingsNavigatorParamList, @@ -62,17 +62,14 @@ function IssueNewCardConfirmValidateCodePage({route}: IssueNewCardConfirmValidat clearIssueNewCardFlow(policyID); }, [backPath, isSuccessful, policyID, shouldUseBackToParam]); - const handleSubmit = useCallback( - (validateCode: string) => { - // The request carries no feedCountry, so the backend resolves it from the domain's provisioned card programs - issueExpensifyCard(defaultFundID, policyID, validateCode, assigneeTimeZone, data); - }, - [data, defaultFundID, policyID, assigneeTimeZone], - ); + const handleSubmit = (validateCode: string) => { + // The request carries no feedCountry, so the backend resolves it from the domain's provisioned card programs + issueExpensifyCard(defaultFundID, policyID, validateCode, assigneeTimeZone, data); + }; - const handleClose = useCallback(() => { + const handleClose = () => { Navigation.goBack(backPath); - }, [backPath]); + }; return ( { - if (isEditing) { - return CONST.EXPENSIFY_CARD.STEP.CONFIRMATION; - } - if (areSpendRulesAvailable || issueNewCard?.data.cardType === CONST.EXPENSIFY_CARD.CARD_TYPE.VIRTUAL) { - return CONST.EXPENSIFY_CARD.STEP.SPEND_RULES; - } - return CONST.EXPENSIFY_CARD.STEP.CARD_NAME; - }, [areSpendRulesAvailable, isEditing, issueNewCard?.data.cardType]); + let nextStep; + if (isEditing) { + nextStep = CONST.EXPENSIFY_CARD.STEP.CONFIRMATION; + } else if (areSpendRulesAvailable || issueNewCard?.data.cardType === CONST.EXPENSIFY_CARD.CARD_TYPE.VIRTUAL) { + nextStep = CONST.EXPENSIFY_CARD.STEP.SPEND_RULES; + } else { + nextStep = CONST.EXPENSIFY_CARD.STEP.CARD_NAME; + } - const onInputFocus = useCallback(() => { + const onInputFocus = () => { formRef.current?.scrollToEnd(); - }, []); - - const submit = useCallback( - (values: FormOnyxValues) => { - KeyboardUtils.dismiss().then(() => { - const limit = convertToBackendAmount(Number(values?.limit)); - setIssueNewCardStepAndData({ - step: nextStep, - data: {limitType: typeSelected, limit}, - isEditing: false, - policyID, - }); + }; + + const submit = (values: FormOnyxValues) => { + KeyboardUtils.dismiss().then(() => { + const limit = convertToBackendAmount(Number(values?.limit)); + setIssueNewCardStepAndData({ + step: nextStep, + data: {limitType: typeSelected, limit}, + isEditing: false, + policyID, }); - }, - [nextStep, typeSelected, policyID], - ); + }); + }; - const handleBackButtonPress = useCallback(() => { + const handleBackButtonPress = () => { if (isEditing) { setIssueNewCardStepAndData({step: CONST.EXPENSIFY_CARD.STEP.CONFIRMATION, isEditing: false, policyID}); return; } setIssueNewCardStepAndData({step: CONST.EXPENSIFY_CARD.STEP.CARD_TYPE, policyID}); - }, [isEditing, policyID]); + }; const workspaceWorkflowsPageURL = canReadWorkflows ? `${environmentURL}/${ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID, CONST.TAB.WORKFLOWS.APPROVALS)}` : undefined; - const data = useMemo(() => { - const options = []; - + const options = []; + options.push({ + value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART, + label: translate('workspace.card.issueNewCard.smartLimit'), + description: areApprovalsConfigured ? translate('workspace.card.issueNewCard.smartLimitDescription') : undefined, + alternateTextComponent: areApprovalsConfigured ? undefined : , + rightElement: areApprovalsConfigured ? undefined : ( + + ), + shouldHideSelectionButton: !areApprovalsConfigured, + keyForList: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART, + isSelected: typeSelected === CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART, + isDisabled: !areApprovalsConfigured, + titleStyles: areApprovalsConfigured + ? undefined + : { + color: theme.heading, + }, + }); + options.push( + { + value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY, + label: translate('workspace.card.issueNewCard.monthly'), + description: translate('workspace.card.issueNewCard.monthlyDescription'), + keyForList: CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY, + isSelected: typeSelected === CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY, + }, + { + value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED, + label: translate('workspace.card.issueNewCard.fixedAmount'), + description: translate('workspace.card.issueNewCard.fixedAmountDescription'), + keyForList: CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED, + isSelected: typeSelected === CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED, + }, + ); + if (issueNewCard?.data?.cardType === CONST.EXPENSIFY_CARD.CARD_TYPE.VIRTUAL) { options.push({ - value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART, - label: translate('workspace.card.issueNewCard.smartLimit'), - description: areApprovalsConfigured ? translate('workspace.card.issueNewCard.smartLimitDescription') : undefined, - alternateTextComponent: areApprovalsConfigured ? undefined : ( - - ), - rightElement: areApprovalsConfigured ? undefined : ( - - ), - shouldHideSelectionButton: !areApprovalsConfigured, - keyForList: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART, - isSelected: typeSelected === CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART, - isDisabled: !areApprovalsConfigured, - titleStyles: areApprovalsConfigured ? undefined : {color: theme.heading}, + value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SINGLE_USE, + label: translate('workspace.card.issueNewCard.singleUse'), + description: translate('workspace.card.issueNewCard.singleUseDescription'), + keyForList: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SINGLE_USE, + isSelected: typeSelected === CONST.EXPENSIFY_CARD.LIMIT_TYPES.SINGLE_USE, }); + } + const data = options; - options.push( - { - value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY, - label: translate('workspace.card.issueNewCard.monthly'), - description: translate('workspace.card.issueNewCard.monthlyDescription'), - keyForList: CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY, - isSelected: typeSelected === CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY, - }, - { - value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED, - label: translate('workspace.card.issueNewCard.fixedAmount'), - description: translate('workspace.card.issueNewCard.fixedAmountDescription'), - keyForList: CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED, - isSelected: typeSelected === CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED, - }, - ); - - if (issueNewCard?.data?.cardType === CONST.EXPENSIFY_CARD.CARD_TYPE.VIRTUAL) { - options.push({ - value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SINGLE_USE, - label: translate('workspace.card.issueNewCard.singleUse'), - description: translate('workspace.card.issueNewCard.singleUseDescription'), - keyForList: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SINGLE_USE, - isSelected: typeSelected === CONST.EXPENSIFY_CARD.LIMIT_TYPES.SINGLE_USE, - }); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, [INPUT_IDS.LIMIT], translate); + + // We only want integers to be sent as the limit + if (!Number(values.limit)) { + errors.limit = translate('iou.error.invalidAmount'); + } else if (!Number.isInteger(Number(values.limit))) { + errors.limit = translate('iou.error.invalidIntegerAmount'); } - return options; - }, [areApprovalsConfigured, expensifyIcons.Lock, issueNewCard?.data?.cardType, theme.heading, theme.icon, translate, typeSelected, workspaceWorkflowsPageURL]); - - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, [INPUT_IDS.LIMIT], translate); - - // We only want integers to be sent as the limit - if (!Number(values.limit)) { - errors.limit = translate('iou.error.invalidAmount'); - } else if (!Number.isInteger(Number(values.limit))) { - errors.limit = translate('iou.error.invalidIntegerAmount'); - } - - if (Number(values.limit) > CONST.EXPENSIFY_CARD.LIMIT_VALUE) { - errors.limit = translate('workspace.card.issueNewCard.cardLimitError'); - } - return errors; - }, - [translate], - ); + + if (Number(values.limit) > CONST.EXPENSIFY_CARD.LIMIT_VALUE) { + errors.limit = translate('workspace.card.issueNewCard.cardLimitError'); + } + return errors; + }; return ( detail?.login === issueNewCard?.data?.assigneeEmail); const assigneeTimeZone = assigneePersonalDetails?.timezone?.selected; - const minDate = useMemo(() => { - if (!assigneeTimeZone) { - return new Date(); - } - return toZonedTime(new Date(), assigneeTimeZone); - }, [assigneeTimeZone]); + const minDate = !assigneeTimeZone ? new Date() : toZonedTime(new Date(), assigneeTimeZone); const spendRuleTabs = [ { @@ -171,7 +166,7 @@ function SetSpendRulesStep({policyID, stepNames, startStepIndex}: SetSpendRulesS const hasAnyMerchant = !!spendRuleForm.merchantNames?.some((name) => name.trim() !== '') && !isRestrictMerchantsOff; const hasAnyRuleApplied = hasAnyMerchant || hasAnyCategory || hasMaxAmount || hasAnyCurrency; - const getSpendRuleErrorMessage = useCallback(() => { + const getSpendRuleErrorMessage = () => { if (!spendRuleEnabled) { return ''; } @@ -185,7 +180,7 @@ function SetSpendRulesStep({policyID, stepNames, startStepIndex}: SetSpendRulesS } return ''; - }, [hasAnyRuleApplied, spendRuleEnabled, spendRuleID, spendRuleOption, translate]); + }; const submit = (values: FormOnyxValues) => { const formSpendRuleError = getSpendRuleErrorMessage(); diff --git a/src/pages/workspace/fields/CreateFieldsPage.tsx b/src/pages/workspace/fields/CreateFieldsPage.tsx index 171d0ad356e8..36ae2013625e 100644 --- a/src/pages/workspace/fields/CreateFieldsPage.tsx +++ b/src/pages/workspace/fields/CreateFieldsPage.tsx @@ -34,7 +34,7 @@ import type {PolicyReportFieldType} from '@src/types/onyx/Policy'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import React, {useCallback, useRef} from 'react'; +import React, {useRef} from 'react'; import {View} from 'react-native'; type CreateFieldsPageProps = { @@ -57,36 +57,30 @@ function CreateFieldsPage({policy, policyID, isInvoiceField, listValuesRoute, ge const reportTypeForTarget = isInvoiceField ? CONST.REPORT.TYPE.INVOICE : CONST.REPORT.TYPE.EXPENSE; const fieldTarget = isInvoiceField ? CONST.REPORT_FIELD_TARGETS.INVOICE : CONST.REPORT_FIELD_TARGETS.EXPENSE; - const policyReportIDsSelector = useCallback( - (reports: OnyxCollection) => - Object.values(reports ?? {}) - .filter((report) => report?.policyID === policyID && report.type === reportTypeForTarget) - .map((report) => report?.reportID), - [policyID, reportTypeForTarget], - ); + const policyReportIDsSelector = (reports: OnyxCollection) => + Object.values(reports ?? {}) + .filter((report) => report?.policyID === policyID && report.type === reportTypeForTarget) + .map((report) => report?.reportID); const [policyReportIDs] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: policyReportIDsSelector}); const availableListValuesLength = (formDraft?.[INPUT_IDS.DISABLED_LIST_VALUES] ?? []).filter((disabledListValue) => !disabledListValue).length; - const submitForm = useCallback( - (values: FormOnyxValues) => { - const shouldClearListInitialValue = values[INPUT_IDS.TYPE] === CONST.REPORT_FIELD_TYPES.LIST && availableListValuesLength === 0; - createReportField({ - policy, - name: values[INPUT_IDS.NAME], - type: values[INPUT_IDS.TYPE], - initialValue: shouldClearListInitialValue ? '' : values[INPUT_IDS.INITIAL_VALUE], - listValues: formDraft?.[INPUT_IDS.LIST_VALUES] ?? [], - disabledListValues: formDraft?.[INPUT_IDS.DISABLED_LIST_VALUES] ?? [], - policyReportIDs, - isInvoiceField, - }); - setInitialCreateReportFieldsForm(); - Navigation.goBack(); - }, - [availableListValuesLength, formDraft, isInvoiceField, policy, policyReportIDs], - ); + const submitForm = (values: FormOnyxValues) => { + const shouldClearListInitialValue = values[INPUT_IDS.TYPE] === CONST.REPORT_FIELD_TYPES.LIST && availableListValuesLength === 0; + createReportField({ + policy, + name: values[INPUT_IDS.NAME], + type: values[INPUT_IDS.TYPE], + initialValue: shouldClearListInitialValue ? '' : values[INPUT_IDS.INITIAL_VALUE], + listValues: formDraft?.[INPUT_IDS.LIST_VALUES] ?? [], + disabledListValues: formDraft?.[INPUT_IDS.DISABLED_LIST_VALUES] ?? [], + policyReportIDs, + isInvoiceField, + }); + setInitialCreateReportFieldsForm(); + Navigation.goBack(); + }; const getExistingFieldNameError = (name: string) => { const existingField = getExistingReportFieldByName(policy?.fieldList, name); diff --git a/src/pages/workspace/fields/FieldsAddListValuePage.tsx b/src/pages/workspace/fields/FieldsAddListValuePage.tsx index 0d6dfe262ac3..9caea0b6ddee 100644 --- a/src/pages/workspace/fields/FieldsAddListValuePage.tsx +++ b/src/pages/workspace/fields/FieldsAddListValuePage.tsx @@ -29,7 +29,7 @@ import type {Policy} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {Keyboard} from 'react-native'; type FieldsAddListValuePageProps = { @@ -53,38 +53,25 @@ function FieldsAddListValuePage({policy, policyID, reportFieldID, featureName, p const draftListValues = formDraft?.[INPUT_IDS.LIST_VALUES]; const draftDisabledListValues = formDraft?.[INPUT_IDS.DISABLED_LIST_VALUES]; - const listValues = useMemo(() => { - let reportFieldListValues: string[]; + const reportFieldListValues: string[] = reportFieldID ? Object.values(reportField?.values ?? {}) : (draftListValues ?? []); + const listValues = reportFieldListValues; + + const validate = (values: FormOnyxValues) => + validateReportFieldListValueName(values[INPUT_IDS.VALUE_NAME].trim(), '', listValues, INPUT_IDS.VALUE_NAME, translate); + + const createValue = (values: FormOnyxValues) => { if (reportFieldID) { - reportFieldListValues = Object.values(reportField?.values ?? {}); + addReportFieldListValue({policy, reportFieldID, valueName: values[INPUT_IDS.VALUE_NAME]}); } else { - reportFieldListValues = draftListValues ?? []; + createReportFieldsListValue({ + valueName: values[INPUT_IDS.VALUE_NAME], + listValues: draftListValues ?? [], + disabledListValues: draftDisabledListValues ?? [], + }); } - return reportFieldListValues; - }, [draftListValues, reportField?.values, reportFieldID]); - - const validate = useCallback( - (values: FormOnyxValues) => - validateReportFieldListValueName(values[INPUT_IDS.VALUE_NAME].trim(), '', listValues, INPUT_IDS.VALUE_NAME, translate), - [listValues, translate], - ); - - const createValue = useCallback( - (values: FormOnyxValues) => { - if (reportFieldID) { - addReportFieldListValue({policy, reportFieldID, valueName: values[INPUT_IDS.VALUE_NAME]}); - } else { - createReportFieldsListValue({ - valueName: values[INPUT_IDS.VALUE_NAME], - listValues: draftListValues ?? [], - disabledListValues: draftDisabledListValues ?? [], - }); - } - Keyboard.dismiss(); - Navigation.goBack(); - }, - [draftDisabledListValues, draftListValues, policy, reportFieldID], - ); + Keyboard.dismiss(); + Navigation.goBack(); + }; if (isReportFieldInvalid) { return ; diff --git a/src/pages/workspace/fields/FieldsEditValuePage.tsx b/src/pages/workspace/fields/FieldsEditValuePage.tsx index cca8f40c15df..70c2c8388bfc 100644 --- a/src/pages/workspace/fields/FieldsEditValuePage.tsx +++ b/src/pages/workspace/fields/FieldsEditValuePage.tsx @@ -27,7 +27,7 @@ import type {Policy} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import React, {useCallback} from 'react'; +import React from 'react'; import {Keyboard} from 'react-native'; type FieldsEditValuePageProps = { @@ -48,27 +48,21 @@ function FieldsEditValuePage({policy, policyID, valueIndex, featureName, policyF const currentValueName = formDraft?.listValues?.[valueIndex] ?? ''; - const validate = useCallback( - (values: FormOnyxValues) => - validateReportFieldListValueName(values[INPUT_IDS.NEW_VALUE_NAME].trim(), currentValueName, formDraft?.[INPUT_IDS.LIST_VALUES] ?? [], INPUT_IDS.NEW_VALUE_NAME, translate), - [currentValueName, formDraft, translate], - ); + const validate = (values: FormOnyxValues) => + validateReportFieldListValueName(values[INPUT_IDS.NEW_VALUE_NAME].trim(), currentValueName, formDraft?.[INPUT_IDS.LIST_VALUES] ?? [], INPUT_IDS.NEW_VALUE_NAME, translate); - const editValue = useCallback( - (values: FormOnyxValues) => { - const valueName = values[INPUT_IDS.NEW_VALUE_NAME]?.trim(); - if (currentValueName !== valueName) { - renameReportFieldsListValue({ - valueIndex, - newValueName: valueName, - listValues: formDraft?.[INPUT_IDS.LIST_VALUES] ?? [], - }); - } - Keyboard.dismiss(); - Navigation.goBack(); - }, - [currentValueName, formDraft, valueIndex], - ); + const editValue = (values: FormOnyxValues) => { + const valueName = values[INPUT_IDS.NEW_VALUE_NAME]?.trim(); + if (currentValueName !== valueName) { + renameReportFieldsListValue({ + valueIndex, + newValueName: valueName, + listValues: formDraft?.[INPUT_IDS.LIST_VALUES] ?? [], + }); + } + Keyboard.dismiss(); + Navigation.goBack(); + }; return ( ) => { - if (currentInitialValue !== values.initialValue) { - updateReportFieldInitialValue({policy, reportFieldID, newInitialValue: values.initialValue}); - } - Navigation.goBack(); - }, - [currentInitialValue, policy, reportFieldID], - ); + const submitForm = (values: FormOnyxValues) => { + if (currentInitialValue !== values.initialValue) { + updateReportFieldInitialValue({policy, reportFieldID, newInitialValue: values.initialValue}); + } + Navigation.goBack(); + }; const submitListValueUpdate = (value: string) => { updateReportFieldInitialValue({policy, reportFieldID, newInitialValue: currentInitialValue === value ? '' : value}); Navigation.goBack(); }; - const validateForm = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const {initialValue: formInitialValue} = values; - const errors: FormInputErrors = {}; - - if (formInitialValue.length > CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH) { - errors[INPUT_IDS.INITIAL_VALUE] = translate('common.error.characterLimitExceedCounter', formInitialValue.length, CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH); - } - - if ( - (reportField?.type === CONST.REPORT_FIELD_TYPES.TEXT || reportField?.type === CONST.REPORT_FIELD_TYPES.FORMULA) && - hasCircularReferences(formInitialValue, reportField?.name, getReportFieldsForTarget(policy?.fieldList, expectedTarget)) - ) { - errors[INPUT_IDS.INITIAL_VALUE] = translate('workspace.reportFields.circularReferenceError'); - } - - if ((reportField?.type === CONST.REPORT_FIELD_TYPES.TEXT || reportField?.type === CONST.REPORT_FIELD_TYPES.FORMULA) && !!formInitialValue && !errors[INPUT_IDS.INITIAL_VALUE]) { - const unsupportedFormulaParts = getUnsupportedReportFieldFormulaParts(formInitialValue); - if (unsupportedFormulaParts.length > 0) { - errors[INPUT_IDS.INITIAL_VALUE] = translate('workspace.reportFields.unsupportedFormulaValueError', unsupportedFormulaParts.join(', ')); - } + const validateForm = (values: FormOnyxValues): FormInputErrors => { + const {initialValue: formInitialValue} = values; + const errors: FormInputErrors = {}; + + if (formInitialValue.length > CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH) { + errors[INPUT_IDS.INITIAL_VALUE] = translate('common.error.characterLimitExceedCounter', formInitialValue.length, CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH); + } + + if ( + (reportField?.type === CONST.REPORT_FIELD_TYPES.TEXT || reportField?.type === CONST.REPORT_FIELD_TYPES.FORMULA) && + hasCircularReferences(formInitialValue, reportField?.name, getReportFieldsForTarget(policy?.fieldList, expectedTarget)) + ) { + errors[INPUT_IDS.INITIAL_VALUE] = translate('workspace.reportFields.circularReferenceError'); + } + + if ((reportField?.type === CONST.REPORT_FIELD_TYPES.TEXT || reportField?.type === CONST.REPORT_FIELD_TYPES.FORMULA) && !!formInitialValue && !errors[INPUT_IDS.INITIAL_VALUE]) { + const unsupportedFormulaParts = getUnsupportedReportFieldFormulaParts(formInitialValue); + if (unsupportedFormulaParts.length > 0) { + errors[INPUT_IDS.INITIAL_VALUE] = translate('workspace.reportFields.unsupportedFormulaValueError', unsupportedFormulaParts.join(', ')); } + } - if (reportField?.type === CONST.REPORT_FIELD_TYPES.LIST && hasAvailableListValues && !isRequiredFulfilled(formInitialValue)) { - errors[INPUT_IDS.INITIAL_VALUE] = translate( - isInvoiceField ? 'workspace.invoiceFields.invoiceFieldInitialValueRequiredError' : 'workspace.reportFields.reportFieldInitialValueRequiredError', - ); - } + if (reportField?.type === CONST.REPORT_FIELD_TYPES.LIST && hasAvailableListValues && !isRequiredFulfilled(formInitialValue)) { + errors[INPUT_IDS.INITIAL_VALUE] = translate( + isInvoiceField ? 'workspace.invoiceFields.invoiceFieldInitialValueRequiredError' : 'workspace.reportFields.reportFieldInitialValueRequiredError', + ); + } - return errors; - }, - [hasAvailableListValues, expectedTarget, isInvoiceField, reportField?.name, reportField?.type, policy?.fieldList, translate], - ); + return errors; + }; if (!reportField || !isReportFieldTargetValid(reportField, expectedTarget)) { return ; diff --git a/src/pages/workspace/fields/FieldsListValuesPage.tsx b/src/pages/workspace/fields/FieldsListValuesPage.tsx index 40e3b3f9a77f..6148e2019f71 100644 --- a/src/pages/workspace/fields/FieldsListValuesPage.tsx +++ b/src/pages/workspace/fields/FieldsListValuesPage.tsx @@ -46,7 +46,7 @@ import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type FieldsListValuesPageProps = { @@ -102,83 +102,64 @@ function FieldsListValuesPage({policy, policyID, reportFieldID, isInvoicePage, f const isReportFieldInvalid = !!reportFieldID && (!reportField || !isReportFieldTargetValid(reportField, expectedTarget)); const listInputSubtitleKey = isInvoicePage ? 'workspace.invoiceFields.listInputSubtitle' : 'workspace.reportFields.listInputSubtitle'; - const [listValues, disabledListValues] = useMemo(() => { - let reportFieldValues: string[]; - let reportFieldDisabledValues: boolean[]; + let reportFieldValues: string[]; + let reportFieldDisabledValues: boolean[]; + if (reportFieldID) { + const reportFieldKey = getReportFieldKey(reportFieldID); + reportFieldValues = Object.values(policy?.fieldList?.[reportFieldKey]?.values ?? {}); + reportFieldDisabledValues = Object.values(policy?.fieldList?.[reportFieldKey]?.disabledOptions ?? {}); + } else { + reportFieldValues = formDraft?.listValues ?? []; + reportFieldDisabledValues = formDraft?.disabledListValues ?? []; + } + const [listValues, disabledListValues] = [reportFieldValues, reportFieldDisabledValues]; + const updateReportFieldListValueEnabled = (value: boolean, valueIndex: number) => { if (reportFieldID) { - const reportFieldKey = getReportFieldKey(reportFieldID); - - reportFieldValues = Object.values(policy?.fieldList?.[reportFieldKey]?.values ?? {}); - reportFieldDisabledValues = Object.values(policy?.fieldList?.[reportFieldKey]?.disabledOptions ?? {}); - } else { - reportFieldValues = formDraft?.listValues ?? []; - reportFieldDisabledValues = formDraft?.disabledListValues ?? []; - } - - return [reportFieldValues, reportFieldDisabledValues]; - }, [formDraft?.disabledListValues, formDraft?.listValues, policy?.fieldList, reportFieldID]); - - const updateReportFieldListValueEnabled = useCallback( - (value: boolean, valueIndex: number) => { - if (reportFieldID) { - updateReportFieldListValueEnabledReportField({ - policy, - reportFieldID, - valueIndexes: [Number(valueIndex)], - enabled: value, - }); - return; - } - - setReportFieldsListValueEnabled({ - valueIndexes: [valueIndex], + updateReportFieldListValueEnabledReportField({ + policy, + reportFieldID, + valueIndexes: [Number(valueIndex)], enabled: value, - disabledListValues, }); - }, - [disabledListValues, policy, reportFieldID], - ); - - const openListValuePage = useCallback( - (valueIndex: number) => { - Navigation.navigate(getValueSettingsRoute(isInvoicePage, policyID, valueIndex, reportFieldID)); - }, - [getValueSettingsRoute, isInvoicePage, policyID, reportFieldID], - ); + return; + } - const listValueRows = useMemo( - () => - listValues.map((value, index) => ({ - keyForList: value, - value, - name: value, - index, - enabled: !disabledListValues.at(index), - isLocked: !canWrite, - isSwitchDisabled: !canWrite, - action: () => openListValuePage(index), - onToggleEnabled: (enabled: boolean) => updateReportFieldListValueEnabled(enabled, index), - onDisabledSwitchPress: withReadOnlyFallback(), - })), - [canWrite, disabledListValues, listValues, openListValuePage, updateReportFieldListValueEnabled, withReadOnlyFallback], - ); + setReportFieldsListValueEnabled({ + valueIndexes: [valueIndex], + enabled: value, + disabledListValues, + }); + }; - const listValueRowsKeyed = useMemo( - () => - listValueRows.reduce>((acc, row) => { - acc[row.keyForList] = row; - return acc; - }, {}), - [listValueRows], - ); + const openListValuePage = (valueIndex: number) => { + Navigation.navigate(getValueSettingsRoute(isInvoicePage, policyID, valueIndex, reportFieldID)); + }; - const filterListValueRow = useCallback((row?: ReportFieldListValueRowData) => !!row, []); + const listValueRows: ReportFieldListValueRowData[] = listValues.map((value, index) => ({ + keyForList: value, + value, + name: value, + index, + enabled: !disabledListValues.at(index), + isLocked: !canWrite, + isSwitchDisabled: !canWrite, + action: () => openListValuePage(index), + onToggleEnabled: (enabled: boolean) => updateReportFieldListValueEnabled(enabled, index), + onDisabledSwitchPress: withReadOnlyFallback(), + })); + + const listValueRowsKeyed = listValueRows.reduce>((acc, row) => { + acc[row.keyForList] = row; + return acc; + }, {}); + + const filterListValueRow = (row?: ReportFieldListValueRowData) => !!row; const [selectedKeys, setSelectedKeys] = useFilteredSelection(listValueRowsKeyed, filterListValueRow); - const clearTableSelection = useCallback(() => { + const clearTableSelection = () => { setSelectedKeys((prevSelectedKeys) => (prevSelectedKeys.length > 0 ? [] : prevSelectedKeys)); - }, [setSelectedKeys]); + }; useCleanupSelectedOptions(clearTableSelection); @@ -189,7 +170,7 @@ function FieldsListValuesPage({policy, policyID, reportFieldID, isInvoicePage, f const icons = useMemoizedLazyExpensifyIcons(['Checkmark', 'Close', 'Plus', 'Trashcan']); - const handleDeleteValues = useCallback(() => { + const handleDeleteValues = () => { const valuesToDelete = selectedKeys.map((value) => listValues.indexOf(value)).filter((index) => index !== -1); if (reportFieldID) { @@ -207,7 +188,7 @@ function FieldsListValuesPage({policy, policyID, reportFieldID, isInvoicePage, f } clearTableSelection(); - }, [clearTableSelection, disabledListValues, listValues, policy, reportFieldID, selectedKeys]); + }; const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); diff --git a/src/pages/workspace/fields/FieldsValueSettingsPage.tsx b/src/pages/workspace/fields/FieldsValueSettingsPage.tsx index 5a7cef22e23b..6a0d710ad3cd 100644 --- a/src/pages/workspace/fields/FieldsValueSettingsPage.tsx +++ b/src/pages/workspace/fields/FieldsValueSettingsPage.tsx @@ -31,7 +31,7 @@ import type {Policy} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type FieldsValueSettingsPageProps = { @@ -54,22 +54,17 @@ function FieldsValueSettingsPage({policy, policyID, valueIndex, reportFieldID, i const [formDraft] = useOnyx(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT); const {canWrite, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, policyFeature); - const [currentValueName, currentValueDisabled] = useMemo(() => { - let reportFieldValue: string; - let reportFieldDisabledValue: boolean; - - if (reportFieldID) { - const reportFieldKey = getReportFieldKey(reportFieldID); - - reportFieldValue = Object.values(policy?.fieldList?.[reportFieldKey]?.values ?? {})?.at(valueIndex) ?? ''; - reportFieldDisabledValue = Object.values(policy?.fieldList?.[reportFieldKey]?.disabledOptions ?? {})?.at(valueIndex) ?? false; - } else { - reportFieldValue = formDraft?.listValues?.[valueIndex] ?? ''; - reportFieldDisabledValue = formDraft?.disabledListValues?.[valueIndex] ?? false; - } - - return [reportFieldValue, reportFieldDisabledValue]; - }, [formDraft?.disabledListValues, formDraft?.listValues, policy?.fieldList, reportFieldID, valueIndex]); + let reportFieldValue: string; + let reportFieldDisabledValue: boolean; + if (reportFieldID) { + const reportFieldKey = getReportFieldKey(reportFieldID); + reportFieldValue = Object.values(policy?.fieldList?.[reportFieldKey]?.values ?? {})?.at(valueIndex) ?? ''; + reportFieldDisabledValue = Object.values(policy?.fieldList?.[reportFieldKey]?.disabledOptions ?? {})?.at(valueIndex) ?? false; + } else { + reportFieldValue = formDraft?.listValues?.[valueIndex] ?? ''; + reportFieldDisabledValue = formDraft?.disabledListValues?.[valueIndex] ?? false; + } + const [currentValueName, currentValueDisabled] = [reportFieldValue, reportFieldDisabledValue]; const reportField = reportFieldID ? policy?.fieldList?.[getReportFieldKey(reportFieldID)] : undefined; const isImportedReportField = isReportFieldImportedFromIntegration(reportField, policy); diff --git a/src/pages/workspace/fields/WorkspaceFieldsSection.tsx b/src/pages/workspace/fields/WorkspaceFieldsSection.tsx index 153549291517..ca80432391cc 100644 --- a/src/pages/workspace/fields/WorkspaceFieldsSection.tsx +++ b/src/pages/workspace/fields/WorkspaceFieldsSection.tsx @@ -39,7 +39,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {FlashList} from '@shopify/flash-list'; import {Str} from 'expensify-common'; -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; type FieldListValue = NonNullable[string]; @@ -117,15 +117,12 @@ function WorkspaceFieldsSection({ const isConnectionVerified = connectedIntegration && !isConnectionUnverified(policy, connectedIntegration); const currentConnectionName = getCurrentAccountingIntegrationName(policy, translate); const fieldList = policy?.fieldList; - const hasImportedField = useMemo( - () => Object.values(fieldList ?? {}).some((field) => fieldFilter(field) && isReportFieldImportedFromIntegration(field, policy)), - [fieldFilter, fieldList, policy], - ); + const hasImportedField = Object.values(fieldList ?? {}).some((field) => fieldFilter(field) && isReportFieldImportedFromIntegration(field, policy)); const {canWrite, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, policyFeature); - const fetchFields = useCallback(() => { + const fetchFields = () => { openFieldsPage(policyID); - }, [openFieldsPage, policyID]); + }; const {isOffline} = useNetwork({onReconnect: fetchFields}); @@ -133,51 +130,41 @@ function WorkspaceFieldsSection({ fetchFields(); }, [fetchFields]); - const fields = useMemo(() => { - if (!fieldList) { - return []; - } + const fields: FieldListItem[] = !fieldList + ? [] + : Object.values(fieldList) + .filter(fieldFilter) + .sort((a, b) => localeCompare(a.name, b.name)) + .map((field) => ({ + text: field.name, + keyForList: String(field.fieldID), + fieldID: field.fieldID, + pendingAction: field.pendingAction, + isDisabled: field.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + rightLabel: Str.recapitalize(translate(getReportFieldTypeTranslationKey(field.type ?? CONST.REPORT_FIELD_TYPES.TEXT))), + })); - return Object.values(fieldList) - .filter(fieldFilter) - .sort((a, b) => localeCompare(a.name, b.name)) - .map((field) => ({ - text: field.name, - keyForList: String(field.fieldID), - fieldID: field.fieldID, - pendingAction: field.pendingAction, - isDisabled: field.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - rightLabel: Str.recapitalize(translate(getReportFieldTypeTranslationKey(field.type ?? CONST.REPORT_FIELD_TYPES.TEXT))), - })); - }, [fieldFilter, fieldList, localeCompare, translate]); - - const navigateToFieldSettings = useCallback( - (item: FieldListItem) => { - if (!canWrite) { - return; - } + const navigateToFieldSettings = (item: FieldListItem) => { + if (!canWrite) { + return; + } - Navigation.navigate(getSettingsRoute(policyID, item.fieldID)); - }, - [canWrite, getSettingsRoute, policyID], - ); + Navigation.navigate(getSettingsRoute(policyID, item.fieldID)); + }; - const renderItem = useCallback( - ({item}: ListRenderItemInfo) => ( - - navigateToFieldSettings(item)} - description={item.text} - disabled={item.isDisabled} - shouldShowRightIcon={!item.isDisabled && canWrite} - interactive={!item.isDisabled && canWrite} - rightLabel={item.rightLabel} - descriptionTextStyle={[styles.popoverMenuText, styles.textStrong]} - /> - - ), - [canWrite, navigateToFieldSettings, cardPaddingHorizontal, styles.popoverMenuText, styles.textStrong], + const renderItem = ({item}: ListRenderItemInfo) => ( + + navigateToFieldSettings(item)} + description={item.text} + disabled={item.isDisabled} + shouldShowRightIcon={!item.isDisabled && canWrite} + interactive={!item.isDisabled && canWrite} + rightLabel={item.rightLabel} + descriptionTextStyle={[styles.popoverMenuText, styles.textStrong]} + /> + ); const headerText = diff --git a/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx b/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx index a5d40240b47d..1ed803f45296 100644 --- a/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx +++ b/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx @@ -28,7 +28,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import type SCREENS from '@src/SCREENS'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; type MergeHRGroupsPageProps = PlatformStackScreenProps; @@ -112,18 +112,15 @@ function MergeHRGroupsPage({ Navigation.goBack(); }; - const listEmptyContent = useMemo( - () => ( - - ), - [illustrations.Telescope, translate, styles.pb10], + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/invoices/WorkspaceInvoiceVBASection.tsx b/src/pages/workspace/invoices/WorkspaceInvoiceVBASection.tsx index d919668cd264..80e9aa6ad437 100644 --- a/src/pages/workspace/invoices/WorkspaceInvoiceVBASection.tsx +++ b/src/pages/workspace/invoices/WorkspaceInvoiceVBASection.tsx @@ -33,7 +33,7 @@ import ROUTES from '@src/ROUTES'; import type {TupleToUnion} from 'type-fest'; -import React, {useCallback, useMemo, useRef} from 'react'; +import React, {useRef} from 'react'; type WorkspaceInvoiceVBASectionProps = { /** The policy ID currently being configured */ @@ -81,17 +81,15 @@ function WorkspaceInvoiceVBASection({policyID, canWriteMoreFeatures, showReadOnl const isNonUSDWorkspace = policy?.outputCurrency !== CONST.CURRENCY.USD; const achData = reimbursementAccount?.achData; - const shouldShowContinueModal = useMemo(() => { - return hasInProgressVBBA(achData, isNonUSDWorkspace, policyID); - }, [achData, isNonUSDWorkspace, policyID]); + const shouldShowContinueModal = hasInProgressVBBA(achData, isNonUSDWorkspace, policyID); - const confirmCurrencyChangeAndHideModal = useCallback(() => { + const confirmCurrencyChangeAndHideModal = () => { if (!policy) { return; } Navigation.navigate(ROUTES.WORKSPACE_OVERVIEW_CURRENCY.getRoute(policy.id, {isForcedToChangeCurrency: true})); - }, [policy]); + }; /** * Display the delete/default menu, or the add payment method menu @@ -122,7 +120,7 @@ function WorkspaceInvoiceVBASection({policyID, canWriteMoreFeatures, showReadOnl } }; - const deletePaymentMethod = useCallback(() => { + const deletePaymentMethod = () => { const bankAccountID = paymentMethod.selectedPaymentMethod.bankAccountID; if (paymentMethod.selectedPaymentMethodType !== CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT || !bankAccountID) { return; @@ -134,14 +132,14 @@ function WorkspaceInvoiceVBASection({policyID, canWriteMoreFeatures, showReadOnl } deletePaymentBankAccount(bankAccountID, personalPolicyID); - }, [availableBankAccounts, paymentMethod.selectedPaymentMethod.bankAccountID, paymentMethod.selectedPaymentMethodType, personalPolicyID, policyID, transferBankAccountID]); + }; - const makeDefaultPaymentMethod = useCallback(() => { + const makeDefaultPaymentMethod = () => { if (paymentMethod.selectedPaymentMethodType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT && selectedAvailableBankAccountID !== undefined) { setInvoicingTransferBankAccount(selectedAvailableBankAccountID, policyID, transferBankAccountID); } resetSelectedPaymentMethodData(); - }, [paymentMethod.selectedPaymentMethodType, selectedAvailableBankAccountID, policyID, transferBankAccountID, resetSelectedPaymentMethodData]); + }; const onBankAccountRowPressed = ({accountData}: PaymentMethodPressHandlerParams) => { const accountPolicyID = accountData?.additionalData?.policyID; @@ -194,81 +192,54 @@ function WorkspaceInvoiceVBASection({policyID, canWriteMoreFeatures, showReadOnl continueAddBankAccountFlow(); }; - const threeDotsMenuItems = useMemo(() => { - const items: PopoverMenuItem[] = []; - - if (isPopoverBottomMount) { - items.push({ - text: paymentMethod.formattedSelectedPaymentMethod.title, - icon: paymentMethod.formattedSelectedPaymentMethod.icon?.icon, - numberOfLinesTitle: 0, - iconHeight: paymentMethod.formattedSelectedPaymentMethod.icon?.iconHeight ?? paymentMethod.formattedSelectedPaymentMethod.icon?.iconSize, - iconWidth: paymentMethod.formattedSelectedPaymentMethod.icon?.iconWidth ?? paymentMethod.formattedSelectedPaymentMethod.icon?.iconSize, - iconStyles: paymentMethod.formattedSelectedPaymentMethod.icon?.iconStyles, - description: paymentMethod.formattedSelectedPaymentMethod.description, - wrapperStyle: [styles.mb1, styles.ph5, styles.pt5], - interactive: false, - displayInDefaultIconColor: true, - }); - } - - if (shouldShowMakeDefaultButton) { - items.push({ - text: translate('walletPage.setDefaultConfirmation'), - icon: icons.Star, - onSelected: () => { - makeDefaultPaymentMethod(); - }, - numberOfLinesTitle: 0, - }); - } - + const items: PopoverMenuItem[] = []; + if (isPopoverBottomMount) { + items.push({ + text: paymentMethod.formattedSelectedPaymentMethod.title, + icon: paymentMethod.formattedSelectedPaymentMethod.icon?.icon, + numberOfLinesTitle: 0, + iconHeight: paymentMethod.formattedSelectedPaymentMethod.icon?.iconHeight ?? paymentMethod.formattedSelectedPaymentMethod.icon?.iconSize, + iconWidth: paymentMethod.formattedSelectedPaymentMethod.icon?.iconWidth ?? paymentMethod.formattedSelectedPaymentMethod.icon?.iconSize, + iconStyles: paymentMethod.formattedSelectedPaymentMethod.icon?.iconStyles, + description: paymentMethod.formattedSelectedPaymentMethod.description, + wrapperStyle: [styles.mb1, styles.ph5, styles.pt5], + interactive: false, + displayInDefaultIconColor: true, + }); + } + if (shouldShowMakeDefaultButton) { items.push({ - text: translate('common.delete'), - icon: icons.Trashcan, + text: translate('walletPage.setDefaultConfirmation'), + icon: icons.Star, onSelected: () => { - closeModal(() => { - showConfirmModal({ - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - title: translate('walletPage.deleteAccount'), - prompt: translate('walletPage.deleteConfirmation'), - confirmText: translate('common.delete'), - cancelText: translate('common.cancel'), - shouldShowCancelButton: true, - onModalHide: resetSelectedPaymentMethodData, - }).then((result) => { - if (result.action !== ModalActions.CONFIRM) { - return; - } - - deletePaymentMethod(); - }); - }); + makeDefaultPaymentMethod(); }, + numberOfLinesTitle: 0, }); - - return items; - }, [ - isPopoverBottomMount, - shouldShowMakeDefaultButton, - translate, - icons.Trashcan, - icons.Star, - paymentMethod.formattedSelectedPaymentMethod.title, - paymentMethod.formattedSelectedPaymentMethod.icon?.icon, - paymentMethod.formattedSelectedPaymentMethod.icon?.iconHeight, - paymentMethod.formattedSelectedPaymentMethod.icon?.iconSize, - paymentMethod.formattedSelectedPaymentMethod.icon?.iconWidth, - paymentMethod.formattedSelectedPaymentMethod.icon?.iconStyles, - paymentMethod.formattedSelectedPaymentMethod.description, - styles.mb1, - styles.ph5, - styles.pt5, - makeDefaultPaymentMethod, - showConfirmModal, - deletePaymentMethod, - resetSelectedPaymentMethodData, - ]); + } + items.push({ + text: translate('common.delete'), + icon: icons.Trashcan, + onSelected: () => { + closeModal(() => { + showConfirmModal({ + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + title: translate('walletPage.deleteAccount'), + prompt: translate('walletPage.deleteConfirmation'), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + shouldShowCancelButton: true, + onModalHide: resetSelectedPaymentMethodData, + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + deletePaymentMethod(); + }); + }); + }, + }); + const threeDotsMenuItems = items; return (
          ; @@ -77,7 +77,7 @@ function ImportedMembersPage({route}: ImportedMembersPageProps) { // checks if all required columns are mapped and no column is mapped more than once // returns found errors or empty object if both conditions are met - const validate = useCallback(() => { + const validate = () => { const columns = Object.values(spreadsheet?.columns ?? {}); let errors: Record = {}; const missingRequiredColumns = requiredColumns.find((requiredColumn) => !columns.includes(requiredColumn.value)); @@ -104,7 +104,7 @@ function ImportedMembersPage({route}: ImportedMembersPageProps) { } return errors; - }, [containsHeader, currency, currencySymbol, getCurrencyDecimals, requiredColumns, spreadsheet?.columns, spreadsheet?.data, translate]); + }; const closeImportPageAndModal = () => { setIsClosing(true); diff --git a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx index ff2caf952955..0d8e327914a3 100644 --- a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx +++ b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx @@ -62,7 +62,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {GestureResponderEvent} from 'react-native/Libraries/Types/CoreEventTypes'; import {Str} from 'expensify-common'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {Keyboard, View} from 'react-native'; type WorkspaceInviteMessageComponentProps = { @@ -172,14 +172,11 @@ function WorkspaceInviteMessageComponent({ }) .join(', '); - const welcomeNoteSubject = useMemo( - () => `# ${currentUserPersonalDetails?.displayName ?? ''} invited you to ${policy?.name ?? 'a workspace'}`, - [policy?.name, currentUserPersonalDetails?.displayName], - ); + const welcomeNoteSubject = `# ${currentUserPersonalDetails?.displayName ?? ''} invited you to ${policy?.name ?? 'a workspace'}`; - const getDefaultWelcomeNote = useCallback(() => { + const getDefaultWelcomeNote = () => { return formData?.[INPUT_IDS.WELCOME_MESSAGE] ?? workspaceInviteMessageDraft ?? translate('workspace.common.welcomeNote'); - }, [workspaceInviteMessageDraft, translate, formData]); + }; useEffect(() => { if (isOnyxLoading) { diff --git a/src/pages/workspace/members/WorkspaceMemberCustomFieldPage.tsx b/src/pages/workspace/members/WorkspaceMemberCustomFieldPage.tsx index a2e61958e358..52d5544e2415 100644 --- a/src/pages/workspace/members/WorkspaceMemberCustomFieldPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberCustomFieldPage.tsx @@ -27,7 +27,7 @@ import type {PersonalDetailsList} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useCallback, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; type WorkspacePolicyOnyxProps = { @@ -50,9 +50,9 @@ function WorkspaceMemberCustomFieldPage({policy, route, personalDetails}: Worksp const [customField, setCustomField] = useState(member?.[customFieldKey ?? ''] ?? ''); const customFieldText = translate(`workspace.common.${customFieldType}`); const policyID = params.policyID; - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); - }, [accountID, policyID]); + }; return ( { + const updateDisplayTexts = () => { const changeOwnerErrors = Object.keys(policy?.errorFields?.changeOwner ?? {}); if (error !== changeOwnerErrors.at(0)) { return; @@ -53,13 +53,13 @@ function WorkspaceOwnerChangeCheck({policy, accountID, error}: WorkspaceOwnerCha const texts = getOwnershipChecksDisplayText(error, translate, convertToDisplayString, policy, userPersonalDetails?.login); setDisplayTexts(texts); - }, [error, userPersonalDetails?.login, policy, translate, convertToDisplayString]); + }; useEffect(() => { updateDisplayTexts(); }, [updateDisplayTexts]); - const confirm = useCallback(() => { + const confirm = () => { if (!policyID) { return; } @@ -72,7 +72,7 @@ function WorkspaceOwnerChangeCheck({policy, accountID, error}: WorkspaceOwnerCha } requestWorkspaceOwnerChange(policy, currentUserPersonalDetails.accountID, currentUserPersonalDetails.login ?? ''); - }, [policy, accountID, error, policyID, currentUserPersonalDetails.accountID, currentUserPersonalDetails.login]); + }; return ( <> diff --git a/src/pages/workspace/members/WorkspaceOwnerPaymentCardForm.tsx b/src/pages/workspace/members/WorkspaceOwnerPaymentCardForm.tsx index ad8139baad34..2d18b0beabbd 100644 --- a/src/pages/workspace/members/WorkspaceOwnerPaymentCardForm.tsx +++ b/src/pages/workspace/members/WorkspaceOwnerPaymentCardForm.tsx @@ -25,7 +25,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {useRoute} from '@react-navigation/native'; -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; type WorkspaceOwnerPaymentCardFormProps = { @@ -41,14 +41,14 @@ function WorkspaceOwnerPaymentCardForm({policy}: WorkspaceOwnerPaymentCardFormPr const {asset: ShieldYellow} = useMemoizedLazyAsset(() => loadIllustration('ShieldYellow' as IllustrationName)); const {accountID: currentUserAccountID, email: currentUserEmail = ''} = useCurrentUserPersonalDetails(); - const checkIfCanBeRendered = useCallback(() => { + const checkIfCanBeRendered = () => { const changeOwnerErrors = Object.keys(policy?.errorFields?.changeOwner ?? {}); if (changeOwnerErrors.at(0) !== CONST.POLICY.OWNERSHIP_ERRORS.NO_BILLING_CARD) { setShouldShowPaymentCardForm(false); } setShouldShowPaymentCardForm(true); - }, [policy?.errorFields?.changeOwner]); + }; useEffect( () => { @@ -67,21 +67,18 @@ function WorkspaceOwnerPaymentCardForm({policy}: WorkspaceOwnerPaymentCardFormPr checkIfCanBeRendered(); }, [checkIfCanBeRendered]); - const addPaymentCard = useCallback( - (values: FormOnyxValues) => { - const cardData = { - cardNumber: getMCardNumberString(values.cardNumber), - cardMonth: getMonthFromExpirationDateString(values.expirationDate), - cardYear: getYearFromExpirationDateString(values.expirationDate), - cardCVV: values.securityCode, - addressName: values.nameOnCard, - addressZip: values.addressZipCode, - currency: values.currency, - }; - addBillingCardAndRequestPolicyOwnerChange(policy, currentUserAccountID, currentUserEmail, cardData, route.name); - }, - [currentUserAccountID, currentUserEmail, policy, route.name], - ); + const addPaymentCard = (values: FormOnyxValues) => { + const cardData = { + cardNumber: getMCardNumberString(values.cardNumber), + cardMonth: getMonthFromExpirationDateString(values.expirationDate), + cardYear: getYearFromExpirationDateString(values.expirationDate), + cardCVV: values.securityCode, + addressName: values.nameOnCard, + addressZip: values.addressZipCode, + currency: values.currency, + }; + addBillingCardAndRequestPolicyOwnerChange(policy, currentUserAccountID, currentUserEmail, cardData, route.name); + }; const icons = useMemoizedLazyExpensifyIcons(['Checkmark']); return ( diff --git a/src/pages/workspace/perDiem/EditPerDiemCurrencyPage.tsx b/src/pages/workspace/perDiem/EditPerDiemCurrencyPage.tsx index 5026420df30b..313d148ca390 100644 --- a/src/pages/workspace/perDiem/EditPerDiemCurrencyPage.tsx +++ b/src/pages/workspace/perDiem/EditPerDiemCurrencyPage.tsx @@ -23,7 +23,7 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; type EditPerDiemCurrencyPageProps = PlatformStackScreenProps; @@ -40,16 +40,13 @@ function EditPerDiemCurrencyPage({route}: EditPerDiemCurrencyPageProps) { const selectedRate = customUnit?.rates?.[rateID]; - const editCurrency = useCallback( - (item: CurrencyListItem) => { - const newCurrency = item.currencyCode; - if (newCurrency !== selectedRate?.currency) { - editPerDiemRateCurrency(policyID, rateID, customUnit, newCurrency); - } - Navigation.goBack(ROUTES.WORKSPACE_PER_DIEM_DETAILS.getRoute(policyID, rateID, subRateID)); - }, - [selectedRate?.currency, policyID, rateID, subRateID, customUnit], - ); + const editCurrency = (item: CurrencyListItem) => { + const newCurrency = item.currencyCode; + if (newCurrency !== selectedRate?.currency) { + editPerDiemRateCurrency(policyID, rateID, customUnit, newCurrency); + } + Navigation.goBack(ROUTES.WORKSPACE_PER_DIEM_DETAILS.getRoute(policyID, rateID, subRateID)); + }; return ( ; @@ -49,30 +49,24 @@ function EditPerDiemDestinationPage({route}: EditPerDiemDestinationPageProps) { const {inputCallbackRef} = useAutoFocusInput(); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; - const nameError = getPerDiemNameError(values.destination); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + const nameError = getPerDiemNameError(values.destination); - if (nameError) { - errors.destination = getPerDiemNameErrorMessage(translate, nameError, values.destination); - } + if (nameError) { + errors.destination = getPerDiemNameErrorMessage(translate, nameError, values.destination); + } - return errors; - }, - [translate], - ); + return errors; + }; - const editDestination = useCallback( - (values: FormOnyxValues) => { - const newDestination = StringUtils.sanitizeName(values.destination); - if (newDestination !== selectedRate?.name) { - editPerDiemRateDestination(policyID, rateID, customUnit, newDestination); - } - Navigation.goBack(ROUTES.WORKSPACE_PER_DIEM_DETAILS.getRoute(policyID, rateID, subRateID)); - }, - [selectedRate?.name, policyID, rateID, subRateID, customUnit], - ); + const editDestination = (values: FormOnyxValues) => { + const newDestination = StringUtils.sanitizeName(values.destination); + if (newDestination !== selectedRate?.name) { + editPerDiemRateDestination(policyID, rateID, customUnit, newDestination); + } + Navigation.goBack(ROUTES.WORKSPACE_PER_DIEM_DETAILS.getRoute(policyID, rateID, subRateID)); + }; return ( { - const customUnits = getPerDiemCustomUnit(policy); - const customUnitRates: Record = customUnits?.rates ?? {}; - const allRates = Object.values(customUnitRates); - const allSubRatesMemo = getSubRatesData(allRates); - return [customUnits, allSubRatesMemo]; - }, [policy]); + const customUnits = getPerDiemCustomUnit(policy); + const customUnitRates: Record = customUnits?.rates ?? {}; + const allRates = Object.values(customUnitRates); + const allSubRatesMemo = getSubRatesData(allRates); + const [customUnit, allSubRates] = [customUnits, allSubRatesMemo]; - const fetchPerDiem = useCallback(() => { + const fetchPerDiem = () => { openPolicyPerDiemPage(policyID); - }, [policyID]); + }; const {isOffline} = useNetwork({onReconnect: fetchPerDiem}); @@ -130,38 +128,33 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { }, [fetchPerDiem]), ); - const selectableSubRatesByKey = useMemo(() => { - const map: Record = {}; - for (const subRate of allSubRates) { - if (subRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - map[getPerDiemRowKey(subRate.rateID, subRate.subRateID)] = subRate; - } + const map: Record = {}; + for (const subRate of allSubRates) { + if (subRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + map[getPerDiemRowKey(subRate.rateID, subRate.subRateID)] = subRate; } - return map; - }, [allSubRates]); + } + const selectableSubRatesByKey = map; - const filterSelectableSubRate = useCallback((subRate?: SubRateData) => !!subRate, []); + const filterSelectableSubRate = (subRate?: SubRateData) => !!subRate; const [selectedSubRateKeys, setSelectedSubRateKeys] = useFilteredSelection(selectableSubRatesByKey, filterSelectableSubRate); - const clearTableSelection = useCallback(() => { + const clearTableSelection = () => { setSelectedSubRateKeys((prev) => (prev.length > 0 ? [] : prev)); - }, [setSelectedSubRateKeys]); + }; useCleanupSelectedOptions(clearTableSelection); - const openSettings = useCallback(() => { + const openSettings = () => { Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_SETTINGS.getRoute(policyID)); - }, [policyID]); + }; - const openSubRateDetails = useCallback( - (rateID: string, subRateID: string) => { - Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_DETAILS.getRoute(policyID, rateID, subRateID)); - }, - [policyID], - ); + const openSubRateDetails = (rateID: string, subRateID: string) => { + Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_DETAILS.getRoute(policyID, rateID, subRateID)); + }; - const showOfflineModal = useCallback(() => { + const showOfflineModal = () => { close(() => { showConfirmModal({ title: translate('common.youAppearToBeOffline'), @@ -170,116 +163,94 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { shouldShowCancelButton: false, }); }); - }, [showConfirmModal, translate]); + }; - const handleDeletePerDiemRates = useCallback(() => { + const handleDeletePerDiemRates = () => { const subRatesToDelete = selectedSubRateKeys.map((rowKey) => selectableSubRatesByKey[rowKey]).filter((subRate): subRate is SubRateData => !!subRate); deleteWorkspacePerDiemRates(policyID, customUnit, subRatesToDelete); setSelectedSubRateKeys([]); turnOffMobileSelectionMode(); - }, [selectedSubRateKeys, selectableSubRatesByKey, policyID, customUnit, setSelectedSubRateKeys]); + }; const hasVisibleSubRates = allSubRates.some((subRate) => subRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline); const isSelectionModeActive = selectedSubRateKeys.length > 0 || isMobileSelectionModeEnabled; - const perDiemRows: PerDiemTableRowData[] = useMemo( - () => - allSubRates - .filter((subRate) => isOffline || subRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) - .map((subRate) => { - const isDeleting = subRate.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - const canEdit = canWritePerDiem && !isDeleting && !isSelectionModeActive; - - return { - keyForList: getPerDiemRowKey(subRate.rateID, subRate.subRateID), - subRateID: subRate.subRateID, - rateID: subRate.rateID, - destination: subRate.destination, - subRateName: subRate.subRateName, - rate: subRate.rate, - currency: subRate.currency, - formattedAmount: convertAmountToDisplayString(subRate.rate, subRate.currency), - disabled: isDeleting, - pendingAction: subRate.pendingAction, - errors: subRate.errors, - canEditDestination: canEdit, - canEditSubrate: canEdit, - canEditAmount: canEdit, - action: () => openSubRateDetails(subRate.rateID, subRate.subRateID), - onRenameDestination: (newName: string) => - showInlineEditError(renamePerDiemDestinationInline(translate, policyID, subRate.rateID, customUnit, subRate.destination, newName)), - onRenameSubrate: (newName: string) => - showInlineEditError(renamePerDiemSubrateInline(translate, policyID, subRate.rateID, subRate.subRateID, customUnit, subRate.subRateName, newName)), - onChangeAmount: (newAmount: string) => - showInlineEditError(updatePerDiemAmountInline(translate, policyID, subRate.rateID, subRate.subRateID, customUnit, subRate.rate, newAmount)), - dismissError: () => { - if (!customUnit) { - return; - } - clearPerDiemRateError(policyID, customUnit.customUnitID, subRate.rateID, subRate.subRateID); - }, - }; - }), - [allSubRates, isOffline, openSubRateDetails, canWritePerDiem, isSelectionModeActive, policyID, customUnit, translate], - ); - - const secondaryActions = useMemo(() => { - const menuItems = []; - if (canWritePerDiem && policy?.areCategoriesEnabled && hasEnabledOptions(policyCategories ?? {})) { - menuItems.push({ - icon: expensifyIcons.Gear, - text: translate('common.settings'), - onSelected: openSettings, - value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, - }); - } - if (canWritePerDiem) { - menuItems.push({ - icon: expensifyIcons.Table, - text: translate('spreadsheet.importSpreadsheet'), - onSelected: () => { - if (isOffline) { - showOfflineModal(); + const perDiemRows: PerDiemTableRowData[] = allSubRates + .filter((subRate) => isOffline || subRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map((subRate) => { + const isDeleting = subRate.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + const canEdit = canWritePerDiem && !isDeleting && !isSelectionModeActive; + + return { + keyForList: getPerDiemRowKey(subRate.rateID, subRate.subRateID), + subRateID: subRate.subRateID, + rateID: subRate.rateID, + destination: subRate.destination, + subRateName: subRate.subRateName, + rate: subRate.rate, + currency: subRate.currency, + formattedAmount: convertAmountToDisplayString(subRate.rate, subRate.currency), + disabled: isDeleting, + pendingAction: subRate.pendingAction, + errors: subRate.errors, + canEditDestination: canEdit, + canEditSubrate: canEdit, + canEditAmount: canEdit, + action: () => openSubRateDetails(subRate.rateID, subRate.subRateID), + onRenameDestination: (newName: string) => showInlineEditError(renamePerDiemDestinationInline(translate, policyID, subRate.rateID, customUnit, subRate.destination, newName)), + onRenameSubrate: (newName: string) => + showInlineEditError(renamePerDiemSubrateInline(translate, policyID, subRate.rateID, subRate.subRateID, customUnit, subRate.subRateName, newName)), + onChangeAmount: (newAmount: string) => + showInlineEditError(updatePerDiemAmountInline(translate, policyID, subRate.rateID, subRate.subRateID, customUnit, subRate.rate, newAmount)), + dismissError: () => { + if (!customUnit) { return; } - Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_IMPORT.getRoute(policyID)); + clearPerDiemRateError(policyID, customUnit.customUnitID, subRate.rateID, subRate.subRateID); }, - value: CONST.POLICY.SECONDARY_ACTIONS.IMPORT_SPREADSHEET, - }); - } - // Admins can download the CSV before adding rates, to use it as an import template - if (hasVisibleSubRates || canWritePerDiem) { - menuItems.push({ - icon: expensifyIcons.Download, - text: translate('spreadsheet.downloadCSV'), - onSelected: () => { - if (isOffline) { - showOfflineModal(); - return; - } - close(() => downloadPerDiemCSV(policyID, showDownloadErrorModal, translate)); - }, - value: CONST.POLICY.SECONDARY_ACTIONS.DOWNLOAD_CSV, - }); - } + }; + }); - return menuItems; - }, [ - showOfflineModal, - showDownloadErrorModal, - policy?.areCategoriesEnabled, - policyCategories, - canWritePerDiem, - translate, - hasVisibleSubRates, - openSettings, - isOffline, - policyID, - expensifyIcons.Gear, - expensifyIcons.Table, - expensifyIcons.Download, - ]); + const menuItems = []; + if (canWritePerDiem && policy?.areCategoriesEnabled && hasEnabledOptions(policyCategories ?? {})) { + menuItems.push({ + icon: expensifyIcons.Gear, + text: translate('common.settings'), + onSelected: openSettings, + value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, + }); + } + if (canWritePerDiem) { + menuItems.push({ + icon: expensifyIcons.Table, + text: translate('spreadsheet.importSpreadsheet'), + onSelected: () => { + if (isOffline) { + showOfflineModal(); + return; + } + Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_IMPORT.getRoute(policyID)); + }, + value: CONST.POLICY.SECONDARY_ACTIONS.IMPORT_SPREADSHEET, + }); + } + // Admins can download the CSV before adding rates, to use it as an import template + if (hasVisibleSubRates || canWritePerDiem) { + menuItems.push({ + icon: expensifyIcons.Download, + text: translate('spreadsheet.downloadCSV'), + onSelected: () => { + if (isOffline) { + showOfflineModal(); + return; + } + close(() => downloadPerDiemCSV(policyID, showDownloadErrorModal, translate)); + }, + value: CONST.POLICY.SECONDARY_ACTIONS.DOWNLOAD_CSV, + }); + } + const secondaryActions = menuItems; const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); diff --git a/src/pages/workspace/receiptPartners/DynamicEditInviteReceiptPartnerPolicyPage.tsx b/src/pages/workspace/receiptPartners/DynamicEditInviteReceiptPartnerPolicyPage.tsx index da95bb6d2f7c..1f1a202281b2 100644 --- a/src/pages/workspace/receiptPartners/DynamicEditInviteReceiptPartnerPolicyPage.tsx +++ b/src/pages/workspace/receiptPartners/DynamicEditInviteReceiptPartnerPolicyPage.tsx @@ -43,7 +43,7 @@ import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type {TupleToUnion, ValueOf} from 'type-fest'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; type DynamicEditInviteReceiptPartnerPolicyPageProps = PlatformStackScreenProps; @@ -63,232 +63,193 @@ function DynamicEditInviteReceiptPartnerPolicyPage({route}: DynamicEditInviteRec const policy = usePolicy(policyID); const employeePersonalDetails = usePersonalDetailsByLogins(Object.keys(policy?.employeeList ?? {})); - const inviteOrResend = useCallback( - (email: string) => { - if (!policyID) { - return; - } - inviteWorkspaceEmployeesToUber(policyID, [email]); - }, - [policyID], - ); + const inviteOrResend = (email: string) => { + if (!policyID) { + return; + } + inviteWorkspaceEmployeesToUber(policyID, [email]); + }; - const dismissError = useCallback( - (item: MemberForList) => { - if (!policyID || !item.login) { - return; - } - clearUberEmployeeError(policyID, item.login); - }, - [policyID], - ); + const dismissError = (item: MemberForList) => { + if (!policyID || !item.login) { + return; + } + clearUberEmployeeError(policyID, item.login); + }; // Maintain independent search state per tab to avoid carryover across tabs const [allSearchTerm, allDebouncedSearchTerm, setAllSearchTerm] = useDebouncedState(''); const [linkedSearchTerm, linkedDebouncedSearchTerm, setLinkedSearchTerm] = useDebouncedState(''); const [outstandingSearchTerm, outstandingDebouncedSearchTerm, setOutstandingSearchTerm] = useDebouncedState(''); - const getSearchStateForTab = useCallback( - (tab: ReceiptPartnersTab) => { - if (tab === CONST.TAB.RECEIPT_PARTNERS.ALL) { - return {searchTerm: allSearchTerm, debouncedSearchTerm: allDebouncedSearchTerm, setSearchTerm: setAllSearchTerm}; - } - if (tab === CONST.TAB.RECEIPT_PARTNERS.LINKED) { - return {searchTerm: linkedSearchTerm, debouncedSearchTerm: linkedDebouncedSearchTerm, setSearchTerm: setLinkedSearchTerm}; - } - return {searchTerm: outstandingSearchTerm, debouncedSearchTerm: outstandingDebouncedSearchTerm, setSearchTerm: setOutstandingSearchTerm}; - }, - [ - allSearchTerm, - allDebouncedSearchTerm, - linkedSearchTerm, - linkedDebouncedSearchTerm, - outstandingSearchTerm, - outstandingDebouncedSearchTerm, - setAllSearchTerm, - setLinkedSearchTerm, - setOutstandingSearchTerm, - ], - ); - - const uberEmployeesByEmail = useMemo}>>(() => { - const policyWithEmployees = policy as typeof policy & { - receiptPartners?: { - uber?: { - employees?: Record}>; - }; + const getSearchStateForTab = (tab: ReceiptPartnersTab) => { + if (tab === CONST.TAB.RECEIPT_PARTNERS.ALL) { + return {searchTerm: allSearchTerm, debouncedSearchTerm: allDebouncedSearchTerm, setSearchTerm: setAllSearchTerm}; + } + if (tab === CONST.TAB.RECEIPT_PARTNERS.LINKED) { + return {searchTerm: linkedSearchTerm, debouncedSearchTerm: linkedDebouncedSearchTerm, setSearchTerm: setLinkedSearchTerm}; + } + return {searchTerm: outstandingSearchTerm, debouncedSearchTerm: outstandingDebouncedSearchTerm, setSearchTerm: setOutstandingSearchTerm}; + }; + + const policyWithEmployees = policy as typeof policy & { + receiptPartners?: { + uber?: { + employees?: Record< + string, + { + status?: string; + pendingAction?: PendingAction; + errors?: Record; + } + >; }; }; - return policyWithEmployees?.receiptPartners?.uber?.employees ?? {}; - }, [policy]); - - const deriveStatus = useCallback( - (email: string): UberEmployeeStatus => { - const status = uberEmployeesByEmail[email]?.status as UberEmployeeStatus; - - return status ?? CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.NONE; + }; + const uberEmployeesByEmail: Record< + string, + { + status?: string; + pendingAction?: PendingAction; + errors?: Record; + } + > = policyWithEmployees?.receiptPartners?.uber?.employees ?? {}; + + const deriveStatus = (email: string): UberEmployeeStatus => { + const status = uberEmployeesByEmail[email]?.status as UberEmployeeStatus; + + return status ?? CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.NONE; + }; + + const list: Array = []; + const employees = policy?.employeeList ?? {}; + const buttonStyles = [ + styles.button, + StyleUtils.getButtonStyleWithIcon(styles, CONST.DROPDOWN_BUTTON_SIZE.EXTRA_SMALL, true, false), + styles.ml3, + { + minWidth: variables.uberEmployeeInviteButtonWidth, }, - [uberEmployeesByEmail], - ); - - const members = useMemo>(() => { - const list: Array = []; - const employees = policy?.employeeList ?? {}; - - const buttonStyles = [ - styles.button, - StyleUtils.getButtonStyleWithIcon(styles, CONST.DROPDOWN_BUTTON_SIZE.EXTRA_SMALL, true, false), - styles.ml3, - {minWidth: variables.uberEmployeeInviteButtonWidth}, - ]; - const buttonTextStyles = [styles.buttonText, styles.buttonSmallText]; - - for (const [email, policyEmployee] of Object.entries(employees)) { - // Skip deleted policy employees - if (isDeletedPolicyEmployee(policyEmployee, isOffline)) { - continue; - } - const personalDetail = employeePersonalDetails[email ?? '']; - const status = deriveStatus(email); - - let rightElement; - const isResend = status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.CREATED || status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.INVITED; - const isInvite = status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.DELETED || status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.NONE; - if (isResend || isInvite) { - const text = isResend ? translate('workspace.receiptPartners.uber.status.resend') : translate('workspace.receiptPartners.uber.status.invite'); - const textChecked = translate('workspace.receiptPartners.uber.status.resend'); - rightElement = ( - inviteOrResend(email)} - styles={[...buttonStyles, isInvite ? styles.buttonSuccess : undefined]} - textStyles={[...buttonTextStyles, isInvite ? styles.buttonSuccessText : undefined]} - iconChecked={icons.Checkmark} - inline={false} - sentryLabel={CONST.SENTRY_LABEL.RECEIPT_PARTNERS.INVITE_RESEND_BUTTON} - accessible={false} - /> - ); - } else { - const badgeText = translate(`workspace.receiptPartners.uber.status.${status}`); - const isSuccess = status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.LINKED; - rightElement = ( - - ); - } - - const option = formatMemberForList({ - text: personalDetail?.displayName ?? email, - alternateText: email, - login: email, - accountID: personalDetail?.accountID, - icons: [ - { - source: personalDetail?.avatar ?? icons.FallbackAvatar, - name: formatPhoneNumber(email), - type: CONST.ICON_TYPE_AVATAR, - id: personalDetail?.accountID, - }, - ], - keyForList: email, - reportID: '', - isDisabled: true, - pendingAction: uberEmployeesByEmail[email]?.pendingAction, - }); - - const optionWithErrorsAndRightElement = { - ...option, - rightElement, - errors: uberEmployeesByEmail[email]?.errors, - itemStyle: styles.cursorDefault, - }; - - list.push(optionWithErrorsAndRightElement as MemberForList & ListItem); + ]; + const buttonTextStyles = [styles.buttonText, styles.buttonSmallText]; + for (const [email, policyEmployee] of Object.entries(employees)) { + // Skip deleted policy employees + if (isDeletedPolicyEmployee(policyEmployee, isOffline)) { + continue; } - return sortAlphabetically(list, 'text', localeCompare); - }, [ - policy?.employeeList, - styles, - StyleUtils, - localeCompare, - isOffline, - deriveStatus, - uberEmployeesByEmail, - translate, - inviteOrResend, - icons.Checkmark, - icons.FallbackAvatar, - formatPhoneNumber, - employeePersonalDetails, - ]); + const personalDetail = employeePersonalDetails[email ?? '']; + const status = deriveStatus(email); + let rightElement; + const isResend = status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.CREATED || status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.INVITED; + const isInvite = status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.DELETED || status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.NONE; + if (isResend || isInvite) { + const text = isResend ? translate('workspace.receiptPartners.uber.status.resend') : translate('workspace.receiptPartners.uber.status.invite'); + const textChecked = translate('workspace.receiptPartners.uber.status.resend'); + rightElement = ( + inviteOrResend(email)} + styles={[...buttonStyles, isInvite ? styles.buttonSuccess : undefined]} + textStyles={[...buttonTextStyles, isInvite ? styles.buttonSuccessText : undefined]} + iconChecked={icons.Checkmark} + inline={false} + sentryLabel={CONST.SENTRY_LABEL.RECEIPT_PARTNERS.INVITE_RESEND_BUTTON} + accessible={false} + /> + ); + } else { + const badgeText = translate(`workspace.receiptPartners.uber.status.${status}`); + const isSuccess = status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.LINKED; + rightElement = ( + + ); + } + const option = formatMemberForList({ + text: personalDetail?.displayName ?? email, + alternateText: email, + login: email, + accountID: personalDetail?.accountID, + icons: [ + { + source: personalDetail?.avatar ?? icons.FallbackAvatar, + name: formatPhoneNumber(email), + type: CONST.ICON_TYPE_AVATAR, + id: personalDetail?.accountID, + }, + ], + keyForList: email, + reportID: '', + isDisabled: true, + pendingAction: uberEmployeesByEmail[email]?.pendingAction, + }); + const optionWithErrorsAndRightElement = { + ...option, + rightElement, + errors: uberEmployeesByEmail[email]?.errors, + itemStyle: styles.cursorDefault, + }; + list.push(optionWithErrorsAndRightElement as MemberForList & ListItem); + } + const members: Array = sortAlphabetically(list, 'text', localeCompare); - const applyTabStatusFilter = useCallback( - (tab: ReceiptPartnersTab, data: MemberForList[]) => { - if (tab === CONST.TAB.RECEIPT_PARTNERS.ALL) { - return data; - } - if (tab === CONST.TAB.RECEIPT_PARTNERS.LINKED) { - return data.filter((m) => { - const status = deriveStatus(m.login ?? ''); - return ( - status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.LINKED || - status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.LINKED_PENDING_APPROVAL || - status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.SUSPENDED - ); - }); - } - // OUTSTANDING + const applyTabStatusFilter = (tab: ReceiptPartnersTab, data: MemberForList[]) => { + if (tab === CONST.TAB.RECEIPT_PARTNERS.ALL) { + return data; + } + if (tab === CONST.TAB.RECEIPT_PARTNERS.LINKED) { return data.filter((m) => { - const email = m.login ?? ''; - const status = deriveStatus(email); + const status = deriveStatus(m.login ?? ''); return ( - status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.CREATED || - status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.INVITED || - status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.DELETED || - status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.NONE + status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.LINKED || + status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.LINKED_PENDING_APPROVAL || + status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.SUSPENDED ); }); - }, - [deriveStatus], - ); - - const getMembersForTabWithoutSearch = useCallback((tab: ReceiptPartnersTab) => applyTabStatusFilter(tab, members), [applyTabStatusFilter, members]); - - const filterMembers = useCallback( - (tab: ReceiptPartnersTab) => { - const {debouncedSearchTerm} = getSearchStateForTab(tab); - const search = debouncedSearchTerm.trim().toLowerCase(); - let data = members; - if (search) { - data = tokenizedSearch(members, search, (m) => [m.text ?? '', m.alternateText ?? '']); - } - return applyTabStatusFilter(tab, data); - }, - [applyTabStatusFilter, getSearchStateForTab, members], - ); - - const listEmptyContent = useMemo( - () => ( - - ), - [translate, styles.textSupporting, styles.mb2, styles.pb5, styles.ph5, illustrations.SewerDino], + } + // OUTSTANDING + return data.filter((m) => { + const email = m.login ?? ''; + const status = deriveStatus(email); + return ( + status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.CREATED || + status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.INVITED || + status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.DELETED || + status === CONST.POLICY.RECEIPT_PARTNERS.UBER_EMPLOYEE_STATUS.NONE + ); + }); + }; + + const getMembersForTabWithoutSearch = (tab: ReceiptPartnersTab) => applyTabStatusFilter(tab, members); + + const filterMembers = (tab: ReceiptPartnersTab) => { + const {debouncedSearchTerm} = getSearchStateForTab(tab); + const search = debouncedSearchTerm.trim().toLowerCase(); + let data = members; + if (search) { + data = tokenizedSearch(members, search, (m) => [m.text ?? '', m.alternateText ?? '']); + } + return applyTabStatusFilter(tab, data); + }; + + const listEmptyContent = ( + ); return ( diff --git a/src/pages/workspace/reports/InitialListValueSelector/ReportFieldsInitialListValuePicker.tsx b/src/pages/workspace/reports/InitialListValueSelector/ReportFieldsInitialListValuePicker.tsx index 8eeb590a4cf3..61e883368adf 100644 --- a/src/pages/workspace/reports/InitialListValueSelector/ReportFieldsInitialListValuePicker.tsx +++ b/src/pages/workspace/reports/InitialListValueSelector/ReportFieldsInitialListValuePicker.tsx @@ -6,7 +6,7 @@ import useLocalize from '@hooks/useLocalize'; import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; -import React, {useMemo} from 'react'; +import React from 'react'; type ReportFieldsInitialListValuePickerProps = { /** Options to select from if field is of type list */ @@ -21,19 +21,15 @@ function ReportFieldsInitialListValuePicker({listValues, disabledOptions, value, const {localeCompare} = useLocalize(); // Freeze the value that was selected when this picker opened so the pre-selected option stays pinned to the top for the whole open/focus cycle, even as the live selection changes. const initialValue = useInitialSelection(value, {resetOnFocus: true}); - const listValueOptions = useMemo( - () => - Object.values(listValues ?? {}) - .filter((listValue, index) => !disabledOptions.at(index)) - .sort(localeCompare) - .map((listValue) => ({ - keyForList: listValue, - value: listValue, - isSelected: value === listValue, - text: listValue, - })), - [value, listValues, disabledOptions, localeCompare], - ); + const listValueOptions = Object.values(listValues ?? {}) + .filter((listValue, index) => !disabledOptions.at(index)) + .sort(localeCompare) + .map((listValue) => ({ + keyForList: listValue, + value: listValue, + isSelected: value === listValue, + text: listValue, + })); const orderedListValueOptions = moveInitialSelectionToTop(listValueOptions, initialValue ? [initialValue] : []); return ( diff --git a/src/pages/workspace/reports/ReportsDefaultTitle.tsx b/src/pages/workspace/reports/ReportsDefaultTitle.tsx index 92756a4fa607..8916898afeb7 100644 --- a/src/pages/workspace/reports/ReportsDefaultTitle.tsx +++ b/src/pages/workspace/reports/ReportsDefaultTitle.tsx @@ -34,7 +34,7 @@ import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/ReportsDefaultTitleModalForm'; import {Str} from 'expensify-common'; -import React, {useCallback, useRef} from 'react'; +import React, {useRef} from 'react'; import {View} from 'react-native'; type RulesCustomNamePageProps = PlatformStackScreenProps; @@ -58,18 +58,15 @@ function ReportsDefaultTitlePage({route}: RulesCustomNamePageProps) { const titleField = getTitleFieldWithFallback(policy); const customNameDefaultValue = Str.htmlDecode(titleField?.defaultValue ?? ''); - const validateCustomName = useCallback( - ({defaultTitle}: FormOnyxValues) => { - const errors: FormInputErrors = {}; - if (!defaultTitle) { - errors[INPUT_IDS.DEFAULT_TITLE] = translate('common.error.fieldRequired'); - } else if (defaultTitle.length > CONST.REPORT_TITLE_FORMULA_LIMIT) { - errors[INPUT_IDS.DEFAULT_TITLE] = translate('common.error.characterLimitExceedCounter', defaultTitle.length, CONST.REPORT_TITLE_FORMULA_LIMIT); - } - return errors; - }, - [translate], - ); + const validateCustomName = ({defaultTitle}: FormOnyxValues) => { + const errors: FormInputErrors = {}; + if (!defaultTitle) { + errors[INPUT_IDS.DEFAULT_TITLE] = translate('common.error.fieldRequired'); + } else if (defaultTitle.length > CONST.REPORT_TITLE_FORMULA_LIMIT) { + errors[INPUT_IDS.DEFAULT_TITLE] = translate('common.error.characterLimitExceedCounter', defaultTitle.length, CONST.REPORT_TITLE_FORMULA_LIMIT); + } + return errors; + }; const clearTitleFieldError = () => { clearPolicyTitleFieldError(policyID); diff --git a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRuleAmountPageBase.tsx b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRuleAmountPageBase.tsx index 472568d0c9cb..e098a90124bf 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRuleAmountPageBase.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRuleAmountPageBase.tsx @@ -29,7 +29,7 @@ import FLAG_FOR_REVIEW_RULE_INPUT_IDS from '@src/types/form/FlagForReviewRuleFor import INPUT_IDS from '@src/types/form/FlagForReviewRuleMaxAmountForm'; import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; type FlagForReviewRuleAmountPageBaseProps = { @@ -68,17 +68,13 @@ function FlagForReviewRuleAmountPageBase({policyID, categoryName, isCategoryLock backToRoute = ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(policyID, isCategoryLocked ? form?.[FLAG_FOR_REVIEW_RULE_INPUT_IDS.CATEGORY] : undefined); } - const expenseLimitTypes = useMemo( - () => - Object.values(CONST.POLICY.EXPENSE_LIMIT_TYPES).map((value) => ({ - value, - text: translate(`workspace.rules.categoryRules.expenseLimitTypes.${value}`), - alternateText: translate(`workspace.rules.categoryRules.expenseLimitTypes.${value}Subtitle`), - keyForList: value, - isSelected: selectedExpenseLimitType === value, - })), - [selectedExpenseLimitType, translate], - ); + const expenseLimitTypes = Object.values(CONST.POLICY.EXPENSE_LIMIT_TYPES).map((value) => ({ + value, + text: translate(`workspace.rules.categoryRules.expenseLimitTypes.${value}`), + alternateText: translate(`workspace.rules.categoryRules.expenseLimitTypes.${value}Subtitle`), + keyForList: value, + isSelected: selectedExpenseLimitType === value, + })); const goBack = () => { Navigation.goBack(backToRoute); diff --git a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx index 369da9990019..448120a99af0 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx @@ -136,12 +136,12 @@ function FlagForReviewRulePageBase({ initializedDraftForRuleKeyRef.current = categoryName; setDraftFlagForReviewRule(getFlagForReviewFormFromCategory(category, getCurrencyDecimals, policyCurrency)); }, [category, categoryName, draftMaxExpenseAmount, getCurrencyDecimals, hasDraft, initialCategoryName, isEditing, policyCurrency, selectedCategoryName]); - const fetchPolicyData = useCallback(() => { + const fetchPolicyData = () => { if (!policy?.areCategoriesEnabled || policyCategories) { return; } openPolicyCategoriesPage(policyID); - }, [policyID, policy?.areCategoriesEnabled, policyCategories]); + }; useNetwork({onReconnect: fetchPolicyData}); diff --git a/src/pages/workspace/rules/IndividualExpenseRulesSectionRevamp.tsx b/src/pages/workspace/rules/IndividualExpenseRulesSectionRevamp.tsx index 810c6fb518f3..caa83fba4def 100644 --- a/src/pages/workspace/rules/IndividualExpenseRulesSectionRevamp.tsx +++ b/src/pages/workspace/rules/IndividualExpenseRulesSectionRevamp.tsx @@ -33,7 +33,7 @@ import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; -import React, {useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import PublicReceiptVisibilityToggle from './PublicReceiptVisibilityToggle'; @@ -167,17 +167,15 @@ function IndividualExpenseRulesSectionRevamp({policyID, canWriteRules}: Individu const requiredFieldsList = [policy?.requiresCategory && translate('common.category'), ...requiredTagLabels].filter(Boolean).join(', '); - const prohibitedExpensesText = useMemo(() => { - const prohibitedExpensesList = Object.values(CONST.POLICY.PROHIBITED_EXPENSES) - .filter((key) => policy?.prohibitedExpenses?.[key]) - .map((key) => translate(`workspace.rules.individualExpenseRules.${key}`)); - - if (!prohibitedExpensesList.length) { - return ''; - } - - return prohibitedExpensesList.join(', '); - }, [policy?.prohibitedExpenses, translate]); + let prohibitedExpensesText; + const prohibitedExpensesList = Object.values(CONST.POLICY.PROHIBITED_EXPENSES) + .filter((key) => policy?.prohibitedExpenses?.[key]) + .map((key) => translate(`workspace.rules.individualExpenseRules.${key}`)); + if (!prohibitedExpensesList.length) { + prohibitedExpensesText = ''; + } else { + prohibitedExpensesText = prohibitedExpensesList.join(', '); + } const policyControlItems: BasicRuleMenuItem[] = [ { diff --git a/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx b/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx index 739bac6193b0..ae5d793a1914 100644 --- a/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx @@ -13,7 +13,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import React, {useMemo} from 'react'; +import React from 'react'; import useMerchantRuleRoute from './useMerchantRuleRoute'; @@ -28,14 +28,12 @@ function AddCategoryPage({route}: AddCategoryPageProps) { const selectedCategoryItem = form?.category ? {name: getDecodedCategoryName(form.category), value: form.category} : undefined; - const categoryItems = useMemo(() => { - return Object.values(policyCategories ?? {}) - .filter((category) => category.enabled && category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) - .map((category) => { - const decodedCategoryName = getDecodedCategoryName(category.name); - return {name: decodedCategoryName, value: category.name}; - }); - }, [policyCategories]); + const categoryItems = Object.values(policyCategories ?? {}) + .filter((category) => category.enabled && category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map((category) => { + const decodedCategoryName = getDecodedCategoryName(category.name); + return {name: decodedCategoryName, value: category.name}; + }); const onSave = (value?: string) => { updateDraftMerchantRule({category: value}); diff --git a/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx b/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx index e38b48301273..1ae7c4c7cda8 100644 --- a/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx @@ -19,7 +19,7 @@ import getEmptyArray from '@src/types/utils/getEmptyArray'; import type {ValueOf} from 'type-fest'; -import React, {useMemo} from 'react'; +import React from 'react'; import useMerchantRuleRoute from './useMerchantRuleRoute'; @@ -39,24 +39,27 @@ function AddTagPage({route}: AddTagPageProps) { const formTag = formTags.at(orderWeight); const parentTagPath = formTags.slice(0, orderWeight).join(':'); - const tagItems = useMemo(() => { - const tags: Array<{name: string; value: string}> = []; - for (const tag of Object.values(tagList?.tags ?? {})) { - // The rule keeps its own tag even once disabled, but not one being deleted, matching the rule page's tag row. - if (tag.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - continue; - } - if (tag.name !== formTag && !tag.enabled) { - continue; - } - if (!matchesParentTagPath(tag, parentTagPath)) { - continue; - } - tags.push({name: getCleanedTagName(tag.name), value: tag.name}); + const tags: Array<{ + name: string; + value: string; + }> = []; + for (const tag of Object.values(tagList?.tags ?? {})) { + // The rule keeps its own tag even once disabled, but not one being deleted, matching the rule page's tag row. + if (tag.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + continue; } - - return tags; - }, [tagList?.tags, formTag, parentTagPath]); + if (tag.name !== formTag && !tag.enabled) { + continue; + } + if (!matchesParentTagPath(tag, parentTagPath)) { + continue; + } + tags.push({ + name: getCleanedTagName(tag.name), + value: tag.name, + }); + } + const tagItems = tags; const selectedTagItem = tagItems.find(({value}) => value === formTag); diff --git a/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx b/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx index a8b2b76b8a5d..cc5994284cbb 100644 --- a/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx @@ -45,7 +45,7 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useState} from 'react'; /** Column roles that update the matched expense; at least one must be mapped alongside a merchant filter */ const ACTION_COLUMNS: string[] = [ @@ -319,24 +319,24 @@ function ImportedMerchantRulesPage({route}: ImportedMerchantRulesPageProps) { // Fetch categories if they're not loaded (e.g. after a cache clear) so imported category cells are // validated against the policy's real category list instead of an empty one - const fetchPolicyCategories = useCallback(() => { + const fetchPolicyCategories = () => { if (!policy?.areCategoriesEnabled || policyCategories) { return; } openPolicyCategoriesPage(policyID); - }, [policyID, policy?.areCategoriesEnabled, policyCategories]); + }; - const fetchPolicyVendors = useCallback(() => { + const fetchPolicyVendors = () => { if (!isVendorFeatureAvailable || policyVendors !== undefined) { return; } openPolicyVendorsPage(policyID); - }, [isVendorFeatureAvailable, policyID, policyVendors]); + }; - const fetchDependencies = useCallback(() => { + const fetchDependencies = () => { fetchPolicyCategories(); fetchPolicyVendors(); - }, [fetchPolicyCategories, fetchPolicyVendors]); + }; const {isOffline} = useNetwork({onReconnect: fetchDependencies}); @@ -408,10 +408,7 @@ function ImportedMerchantRulesPage({route}: ImportedMerchantRulesPageProps) { // Parse once and reuse the result for both the offline button-enablement check and the import itself, so the // button can never be enabled offline for an import that actually needs the (non-retryable) API call - const parsedRules = useMemo( - () => parseSpreadsheetRules(spreadsheet, containsHeader, policy, policyCategories, rules, isVendorListReady, policyVendors), - [spreadsheet, containsHeader, policy, policyCategories, rules, isVendorListReady, policyVendors], - ); + const parsedRules = parseSpreadsheetRules(spreadsheet, containsHeader, policy, policyCategories, rules, isVendorListReady, policyVendors); // When categories are enabled but not yet cached (e.g. after a cache clear, before the on-focus fetch), the // category lookup is empty so every category is wrongly flagged invalid. Invalid-category counts are only diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index f36e87d8a94a..92d391890e45 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -73,7 +73,7 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {ValueOf} from 'type-fest'; import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import useMerchantRuleRoute from './useMerchantRuleRoute'; @@ -196,7 +196,7 @@ function MerchantRulePageBase({ const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const [policyTagsFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); - const policyTags = useMemo(() => getTagLists(policyTagsFromOnyx) ?? getEmptyArray>(), [policyTagsFromOnyx]); + const policyTags = getTagLists(policyTagsFromOnyx) ?? getEmptyArray>(); const [shouldShowError, setShouldShowError] = useState(false); const {showConfirmModal} = useConfirmModal(); const [shouldUpdateMatchingTransactions, setShouldUpdateMatchingTransactions] = useState(false); @@ -268,14 +268,14 @@ function MerchantRulePageBase({ useEffect(() => () => clearDraftMerchantRule(), []); // Fetch categories and tags if they're not loaded (e.g. after cache clear) - const fetchPolicyData = useCallback(() => { + const fetchPolicyData = () => { if (policy?.areCategoriesEnabled && !policyCategories) { openPolicyCategoriesPage(policyID); } if (policy?.areTagsEnabled && !policyTagsFromOnyx) { openPolicyTagsPage(policyID); } - }, [policyID, policy?.areCategoriesEnabled, policy?.areTagsEnabled, policyCategories, policyTagsFromOnyx]); + }; useNetwork({onReconnect: fetchPolicyData}); diff --git a/src/pages/workspace/rules/MerchantTypeRules/MerchantTypeRulePageBase.tsx b/src/pages/workspace/rules/MerchantTypeRules/MerchantTypeRulePageBase.tsx index 75f8a89b94e4..2288afedd21c 100644 --- a/src/pages/workspace/rules/MerchantTypeRules/MerchantTypeRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantTypeRules/MerchantTypeRulePageBase.tsx @@ -84,12 +84,12 @@ function MerchantTypeRulePageBase({policyID, groupID, testID}: MerchantTypeRuleP setDraftMerchantTypeRule(getMerchantTypeRuleFormFromMccGroup(groupID, currentCategory)); }, [currentCategory, groupID]); - const fetchPolicyData = useCallback(() => { + const fetchPolicyData = () => { if (!policy?.areCategoriesEnabled || policyCategories) { return; } openPolicyCategoriesPage(policyID); - }, [policyID, policy?.areCategoriesEnabled, policyCategories]); + }; useNetwork({onReconnect: fetchPolicyData}); diff --git a/src/pages/workspace/rules/PolicyRulesPageRevamp.tsx b/src/pages/workspace/rules/PolicyRulesPageRevamp.tsx index 7ae3d178d1b9..748eb2886931 100644 --- a/src/pages/workspace/rules/PolicyRulesPageRevamp.tsx +++ b/src/pages/workspace/rules/PolicyRulesPageRevamp.tsx @@ -42,7 +42,7 @@ import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type {OnyxEntry} from 'react-native-onyx'; -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import type {RulesTab, TableSelectionTab} from './tabs/useRulesTableBulkActions'; @@ -139,14 +139,14 @@ function PolicyRulesPageRevamp({route}: PolicyRulesPageRevampProps) { Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, requestedTab); }, [requestedTab]); - const clearAllTableSelection = useCallback(() => { + const clearAllTableSelection = () => { setSelectedRuleKeysByTab((prev) => (Object.keys(prev).length > 0 ? {} : prev)); turnOffMobileSelectionMode(); - }, []); + }; useCleanupSelectedOptions(clearAllTableSelection); - const clearTableSelection = useCallback(() => { + const clearTableSelection = () => { if (!isTableSelectionTab(activeTab)) { return; } @@ -159,9 +159,9 @@ function PolicyRulesPageRevamp({route}: PolicyRulesPageRevampProps) { return {...prev, [activeTab]: []}; }); turnOffMobileSelectionMode(); - }, [activeTab]); + }; - const updateTabSelectionKeys = useCallback((tab: TableSelectionTab, selectedRowKeys: string[]) => { + const updateTabSelectionKeys = (tab: TableSelectionTab, selectedRowKeys: string[]) => { setSelectedRuleKeysByTab((prev) => { const nextKeys = updateSelectionKeysIfChanged(prev[tab] ?? [], selectedRowKeys); if (prev[tab] === nextKeys) { @@ -170,12 +170,12 @@ function PolicyRulesPageRevamp({route}: PolicyRulesPageRevampProps) { return {...prev, [tab]: nextKeys}; }); - }, []); + }; - const handleSpendRuleSelectionChange = useCallback((selectedRowKeys: string[]) => updateTabSelectionKeys(RULES_TAB.CARD_RESTRICTIONS, selectedRowKeys), [updateTabSelectionKeys]); - const handleExpenseDefaultSelectionChange = useCallback((selectedRowKeys: string[]) => updateTabSelectionKeys(RULES_TAB.EXPENSE_DEFAULTS, selectedRowKeys), [updateTabSelectionKeys]); - const handleRequireFieldsRuleSelectionChange = useCallback((selectedRowKeys: string[]) => updateTabSelectionKeys(RULES_TAB.REQUIRE_FIELDS, selectedRowKeys), [updateTabSelectionKeys]); - const handleFlagForReviewRuleSelectionChange = useCallback((selectedRowKeys: string[]) => updateTabSelectionKeys(RULES_TAB.FLAG_FOR_REVIEW, selectedRowKeys), [updateTabSelectionKeys]); + const handleSpendRuleSelectionChange = (selectedRowKeys: string[]) => updateTabSelectionKeys(RULES_TAB.CARD_RESTRICTIONS, selectedRowKeys); + const handleExpenseDefaultSelectionChange = (selectedRowKeys: string[]) => updateTabSelectionKeys(RULES_TAB.EXPENSE_DEFAULTS, selectedRowKeys); + const handleRequireFieldsRuleSelectionChange = (selectedRowKeys: string[]) => updateTabSelectionKeys(RULES_TAB.REQUIRE_FIELDS, selectedRowKeys); + const handleFlagForReviewRuleSelectionChange = (selectedRowKeys: string[]) => updateTabSelectionKeys(RULES_TAB.FLAG_FOR_REVIEW, selectedRowKeys); const {selectedRuleKeys, getFilteredSelectedKeys, deleteSelectedForActiveTab} = useRulesTableBulkActions({ policyID, diff --git a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx index cf569027a4d0..7ee22b07ed5d 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx @@ -224,12 +224,12 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, }); }, [category, categoryName, form, initialCategoryName, isEditing]); - const fetchPolicyData = useCallback(() => { + const fetchPolicyData = () => { if (!policy?.areCategoriesEnabled || policyCategories) { return; } openPolicyCategoriesPage(policyID); - }, [policyID, policy?.areCategoriesEnabled, policyCategories]); + }; useNetwork({onReconnect: fetchPolicyData}); diff --git a/src/pages/workspace/rules/RulesCustomPage.tsx b/src/pages/workspace/rules/RulesCustomPage.tsx index f6003742843a..cf344e3fb6b1 100644 --- a/src/pages/workspace/rules/RulesCustomPage.tsx +++ b/src/pages/workspace/rules/RulesCustomPage.tsx @@ -23,7 +23,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/RulesCustomForm'; -import React, {useCallback, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; type RulesCustomPageProps = PlatformStackScreenProps; @@ -41,9 +41,9 @@ function RulesCustomPage({ const [customRulesValue, setCustomRulesValue] = useState(() => Parser.htmlToMarkdown(policy?.customRules ?? '')); - const onChangeCustomRules = useCallback((newValue: string) => { + const onChangeCustomRules = (newValue: string) => { setCustomRulesValue(newValue); - }, []); + }; return ( ; @@ -45,12 +45,12 @@ function RulesMaxExpenseAgePage({ const [maxExpenseAgeValue, setMaxExpenseAgeValue] = useState(maxExpenseAgeDefaultValue); - const onChangeMaxExpenseAge = useCallback((newValue: string) => { + const onChangeMaxExpenseAge = (newValue: string) => { // replace all characters that are not spaces or digits let validMaxExpenseAge = newValue.replaceAll(/[^0-9]/g, ''); validMaxExpenseAge = validMaxExpenseAge.match(/(?:\d *){1,5}/)?.[0] ?? ''; setMaxExpenseAgeValue(validMaxExpenseAge); - }, []); + }; return ( ; @@ -64,7 +64,7 @@ function RulesProhibitedDefaultPage({ const {translate} = useLocalize(); const styles = useThemeStyles(); - const initialProhibitedExpenses = useMemo(() => getProhibitedExpensesState(policy?.prohibitedExpenses), [policy?.prohibitedExpenses]); + const initialProhibitedExpenses = getProhibitedExpensesState(policy?.prohibitedExpenses); const [draftProhibitedExpenses, setDraftProhibitedExpenses] = useState(initialProhibitedExpenses); const syncedPolicyIDRef = useRef(undefined); @@ -81,12 +81,9 @@ function RulesProhibitedDefaultPage({ setDraftProhibitedExpenses(getProhibitedExpensesState(policy.prohibitedExpenses)); }, [policy?.id, policy?.isLoading, policy?.prohibitedExpenses]); - const hasChanges = useMemo( - () => PROHIBITED_EXPENSE_KEYS.some((key) => draftProhibitedExpenses[key] !== initialProhibitedExpenses[key]), - [draftProhibitedExpenses, initialProhibitedExpenses], - ); + const hasChanges = PROHIBITED_EXPENSE_KEYS.some((key) => draftProhibitedExpenses[key] !== initialProhibitedExpenses[key]); - const handleSave = useCallback(() => { + const handleSave = () => { if (!hasChanges) { Navigation.goBack(); return; @@ -96,14 +93,14 @@ function RulesProhibitedDefaultPage({ setPolicyProhibitedExpenses(policyID, prohibitedExpensesToSave, policy?.prohibitedExpenses); Navigation.setNavigationActionToMicrotaskQueue(Navigation.goBack); - }, [draftProhibitedExpenses, hasChanges, policy?.prohibitedExpenses, policyID]); + }; - const handleToggle = useCallback((prohibitedExpense: ProhibitedExpenseKey) => { + const handleToggle = (prohibitedExpense: ProhibitedExpenseKey) => { setDraftProhibitedExpenses((previousProhibitedExpenses) => ({ ...previousProhibitedExpenses, [prohibitedExpense]: !previousProhibitedExpenses[prohibitedExpense], })); - }, []); + }; return ( ; @@ -46,22 +46,19 @@ function RulesReimbursableDefaultPage({ keyForList: mode, })); - const saveAndGoBack = useCallback(() => { + const saveAndGoBack = () => { if (!selectedMode) { return; } setPolicyReimbursableMode(policyID, selectedMode, policy?.defaultReimbursable, policy?.disabledFields?.reimbursable); Navigation.goBack(); - }, [policyID, selectedMode, policy?.defaultReimbursable, policy?.disabledFields?.reimbursable]); + }; - const confirmButtonOptions = useMemo( - () => ({ - showButton: true, - text: translate('common.save'), - onConfirm: saveAndGoBack, - }), - [saveAndGoBack, translate], - ); + const confirmButtonOptions = { + showButton: true, + text: translate('common.save'), + onConfirm: saveAndGoBack, + }; return ( ; @@ -51,14 +51,8 @@ function RulesRequireReceiptsPage({ const initialReceiptEnabled = isMaxExpenseAmountSet(policy?.maxExpenseAmountNoReceipt); const initialItemizedEnabled = isMaxExpenseAmountSet(policy?.maxExpenseAmountNoItemizedReceipt); - const initialReceiptAmount = useMemo( - () => (initialReceiptEnabled ? convertToFrontendAmountAsString(policy?.maxExpenseAmountNoReceipt ?? 0, decimals) : ''), - [initialReceiptEnabled, policy?.maxExpenseAmountNoReceipt, decimals], - ); - const initialItemizedAmount = useMemo( - () => (initialItemizedEnabled ? convertToFrontendAmountAsString(policy?.maxExpenseAmountNoItemizedReceipt ?? 0, decimals) : ''), - [initialItemizedEnabled, policy?.maxExpenseAmountNoItemizedReceipt, decimals], - ); + const initialReceiptAmount = initialReceiptEnabled ? convertToFrontendAmountAsString(policy?.maxExpenseAmountNoReceipt ?? 0, decimals) : ''; + const initialItemizedAmount = initialItemizedEnabled ? convertToFrontendAmountAsString(policy?.maxExpenseAmountNoItemizedReceipt ?? 0, decimals) : ''; const [receiptEnabled, setReceiptEnabled] = useState(initialReceiptEnabled); const [itemizedEnabled, setItemizedEnabled] = useState(initialItemizedEnabled); @@ -83,104 +77,86 @@ function RulesRequireReceiptsPage({ setItemizedEnabled(isMaxExpenseAmountSet(policy.maxExpenseAmountNoItemizedReceipt)); }, [policy?.id, policy?.isLoading, policy?.maxExpenseAmountNoReceipt, policy?.maxExpenseAmountNoItemizedReceipt]); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; - if (!receiptEnabled && !itemizedEnabled) { - return errors; - } + if (!receiptEnabled && !itemizedEnabled) { + return errors; + } - const emptyAmountError = translate('workspace.rules.requireReceipts.emptyAmountError'); + const emptyAmountError = translate('workspace.rules.requireReceipts.emptyAmountError'); - if (receiptEnabled && !values.maxExpenseAmountNoReceipt?.trim()) { - errors.maxExpenseAmountNoReceipt = emptyAmountError; - } + if (receiptEnabled && !values.maxExpenseAmountNoReceipt?.trim()) { + errors.maxExpenseAmountNoReceipt = emptyAmountError; + } - if (itemizedEnabled && !values.maxExpenseAmountNoItemizedReceipt?.trim()) { - errors.maxExpenseAmountNoItemizedReceipt = emptyAmountError; - } + if (itemizedEnabled && !values.maxExpenseAmountNoItemizedReceipt?.trim()) { + errors.maxExpenseAmountNoItemizedReceipt = emptyAmountError; + } - return errors; - }, - [receiptEnabled, itemizedEnabled, translate], - ); + return errors; + }; - const clearAmountConflictError = useCallback(() => setAmountConflictError(''), []); - - const handleSubmit = useCallback( - (values: FormOnyxValues) => { - // Checked here rather than in `validate` because this runs only on a real save. The message is shown once - // below the pair instead of on either field, and the inputs clear it again on the next edit. - if ( - receiptEnabled && - itemizedEnabled && - convertToBackendAmount(Number(values.maxExpenseAmountNoReceipt) || 0) > convertToBackendAmount(Number(values.maxExpenseAmountNoItemizedReceipt) || 0) - ) { - setAmountConflictError(translate('workspace.rules.requireReceipts.receiptAmountGreaterThanItemizedError')); - return; - } + const clearAmountConflictError = () => setAmountConflictError(''); - const receiptChanged = receiptEnabled !== initialReceiptEnabled || (receiptEnabled && values.maxExpenseAmountNoReceipt !== initialReceiptAmount); - const itemizedChanged = itemizedEnabled !== initialItemizedEnabled || (itemizedEnabled && values.maxExpenseAmountNoItemizedReceipt !== initialItemizedAmount); + const handleSubmit = (values: FormOnyxValues) => { + // Checked here rather than in `validate` because this runs only on a real save. The message is shown once + // below the pair instead of on either field, and the inputs clear it again on the next edit. + if ( + receiptEnabled && + itemizedEnabled && + convertToBackendAmount(Number(values.maxExpenseAmountNoReceipt) || 0) > convertToBackendAmount(Number(values.maxExpenseAmountNoItemizedReceipt) || 0) + ) { + setAmountConflictError(translate('workspace.rules.requireReceipts.receiptAmountGreaterThanItemizedError')); + return; + } - if (!receiptChanged && !itemizedChanged) { - Navigation.goBack(); - return; - } + const receiptChanged = receiptEnabled !== initialReceiptEnabled || (receiptEnabled && values.maxExpenseAmountNoReceipt !== initialReceiptAmount); + const itemizedChanged = itemizedEnabled !== initialItemizedEnabled || (itemizedEnabled && values.maxExpenseAmountNoItemizedReceipt !== initialItemizedAmount); - const receiptValue = receiptEnabled ? values.maxExpenseAmountNoReceipt : ''; - const itemizedValue = itemizedEnabled ? values.maxExpenseAmountNoItemizedReceipt : ''; - - // Only one of the two calls below should carry this, otherwise both requests would ask the backend to - // complete the same onboarding task with a different reportActionID, creating a duplicate completion. - let reviewWorkspaceSettingsTaskData = getReviewWorkspaceSettingsTaskCompletion(); - const updateReceipt = () => { - setPolicyMaxExpenseAmountNoReceipt(policyID, receiptValue, policy?.maxExpenseAmountNoReceipt, reviewWorkspaceSettingsTaskData); - reviewWorkspaceSettingsTaskData = {}; - }; - const updateItemized = () => { - setPolicyMaxExpenseAmountNoItemizedReceipt(policyID, itemizedValue, policy?.maxExpenseAmountNoItemizedReceipt, reviewWorkspaceSettingsTaskData); - reviewWorkspaceSettingsTaskData = {}; - }; - - if (receiptChanged && itemizedChanged) { - // The two amounts are saved as separate requests, and each is validated on the server against the OTHER - // amount still stored there, under the invariant "require-receipt amount <= require-itemized-receipt amount". - // Send the update that keeps that invariant true first, so no intermediate server state is ever rejected. - const oldReceiptCents = policy?.maxExpenseAmountNoReceipt ?? 0; - const newItemizedCents = itemizedValue === '' ? CONST.DISABLED_MAX_EXPENSE_VALUE : convertToBackendAmount(Number(itemizedValue)); - // Sending the itemized update first is safe unless it would lower the itemized amount below the receipt - // amount still on the server; in that (lowering) case send the receipt update first instead. - const itemizedFirstIsSafe = oldReceiptCents === CONST.DISABLED_MAX_EXPENSE_VALUE || newItemizedCents >= oldReceiptCents; - if (itemizedFirstIsSafe) { - updateItemized(); - updateReceipt(); - } else { - updateReceipt(); - updateItemized(); - } - } else if (receiptChanged) { + if (!receiptChanged && !itemizedChanged) { + Navigation.goBack(); + return; + } + + const receiptValue = receiptEnabled ? values.maxExpenseAmountNoReceipt : ''; + const itemizedValue = itemizedEnabled ? values.maxExpenseAmountNoItemizedReceipt : ''; + + // Only one of the two calls below should carry this, otherwise both requests would ask the backend to + // complete the same onboarding task with a different reportActionID, creating a duplicate completion. + let reviewWorkspaceSettingsTaskData = getReviewWorkspaceSettingsTaskCompletion(); + const updateReceipt = () => { + setPolicyMaxExpenseAmountNoReceipt(policyID, receiptValue, policy?.maxExpenseAmountNoReceipt, reviewWorkspaceSettingsTaskData); + reviewWorkspaceSettingsTaskData = {}; + }; + const updateItemized = () => { + setPolicyMaxExpenseAmountNoItemizedReceipt(policyID, itemizedValue, policy?.maxExpenseAmountNoItemizedReceipt, reviewWorkspaceSettingsTaskData); + reviewWorkspaceSettingsTaskData = {}; + }; + + if (receiptChanged && itemizedChanged) { + // The two amounts are saved as separate requests, and each is validated on the server against the OTHER + // amount still stored there, under the invariant "require-receipt amount <= require-itemized-receipt amount". + // Send the update that keeps that invariant true first, so no intermediate server state is ever rejected. + const oldReceiptCents = policy?.maxExpenseAmountNoReceipt ?? 0; + const newItemizedCents = itemizedValue === '' ? CONST.DISABLED_MAX_EXPENSE_VALUE : convertToBackendAmount(Number(itemizedValue)); + // Sending the itemized update first is safe unless it would lower the itemized amount below the receipt + // amount still on the server; in that (lowering) case send the receipt update first instead. + const itemizedFirstIsSafe = oldReceiptCents === CONST.DISABLED_MAX_EXPENSE_VALUE || newItemizedCents >= oldReceiptCents; + if (itemizedFirstIsSafe) { + updateItemized(); + updateReceipt(); + } else { updateReceipt(); - } else if (itemizedChanged) { updateItemized(); } - Navigation.setNavigationActionToMicrotaskQueue(Navigation.goBack); - }, - [ - receiptEnabled, - initialReceiptEnabled, - initialReceiptAmount, - itemizedEnabled, - initialItemizedEnabled, - initialItemizedAmount, - policyID, - policy?.maxExpenseAmountNoReceipt, - policy?.maxExpenseAmountNoItemizedReceipt, - getReviewWorkspaceSettingsTaskCompletion, - translate, - ], - ); + } else if (receiptChanged) { + updateReceipt(); + } else if (itemizedChanged) { + updateItemized(); + } + Navigation.setNavigationActionToMicrotaskQueue(Navigation.goBack); + }; return ( getSpendRuleFormValuesFromCardRule(existingRule), [existingRule]); + const existingFormValues = getSpendRuleFormValuesFromCardRule(existingRule); const [isErrorVisible, setIsErrorVisible] = useState(false); const [isRestrictMerchantsOff, setIsRestrictMerchantsOff] = useState(() => !hasSpendRuleMerchantRestrictions(isNewRule ? spendRuleForm : existingFormValues)); diff --git a/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts b/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts index 86e572da9fa6..4bef0d0ebec0 100644 --- a/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts +++ b/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts @@ -35,7 +35,7 @@ import ROUTES from '@src/ROUTES'; import type {ValueOf} from 'type-fest'; -import {useCallback, useEffect, useRef} from 'react'; +import {useEffect, useRef} from 'react'; const DEFAULT_SPEND_RULE_ID = 'default-rule'; const RULES_TAB = CONST.TAB.RULES; @@ -210,7 +210,7 @@ function useRulesTableBulkActions({policyID, activeTab, selectedRuleKeysByTab, c const selectedRuleKeys = isTableSelectionTab(activeTab) ? getFilteredSelectedKeys(activeTab) : []; - const deleteSelectedForActiveTab = useCallback(() => { + const deleteSelectedForActiveTab = () => { if (!isTableSelectionTab(activeTab)) { return; } @@ -283,22 +283,7 @@ function useRulesTableBulkActions({policyID, activeTab, selectedRuleKeysByTab, c deletePolicyCategoryTaxes(policy, selectedCategoryNames, getReviewWorkspaceSettingsTaskCompletion()); } clearTableSelection(); - }, [ - isVendorMatchingBetaEnabled, - activeTab, - clearTableSelection, - defaultFundID, - expensifyCardSettings?.cardRules, - filteredSelectedExpenseDefaultKeys, - filteredSelectedFlagForReviewRuleKeys, - filteredSelectedRequireFieldsRuleKeys, - rules, - filteredSelectedSpendRuleKeys, - policy, - policyData, - policyID, - getReviewWorkspaceSettingsTaskCompletion, - ]); + }; return { selectedRuleKeys, diff --git a/src/pages/workspace/tags/DynamicEditTagPage.tsx b/src/pages/workspace/tags/DynamicEditTagPage.tsx index a7c492b905dd..bb355b8a67fb 100644 --- a/src/pages/workspace/tags/DynamicEditTagPage.tsx +++ b/src/pages/workspace/tags/DynamicEditTagPage.tsx @@ -30,7 +30,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/WorkspaceTagForm'; -import React, {useCallback} from 'react'; +import React from 'react'; import {Keyboard} from 'react-native'; type DynamicEditTagPageProps = @@ -51,33 +51,27 @@ function DynamicEditTagPage({route}: DynamicEditTagPageProps) { const isQuickSettingsFlow = route.name === SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_EDIT; const backPath = useDynamicBackPath(isQuickSettingsFlow ? DYNAMIC_ROUTES.SETTINGS_TAG_EDIT.path : DYNAMIC_ROUTES.WORKSPACE_TAG_EDIT.path); - const validate = useCallback( - (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; - const {tags} = getTagListByOrderWeight(policyTags, orderWeight); - const error = getTagNameError(tags, values.tagName, currentTagName); + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const {tags} = getTagListByOrderWeight(policyTags, orderWeight); + const error = getTagNameError(tags, values.tagName, currentTagName); - if (error) { - errors.tagName = getTagNameErrorMessage(translate, error, values.tagName); - } + if (error) { + errors.tagName = getTagNameErrorMessage(translate, error, values.tagName); + } - return errors; - }, - [policyTags, orderWeight, currentTagName, translate], - ); + return errors; + }; - const editTag = useCallback( - (values: FormOnyxValues) => { - const tagName = StringUtils.sanitizeName(values.tagName); - // Do not call the API if the edited tag name is the same as the current tag name - if (currentTagName !== tagName) { - renamePolicyTag(policyData, {oldName: route.params.tagName, newName: tagName}, orderWeight, isVendorMatchingBetaEnabled); - } - Keyboard.dismiss(); - Navigation.goBack(backPath); - }, - [policyData, currentTagName, route.params.tagName, orderWeight, backPath, isVendorMatchingBetaEnabled], - ); + const editTag = (values: FormOnyxValues) => { + const tagName = StringUtils.sanitizeName(values.tagName); + // Do not call the API if the edited tag name is the same as the current tag name + if (currentTagName !== tagName) { + renamePolicyTag(policyData, {oldName: route.params.tagName, newName: tagName}, orderWeight, isVendorMatchingBetaEnabled); + } + Keyboard.dismiss(); + Navigation.goBack(backPath); + }; return ( @@ -53,41 +53,35 @@ function DynamicTagGLCodePage({route}: DynamicEditTagGLCodePageProps) { const isQuickSettingsFlow = route.name === SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_GL_CODE; const backPath = useDynamicBackPath(isQuickSettingsFlow ? DYNAMIC_ROUTES.SETTINGS_TAG_GL_CODE.path : DYNAMIC_ROUTES.WORKSPACE_TAG_GL_CODE.path); - const goBack = useCallback(() => { + const goBack = () => { Navigation.goBack(isQuickSettingsFlow ? backPath : undefined); - }, [backPath, isQuickSettingsFlow]); - - const validate = useCallback( - (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; - const tagGLCode = values.glCode.trim(); - - if (tagGLCode.length > CONST.MAX_LENGTH_256) { - errors.glCode = translate('common.error.characterLimitExceedCounter', tagGLCode.length, CONST.MAX_LENGTH_256); - } - - return errors; - }, - [translate], - ); - - const editGLCode = useCallback( - (values: FormOnyxValues) => { - const newGLCode = values.glCode.trim(); - if (newGLCode !== glCode) { - setPolicyTagGLCode({ - policyID, - tagName, - tagListIndex: orderWeight, - glCode: newGLCode, - policyTags, - parentTagsFilter, - }); - } - goBack(); - }, - [glCode, goBack, policyID, tagName, orderWeight, policyTags, parentTagsFilter], - ); + }; + + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const tagGLCode = values.glCode.trim(); + + if (tagGLCode.length > CONST.MAX_LENGTH_256) { + errors.glCode = translate('common.error.characterLimitExceedCounter', tagGLCode.length, CONST.MAX_LENGTH_256); + } + + return errors; + }; + + const editGLCode = (values: FormOnyxValues) => { + const newGLCode = values.glCode.trim(); + if (newGLCode !== glCode) { + setPolicyTagGLCode({ + policyID, + tagName, + tagListIndex: orderWeight, + glCode: newGLCode, + policyTags, + parentTagsFilter, + }); + } + goBack(); + }; if (!currentPolicyTagEntry && !isLoadingOnyxValue(policyTagsMetadata)) { return ; diff --git a/src/pages/workspace/tags/DynamicWorkspaceViewTagsPage.tsx b/src/pages/workspace/tags/DynamicWorkspaceViewTagsPage.tsx index 102f47caac84..105ed8ea4c1c 100644 --- a/src/pages/workspace/tags/DynamicWorkspaceViewTagsPage.tsx +++ b/src/pages/workspace/tags/DynamicWorkspaceViewTagsPage.tsx @@ -54,7 +54,7 @@ import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type {ComponentRef} from 'react'; import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; type DynamicWorkspaceViewTagsProps = @@ -81,18 +81,18 @@ function DynamicWorkspaceViewTagsPage({route}: DynamicWorkspaceViewTagsProps) { const policyData = usePolicyData(policyID); const {policy, tags: policyTags} = policyData; const isMobileSelectionModeEnabled = useMobileSelectionMode(); - const currentTagListName = useMemo(() => getTagListName(policyTags, orderWeight), [policyTags, orderWeight]); - const hasDependentTags = useMemo(() => hasDependentTagsPolicyUtils(policy, policyTags), [policy, policyTags]); + const currentTagListName = getTagListName(policyTags, orderWeight); + const hasDependentTags = hasDependentTagsPolicyUtils(policy, policyTags); const isMultiLevelTags = isMultiLevelTagsPolicyUtils(policyTags); const currentPolicyTag = policyTags?.[currentTagListName]; const {canWrite: canWriteTags, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.TAGS); const isQuickSettingsFlow = route.name === SCREENS.SETTINGS_TAGS.DYNAMIC_SETTINGS_TAG_LIST_VIEW; const backPath = useDynamicBackPath(isQuickSettingsFlow ? DYNAMIC_ROUTES.SETTINGS_TAG_LIST_VIEW.path : DYNAMIC_ROUTES.WORKSPACE_TAG_LIST_VIEW.path); - const fetchTags = useCallback(() => { + const fetchTags = () => { openPolicyTagsPage(policyID); - }, [policyID]); + }; - const filterFunction = useCallback((tag: PolicyTag | undefined) => !!tag && tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, []); + const filterFunction = (tag: PolicyTag | undefined) => !!tag && tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const [selectedTags, setSelectedTags] = useFilteredSelection(currentPolicyTag?.tags, filterFunction); @@ -115,89 +115,72 @@ function DynamicWorkspaceViewTagsPage({route}: DynamicWorkspaceViewTagsProps) { onNavigationCallBack: () => Navigation.goBack(backPath), }); - const updateWorkspaceTagEnabled = useCallback( - (value: boolean, tagName: string) => { - if (!canWriteTags) { - showReadOnlyModal(); - return; - } - - setWorkspaceTagEnabled(policyData, {[tagName]: {name: tagName, enabled: value}}, orderWeight, isVendorMatchingBetaEnabled); - }, - [canWriteTags, policyData, orderWeight, showReadOnlyModal, isVendorMatchingBetaEnabled], - ); - - const navigateToTagSettings = useCallback( - (tag: PolicyTag) => { - if (!canWriteTags) { - return; - } - - const parentTagsFilter = tag?.rules?.parentTagsFilter ?? tag?.parentTagsFilter; - const tagSettingsSuffix = appendParentTagsFilter(DYNAMIC_ROUTES.WORKSPACE_TAG_SETTINGS.getRoute(orderWeight, tag.name), parentTagsFilter); - const settingsTagSettingsSuffix = appendParentTagsFilter(DYNAMIC_ROUTES.SETTINGS_TAG_SETTINGS.getRoute(orderWeight, tag.name), parentTagsFilter); - - Navigation.navigate(isQuickSettingsFlow ? createDynamicRoute(settingsTagSettingsSuffix) : createDynamicRoute(tagSettingsSuffix)); - }, - [canWriteTags, isQuickSettingsFlow, orderWeight], - ); + const updateWorkspaceTagEnabled = (value: boolean, tagName: string) => { + if (!canWriteTags) { + showReadOnlyModal(); + return; + } - const tagRows = useMemo(() => { - const enabledTagsCount = getCountOfEnabledTagsOfList(currentPolicyTag?.tags); + setWorkspaceTagEnabled(policyData, {[tagName]: {name: tagName, enabled: value}}, orderWeight, isVendorMatchingBetaEnabled); + }; - return Object.values(currentPolicyTag?.tags ?? {}).reduce((acc, tag) => { - const isDisabled = tag.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + const navigateToTagSettings = (tag: PolicyTag) => { + if (!canWriteTags) { + return; + } - if (!isOffline && isDisabled) { - return acc; - } + const parentTagsFilter = tag?.rules?.parentTagsFilter ?? tag?.parentTagsFilter; + const tagSettingsSuffix = appendParentTagsFilter(DYNAMIC_ROUTES.WORKSPACE_TAG_SETTINGS.getRoute(orderWeight, tag.name), parentTagsFilter); + const settingsTagSettingsSuffix = appendParentTagsFilter(DYNAMIC_ROUTES.SETTINGS_TAG_SETTINGS.getRoute(orderWeight, tag.name), parentTagsFilter); - const isDisablingLastEnabledTag = isDisablingOrDeletingLastEnabledTag(currentPolicyTag, [tag], enabledTagsCount); - - acc.push({ - keyForList: hasDependentTags ? `${tag.name}-${tag.rules?.parentTagsFilter ?? ''}` : tag.name, - value: tag.name, - name: hasDependentTags ? tag.name : getCleanedTagName(tag.name), - enabled: tag.enabled, - disabled: isDisabled, - errors: tag.errors ?? undefined, - pendingAction: tag.pendingAction, - isLocked: !canWriteTags || isDisablingLastEnabledTag, - showEnabledSwitch: !hasDependentTags, - action: () => navigateToTagSettings(tag), - onToggleEnabled: (enabled: boolean) => { - if (isDisablingLastEnabledTag) { - showConfirmModal({ - title: translate('workspace.tags.cannotDeleteOrDisableAllTags.title'), - prompt: translate('workspace.tags.cannotDeleteOrDisableAllTags.description'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - return; - } - updateWorkspaceTagEnabled(enabled, tag.name); - }, - onClose: () => - clearPolicyTagErrors({ - policyID, - tagName: tag.name, - tagListIndex: orderWeight, - policyTags, - }), - }); + Navigation.navigate(isQuickSettingsFlow ? createDynamicRoute(settingsTagSettingsSuffix) : createDynamicRoute(tagSettingsSuffix)); + }; + const enabledTagsCount = getCountOfEnabledTagsOfList(currentPolicyTag?.tags); + const tagRows: WorkspaceTagTableRowData[] = Object.values(currentPolicyTag?.tags ?? {}).reduce((acc, tag) => { + const isDisabled = tag.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + if (!isOffline && isDisabled) { return acc; - }, []); - }, [canWriteTags, currentPolicyTag, hasDependentTags, isOffline, navigateToTagSettings, orderWeight, policyID, policyTags, showConfirmModal, translate, updateWorkspaceTagEnabled]); - - const tagListKeyedByName = useMemo( - () => - tagRows.reduce>((acc, tag) => { - acc[tag.value] = tag; - return acc; - }, {}), - [tagRows], - ); + } + const isDisablingLastEnabledTag = isDisablingOrDeletingLastEnabledTag(currentPolicyTag, [tag], enabledTagsCount); + acc.push({ + keyForList: hasDependentTags ? `${tag.name}-${tag.rules?.parentTagsFilter ?? ''}` : tag.name, + value: tag.name, + name: hasDependentTags ? tag.name : getCleanedTagName(tag.name), + enabled: tag.enabled, + disabled: isDisabled, + errors: tag.errors ?? undefined, + pendingAction: tag.pendingAction, + isLocked: !canWriteTags || isDisablingLastEnabledTag, + showEnabledSwitch: !hasDependentTags, + action: () => navigateToTagSettings(tag), + onToggleEnabled: (enabled: boolean) => { + if (isDisablingLastEnabledTag) { + showConfirmModal({ + title: translate('workspace.tags.cannotDeleteOrDisableAllTags.title'), + prompt: translate('workspace.tags.cannotDeleteOrDisableAllTags.description'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + return; + } + updateWorkspaceTagEnabled(enabled, tag.name); + }, + onClose: () => + clearPolicyTagErrors({ + policyID, + tagName: tag.name, + tagListIndex: orderWeight, + policyTags, + }), + }); + return acc; + }, []); + + const tagListKeyedByName = tagRows.reduce>((acc, tag) => { + acc[tag.value] = tag; + return acc; + }, {}); if (!currentPolicyTag) { return ; diff --git a/src/pages/workspace/tags/ImportTagsOptionsPage.tsx b/src/pages/workspace/tags/ImportTagsOptionsPage.tsx index af93b3fb8d1c..3ac980483064 100644 --- a/src/pages/workspace/tags/ImportTagsOptionsPage.tsx +++ b/src/pages/workspace/tags/ImportTagsOptionsPage.tsx @@ -40,7 +40,7 @@ import type SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useState} from 'react'; type ImportTagsOptionsPageProps = PlatformStackScreenProps; @@ -64,51 +64,23 @@ function ImportTagsOptionsPage({route}: ImportTagsOptionsPageProps) { const [shouldRunPostUpgradeFlow, setShouldRunPostUpgradeFlow] = useState(false); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); const workspaceTagsImportPath = createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_TAGS_IMPORT.path, ROUTES.WORKSPACE_TAGS.getRoute(policyID)); - const [policyTagLists, isMultiLevelTags, hasDependentTags] = useMemo( - () => [getTagLists(policyTags), isMultiLevelTagsPolicyUtils(policyTags), hasDependentTagsPolicyUtils(policy, policyTags)], - [policy, policyTags], - ); + const [policyTagLists, isMultiLevelTags, hasDependentTags] = [getTagLists(policyTags), isMultiLevelTagsPolicyUtils(policyTags), hasDependentTagsPolicyUtils(policy, policyTags)]; // If required tags was enabled before clearing tags for a level switch, the next first tag should restore that setting. const shouldRestoreRequiresTagAfterTagCreate = (policy?.requiresTag ?? false) || policyTagLists.some((policyTagList) => policyTagList.required); - const hasVisibleTags = useMemo(() => { - if (isMultiLevelTags) { - return policyTagLists.some((policyTagList) => Object.values(policyTagList.tags ?? {}).some((tag) => tag.enabled)); - } - + let hasVisibleTags; + if (isMultiLevelTags) { + hasVisibleTags = policyTagLists.some((policyTagList) => Object.values(policyTagList.tags ?? {}).some((tag) => tag.enabled)); + } else { const singleLevelTags = policyTagLists.at(0)?.tags ?? {}; - return Object.values(singleLevelTags).some((tag) => tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); - }, [isMultiLevelTags, policyTagLists]); - - const overrideMultiTagPrompt = useMemo( - () => ( - - {translate('workspace.tags.overrideMultiTagWarning.prompt1')} - <> - {translate('workspace.tags.overrideMultiTagWarning.prompt2')} - { - if (isMultiLevelTags) { - downloadMultiLevelTagsCSV(policyID, showDownloadErrorModalAfterClose, hasDependentTags, translate); - } else { - downloadTagsCSV(policyID, showDownloadErrorModalAfterClose, translate); - } - }} - > - {translate('workspace.tags.overrideMultiTagWarning.prompt3')} - - {translate('workspace.tags.overrideMultiTagWarning.prompt4')} - - - ), - [translate, isMultiLevelTags, policyID, hasDependentTags, showDownloadErrorModalAfterClose], - ); + hasVisibleTags = Object.values(singleLevelTags).some((tag) => tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + } - const switchSingleToMultiLevelTagPrompt = useMemo( - () => ( - - {translate('workspace.tags.switchSingleToMultiLevelTagWarning.prompt1')} - {translate('workspace.tags.switchSingleToMultiLevelTagWarning.prompt2')} + const overrideMultiTagPrompt = ( + + {translate('workspace.tags.overrideMultiTagWarning.prompt1')} + <> + {translate('workspace.tags.overrideMultiTagWarning.prompt2')} { if (isMultiLevelTags) { @@ -118,17 +90,35 @@ function ImportTagsOptionsPage({route}: ImportTagsOptionsPageProps) { } }} > - {translate('workspace.tags.switchSingleToMultiLevelTagWarning.prompt3')} + {translate('workspace.tags.overrideMultiTagWarning.prompt3')} - {translate('workspace.tags.switchSingleToMultiLevelTagWarning.prompt4')} - {translate('workspace.tags.switchSingleToMultiLevelTagWarning.prompt5')} - {translate('workspace.tags.switchSingleToMultiLevelTagWarning.prompt6')} - - ), - [translate, policyID, hasDependentTags, isMultiLevelTags, showDownloadErrorModalAfterClose], + {translate('workspace.tags.overrideMultiTagWarning.prompt4')} + + ); - const startMultiLevelTagImportFlow = useCallback(async () => { + const switchSingleToMultiLevelTagPrompt = ( + + {translate('workspace.tags.switchSingleToMultiLevelTagWarning.prompt1')} + {translate('workspace.tags.switchSingleToMultiLevelTagWarning.prompt2')} + { + if (isMultiLevelTags) { + downloadMultiLevelTagsCSV(policyID, showDownloadErrorModalAfterClose, hasDependentTags, translate); + } else { + downloadTagsCSV(policyID, showDownloadErrorModalAfterClose, translate); + } + }} + > + {translate('workspace.tags.switchSingleToMultiLevelTagWarning.prompt3')} + + {translate('workspace.tags.switchSingleToMultiLevelTagWarning.prompt4')} + {translate('workspace.tags.switchSingleToMultiLevelTagWarning.prompt5')} + {translate('workspace.tags.switchSingleToMultiLevelTagWarning.prompt6')} + + ); + + const startMultiLevelTagImportFlow = async () => { setImportedSpreadsheetIsImportingMultiLevelTags(true); if (hasVisibleTags) { if (isMultiLevelTags) { @@ -166,19 +156,7 @@ function ImportTagsOptionsPage({route}: ImportTagsOptionsPageProps) { } else { Navigation.navigate(isQuickSettingsFlow ? ROUTES.SETTINGS_TAGS_IMPORT.getRoute(policyID, ROUTES.SETTINGS_TAGS_ROOT.getRoute(policyID, backTo)) : workspaceTagsImportPath); } - }, [ - hasVisibleTags, - isMultiLevelTags, - showConfirmModal, - translate, - overrideMultiTagPrompt, - isQuickSettingsFlow, - policyID, - shouldRestoreRequiresTagAfterTagCreate, - backTo, - switchSingleToMultiLevelTagPrompt, - workspaceTagsImportPath, - ]); + }; useFocusEffect( useCallback(() => { diff --git a/src/pages/workspace/tags/ImportedTagsPage.tsx b/src/pages/workspace/tags/ImportedTagsPage.tsx index c4b52a86473c..d67462b662d7 100644 --- a/src/pages/workspace/tags/ImportedTagsPage.tsx +++ b/src/pages/workspace/tags/ImportedTagsPage.tsx @@ -26,7 +26,7 @@ import SCREENS from '@src/SCREENS'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; type ImportedTagsPageProps = | PlatformStackScreenProps @@ -40,7 +40,7 @@ function ImportedTagsPage({route}: ImportedTagsPageProps) { const [isValidationEnabled, setIsValidationEnabled] = useState(false); const policyID = route.params.policyID; const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); - const policyTagLists = useMemo(() => getTagLists(policyTags), [policyTags]); + const policyTagLists = getTagLists(policyTags); const policy = usePolicy(policyID); const columnNames = generateColumnNames(spreadsheet?.data?.length ?? 0); const isQuickSettingsFlow = route.name === SCREENS.SETTINGS_TAGS.SETTINGS_TAGS_IMPORTED; @@ -69,7 +69,7 @@ function ImportedTagsPage({route}: ImportedTagsPageProps) { const requiredColumns = columnRoles.filter((role) => role.isRequired).map((role) => role); - const validate = useCallback(() => { + const validate = () => { const columns = Object.values(spreadsheet?.columns ?? {}); let errors: Errors = {}; @@ -91,7 +91,7 @@ function ImportedTagsPage({route}: ImportedTagsPageProps) { } } return errors; - }, [requiredColumns, spreadsheet?.columns, translate, containsHeader, spreadsheet?.data]); + }; const closeImportPageAndModal = () => { setIsClosing(true); diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index d228033b0574..1ce94cb69307 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -82,7 +82,7 @@ import SCREENS from '@src/SCREENS'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import type {PolicyTag, PolicyTagList} from './types'; @@ -133,10 +133,12 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { const expensifyIcons = useMemoizedLazyExpensifyIcons(['Gear', 'Table', 'Download', 'Plus', 'Trashcan', 'Close', 'Trashcan', 'Checkmark']); const buildDynamicRoute = useScreenBoundDynamicRoute(); - const [policyTagLists, isMultiLevelTags, hasDependentTags, hasIndependentTags] = useMemo( - () => [getTagLists(policyTags), isMultiLevelTagsPolicyUtils(policyTags), hasDependentTagsPolicyUtils(policy, policyTags), hasIndependentTagsPolicyUtils(policy, policyTags)], - [policy, policyTags], - ); + const [policyTagLists, isMultiLevelTags, hasDependentTags, hasIndependentTags] = [ + getTagLists(policyTags), + isMultiLevelTagsPolicyUtils(policyTags), + hasDependentTagsPolicyUtils(policy, policyTags), + hasIndependentTagsPolicyUtils(policy, policyTags), + ]; const {canWrite: canWriteTags, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.TAGS); // Multi-level tag rows only ever offered the Required bulk actions, and those moved to Rules, so selecting them @@ -144,39 +146,32 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { const isSelectionEnabled = canWriteTags && !hasDependentTags && !isMultiLevelTags; const canSelectMultiple = isSelectionEnabled && (shouldUseNarrowLayout ? isMobileSelectionModeEnabled : true); const isControlPolicyWithWideLayout = !shouldUseNarrowLayout && isControlPolicy(policy); - const tagApproverEmails = useMemo(() => { - const approverEmails: Record = {}; - - if (isMultiLevelTags) { - return approverEmails; - } - + let tagApproverEmails; + const approverEmails: Record = {}; + if (isMultiLevelTags) { + tagApproverEmails = approverEmails; + } else { for (const tag of Object.values(policyTagLists?.at(0)?.tags ?? {})) { const approverEmail = getTagApproverRule(policy, tag.name)?.approver; - if (approverEmail) { approverEmails[tag.name] = approverEmail; } } - - return approverEmails; - }, [isMultiLevelTags, policy, policyTagLists]); + tagApproverEmails = approverEmails; + } const shouldShowApproverColumn = isControlPolicyWithWideLayout && !isMultiLevelTags && arePolicyRulesEnabled(policy, policyCategories) && Object.keys(tagApproverEmails).length > 0; - const fetchTags = useCallback(() => { + const fetchTags = () => { openPolicyTagsPage(policyID); - }, [policyID]); + }; const isQuickSettingsFlow = route.name === SCREENS.SETTINGS_TAGS.SETTINGS_TAGS_ROOT; - const tagsList = useMemo(() => { - if (isMultiLevelTags) { - return policyTagLists.reduce>((acc, policyTagList) => { - acc[policyTagList.name] = policyTagList; - return acc; - }, {}); - } - return policyTagLists?.at(0)?.tags; - }, [isMultiLevelTags, policyTagLists]); + const tagsList = isMultiLevelTags + ? policyTagLists.reduce>((acc, policyTagList) => { + acc[policyTagList.name] = policyTagList; + return acc; + }, {}) + : policyTagLists?.at(0)?.tags; const [selectedTagKeys, setSelectedTagKeys] = useState([]); @@ -236,9 +231,9 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [tagsList]); - const clearTableSelection = useCallback(() => { + const clearTableSelection = () => { setSelectedTagKeys((prevSelectedTagKeys) => (prevSelectedTagKeys.length > 0 ? [] : prevSelectedTagKeys)); - }, []); + }; useCleanupSelectedOptions(clearTableSelection); @@ -247,73 +242,61 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { onNavigationCallBack: () => Navigation.goBack(backTo), }); - const updateWorkspaceTagEnabled = useCallback( - (value: boolean, tagName: string) => { - if (!canWriteTags) { - showReadOnlyModal(); - return; - } + const updateWorkspaceTagEnabled = (value: boolean, tagName: string) => { + if (!canWriteTags) { + showReadOnlyModal(); + return; + } - setWorkspaceTagEnabled(policyData, {[tagName]: {name: tagName, enabled: value}}, 0, isVendorMatchingBetaEnabled); - }, - [canWriteTags, policyData, showReadOnlyModal, isVendorMatchingBetaEnabled], - ); + setWorkspaceTagEnabled(policyData, {[tagName]: {name: tagName, enabled: value}}, 0, isVendorMatchingBetaEnabled); + }; - const updateWorkspaceRequiresTag = useCallback( - (value: boolean, orderWeight: number) => { - if (!canWriteTags) { - showReadOnlyModal(); - return; - } + const updateWorkspaceRequiresTag = (value: boolean, orderWeight: number) => { + if (!canWriteTags) { + showReadOnlyModal(); + return; + } - setPolicyTagsRequired(policyData, value, orderWeight, isVendorMatchingBetaEnabled); - }, - [canWriteTags, policyData, showReadOnlyModal, isVendorMatchingBetaEnabled], - ); + setPolicyTagsRequired(policyData, value, orderWeight, isVendorMatchingBetaEnabled); + }; const shouldShowGLCodeColumn = isControlPolicyWithWideLayout && !isMultiLevelTags && Object.values(policyTagLists?.at(0)?.tags ?? {}).some((tag) => !!tag['GL Code']); - const showAllTagsDisabledWarning = useCallback(() => { + const showAllTagsDisabledWarning = () => { showConfirmModal({ title: translate('workspace.tags.cannotDeleteOrDisableAllTags.title'), prompt: translate('workspace.tags.cannotDeleteOrDisableAllTags.description'), confirmText: translate('common.buttonConfirm'), shouldShowCancelButton: false, }); - }, [showConfirmModal, translate]); + }; - const handleTagEnabledToggle = useCallback( - (enabled: boolean, tag: PolicyTag) => { - if (!canWriteTags) { - showReadOnlyModal(); - return; - } + const handleTagEnabledToggle = (enabled: boolean, tag: PolicyTag) => { + if (!canWriteTags) { + showReadOnlyModal(); + return; + } - if (isDisablingOrDeletingLastEnabledTag(policyTagLists.at(0), [tag])) { - showAllTagsDisabledWarning(); - return; - } + if (isDisablingOrDeletingLastEnabledTag(policyTagLists.at(0), [tag])) { + showAllTagsDisabledWarning(); + return; + } - updateWorkspaceTagEnabled(enabled, tag.name); - }, - [canWriteTags, policyTagLists, showAllTagsDisabledWarning, showReadOnlyModal, updateWorkspaceTagEnabled], - ); + updateWorkspaceTagEnabled(enabled, tag.name); + }; - const navigateToTagSettings = useCallback( - (tagValue: string, orderWeight?: number) => { - if (orderWeight !== undefined) { - Navigation.navigate( - buildDynamicRoute(isQuickSettingsFlow ? DYNAMIC_ROUTES.SETTINGS_TAG_LIST_VIEW.getRoute(orderWeight) : DYNAMIC_ROUTES.WORKSPACE_TAG_LIST_VIEW.getRoute(orderWeight)), - ); - } else { - Navigation.navigate( - isQuickSettingsFlow - ? buildDynamicRoute(DYNAMIC_ROUTES.SETTINGS_TAG_SETTINGS.getRoute(0, tagValue)) - : buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_TAG_SETTINGS.getRoute(0, tagValue)), - ); - } - }, - [buildDynamicRoute, isQuickSettingsFlow], - ); + const navigateToTagSettings = (tagValue: string, orderWeight?: number) => { + if (orderWeight !== undefined) { + Navigation.navigate( + buildDynamicRoute(isQuickSettingsFlow ? DYNAMIC_ROUTES.SETTINGS_TAG_LIST_VIEW.getRoute(orderWeight) : DYNAMIC_ROUTES.WORKSPACE_TAG_LIST_VIEW.getRoute(orderWeight)), + ); + } else { + Navigation.navigate( + isQuickSettingsFlow + ? buildDynamicRoute(DYNAMIC_ROUTES.SETTINGS_TAG_SETTINGS.getRoute(0, tagValue)) + : buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_TAG_SETTINGS.getRoute(0, tagValue)), + ); + } + }; useEffect(() => { if (!canWriteTags || !isMultiLevelTags || hasDependentTags) { @@ -330,61 +313,52 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { const isSelectionModeActive = selectedTagKeys.length > 0 || isMobileSelectionModeEnabled; - const tagRows = useMemo(() => { - if (isMultiLevelTags) { - return policyTagLists.reduce((acc, policyTagList) => { - const isDisabled = policyTagList.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - - if (!isOffline && isDisabled) { - return acc; - } - - const areTagsEnabled = !!Object.values(policyTagList?.tags ?? {}).some((tag) => tag.enabled); - const isSwitchDisabled = !policyTagList.required && !areTagsEnabled; - const isSwitchEnabled = policyTagList.required && areTagsEnabled; - const tagCount = Object.keys(policyTagList?.tags ?? {}).length; - - acc.push({ - keyForList: policyTagList.name, - value: policyTagList.name, - name: getCleanedTagName(policyTagList.name), - orderWeight: policyTagList.orderWeight, - enabled: true, - required: isSwitchEnabled, - tagCount, - disabled: isDisabled, - isSelectionDisabled: isSwitchDisabled, - isSwitchDisabled, - pendingAction: getPendingAction(policyTagList), - isLocked: !canWriteTags || isMakingLastRequiredTagListOptional(policy, policyTags, [policyTagList]), - showEnabledSwitch: false, - // Inline renaming targets single-level tags only; tag lists are renamed from their settings page. - action: () => navigateToTagSettings(policyTagList.name, policyTagList.orderWeight), - onClose: () => {}, - }); - + let tagRows: WorkspaceTagTableRowData[]; + if (isMultiLevelTags) { + tagRows = policyTagLists.reduce((acc, policyTagList) => { + const isDisabled = policyTagList.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + if (!isOffline && isDisabled) { return acc; - }, []); - } - + } + const areTagsEnabled = !!Object.values(policyTagList?.tags ?? {}).some((tag) => tag.enabled); + const isSwitchDisabled = !policyTagList.required && !areTagsEnabled; + const isSwitchEnabled = policyTagList.required && areTagsEnabled; + const tagCount = Object.keys(policyTagList?.tags ?? {}).length; + acc.push({ + keyForList: policyTagList.name, + value: policyTagList.name, + name: getCleanedTagName(policyTagList.name), + orderWeight: policyTagList.orderWeight, + enabled: true, + required: isSwitchEnabled, + tagCount, + disabled: isDisabled, + isSelectionDisabled: isSwitchDisabled, + isSwitchDisabled, + pendingAction: getPendingAction(policyTagList), + isLocked: !canWriteTags || isMakingLastRequiredTagListOptional(policy, policyTags, [policyTagList]), + showEnabledSwitch: false, + // Inline renaming targets single-level tags only; tag lists are renamed from their settings page. + action: () => navigateToTagSettings(policyTagList.name, policyTagList.orderWeight), + onClose: () => {}, + }); + return acc; + }, []); + } else { const firstTagList = policyTagLists.at(0); const enabledTagsCount = getCountOfEnabledTagsOfList(firstTagList?.tags); const isLastEnabledTagLocked = !!firstTagList?.required && enabledTagsCount === 1; const shouldShowPendingSwitch = Object.keys(firstTagList?.tags ?? {}).length > PENDING_SWITCH_TAG_COUNT_THRESHOLD; - - return Object.values(firstTagList?.tags ?? {}).reduce((acc, tag) => { + tagRows = Object.values(firstTagList?.tags ?? {}).reduce((acc, tag) => { const isDisabled = tag.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - if (!isOffline && isDisabled) { return acc; } - const approverEmail = shouldShowApproverColumn ? tagApproverEmails[tag.name] : undefined; const approverPersonalDetail = employeePersonalDetails[approverEmail ?? '']; const {avatar: approverAvatar, displayName = approverEmail, accountID: approverAccountID} = approverPersonalDetail ?? {}; const approverDisplayName = displayName ? formatPhoneNumber(displayName) : ''; const isLastEnabledTagAndEnabled = isLastEnabledTagLocked && tag.enabled; - acc.push({ keyForList: tag.name, value: tag.name, @@ -404,44 +378,27 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { action: () => navigateToTagSettings(tag.name), onToggleEnabled: (enabled: boolean) => handleTagEnabledToggle(enabled, tag), onRenameName: (newName: string) => showInlineEditError(renameTagInline(translate, policyData, tag.name, newName, isVendorMatchingBetaEnabled)), - onClose: () => clearPolicyTagErrors({policyID, tagName: tag.name, tagListIndex: 0, policyTags}), + onClose: () => + clearPolicyTagErrors({ + policyID, + tagName: tag.name, + tagListIndex: 0, + policyTags, + }), }); - return acc; }, []); - }, [ - canWriteTags, - isSelectionModeActive, - policyData, - handleTagEnabledToggle, - isMultiLevelTags, - isOffline, - navigateToTagSettings, - policy, - policyID, - policyTagLists, - translate, - policyTags, - shouldShowApproverColumn, - tagApproverEmails, - employeePersonalDetails, - formatPhoneNumber, - isVendorMatchingBetaEnabled, - ]); - - const tagRowsKeyedByName = useMemo( - () => - tagRows.reduce>((acc, tag) => { - acc[tag.value] = tag; - return acc; - }, {}), - [tagRows], - ); + } - const navigateToCustomTagName = useCallback(() => { + const tagRowsKeyedByName = tagRows.reduce>((acc, tag) => { + acc[tag.value] = tag; + return acc; + }, {}); + + const navigateToCustomTagName = () => { const orderWeight = policyTagLists.at(0)?.orderWeight ?? 0; Navigation.navigate(buildDynamicRoute(isQuickSettingsFlow ? DYNAMIC_ROUTES.SETTINGS_TAGS_EDIT.getRoute(orderWeight) : DYNAMIC_ROUTES.WORKSPACE_EDIT_TAGS.getRoute(orderWeight))); - }, [buildDynamicRoute, isQuickSettingsFlow, policyTagLists]); + }; const navigateToCreateTagPage = () => { Navigation.navigate(isQuickSettingsFlow ? buildDynamicRoute(DYNAMIC_ROUTES.SETTINGS_TAG_CREATE.path) : buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_TAG_CREATE.path)); @@ -459,7 +416,7 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { const isLoading = !isOffline && policyTags === undefined; const hasVisibleTags = tagRows.some((tag) => tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline); - const navigateToImportSpreadsheet = useCallback(() => { + const navigateToImportSpreadsheet = () => { if (isOffline) { showConfirmModal({ title: translate('common.youAppearToBeOffline'), @@ -475,9 +432,9 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { ? ROUTES.SETTINGS_TAGS_IMPORT.getRoute(policyID, ROUTES.SETTINGS_TAGS_ROOT.getRoute(policyID, backTo)) : ROUTES.WORKSPACE_TAGS_IMPORT_OPTIONS.getRoute(policyID), ); - }, [backTo, isOffline, isQuickSettingsFlow, policyID, showConfirmModal, translate]); + }; - const navigateToAppendCustomTagList = useCallback(() => { + const navigateToAppendCustomTagList = () => { if (isOffline) { showConfirmModal({ title: translate('common.youAppearToBeOffline'), @@ -492,129 +449,102 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { setImportedSpreadsheetIsImportingMultiLevelTags(true); setImportedSpreadsheetIsAppendingToExistingLists(true); Navigation.navigate(buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_TAGS_IMPORT.path)); - }, [buildDynamicRoute, isOffline, showConfirmModal, translate]); + }; const hasAccountingConnections = hasAccountingConnectionsPolicyUtils(policy); // Same as Classic, a custom tag list can only be appended to single-level or independent multi-level tags const shouldShowAppendCustomTagList = canWriteTags && hasAccountingConnections && isIndirectTagUploadsBetaEnabled && !isQuickSettingsFlow && (!isMultiLevelTags || hasIndependentTags); - const secondaryActions = useMemo(() => { - const menuItems = []; - // The former Settings page's rows are surfaced directly in this menu (the dedicated Settings page was removed). - if (canWriteTags && !isMultiLevelTags) { - menuItems.push({ - // Match the design: the "Custom tag name" label is the small gray description on top, the tag list name is the regular-weight title below. - text: policyTagLists.at(0)?.name ?? '', - description: translate('workspace.tags.customTagName'), - shouldShowDescriptionOnTop: true, - titleStyle: styles.fontWeightNormal, - onSelected: navigateToCustomTagName, - shouldShowRightIcon: true, - shouldIgnoreCompactStyle: true, - value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, - pendingAction: policyTags?.[policyTagLists.at(0)?.name ?? '']?.pendingAction, - errors: policyTags?.[policyTagLists.at(0)?.name ?? '']?.errors, - onCloseError: () => clearPolicyTagListErrors({policyID, tagListIndex: policyTagLists.at(0)?.orderWeight ?? 0, policyTags}), - }); - } - if (canWriteTags && !!policy?.glCodes) { - menuItems.push({ - text: translate('workspace.tags.showTagGLCodes'), - value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, - // Selecting the row (click or Enter) toggles it; the Switch is a display-only indicator. Keep the menu open on select. - shouldCloseModalOnSelect: false, - onSelected: () => setPolicyShowTagGLCodes(policyID, !(policy?.showTagGLCodes ?? false), policy?.showTagGLCodes), - shouldIgnoreCompactStyle: true, - // Let the label wrap fully and keep the Switch centered against it on narrow screens. - numberOfLinesTitle: 0, - innerContainerStyle: styles.alignItemsCenter, - titleStyle: [styles.textLabel, styles.fontWeightNormal], - pendingAction: policy?.pendingFields?.showTagGLCodes, - errors: policy?.errorFields?.showTagGLCodes, - onCloseError: () => clearPolicyErrorField(policyID, 'showTagGLCodes'), - switchProps: { - isOn: policy?.showTagGLCodes ?? false, - accessibilityLabel: translate('workspace.tags.showTagGLCodes'), - onToggle: (value: boolean) => setPolicyShowTagGLCodes(policyID, value, policy?.showTagGLCodes), - disabled: !policy?.areTagsEnabled, - }, - }); - } - - if (canWriteTags && !hasAccountingConnections) { - menuItems.push({ - icon: expensifyIcons.Table, - text: translate('spreadsheet.importSpreadsheet'), - onSelected: navigateToImportSpreadsheet, - value: CONST.POLICY.SECONDARY_ACTIONS.IMPORT_SPREADSHEET, - // Group the settings rows apart from the spreadsheet actions. - addSeparatorBefore: true, - }); - } - - if (shouldShowAppendCustomTagList) { - menuItems.push({ - icon: expensifyIcons.Table, - text: translate('workspace.tags.appendCustomTagList'), - onSelected: navigateToAppendCustomTagList, - value: CONST.POLICY.SECONDARY_ACTIONS.APPEND_CUSTOM_TAG_LIST, - }); - } - - if (hasVisibleTags) { - menuItems.push({ - icon: expensifyIcons.Download, - text: translate('spreadsheet.downloadCSV'), - onSelected: () => { - if (isOffline) { - showConfirmModal({ - title: translate('common.youAppearToBeOffline'), - prompt: translate('common.thisFeatureRequiresInternet'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - shouldHandleNavigationBack: true, - }); - return; - } - close(() => { - if (isMultiLevelTags) { - downloadMultiLevelTagsCSV(policyID, showDownloadErrorModal, hasDependentTags, translate); - } else { - downloadTagsCSV(policyID, showDownloadErrorModal, translate); - } + const menuItems = []; + // The former Settings page's rows are surfaced directly in this menu (the dedicated Settings page was removed). + if (canWriteTags && !isMultiLevelTags) { + menuItems.push({ + // Match the design: the "Custom tag name" label is the small gray description on top, the tag list name is the regular-weight title below. + text: policyTagLists.at(0)?.name ?? '', + description: translate('workspace.tags.customTagName'), + shouldShowDescriptionOnTop: true, + titleStyle: styles.fontWeightNormal, + onSelected: navigateToCustomTagName, + shouldShowRightIcon: true, + shouldIgnoreCompactStyle: true, + value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, + pendingAction: policyTags?.[policyTagLists.at(0)?.name ?? '']?.pendingAction, + errors: policyTags?.[policyTagLists.at(0)?.name ?? '']?.errors, + onCloseError: () => + clearPolicyTagListErrors({ + policyID, + tagListIndex: policyTagLists.at(0)?.orderWeight ?? 0, + policyTags, + }), + }); + } + if (canWriteTags && !!policy?.glCodes) { + menuItems.push({ + text: translate('workspace.tags.showTagGLCodes'), + value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, + // Selecting the row (click or Enter) toggles it; the Switch is a display-only indicator. Keep the menu open on select. + shouldCloseModalOnSelect: false, + onSelected: () => setPolicyShowTagGLCodes(policyID, !(policy?.showTagGLCodes ?? false), policy?.showTagGLCodes), + shouldIgnoreCompactStyle: true, + // Let the label wrap fully and keep the Switch centered against it on narrow screens. + numberOfLinesTitle: 0, + innerContainerStyle: styles.alignItemsCenter, + titleStyle: [styles.textLabel, styles.fontWeightNormal], + pendingAction: policy?.pendingFields?.showTagGLCodes, + errors: policy?.errorFields?.showTagGLCodes, + onCloseError: () => clearPolicyErrorField(policyID, 'showTagGLCodes'), + switchProps: { + isOn: policy?.showTagGLCodes ?? false, + accessibilityLabel: translate('workspace.tags.showTagGLCodes'), + onToggle: (value: boolean) => setPolicyShowTagGLCodes(policyID, value, policy?.showTagGLCodes), + disabled: !policy?.areTagsEnabled, + }, + }); + } + if (canWriteTags && !hasAccountingConnections) { + menuItems.push({ + icon: expensifyIcons.Table, + text: translate('spreadsheet.importSpreadsheet'), + onSelected: navigateToImportSpreadsheet, + value: CONST.POLICY.SECONDARY_ACTIONS.IMPORT_SPREADSHEET, + // Group the settings rows apart from the spreadsheet actions. + addSeparatorBefore: true, + }); + } + if (shouldShowAppendCustomTagList) { + menuItems.push({ + icon: expensifyIcons.Table, + text: translate('workspace.tags.appendCustomTagList'), + onSelected: navigateToAppendCustomTagList, + value: CONST.POLICY.SECONDARY_ACTIONS.APPEND_CUSTOM_TAG_LIST, + }); + } + if (hasVisibleTags) { + menuItems.push({ + icon: expensifyIcons.Download, + text: translate('spreadsheet.downloadCSV'), + onSelected: () => { + if (isOffline) { + showConfirmModal({ + title: translate('common.youAppearToBeOffline'), + prompt: translate('common.thisFeatureRequiresInternet'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + shouldHandleNavigationBack: true, }); - }, - value: CONST.POLICY.SECONDARY_ACTIONS.DOWNLOAD_CSV, - }); - } - - return menuItems; - }, [ - translate, - hasAccountingConnections, - shouldShowAppendCustomTagList, - navigateToAppendCustomTagList, - hasVisibleTags, - navigateToImportSpreadsheet, - isOffline, - isMultiLevelTags, - policyID, - hasDependentTags, - expensifyIcons, - showConfirmModal, - showDownloadErrorModal, - canWriteTags, - policyTagLists, - policyTags, - navigateToCustomTagName, - policy?.glCodes, - policy?.showTagGLCodes, - policy?.areTagsEnabled, - policy?.pendingFields?.showTagGLCodes, - policy?.errorFields?.showTagGLCodes, - styles.alignItemsCenter, - styles.textLabel, - styles.fontWeightNormal, - ]); + return; + } + close(() => { + if (isMultiLevelTags) { + downloadMultiLevelTagsCSV(policyID, showDownloadErrorModal, hasDependentTags, translate); + } else { + downloadTagsCSV(policyID, showDownloadErrorModal, translate); + } + }); + }, + value: CONST.POLICY.SECONDARY_ACTIONS.DOWNLOAD_CSV, + }); + } + const secondaryActions = menuItems; const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); @@ -803,16 +733,14 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { const headerContent = {getHeaderSubtitle()}; - const subtitleText = useMemo(() => { - const emptyTagsSubtitle = hasAccountingConnections - ? translate('workspace.tags.emptyTags.subtitleWithAccounting', `${environmentURL}/${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}`, canWriteTags) - : translate('workspace.tags.emptyTags.subtitleHTML'); - return ( - - - - ); - }, [hasAccountingConnections, translate, environmentURL, policyID, canWriteTags, styles.renderHTML, styles.textAlignCenter, styles.alignItemsCenter]); + const emptyTagsSubtitle = hasAccountingConnections + ? translate('workspace.tags.emptyTags.subtitleWithAccounting', `${environmentURL}/${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}`, canWriteTags) + : translate('workspace.tags.emptyTags.subtitleHTML'); + const subtitleText = ( + + + + ); const tagsTableEmptyState = { title: translate('workspace.tags.emptyTags.title'), diff --git a/src/pages/workspace/taxes/NamePage.tsx b/src/pages/workspace/taxes/NamePage.tsx index 3ade3070d648..cc9dc14d3b42 100644 --- a/src/pages/workspace/taxes/NamePage.tsx +++ b/src/pages/workspace/taxes/NamePage.tsx @@ -26,7 +26,7 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/WorkspaceTaxNameForm'; -import React, {useCallback, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; type NamePageProps = WithPolicyAndFullscreenLoadingProps & PlatformStackScreenProps; @@ -44,7 +44,7 @@ function NamePage({ const [name, setName] = useState(currentTaxRate?.name ?? ''); - const goBack = useCallback(() => Navigation.goBack(ROUTES.WORKSPACE_TAX_EDIT.getRoute(policyID, taxID)), [policyID, taxID]); + const goBack = () => Navigation.goBack(ROUTES.WORKSPACE_TAX_EDIT.getRoute(policyID, taxID)); const submit = () => { const taxName = name.trim(); @@ -55,18 +55,15 @@ function NamePage({ goBack(); }; - const validate = useCallback( - (values: FormOnyxValues) => { - if (!policy) { - return {}; - } - if (values[INPUT_IDS.NAME] === currentTaxRate?.name) { - return {}; - } - return validateTaxName(policy, values, translate); - }, - [currentTaxRate?.name, policy, translate], - ); + const validate = (values: FormOnyxValues) => { + if (!policy) { + return {}; + } + if (values[INPUT_IDS.NAME] === currentTaxRate?.name) { + return {}; + } + return validateTaxName(policy, values, translate); + }; if (!currentTaxRate) { return ; diff --git a/src/pages/workspace/taxes/ValuePage.tsx b/src/pages/workspace/taxes/ValuePage.tsx index 4b06ff64c147..394ab75cd304 100644 --- a/src/pages/workspace/taxes/ValuePage.tsx +++ b/src/pages/workspace/taxes/ValuePage.tsx @@ -28,7 +28,7 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/WorkspaceTaxValueForm'; -import React, {useCallback} from 'react'; +import React from 'react'; type ValuePageProps = WithPolicyAndFullscreenLoadingProps & PlatformStackScreenProps; @@ -44,19 +44,16 @@ function ValuePage({ const currentTaxRate = getTaxByID(policy, taxID); const defaultValue = currentTaxRate?.value?.replace('%', ''); - const goBack = useCallback(() => Navigation.goBack(ROUTES.WORKSPACE_TAX_EDIT.getRoute(policyID, taxID)), [policyID, taxID]); + const goBack = () => Navigation.goBack(ROUTES.WORKSPACE_TAX_EDIT.getRoute(policyID, taxID)); - const submit = useCallback( - (values: FormOnyxValues) => { - if (defaultValue === values.value || !policy?.taxRates?.taxes[taxID]) { - goBack(); - return; - } - updatePolicyTaxValue(policyID, taxID, Number(values.value), policy?.taxRates?.taxes[taxID]); + const submit = (values: FormOnyxValues) => { + if (defaultValue === values.value || !policy?.taxRates?.taxes[taxID]) { goBack(); - }, - [defaultValue, policyID, taxID, policy?.taxRates, goBack], - ); + return; + } + updatePolicyTaxValue(policyID, taxID, Number(values.value), policy?.taxRates?.taxes[taxID]); + goBack(); + }; if (!currentTaxRate) { return ; diff --git a/src/pages/workspace/taxes/WorkspaceTaxCodePage.tsx b/src/pages/workspace/taxes/WorkspaceTaxCodePage.tsx index d6328404fa36..ad1ccea287a1 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxCodePage.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxCodePage.tsx @@ -24,7 +24,7 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/WorkspaceTaxCodeForm'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; type WorkspaceTaxCodePageProps = PlatformStackScreenProps; @@ -41,46 +41,40 @@ function WorkspaceTaxCodePage({route}: WorkspaceTaxCodePageProps) { const distanceRateCustomUnit = getDistanceRateCustomUnit(policy); const isTaxCodeCustomized = isTaxCodeCustomizedUtil(currentTaxCode, policy); - const setTaxCode = useCallback( - (values: FormOnyxValues) => { - const newTaxCode = values.taxCode.trim(); - if (currentTaxCode === newTaxCode && isTaxCodeCustomized) { - Navigation.goBack(ROUTES.WORKSPACE_TAX_EDIT.getRoute(policyID, currentTaxCode)); - return; - } - - if (!policy?.taxRates?.taxes[currentTaxCode]) { - return; - } - setPolicyTaxCode( - policyID, - currentTaxCode, - newTaxCode, - policy?.taxRates?.taxes[currentTaxCode], - policy?.taxRates?.foreignTaxDefault, - policy?.taxRates?.defaultExternalID, - distanceRateCustomUnit, - policy?.rules?.expenseRules, - ); + const setTaxCode = (values: FormOnyxValues) => { + const newTaxCode = values.taxCode.trim(); + if (currentTaxCode === newTaxCode && isTaxCodeCustomized) { Navigation.goBack(ROUTES.WORKSPACE_TAX_EDIT.getRoute(policyID, currentTaxCode)); - }, - [currentTaxCode, policyID, policy?.taxRates, policy?.rules?.expenseRules, distanceRateCustomUnit, isTaxCodeCustomized], - ); - - const validate = useCallback( - (values: FormOnyxValues) => { - if (!policy) { - return {}; - } - const newTaxCode = values.taxCode.trim(); - if (newTaxCode === currentTaxCode) { - return {}; - } - - return validateTaxCode(policy, values, translate); - }, - [currentTaxCode, policy, translate], - ); + return; + } + + if (!policy?.taxRates?.taxes[currentTaxCode]) { + return; + } + setPolicyTaxCode( + policyID, + currentTaxCode, + newTaxCode, + policy?.taxRates?.taxes[currentTaxCode], + policy?.taxRates?.foreignTaxDefault, + policy?.taxRates?.defaultExternalID, + distanceRateCustomUnit, + policy?.rules?.expenseRules, + ); + Navigation.goBack(ROUTES.WORKSPACE_TAX_EDIT.getRoute(policyID, currentTaxCode)); + }; + + const validate = (values: FormOnyxValues) => { + if (!policy) { + return {}; + } + const newTaxCode = values.taxCode.trim(); + if (newTaxCode === currentTaxCode) { + return {}; + } + + return validateTaxCode(policy, values, translate); + }; return ( ; @@ -91,9 +91,9 @@ function WorkspaceTaxesPage({ const disabledRatesCount = selectedTaxesIDs.length - enabledRatesCount; const icons = useMemoizedLazyExpensifyIcons(['Checkmark', 'Close', 'Gear', 'Plus', 'Trashcan']); - const fetchTaxes = useCallback(() => { + const fetchTaxes = () => { openPolicyTaxesPage(policyID); - }, [policyID]); + }; const {isOffline} = useNetwork({onReconnect: fetchTaxes}); @@ -102,9 +102,9 @@ function WorkspaceTaxesPage({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const clearTableSelection = useCallback(() => { + const clearTableSelection = () => { setSelectedTaxesIDs((prevSelectedTaxesIDs) => (prevSelectedTaxesIDs.length > 0 ? [] : prevSelectedTaxesIDs)); - }, []); + }; useCleanupSelectedOptions(clearTableSelection); @@ -136,54 +136,41 @@ function WorkspaceTaxesPage({ onNavigationCallBack: () => Navigation.goBack(), }); - const getDefaultLabel = useCallback( - (taxID: string): string => { - if (taxID === defaultExternalID && taxID === foreignTaxDefault) { - return translate('common.default'); - } - if (taxID === defaultExternalID) { - return translate('workspace.taxes.workspaceDefault'); - } - if (taxID === foreignTaxDefault) { - return translate('workspace.taxes.foreignDefault'); - } - return ''; - }, - [defaultExternalID, foreignTaxDefault, translate], - ); - - const updateWorkspaceTaxEnabled = useCallback( - (value: boolean, taxID: string) => { - if (!canWriteTaxes) { - showReadOnlyModal(); - return; - } - setPolicyTaxesEnabled(policy, [taxID], value); - }, - [canWriteTaxes, policy, showReadOnlyModal], - ); - - const navigateToEditTaxRate = useCallback( - (taxID: string) => { - Navigation.navigate(ROUTES.WORKSPACE_TAX_EDIT.getRoute(policyID, taxID)); - }, - [policyID], - ); + const getDefaultLabel = (taxID: string): string => { + if (taxID === defaultExternalID && taxID === foreignTaxDefault) { + return translate('common.default'); + } + if (taxID === defaultExternalID) { + return translate('workspace.taxes.workspaceDefault'); + } + if (taxID === foreignTaxDefault) { + return translate('workspace.taxes.foreignDefault'); + } + return ''; + }; - const taxRows = useMemo(() => { - if (!policy) { - return []; + const updateWorkspaceTaxEnabled = (value: boolean, taxID: string) => { + if (!canWriteTaxes) { + showReadOnlyModal(); + return; } + setPolicyTaxesEnabled(policy, [taxID], value); + }; - return Object.entries(policy.taxRates?.taxes ?? {}).reduce((acc, [key, value]) => { - const isDeleting = value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + const navigateToEditTaxRate = (taxID: string) => { + Navigation.navigate(ROUTES.WORKSPACE_TAX_EDIT.getRoute(policyID, taxID)); + }; + let taxRows: WorkspaceTaxTableRowData[]; + if (!policy) { + taxRows = []; + } else { + taxRows = Object.entries(policy.taxRates?.taxes ?? {}).reduce((acc, [key, value]) => { + const isDeleting = value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; if (!isOffline && isDeleting) { return acc; } - const canDisableOrDeleteTaxRate = canWriteTaxes && canDisableOrDeleteTaxRateUtil(policy, key); - acc.push({ keyForList: key, name: value.name, @@ -203,10 +190,9 @@ function WorkspaceTaxesPage({ onDisabledSwitchPress: withReadOnlyFallback(), onClose: () => clearTaxRateError(policyID, key, value.pendingAction), }); - return acc; }, []); - }, [canWriteTaxes, getDefaultLabel, isOffline, navigateToEditTaxRate, policy, policyID, updateWorkspaceTaxEnabled, withReadOnlyFallback]); + } // Tax codes are a Control feature, and a Collect workspace is sent to the upgrade path instead of an editable code, // so the column has nothing to show there. @@ -216,101 +202,84 @@ function WorkspaceTaxesPage({ const hasVisibleTaxes = taxRows.length > 0; const isLoading = !isOffline && !policy?.taxRates; - const deleteTaxes = useCallback(() => { + const deleteTaxes = () => { if (!policy?.id) { return; } deletePolicyTaxes(policy, selectedTaxesIDs, localeCompare); setSelectedTaxesIDs([]); - }, [policy, selectedTaxesIDs, localeCompare]); - - const toggleTaxes = useCallback( - (isEnabled: boolean) => { - if (!policy?.id) { - return; - } - setPolicyTaxesEnabled(policy, selectedTaxesIDs, isEnabled); - setSelectedTaxesIDs([]); - }, - [policy, selectedTaxesIDs], - ); - - const dropdownMenuOptions = useMemo(() => { - const isMultiple = selectedTaxesIDs.length > 1; - const options: Array> = []; - if (!hasAccountingConnections) { - options.push({ - icon: icons.Trashcan, - text: isMultiple ? translate('workspace.taxes.actions.deleteMultiple') : translate('workspace.taxes.actions.delete'), - value: CONST.POLICY.BULK_ACTION_TYPES.DELETE, - shouldSkipFocusRestore: true, - onSelected: async () => { - const {action} = await showConfirmModal({ - title: translate('workspace.taxes.actions.delete'), - prompt: - selectedTaxesIDs.length > 1 - ? translate('workspace.taxes.deleteMultipleTaxConfirmation', selectedTaxesIDs.length) - : translate('workspace.taxes.deleteTaxConfirmation'), - confirmText: translate('common.delete'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }); - if (action === ModalActions.CONFIRM) { - deleteTaxes(); - } - }, - }); - } + }; - // `Disable rates` when at least one enabled rate is selected. - if (selectedTaxesIDs.some((taxID) => !policy?.taxRates?.taxes[taxID]?.isDisabled)) { - options.push({ - icon: icons.Close, - text: translate('workspace.taxes.actions.disableTaxRates', {count: enabledRatesCount}), - value: CONST.POLICY.BULK_ACTION_TYPES.DISABLE, - onSelected: () => toggleTaxes(false), - }); + const toggleTaxes = (isEnabled: boolean) => { + if (!policy?.id) { + return; } + setPolicyTaxesEnabled(policy, selectedTaxesIDs, isEnabled); + setSelectedTaxesIDs([]); + }; - // `Enable rates` when at least one disabled rate is selected. - if (selectedTaxesIDs.some((taxID) => policy?.taxRates?.taxes[taxID]?.isDisabled)) { - options.push({ - icon: icons.Checkmark, - text: translate('workspace.taxes.actions.enableTaxRates', {count: disabledRatesCount}), - value: CONST.POLICY.BULK_ACTION_TYPES.ENABLE, - onSelected: () => toggleTaxes(true), - }); - } - return options; - }, [ - hasAccountingConnections, - icons.Checkmark, - icons.Close, - icons.Trashcan, - policy?.taxRates?.taxes, - selectedTaxesIDs, - toggleTaxes, - translate, - enabledRatesCount, - disabledRatesCount, - showConfirmModal, - deleteTaxes, - ]); + const isMultiple = selectedTaxesIDs.length > 1; + const options: Array> = []; + if (!hasAccountingConnections) { + options.push({ + icon: icons.Trashcan, + text: isMultiple ? translate('workspace.taxes.actions.deleteMultiple') : translate('workspace.taxes.actions.delete'), + value: CONST.POLICY.BULK_ACTION_TYPES.DELETE, + shouldSkipFocusRestore: true, + onSelected: async () => { + const {action} = await showConfirmModal({ + title: translate('workspace.taxes.actions.delete'), + prompt: + selectedTaxesIDs.length > 1 + ? translate('workspace.taxes.deleteMultipleTaxConfirmation', selectedTaxesIDs.length) + : translate('workspace.taxes.deleteTaxConfirmation'), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }); + if (action === ModalActions.CONFIRM) { + deleteTaxes(); + } + }, + }); + } + + // `Disable rates` when at least one enabled rate is selected. + if (selectedTaxesIDs.some((taxID) => !policy?.taxRates?.taxes[taxID]?.isDisabled)) { + options.push({ + icon: icons.Close, + text: translate('workspace.taxes.actions.disableTaxRates', { + count: enabledRatesCount, + }), + value: CONST.POLICY.BULK_ACTION_TYPES.DISABLE, + onSelected: () => toggleTaxes(false), + }); + } + + // `Enable rates` when at least one disabled rate is selected. + if (selectedTaxesIDs.some((taxID) => policy?.taxRates?.taxes[taxID]?.isDisabled)) { + options.push({ + icon: icons.Checkmark, + text: translate('workspace.taxes.actions.enableTaxRates', { + count: disabledRatesCount, + }), + value: CONST.POLICY.BULK_ACTION_TYPES.ENABLE, + onSelected: () => toggleTaxes(true), + }); + } + const dropdownMenuOptions = options; const shouldShowBulkActionsButton = canWriteTaxes && (shouldUseNarrowLayout ? isMobileSelectionModeEnabled : selectedTaxesIDs.length > 0); - const secondaryActions = useMemo( - () => [ - { - icon: icons.Gear, - text: translate('common.settings'), - onSelected: () => Navigation.navigate(ROUTES.WORKSPACE_TAXES_SETTINGS.getRoute(policyID)), - value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, - }, - ], - [icons.Gear, policyID, translate], - ); + const secondaryActions = [ + { + icon: icons.Gear, + text: translate('common.settings'), + onSelected: () => Navigation.navigate(ROUTES.WORKSPACE_TAXES_SETTINGS.getRoute(policyID)), + value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, + }, + ]; const getHeaderButtons = () => { if (!canWriteTaxes) { diff --git a/src/pages/workspace/taxes/WorkspaceTaxesSettingsCustomTaxName.tsx b/src/pages/workspace/taxes/WorkspaceTaxesSettingsCustomTaxName.tsx index 966177a880ff..d260384f02e1 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxesSettingsCustomTaxName.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxesSettingsCustomTaxName.tsx @@ -27,7 +27,7 @@ import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/WorkspaceTaxCustomName'; import type {WorkspaceTaxCustomName} from '@src/types/form/WorkspaceTaxCustomName'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; type WorkspaceTaxesSettingsCustomTaxNameProps = WithPolicyAndFullscreenLoadingProps & @@ -43,21 +43,18 @@ function WorkspaceTaxesSettingsCustomTaxName({ const {translate} = useLocalize(); const {inputCallbackRef} = useAutoFocusInput(); - const validate = useCallback( - (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; - const customTaxName = values[INPUT_IDS.NAME]; + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const customTaxName = values[INPUT_IDS.NAME]; - if (!isRequiredFulfilled(customTaxName)) { - errors.name = translate('workspace.taxes.error.customNameRequired'); - } else if (customTaxName.length > CONST.TAX_RATES.CUSTOM_NAME_MAX_LENGTH) { - errors.name = translate('common.error.characterLimitExceedCounter', customTaxName.length, CONST.TAX_RATES.CUSTOM_NAME_MAX_LENGTH); - } + if (!isRequiredFulfilled(customTaxName)) { + errors.name = translate('workspace.taxes.error.customNameRequired'); + } else if (customTaxName.length > CONST.TAX_RATES.CUSTOM_NAME_MAX_LENGTH) { + errors.name = translate('common.error.characterLimitExceedCounter', customTaxName.length, CONST.TAX_RATES.CUSTOM_NAME_MAX_LENGTH); + } - return errors; - }, - [translate], - ); + return errors; + }; const submit = ({name}: WorkspaceTaxCustomName) => { setPolicyCustomTaxName(policyID, name, policy?.taxRates?.name); diff --git a/src/pages/workspace/taxes/WorkspaceTaxesSettingsPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesSettingsPage.tsx index fbbe30bbd634..2c94afedbe9d 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxesSettingsPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxesSettingsPage.tsx @@ -19,7 +19,7 @@ import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type WorkspaceTaxesSettingsPageProps = WithPolicyAndFullscreenLoadingProps & PlatformStackScreenProps; @@ -33,29 +33,26 @@ function WorkspaceTaxesSettingsPage({ const styles = useThemeStyles(); const {translate} = useLocalize(); - const menuItems = useMemo( - () => [ - { - title: policy?.taxRates?.name, - description: translate('workspace.taxes.customTaxName'), - action: () => Navigation.navigate(ROUTES.WORKSPACE_TAXES_SETTINGS_CUSTOM_TAX_NAME.getRoute(policyID)), - pendingAction: policy?.taxRates?.pendingFields?.name, - }, - { - title: policy?.taxRates?.taxes?.[policy?.taxRates?.defaultExternalID]?.name, - description: translate('workspace.taxes.workspaceDefault'), - action: () => Navigation.navigate(ROUTES.WORKSPACE_TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT.getRoute(policyID)), - pendingAction: policy?.taxRates?.pendingFields?.defaultExternalID, - }, - { - title: policy?.taxRates?.taxes?.[policy?.taxRates?.foreignTaxDefault]?.name, - description: translate('workspace.taxes.foreignDefault'), - action: () => Navigation.navigate(ROUTES.WORKSPACE_TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT.getRoute(policyID)), - pendingAction: policy?.taxRates?.pendingFields?.foreignTaxDefault, - }, - ], - [policy?.taxRates, policyID, translate], - ); + const menuItems = [ + { + title: policy?.taxRates?.name, + description: translate('workspace.taxes.customTaxName'), + action: () => Navigation.navigate(ROUTES.WORKSPACE_TAXES_SETTINGS_CUSTOM_TAX_NAME.getRoute(policyID)), + pendingAction: policy?.taxRates?.pendingFields?.name, + }, + { + title: policy?.taxRates?.taxes?.[policy?.taxRates?.defaultExternalID]?.name, + description: translate('workspace.taxes.workspaceDefault'), + action: () => Navigation.navigate(ROUTES.WORKSPACE_TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT.getRoute(policyID)), + pendingAction: policy?.taxRates?.pendingFields?.defaultExternalID, + }, + { + title: policy?.taxRates?.taxes?.[policy?.taxRates?.foreignTaxDefault]?.name, + description: translate('workspace.taxes.foreignDefault'), + action: () => Navigation.navigate(ROUTES.WORKSPACE_TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT.getRoute(policyID)), + pendingAction: policy?.taxRates?.pendingFields?.foreignTaxDefault, + }, + ]; return ( { + const fetchTravelData = () => { openPolicyTravelPage(policyID, workspaceAccountID); - }, [policyID, workspaceAccountID]); + }; const isFocused = useIsFocused(); diff --git a/src/pages/workspace/upgrade/UpgradeConfirmation.tsx b/src/pages/workspace/upgrade/UpgradeConfirmation.tsx index 48235ec02faf..5e67dfbf349a 100644 --- a/src/pages/workspace/upgrade/UpgradeConfirmation.tsx +++ b/src/pages/workspace/upgrade/UpgradeConfirmation.tsx @@ -11,7 +11,7 @@ import Navigation from '@libs/Navigation/Navigation'; import ROUTES from '@src/ROUTES'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; type Props = { @@ -35,41 +35,31 @@ function UpgradeConfirmation({policyName, planName, afterUpgradeAcknowledged, is const {environmentURL} = useEnvironment(); const [subscriptionLink, setSubscriptionLink] = useState(''); - const updateSubscriptionLink = useCallback(() => { + const updateSubscriptionLink = () => { const backTo = Navigation.getActiveRoute(); setSubscriptionLink(`${environmentURL}/${ROUTES.SETTINGS_SUBSCRIPTION.getRoute(backTo)}`); - }, [environmentURL]); + }; useEffect(() => { Navigation.isNavigationReady().then(() => updateSubscriptionLink()); }, [updateSubscriptionLink]); - const description = useMemo(() => { - if (isCategorizing || isReporting) { - return {translate('workspace.upgrade.completed.categorizeMessage')}; - } - - if (isTravelUpgrade) { - return {translate('workspace.upgrade.completed.travelMessage')}; - } - - if (isDistanceRateUpgrade) { - return {translate('workspace.upgrade.completed.distanceRateMessage')}; - } - - return ( + let description; + if (isCategorizing || isReporting) { + description = {translate('workspace.upgrade.completed.categorizeMessage')}; + } else if (isTravelUpgrade) { + description = {translate('workspace.upgrade.completed.travelMessage')}; + } else if (isDistanceRateUpgrade) { + description = {translate('workspace.upgrade.completed.distanceRateMessage')}; + } else { + description = ( ); - }, [isDistanceRateUpgrade, isCategorizing, isReporting, isTravelUpgrade, policyName, planName, styles.renderHTML, styles.textAlignCenter, styles.w100, translate, subscriptionLink]); + } - const heading = useMemo(() => { - if (isCategorizing || isReporting) { - return translate('workspace.upgrade.completed.createdWorkspace'); - } - return translate('workspace.upgrade.completed.headline'); - }, [isCategorizing, isReporting, translate]); + const heading = isCategorizing || isReporting ? translate('workspace.upgrade.completed.createdWorkspace') : translate('workspace.upgrade.completed.headline'); return ( diff --git a/src/pages/workspace/upgrade/UpgradeIntro.tsx b/src/pages/workspace/upgrade/UpgradeIntro.tsx index 937be15f44be..26f460cb23b7 100644 --- a/src/pages/workspace/upgrade/UpgradeIntro.tsx +++ b/src/pages/workspace/upgrade/UpgradeIntro.tsx @@ -14,7 +14,7 @@ import type {Route} from '@src/ROUTES'; import type {ValueOf} from 'type-fest'; -import React, {useMemo} from 'react'; +import React from 'react'; import GenericFeaturesView from './GenericFeaturesView'; import UpgradeIntroView from './UpgradeIntroView'; @@ -45,16 +45,11 @@ function UpgradeIntro({feature, onUpgrade, buttonDisabled, loading, isCategorizi const isSubmitFeature = isCurrentPolicySubmit && !!feature?.id && SUBMIT_FEATURE_IDS.has(feature.id); - const formattedPrice = useMemo(() => { - const upgradeCurrency = Object.hasOwn(CONST.SUBSCRIPTION_PRICES, preferredCurrency) ? preferredCurrency : CONST.PAYMENT_CARD_CURRENCY.USD; - const matchesTeamPricingHeuristics = isCategorizing || isDistanceRateUpgrade || isReporting || isSubmitFeature || upgradePlanType === CONST.POLICY.TYPE.TEAM; - // An explicit upgradePlanType (chosen in the Plan RHP) is authoritative over the feature-based heuristics, so pricing matches the plan title shown in GenericFeaturesView. - const shouldUseTeamPricing = upgradePlanType === CONST.POLICY.TYPE.CORPORATE ? false : matchesTeamPricingHeuristics; - return `${convertToShortDisplayString( - CONST.SUBSCRIPTION_PRICES[upgradeCurrency][shouldUseTeamPricing ? CONST.POLICY.TYPE.TEAM : CONST.POLICY.TYPE.CORPORATE][CONST.SUBSCRIPTION.TYPE.ANNUAL], - upgradeCurrency, - )} `; - }, [preferredCurrency, isCategorizing, isDistanceRateUpgrade, isReporting, isSubmitFeature, upgradePlanType]); + const upgradeCurrency = Object.hasOwn(CONST.SUBSCRIPTION_PRICES, preferredCurrency) ? preferredCurrency : CONST.PAYMENT_CARD_CURRENCY.USD; + const matchesTeamPricingHeuristics = isCategorizing || isDistanceRateUpgrade || isReporting || isSubmitFeature || upgradePlanType === CONST.POLICY.TYPE.TEAM; + // An explicit upgradePlanType (chosen in the Plan RHP) is authoritative over the feature-based heuristics, so pricing matches the plan title shown in GenericFeaturesView. + const shouldUseTeamPricing = upgradePlanType === CONST.POLICY.TYPE.CORPORATE ? false : matchesTeamPricingHeuristics; + const formattedPrice = `${convertToShortDisplayString(CONST.SUBSCRIPTION_PRICES[upgradeCurrency][shouldUseTeamPricing ? CONST.POLICY.TYPE.TEAM : CONST.POLICY.TYPE.CORPORATE][CONST.SUBSCRIPTION.TYPE.ANNUAL], upgradeCurrency)} `; const allIconNames = Object.values(CONST.UPGRADE_FEATURE_INTRO_MAPPING) .map((feat) => feat?.icon) diff --git a/src/pages/workspace/upgrade/useWorkspaceUpgradeConfirmation.ts b/src/pages/workspace/upgrade/useWorkspaceUpgradeConfirmation.ts index 99df19fd0383..ec926d948d87 100644 --- a/src/pages/workspace/upgrade/useWorkspaceUpgradeConfirmation.ts +++ b/src/pages/workspace/upgrade/useWorkspaceUpgradeConfirmation.ts @@ -30,7 +30,7 @@ function useWorkspaceUpgradeConfirmation({policyID, isUpgraded, canPerformUpgrad isPendingUpgrade, }); - const confirmSubmitUpgradeIfNeeded = useCallback(() => { + const confirmSubmitUpgradeIfNeeded = () => { const { isUpgraded: currentIsUpgraded, canPerformUpgrade: currentCanPerformUpgrade, @@ -53,7 +53,7 @@ function useWorkspaceUpgradeConfirmation({policyID, isUpgraded, canPerformUpgrad hasConfirmedSubmitUpgradeRef.current = true; confirmUpgradeRef.current(); - }, []); + }; useEffect(() => { hasConfirmedSubmitUpgradeRef.current = false; diff --git a/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx b/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx index d89b64d90c6b..ca3fbfb190a8 100644 --- a/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx +++ b/src/pages/workspace/workflows/ToggleSettingsOptionRow.tsx @@ -22,7 +22,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; import type {ReactNode} from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; -import React, {useEffect, useMemo} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; type ToggleSettingOptionRowProps = { @@ -145,22 +145,18 @@ function ToggleSettingOptionRow({ isAccordionExpanded.set(isActive); }, [isAccordionExpanded, isActive]); - const subtitleHtml = useMemo(() => { - if (!subtitle || !shouldParseSubtitle || typeof subtitle !== 'string') { - return ''; - } - return Parser.replace(subtitle, {shouldEscapeText}); - }, [subtitle, shouldParseSubtitle, shouldEscapeText]); - - const processedSubtitle = useMemo(() => { - let textToWrap = ''; + const subtitleHtml = + !subtitle || !shouldParseSubtitle || typeof subtitle !== 'string' + ? '' + : Parser.replace(subtitle, { + shouldEscapeText, + }); - if (shouldParseSubtitle) { - textToWrap = subtitleHtml; - } - - return textToWrap ? `${textToWrap}` : ''; - }, [shouldParseSubtitle, subtitleHtml]); + let textToWrap = ''; + if (shouldParseSubtitle) { + textToWrap = subtitleHtml; + } + const processedSubtitle = textToWrap ? `${textToWrap}` : ''; const subtitleSpacingStyle = (() => { if (!shouldPlaceSubtitleBelowSwitch) { @@ -170,20 +166,20 @@ function ToggleSettingOptionRow({ return shouldUseCompactSubtitleSpacing ? styles.mt1 : styles.mt3; })(); - const subTitleView = useMemo(() => { - if (typeof subtitle === 'string') { - if (!!subtitle && shouldParseSubtitle) { - return ( - - - - ); - } + let subTitleView; + if (typeof subtitle === 'string') { + if (!!subtitle && shouldParseSubtitle) { + subTitleView = ( + + + + ); + } else { /** * We hide the subtitle from screen readers to avoid double announcements * 'aria-hidden' is used for compatibility with iOS mWeb, while 'accessible={false}' works on iOS native. */ - return ( + subTitleView = ( ); } - - return subtitle; - }, [ - subtitle, - shouldParseSubtitle, - styles.mutedNormalTextLabel, - styles.flexRow, - styles.renderHTML, - styles.textAlignLeft, - subtitleSpacingStyle, - subtitleStyle, - parsedSubtitleContainerStyle, - processedSubtitle, - areSubtitleAndSwitchAccessibilityLabelEqual, - ]); + } else { + subTitleView = subtitle; + } const contentArea = ( diff --git a/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx b/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx index f0dab9d93d69..6df9d199872d 100644 --- a/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx @@ -30,7 +30,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {ValueOf} from 'type-fest'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; type AutoReportingFrequencyKey = ValueOf; @@ -79,23 +79,20 @@ function WorkspaceAutoReportingFrequencyPage({policy, route}: WorkspaceAutoRepor setUserSelectedFrequency(item.keyForList as AutoReportingFrequencyKey); }; - const saveAutoReportingFrequency = useCallback(() => { + const saveAutoReportingFrequency = () => { if (!policyID || !selectedFrequency) { return; } setWorkspaceAutoReportingFrequency(policyID, selectedFrequency, policy?.autoReportingFrequency, policy?.harvesting, getReviewWorkspaceSettingsTaskCompletion()); Navigation.goBack(); - }, [policyID, policy?.autoReportingFrequency, policy?.harvesting, selectedFrequency, getReviewWorkspaceSettingsTaskCompletion]); - - const confirmButtonOptions = useMemo( - () => ({ - showButton: true, - text: translate('common.save'), - onConfirm: saveAutoReportingFrequency, - isDisabled: selectedFrequency === autoReportingFrequency, - }), - [saveAutoReportingFrequency, translate, selectedFrequency, autoReportingFrequency], - ); + }; + + const confirmButtonOptions = { + showButton: true, + text: translate('common.save'), + onConfirm: saveAutoReportingFrequency, + isDisabled: selectedFrequency === autoReportingFrequency, + }; const getDescriptionText = () => { if (policy?.autoReportingOffset === undefined) { diff --git a/src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx b/src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx index 2c06fdfdddd0..1e299616821a 100644 --- a/src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx @@ -27,7 +27,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {ValueOf} from 'type-fest'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; const DAYS_OF_MONTH = 28; @@ -91,32 +91,26 @@ function WorkspaceAutoReportingMonthlyOffsetPage({policy, route}: WorkspaceAutoR setUserSelectedOffset(item.isNumber ? parseInt(item.keyForList, 10) : (item.keyForList as AutoReportingOffsetKeys)); }; - const saveDayOfMonth = useCallback(() => { + const saveDayOfMonth = () => { if (!policyID) { return; } setWorkspaceAutoReportingMonthlyOffset(policyID, selectedOffset, policy?.autoReportingOffset, getReviewWorkspaceSettingsTaskCompletion()); Navigation.goBack(ROUTES.WORKSPACE_WORKFLOWS_AUTOREPORTING_FREQUENCY.getRoute(policyID)); - }, [policyID, policy?.autoReportingOffset, selectedOffset, getReviewWorkspaceSettingsTaskCompletion]); - - const confirmButtonOptions = useMemo( - () => ({ - showButton: true, - text: translate('common.save'), - onConfirm: saveDayOfMonth, - isDisabled: selectedOffset === offset, - }), - [saveDayOfMonth, translate, selectedOffset, offset], - ); - const textInputOptions = useMemo( - () => ({ - label: translate('workflowsPage.submissionFrequencyDateOfMonth'), - value: searchText, - onChangeText: setSearchText, - headerMessage: searchText.trim() && !filteredDaysOfMonth.length ? translate('common.noResultsFound') : '', - }), - [searchText, filteredDaysOfMonth.length, setSearchText, translate], - ); + }; + + const confirmButtonOptions = { + showButton: true, + text: translate('common.save'), + onConfirm: saveDayOfMonth, + isDisabled: selectedOffset === offset, + }; + const textInputOptions = { + label: translate('workflowsPage.submissionFrequencyDateOfMonth'), + value: searchText, + onChangeText: setSearchText, + headerMessage: searchText.trim() && !filteredDaysOfMonth.length ? translate('common.noResultsFound') : '', + }; return ( { + const fetchData = () => { openPolicyWorkflowsPage(policyID, true); getPaymentMethods(); - }, [policyID]); + }; const {isOffline} = useNetwork({onReconnect: fetchData}); const canReadWorkflows = canMemberRead(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS); @@ -99,14 +99,14 @@ function WorkspaceWorkflowsPageRevamp({policy, route}: WorkspaceWorkflowsPageRev // The read-only modal says the role can view but not edit, which isn't true here — a disabled tab can't be opened at // all — so these tabs get their own "no access" explanation. - const showNoAccessModal = useCallback(() => { + const showNoAccessModal = () => { showConfirmModal({ title: translate('workspace.common.readOnlyActionTitle'), prompt: translate('workspace.common.noAccessActionPrompt'), confirmText: translate('common.buttonConfirm'), shouldShowCancelButton: false, }); - }, [showConfirmModal, translate]); + }; const [lastSelectedTab] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.WORKFLOWS_TAB_TYPE}`); // A `?tab=` deep link wins over the persisted tab until it has been handed over to Onyx by the effect below. @@ -189,13 +189,13 @@ function WorkspaceWorkflowsPageRevamp({policy, route}: WorkspaceWorkflowsPageRev } }; - const navigateToSubmitWorkspaceApprovalsUpgrade = useCallback(() => { + const navigateToSubmitWorkspaceApprovalsUpgrade = () => { Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvalSubmit.alias, ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID))); - }, [policyID]); + }; // Reuses the Members spreadsheet importer (it already maps the `submitsTo` / `approvesTo` columns) so approval // workflows can be bulk-imported directly from the Workflows page. - const importWorkflowsAction = useCallback(() => { + const importWorkflowsAction = () => { if (isAccountLocked) { showLockedAccountModal(); return; @@ -216,10 +216,10 @@ function WorkspaceWorkflowsPageRevamp({policy, route}: WorkspaceWorkflowsPageRev return; } Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_IMPORT.getRoute(policyID)); - }, [isAccountLocked, showLockedAccountModal, isOffline, showConfirmModal, translate, policyID, isSubmitPolicyWorkspace, navigateToSubmitWorkspaceApprovalsUpgrade]); + }; // The Workflows CSV export reuses the Members export command so the downloaded file is identical to Members > Download CSV. - const downloadWorkflowsAction = useCallback(() => { + const downloadWorkflowsAction = () => { if (isOffline) { showConfirmModal({ title: translate('common.youAppearToBeOffline'), @@ -242,7 +242,7 @@ function WorkspaceWorkflowsPageRevamp({policy, route}: WorkspaceWorkflowsPageRev }, translate, ); - }, [isOffline, showConfirmModal, translate, policyID]); + }; // A Dynamic External Workflow with "Hide People Table Columns" keeps the approval workflows out of the customer's // hands entirely, so the importer that would edit them is blocked too. diff --git a/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx b/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx index 3808dbaeaf04..1ffd2c26c2f3 100644 --- a/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx +++ b/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx @@ -33,7 +33,7 @@ import type {ScrollView as ScrollViewRN} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {Str} from 'expensify-common'; -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; type ApprovalWorkflowEditorProps = { @@ -57,101 +57,83 @@ function ApprovalWorkflowEditor({approvalWorkflow, removeApprovalWorkflow, polic const approverCount = approvalWorkflow.approvers.length; const currency = policy?.outputCurrency ?? CONST.CURRENCY.USD; - const approverDescription = useCallback( - (index: number) => (approverCount > 1 ? `${toLocaleOrdinalWithWords(index + 1)} ${translate('workflowsPage.approver').toLowerCase()}` : `${translate('workflowsPage.approver')}`), - [approverCount, toLocaleOrdinalWithWords, translate], - ); + const approverDescription = (index: number) => + approverCount > 1 ? `${toLocaleOrdinalWithWords(index + 1)} ${translate('workflowsPage.approver').toLowerCase()}` : `${translate('workflowsPage.approver')}`; - const getApprovalPendingAction = useCallback( - (index: number) => { - // The approver's own `pendingAction` takes precedence — it's set when an approver was - // seeded from an optimistic agent creation (Workflows > Add agent) and the - // CREATE_AGENT response hasn't arrived yet. Showing opacity here is exactly what - // signals to the admin that the agent is still being confirmed by the server. - const approverPendingAction = approvalWorkflow?.approvers.at(index)?.pendingAction; - if (approverPendingAction) { - return approverPendingAction; - } - let pendingAction: PendingAction | undefined; - if (index === 0) { - if (approvalWorkflow?.members) { - for (const member of approvalWorkflow.members) { - pendingAction = pendingAction ?? member.pendingFields?.submitsTo; - } + const getApprovalPendingAction = (index: number) => { + // The approver's own `pendingAction` takes precedence — it's set when an approver was + // seeded from an optimistic agent creation (Workflows > Add agent) and the + // CREATE_AGENT response hasn't arrived yet. Showing opacity here is exactly what + // signals to the admin that the agent is still being confirmed by the server. + const approverPendingAction = approvalWorkflow?.approvers.at(index)?.pendingAction; + if (approverPendingAction) { + return approverPendingAction; + } + let pendingAction: PendingAction | undefined; + if (index === 0) { + if (approvalWorkflow?.members) { + for (const member of approvalWorkflow.members) { + pendingAction = pendingAction ?? member.pendingFields?.submitsTo; } - return pendingAction; } - const previousApprover = approvalWorkflow?.approvers.at(index - 1); - const previousMember = approvalWorkflow?.members?.find((member) => member?.email === previousApprover?.email); - return previousMember?.pendingFields?.forwardsTo; - }, - [approvalWorkflow], - ); + return pendingAction; + } + const previousApprover = approvalWorkflow?.approvers.at(index - 1); + const previousMember = approvalWorkflow?.members?.find((member) => member?.email === previousApprover?.email); + return previousMember?.pendingFields?.forwardsTo; + }; - const sortedMembers = useMemo( - () => (approvalWorkflow.isDefault ? [] : sortAlphabetically(approvalWorkflow.members, 'displayName', localeCompare)), - [approvalWorkflow.isDefault, approvalWorkflow.members, localeCompare], - ); + const sortedMembers = approvalWorkflow.isDefault ? [] : sortAlphabetically(approvalWorkflow.members, 'displayName', localeCompare); const members = approvalWorkflow.isDefault ? translate('workspace.common.everyone') : sortedMembers.map((m) => (Str.isSMSLogin(m.displayName) ? formatPhoneNumber(m.displayName) : m.displayName)).join(', '); - const memberPills = useMemo( - () => - sortedMembers.map((m) => ({ - // A just-invited member is stored without an avatar, so fall back to the email-based default - // avatar instead of the generic fallback icon. - avatar: m.avatar ?? getDefaultAvatarURL({accountEmail: m.email}), - displayName: m.displayName, - email: m.email, - })), - [sortedMembers], - ); + const memberPills = sortedMembers.map((m) => ({ + // A just-invited member is stored without an avatar, so fall back to the email-based default + // avatar instead of the generic fallback icon. + avatar: m.avatar ?? getDefaultAvatarURL({accountEmail: m.email}), + displayName: m.displayName, + email: m.email, + })); - const approverErrorMessage = useCallback( - (approver: Approver | undefined, approverIndex: number) => { - const previousApprover = approvalWorkflow.approvers.slice(0, approverIndex).findLast(Boolean); - const error = approvalWorkflow?.errors?.[`approver-${approverIndex}`]; + const approverErrorMessage = (approver: Approver | undefined, approverIndex: number) => { + const previousApprover = approvalWorkflow.approvers.slice(0, approverIndex).findLast(Boolean); + const error = approvalWorkflow?.errors?.[`approver-${approverIndex}`]; - // Flag a route to a non-member as soon as the editor opens, rather than only after a Save attempt - const nonMemberApproverError = getNonMemberApproverError(approver, approvalWorkflow.isDefault); - if (!error && nonMemberApproverError) { - return translate(nonMemberApproverError); - } + // Flag a route to a non-member as soon as the editor opens, rather than only after a Save attempt + const nonMemberApproverError = getNonMemberApproverError(approver, approvalWorkflow.isDefault); + if (!error && nonMemberApproverError) { + return translate(nonMemberApproverError); + } - if (!error) { - return; - } + if (!error) { + return; + } - if (error === 'workflowsPage.approverCircularReference') { - if (!previousApprover || !approver) { - return; - } - return translate( - 'workflowsPage.approverCircularReference', - Str.isSMSLogin(approver.displayName) ? formatPhoneNumber(approver.displayName) : approver.displayName, - Str.isSMSLogin(previousApprover.displayName) ? formatPhoneNumber(previousApprover.displayName) : previousApprover.displayName, - ); + if (error === 'workflowsPage.approverCircularReference') { + if (!previousApprover || !approver) { + return; } + return translate( + 'workflowsPage.approverCircularReference', + Str.isSMSLogin(approver.displayName) ? formatPhoneNumber(approver.displayName) : approver.displayName, + Str.isSMSLogin(previousApprover.displayName) ? formatPhoneNumber(previousApprover.displayName) : previousApprover.displayName, + ); + } - return translate(error); - }, - [approvalWorkflow.approvers, approvalWorkflow.errors, approvalWorkflow.isDefault, translate, formatPhoneNumber], - ); + return translate(error); + }; - const editApprover = useCallback( - (approverIndex: number) => { - if (approvalWorkflow.action === CONST.APPROVAL_WORKFLOW.ACTION.CREATE) { - Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVER.getRoute(policyID, approverIndex)); - } else { - Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVAL_LIMIT.getRoute(policyID, approverIndex)); - } - }, - [approvalWorkflow.action, policyID], - ); + const editApprover = (approverIndex: number) => { + if (approvalWorkflow.action === CONST.APPROVAL_WORKFLOW.ACTION.CREATE) { + Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVER.getRoute(policyID, approverIndex)); + } else { + Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVAL_LIMIT.getRoute(policyID, approverIndex)); + } + }; - const handleExpensesFromPress = useCallback(() => { + const handleExpensesFromPress = () => { // Key the EDIT backTo off the persisted first approver, not the mutated draft. The edit page // resolves its workflow from policy.employeeList by first-approver email, so an unsaved approver // change would point backTo at a non-existent workflow and render the not-found ("Not here") view. @@ -167,10 +149,10 @@ function ApprovalWorkflowEditor({approvalWorkflow, removeApprovalWorkflow, polic : ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_NEW.getRoute(policyID); Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM.path, backTo)); - }, [approvalWorkflow.action, approvalWorkflow.originalApprovers, approvalWorkflow.memberEmail, policyID]); + }; // User should be allowed to add additional approver only if they upgraded to Control Plan, otherwise redirected to the Upgrade Page - const addAdditionalApprover = useCallback(() => { + const addAdditionalApprover = () => { if (!isControlPolicy(policy) && approverCount > 0) { Navigation.navigate( ROUTES.WORKSPACE_UPGRADE.getRoute( @@ -182,7 +164,7 @@ function ApprovalWorkflowEditor({approvalWorkflow, removeApprovalWorkflow, polic return; } Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVER.getRoute(policyID, approverCount)); - }, [approverCount, policy, policyID]); + }; return ( ; @@ -93,9 +93,7 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe const isInitialCreationFlowRef = useRef(false); const isFastEditRef = useRef(false); - const excludedUsers = useMemo(() => { - return getExcludedUsers(policy?.employeeList); - }, [policy?.employeeList]); + const excludedUsers = getExcludedUsers(policy?.employeeList); const { setSearchTerm: setSearchSelectorTerm, @@ -228,119 +226,94 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe // Derive available members live from policy.employeeList instead of approvalWorkflow.availableMembers, // which is a snapshot taken once when the EditPage first mounts and never refreshed. Without this, // a member invited mid-flow won't appear in the picker when the user returns to expenses-from. - const liveAvailableMembers = useMemo(() => { - const employees = policy?.employeeList ?? {}; - return Object.values(employees) - .filter((employee) => !!employee?.email && employee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) - .map((employee) => { - const personalDetail = employeeAndApprovalMembersPersonalDetails[employee.email ?? '']; - return { - email: employee.email ?? '', - displayName: personalDetail?.displayName ?? employee.email ?? '', - avatar: personalDetail?.avatar, - }; - }); - }, [policy?.employeeList, employeeAndApprovalMembersPersonalDetails]); - - const allApprovers = useMemo(() => { - const members: SelectionListApprover[] = [...selectedMembers]; - const approversEmail = workflowApprovers?.map((member) => member?.email); - - const availableMembers = liveAvailableMembers - .map((member) => { - const accountID = Number(policyMemberEmailsToAccountIDs[member.email] ?? ''); - - const formattedDisplayName = Str.isSMSLogin(member.displayName) ? formatPhoneNumber(member.displayName) : member.displayName; - - return { - text: formattedDisplayName, - alternateText: member.email, - keyForList: member.email, - isSelected: false, - login: member.email, - value: member.email, - icons: [ - { - source: member.avatar ?? icons.FallbackAvatar, - type: CONST.ICON_TYPE_AVATAR, - name: formattedDisplayName, - id: accountID, - }, - ], - rightElement: ( - - ), - }; - }) - .filter( - (member) => - (!policy?.preventSelfApproval || !approversEmail?.includes(member.login)) && - !selectedMembers.some((selectedOption) => normalizeLogin(selectedOption.login) === normalizeLogin(member.login)), - ); - - members.push(...availableMembers); - - // Add search results for non-workspace members - if (debouncedSearchTerm && areOptionsInitialized) { - // Add userToInvite if available - if (availableOptions.userToInvite) { - const userToInvite = availableOptions.userToInvite; - const isAlreadySelected = selectedMembers.some((selectedOption) => selectedOption.login === userToInvite.login); - if (!isAlreadySelected) { - members.push({ - text: userToInvite.text ?? userToInvite.login ?? '', - alternateText: userToInvite.login ?? '', - keyForList: userToInvite.login ?? '', - isSelected: false, - login: userToInvite.login ?? '', - value: userToInvite.login ?? '', - icons: userToInvite.icons ?? [], - }); - } - } - - // Add search results that aren't already in the list (selected approvers or userToInvite) - const searchResults = availableOptions.personalDetails.filter((option) => !members.some((m) => normalizeLogin(m.login) === normalizeLogin(option.login))); - - for (const option of searchResults) { - members.push({ - text: option.text ?? option.login ?? '', - alternateText: option.login ?? '', - keyForList: option.login ?? '', + const employees = policy?.employeeList ?? {}; + const liveAvailableMembers: Member[] = Object.values(employees) + .filter((employee) => !!employee?.email && employee.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .map((employee) => { + const personalDetail = employeeAndApprovalMembersPersonalDetails[employee.email ?? '']; + return { + email: employee.email ?? '', + displayName: personalDetail?.displayName ?? employee.email ?? '', + avatar: personalDetail?.avatar, + }; + }); + + const allApprovers: SelectionListApprover[] = [...selectedMembers]; + const approversEmail = workflowApprovers?.map((member) => member?.email); + const availableMembers = liveAvailableMembers + .map((member) => { + const accountID = Number(policyMemberEmailsToAccountIDs[member.email] ?? ''); + const formattedDisplayName = Str.isSMSLogin(member.displayName) ? formatPhoneNumber(member.displayName) : member.displayName; + return { + text: formattedDisplayName, + alternateText: member.email, + keyForList: member.email, + isSelected: false, + login: member.email, + value: member.email, + icons: [ + { + source: member.avatar ?? icons.FallbackAvatar, + type: CONST.ICON_TYPE_AVATAR, + name: formattedDisplayName, + id: accountID, + }, + ], + rightElement: ( + + ), + }; + }) + .filter( + (member) => + (!policy?.preventSelfApproval || !approversEmail?.includes(member.login)) && + !selectedMembers.some((selectedOption) => normalizeLogin(selectedOption.login) === normalizeLogin(member.login)), + ); + allApprovers.push(...availableMembers); + + // Add search results for non-workspace members + if (debouncedSearchTerm && areOptionsInitialized) { + // Add userToInvite if available + if (availableOptions.userToInvite) { + const userToInvite = availableOptions.userToInvite; + const isAlreadySelected = selectedMembers.some((selectedOption) => selectedOption.login === userToInvite.login); + if (!isAlreadySelected) { + allApprovers.push({ + text: userToInvite.text ?? userToInvite.login ?? '', + alternateText: userToInvite.login ?? '', + keyForList: userToInvite.login ?? '', isSelected: false, - login: option.login ?? '', - value: option.login ?? '', - icons: option.icons ?? [], + login: userToInvite.login ?? '', + value: userToInvite.login ?? '', + icons: userToInvite.icons ?? [], }); } } - return members; - }, [ - selectedMembers, - liveAvailableMembers, - workflowApprovers, - policy?.employeeList, - policy?.owner, - policy?.preventSelfApproval, - debouncedSearchTerm, - areOptionsInitialized, - availableOptions.userToInvite, - availableOptions.personalDetails, - icons.FallbackAvatar, - policyMemberEmailsToAccountIDs, - formatPhoneNumber, - ]); + // Add search results that aren't already in the list (selected approvers or userToInvite) + const searchResults = availableOptions.personalDetails.filter((option) => !allApprovers.some((m) => normalizeLogin(m.login) === normalizeLogin(option.login))); + for (const option of searchResults) { + allApprovers.push({ + text: option.text ?? option.login ?? '', + alternateText: option.login ?? '', + keyForList: option.login ?? '', + isSelected: false, + login: option.login ?? '', + value: option.login ?? '', + icons: option.icons ?? [], + }); + } + } // Drop any selected members who never made it into the workspace. They were staged for invite but // never confirmed, so leaving them in approvalWorkflow.members would carry an un-invited user into // the form and fail backend validation with "Approvals can only be set for members of the policy". // This must run on every back path so unconfirmed invitees are removed from the workflow draft. - const dropUnconfirmedStagedMembers = useCallback(() => { + const dropUnconfirmedStagedMembers = () => { // Going back means we're done with this expenses-from session, so any // hand-off to the invite-message page is no longer in flight. isHandingOffToInviteRef.current = false; @@ -350,21 +323,21 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe if (confirmedMembers.length !== stagedMembers.length) { setApprovalWorkflowMembers(confirmedMembers); } - }, [approvalWorkflow?.members, policy?.employeeList]); + }; - const goBack = useCallback(() => { + const goBack = () => { dropUnconfirmedStagedMembers(); // Don't compare params: the edit screen may carry "Add agent" seed params, so a strict param // match would miss it and REPLACE would mount a fresh edit screen that wipes the unsaved draft. Navigation.goBack(backPath, {compareParams: false}); - }, [backPath, dropUnconfirmedStagedMembers]); + }; // Fall back to goBack — plain Navigation.goBack() closes the modal after a refresh. const onBackButtonPress = () => { goBack(); }; - const nextStep = useCallback(() => { + const nextStep = () => { const existingMembers: Member[] = []; const usersToInvite: Array<{ email: string; @@ -458,24 +431,21 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe // from policy.employeeList via its useEffect and overwrite the selection we just saved. Navigation.goBack(backPath, {compareParams: false}); } - }, [route.params.policyID, selectedMembers, isInitialCreationFlow, backPath, policy, approvalWorkflow, rulesCollection, isMultipleApproversBetaEnabled]); - - const button = useMemo(() => { - let buttonText = isInitialCreationFlow ? translate('common.next') : translate('common.save'); - if (shouldShowListEmptyContent) { - buttonText = translate('common.buttonConfirm'); - } + }; - return ( - Navigation.goBack() : nextStep} - containerStyles={[styles.flexReset, styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto]} - enabledWhenOffline - /> - ); - }, [isInitialCreationFlow, translate, shouldShowListEmptyContent, selectedMembers.length, nextStep, styles]); + let buttonText = isInitialCreationFlow ? translate('common.next') : translate('common.save'); + if (shouldShowListEmptyContent) { + buttonText = translate('common.buttonConfirm'); + } + const button = ( + Navigation.goBack() : nextStep} + containerStyles={[styles.flexReset, styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto]} + enabledWhenOffline + /> + ); // Keep the refs in sync so the unmount cleanup below reads the latest values. useEffect(() => { @@ -503,46 +473,81 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe }; }, [route.params.policyID]); - const toggleMember = useCallback( - (members: SelectionListApprover[]) => { - const applySelection = (nextMembers: SelectionListApprover[]) => { - // Persist non-policy members to the invite draft AND to approvalWorkflow.members - // so they survive re-renders triggered by Onyx updates (e.g. personalDetails - // changes from search results). selectedMembers is derived from - // approvalWorkflow.members and only keeps non-policy members that are also in the - // invite draft, so both stores must stay in sync to avoid dropping the newly - // selected member. - const nextDraft: Record = {}; - const workflowMembers: Member[] = []; - for (const member of nextMembers) { - if (!member.login) { - continue; - } - const iconSource = member.icons?.at(0)?.source; - workflowMembers.push({ - displayName: member.text ?? member.login, - email: member.login, - avatar: typeof iconSource === 'string' ? iconSource : undefined, - }); - if (policy?.employeeList?.[normalizeLogin(member.login)]) { - continue; - } - const iconId = member.icons?.at(0)?.id; - nextDraft[member.login] = typeof iconId === 'number' ? iconId : (invitedEmailsToAccountIDsDraft?.[member.login] ?? CONST.DEFAULT_NUMBER_ID); + const toggleMember = (members: SelectionListApprover[]) => { + const applySelection = (nextMembers: SelectionListApprover[]) => { + // Persist non-policy members to the invite draft AND to approvalWorkflow.members + // so they survive re-renders triggered by Onyx updates (e.g. personalDetails + // changes from search results). selectedMembers is derived from + // approvalWorkflow.members and only keeps non-policy members that are also in the + // invite draft, so both stores must stay in sync to avoid dropping the newly + // selected member. + const nextDraft: Record = {}; + const workflowMembers: Member[] = []; + for (const member of nextMembers) { + if (!member.login) { + continue; } - setWorkspaceInviteMembersDraft(route.params.policyID, nextDraft); - setApprovalWorkflowMembers(workflowMembers); - }; + const iconSource = member.icons?.at(0)?.source; + workflowMembers.push({ + displayName: member.text ?? member.login, + email: member.login, + avatar: typeof iconSource === 'string' ? iconSource : undefined, + }); + if (policy?.employeeList?.[normalizeLogin(member.login)]) { + continue; + } + const iconId = member.icons?.at(0)?.id; + nextDraft[member.login] = typeof iconId === 'number' ? iconId : (invitedEmailsToAccountIDsDraft?.[member.login] ?? CONST.DEFAULT_NUMBER_ID); + } + setWorkspaceInviteMembersDraft(route.params.policyID, nextDraft); + setApprovalWorkflowMembers(workflowMembers); + }; + + // Warn when selecting the last unselected workspace member: that moves everyone into this workflow, + // which deletes every other workflow. + const selectedLogins = selectedMembers.map((member) => member.login); + const nextSelectedLogins = members.map((member) => member.login); + const isSelectingLastMember = !includesEveryWorkspaceMember(selectedLogins, policy?.employeeList) && includesEveryWorkspaceMember(nextSelectedLogins, policy?.employeeList); + if (isSelectingLastMember) { + showConfirmModal({ + title: translate('workflowsExpensesFromPage.moveEveryoneToThisWorkflowTitle'), + prompt: translate('workflowsExpensesFromPage.moveEveryoneToThisWorkflowPrompt'), + confirmText: translate('common.confirm'), + cancelText: translate('common.cancel'), + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + applySelection(members); + }); + return; + } + + // Warn when adding a member who already belongs to another workflow. With the beta on this + // applies to both create and edit (a submitter can only be in one workflow, so confirming + // moves them); legacy keeps the create-only behavior. + const shouldCheckCrossWorkflow = isCreateAction || isMultipleApproversBetaEnabled; + if (shouldCheckCrossWorkflow && members.length > selectedMembers.length) { + const newMember = members.find((m) => !selectedMembers.some((s) => s.login === m.login)); + const existingApproverEmail = newMember?.login ? membersInExistingWorkflows.get(newMember.login) : undefined; + + // With the beta on, compare full workflow identities so a member that submits to the same + // first approver but through a different chain is still treated as a cross-workflow move. + // Legacy has no diverging chains here, so first-approver comparison is enough. + const belongsToDifferentWorkflow = isMultipleApproversBetaEnabled + ? !!newMember?.login && !!submitterToWorkflowKey?.get(newMember.login) && submitterToWorkflowKey.get(newMember.login) !== currentWorkflowKey + : !!existingApproverEmail && existingApproverEmail !== firstApprover; + + if (newMember && existingApproverEmail && belongsToDifferentWorkflow) { + const rawMemberName = newMember.text ?? newMember.login ?? ''; + const memberName = Str.isSMSLogin(rawMemberName) ? formatPhoneNumber(rawMemberName) : rawMemberName; + const approverDetails = employeeAndApprovalMembersPersonalDetails[existingApproverEmail]; + const rawApproverName = approverDetails?.displayName ?? existingApproverEmail; + const approverName = Str.isSMSLogin(rawApproverName) ? formatPhoneNumber(rawApproverName) : rawApproverName; - // Warn when selecting the last unselected workspace member: that moves everyone into this workflow, - // which deletes every other workflow. - const selectedLogins = selectedMembers.map((member) => member.login); - const nextSelectedLogins = members.map((member) => member.login); - const isSelectingLastMember = !includesEveryWorkspaceMember(selectedLogins, policy?.employeeList) && includesEveryWorkspaceMember(nextSelectedLogins, policy?.employeeList); - if (isSelectingLastMember) { showConfirmModal({ - title: translate('workflowsExpensesFromPage.moveEveryoneToThisWorkflowTitle'), - prompt: translate('workflowsExpensesFromPage.moveEveryoneToThisWorkflowPrompt'), + title: translate('workflowsExpensesFromPage.memberAlreadyInWorkflowTitle'), + prompt: translate('workflowsExpensesFromPage.memberAlreadyInWorkflowPrompt', {memberName, approverName}), confirmText: translate('common.confirm'), cancelText: translate('common.cancel'), }).then((result) => { @@ -553,63 +558,10 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe }); return; } + } - // Warn when adding a member who already belongs to another workflow. With the beta on this - // applies to both create and edit (a submitter can only be in one workflow, so confirming - // moves them); legacy keeps the create-only behavior. - const shouldCheckCrossWorkflow = isCreateAction || isMultipleApproversBetaEnabled; - if (shouldCheckCrossWorkflow && members.length > selectedMembers.length) { - const newMember = members.find((m) => !selectedMembers.some((s) => s.login === m.login)); - const existingApproverEmail = newMember?.login ? membersInExistingWorkflows.get(newMember.login) : undefined; - - // With the beta on, compare full workflow identities so a member that submits to the same - // first approver but through a different chain is still treated as a cross-workflow move. - // Legacy has no diverging chains here, so first-approver comparison is enough. - const belongsToDifferentWorkflow = isMultipleApproversBetaEnabled - ? !!newMember?.login && !!submitterToWorkflowKey?.get(newMember.login) && submitterToWorkflowKey.get(newMember.login) !== currentWorkflowKey - : !!existingApproverEmail && existingApproverEmail !== firstApprover; - - if (newMember && existingApproverEmail && belongsToDifferentWorkflow) { - const rawMemberName = newMember.text ?? newMember.login ?? ''; - const memberName = Str.isSMSLogin(rawMemberName) ? formatPhoneNumber(rawMemberName) : rawMemberName; - const approverDetails = employeeAndApprovalMembersPersonalDetails[existingApproverEmail]; - const rawApproverName = approverDetails?.displayName ?? existingApproverEmail; - const approverName = Str.isSMSLogin(rawApproverName) ? formatPhoneNumber(rawApproverName) : rawApproverName; - - showConfirmModal({ - title: translate('workflowsExpensesFromPage.memberAlreadyInWorkflowTitle'), - prompt: translate('workflowsExpensesFromPage.memberAlreadyInWorkflowPrompt', {memberName, approverName}), - confirmText: translate('common.confirm'), - cancelText: translate('common.cancel'), - }).then((result) => { - if (result.action !== ModalActions.CONFIRM) { - return; - } - applySelection(members); - }); - return; - } - } - - applySelection(members); - }, - [ - policy?.employeeList, - employeeAndApprovalMembersPersonalDetails, - invitedEmailsToAccountIDsDraft, - route.params.policyID, - isCreateAction, - isMultipleApproversBetaEnabled, - firstApprover, - selectedMembers, - membersInExistingWorkflows, - submitterToWorkflowKey, - currentWorkflowKey, - showConfirmModal, - translate, - formatPhoneNumber, - ], - ); + applySelection(members); + }; return ( ; @@ -76,29 +76,23 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa const shouldShowNotFoundView = isApprovalWorkflowLockedByIntegration(policy) || shouldHideDynamicExternalWorkflowPeople(policy); const shouldFilterOutExpensifyTeam = shouldFilterExpensifyTeam(policy?.owner, currentUserLogin); - const allApprovers: SelectionListApprover[] = useMemo(() => { - if (isApprovalWorkflowLoading || !employeeList) { - return []; - } - + let allApprovers: SelectionListApprover[]; + if (isApprovalWorkflowLoading || !employeeList) { + allApprovers = []; + } else { const membersEmail = approvalWorkflow?.members?.map((member) => member.email); - const approvers = Object.values(employeeList) .map((employee): SelectionListApprover | null => { const email = employee.email; - if (!email) { return null; } - if (employee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { return null; } - if (shouldFilterOutExpensifyTeam && isExpensifyTeam(email) && visibleSelectedApproverEmail !== email) { return null; } - if (!isDefault && policy?.preventSelfApproval && membersEmail?.includes(email) && visibleSelectedApproverEmail !== email) { return null; } @@ -108,20 +102,15 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa if (isEmailAlreadyInApprovers && visibleSelectedApproverEmail !== email) { return null; } - if (!isMultipleApproversBetaEnabled && !isDefault && approverIndex === 0 && defaultApprover === email) { return null; } - const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(employeeList, employeePersonalDetails); const accountID = Number(policyMemberEmailsToAccountIDs[email] ?? ''); - if (!accountID) { return null; } - const {avatar, displayName = email, login} = personalDetails?.[accountID] ?? {}; - return { text: displayName, alternateText: email, @@ -129,7 +118,14 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa isSelected: visibleSelectedApproverEmail === email, login: email, value: email, - icons: [{source: avatar ?? icons.FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, name: displayName, id: accountID}], + icons: [ + { + source: avatar ?? icons.FallbackAvatar, + type: CONST.ICON_TYPE_AVATAR, + name: displayName, + id: accountID, + }, + ], rightElement: ( !!approver); - if (nonMemberApprover && !approvers.some((approver) => approver.login === nonMemberApprover.email)) { approvers.push({ text: nonMemberApprover.displayName, @@ -149,34 +144,22 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa isSelected: true, login: nonMemberApprover.email, value: nonMemberApprover.email, - icons: [{source: nonMemberApprover.avatar ?? icons.FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, name: nonMemberApprover.displayName, id: nonMemberApproverAccountID}], + icons: [ + { + source: nonMemberApprover.avatar ?? icons.FallbackAvatar, + type: CONST.ICON_TYPE_AVATAR, + name: nonMemberApprover.displayName, + id: nonMemberApproverAccountID, + }, + ], }); } - - return approvers; - }, [ - isApprovalWorkflowLoading, - employeeList, - employeePersonalDetails, - isDefault, - isMultipleApproversBetaEnabled, - policy?.preventSelfApproval, - policy?.owner, - approvalWorkflow?.members, - approversFromWorkflow, - visibleSelectedApproverEmail, - approverIndex, - defaultApprover, - personalDetails, - icons.FallbackAvatar, - shouldFilterOutExpensifyTeam, - nonMemberApprover, - nonMemberApproverAccountID, - ]); + allApprovers = approvers; + } const shouldShowListEmptyContent = !!approvalWorkflow && !isApprovalWorkflowLoading && !removingApprover; - const goBack = useCallback(() => { + const goBack = () => { let backToRoute; if (isInitialCreationFlow) { backToRoute = createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM.path, ROUTES.WORKSPACE_WORKFLOWS.getRoute(route.params.policyID)); @@ -189,79 +172,60 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa // Don't compare params: the edit screen may carry "Add agent" seed params, so a strict param // match would miss it and REPLACE would mount a fresh edit screen that wipes the unsaved draft. Navigation.goBack(backToRoute, {compareParams: false}); - }, [isInitialCreationFlow, approvalWorkflow?.action, approvalWorkflow?.memberEmail, route.params.policyID, rhpRoutes.length, firstApprover]); - - const toggleApprover = useCallback( - (approvers: SelectionListApprover[]) => { - const approver = approvers.at(0); - const isRemovingApprover = approvers.length === 0; - - if (isRemovingApprover) { - setRemovingApprover(visibleSelectedApprover); - clearApprovalWorkflowApprover({approverIndex, currentApprovalWorkflow: approvalWorkflow}); - if (isChangeApproverRoute && approvalWorkflow?.action === CONST.APPROVAL_WORKFLOW.ACTION.EDIT) { - // Don't compare params — see goBack above. - Navigation.goBack(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(route.params.policyID, firstApprover, approvalWorkflow?.memberEmail), {compareParams: false}); - return; - } - goBack(); + }; + + const toggleApprover = (approvers: SelectionListApprover[]) => { + const approver = approvers.at(0); + const isRemovingApprover = approvers.length === 0; + + if (isRemovingApprover) { + setRemovingApprover(visibleSelectedApprover); + clearApprovalWorkflowApprover({approverIndex, currentApprovalWorkflow: approvalWorkflow}); + if (isChangeApproverRoute && approvalWorkflow?.action === CONST.APPROVAL_WORKFLOW.ACTION.EDIT) { + // Don't compare params — see goBack above. + Navigation.goBack(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(route.params.policyID, firstApprover, approvalWorkflow?.memberEmail), {compareParams: false}); return; } + goBack(); + return; + } - // Canonicalize phone logins (e164@expensify.sms) so the approver email stored as submitsTo - // matches the canonical key the invite writes into employeeList. Without this, a phone approver - // invited offline stops resolving once online and the workflow disappears. No-op for emails. - const newSelectedEmail = addSMSDomainIfPhoneNumber(approver?.login ?? ''); - const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(employeeList, employeePersonalDetails); - const accountID = Number(newSelectedEmail ? policyMemberEmailsToAccountIDs[newSelectedEmail] : ''); - const {avatar, displayName = newSelectedEmail} = personalDetails?.[accountID] ?? {}; - - setApprovalWorkflowApprover({ - approver: { - email: newSelectedEmail, - avatar, - displayName, - approvalLimit: null, - overLimitForwardsTo: '', - }, - approverIndex, - currentApprovalWorkflow: approvalWorkflow, - policy, - personalDetailsByEmail, - }); - - // If this is the change approver route, go back to the Approval Limit page - // Otherwise, navigate forward to set the approval limit - if (isChangeApproverRoute) { - Navigation.goBack(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVAL_LIMIT.getRoute(route.params.policyID, approverIndex)); - } else { - Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVAL_LIMIT.getRoute(route.params.policyID, approverIndex)); - } - }, - [ + // Canonicalize phone logins (e164@expensify.sms) so the approver email stored as submitsTo + // matches the canonical key the invite writes into employeeList. Without this, a phone approver + // invited offline stops resolving once online and the workflow disappears. No-op for emails. + const newSelectedEmail = addSMSDomainIfPhoneNumber(approver?.login ?? ''); + const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(employeeList, employeePersonalDetails); + const accountID = Number(newSelectedEmail ? policyMemberEmailsToAccountIDs[newSelectedEmail] : ''); + const {avatar, displayName = newSelectedEmail} = personalDetails?.[accountID] ?? {}; + + setApprovalWorkflowApprover({ + approver: { + email: newSelectedEmail, + avatar, + displayName, + approvalLimit: null, + overLimitForwardsTo: '', + }, approverIndex, - approvalWorkflow, - employeeList, - employeePersonalDetails, - personalDetails, + currentApprovalWorkflow: approvalWorkflow, policy, - route.params.policyID, - goBack, personalDetailsByEmail, - isChangeApproverRoute, - firstApprover, - visibleSelectedApprover, - ], - ); + }); + + // If this is the change approver route, go back to the Approval Limit page + // Otherwise, navigate forward to set the approval limit + if (isChangeApproverRoute) { + Navigation.goBack(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVAL_LIMIT.getRoute(route.params.policyID, approverIndex)); + } else { + Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVAL_LIMIT.getRoute(route.params.policyID, approverIndex)); + } + }; - const subtitle = useMemo( - () => ( - <> - {translate('workflowsApproverPage.title')} - {translate('workflowsApproverPage.description')} - - ), - [translate, styles.textHeadlineH1, styles.mh5, styles.mv3, styles.mb3, styles.textSupporting], + const subtitle = ( + <> + {translate('workflowsApproverPage.title')} + {translate('workflowsApproverPage.description')} + ); return ( diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx index af276a0d1780..a8a6ae89e05a 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx @@ -33,7 +33,7 @@ import type {ComponentRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView} from 'react-native'; -import React, {useCallback, useRef} from 'react'; +import React, {useRef} from 'react'; import {View} from 'react-native'; import ApprovalWorkflowEditor from './ApprovalWorkflowEditor'; @@ -62,7 +62,7 @@ function WorkspaceWorkflowsApprovalsCreatePage({policy, isLoadingReportData = tr isApprovalWorkflowLockedByIntegration(policy) || shouldHideDynamicExternalWorkflowPeople(policy); - const createApprovalWorkflow = useCallback(() => { + const createApprovalWorkflow = () => { if (!approvalWorkflow) { return; } @@ -82,7 +82,7 @@ function WorkspaceWorkflowsApprovalsCreatePage({policy, isLoadingReportData = tr } Navigation.dismissModal(); }); - }, [approvalWorkflow, policy, addExpenseApprovalsTaskReport, rulesCollection, isBetaEnabled, startWithLoading]); + }; return ( ) => filterRulesForPolicy(rules, route.params.policyID), [route.params.policyID]); + const policyRulesSelector = (rules: OnyxCollection) => filterRulesForPolicy(rules, route.params.policyID); const [rulesCollection] = useOnyx(ONYXKEYS.COLLECTION.RULE, {selector: policyRulesSelector}); const isLoadingApprovalWorkflow = isLoadingOnyxValue(approvalWorkflowMetadata); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); diff --git a/src/pages/workspace/workflows/tabs/WorkflowsApprovalsTab.tsx b/src/pages/workspace/workflows/tabs/WorkflowsApprovalsTab.tsx index dbeefa7cb839..f1981b0dc486 100644 --- a/src/pages/workspace/workflows/tabs/WorkflowsApprovalsTab.tsx +++ b/src/pages/workspace/workflows/tabs/WorkflowsApprovalsTab.tsx @@ -43,7 +43,7 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow'; import {Str} from 'expensify-common'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import WorkflowsSectionCard from './WorkflowsSectionCard'; @@ -134,27 +134,27 @@ function WorkflowsApprovalsTab({policyID}: WorkflowsApprovalsTabProps) { const updateApprovalMode = isAdvanceApproval ? CONST.POLICY.APPROVAL_MODE.ADVANCED : CONST.POLICY.APPROVAL_MODE.BASIC; - const confirmDisableApprovals = useCallback(() => { + const confirmDisableApprovals = () => { setWorkspaceApprovalMode(policy, policy?.owner ?? '', CONST.POLICY.APPROVAL_MODE.OPTIONAL, currentUserAccountID, currentUserEmail, isTrackIntentUser, rulesCollection, { transactionViolations, isASAPSubmitBetaEnabled: isBetaEnabledOrUnknown(CONST.BETAS.ASAP_SUBMIT), personalDetailsList: personalDetails, }); - }, [isBetaEnabledOrUnknown, policy, transactionViolations, currentUserAccountID, currentUserEmail, personalDetails, isTrackIntentUser, rulesCollection]); + }; const approvalWorkflowSource = getApprovalWorkflowSource(policy, policyID); const isWorkflowFromIntegration = !!approvalWorkflowSource; const workflowSourceName = approvalWorkflowSource?.providerName ?? ''; const workflowSourceSettingsRoute = approvalWorkflowSource?.settingsRoute; - const navigateToWorkflowSourceSettings = useCallback(() => { + const navigateToWorkflowSourceSettings = () => { if (!workflowSourceSettingsRoute) { return; } Navigation.navigate(workflowSourceSettingsRoute); - }, [workflowSourceSettingsRoute]); + }; - const promptConfigureApprovalsInIntegration = useCallback(async () => { + const promptConfigureApprovalsInIntegration = async () => { const {action} = await showConfirmModal({ title: translate('workspace.moreFeatures.connectionsWarningModal.featureEnabledTitle'), prompt: translate('workflowsPage.integrationApprovalWorkflowLockedPrompt', { @@ -169,14 +169,14 @@ function WorkflowsApprovalsTab({policyID}: WorkflowsApprovalsTabProps) { return; } navigateToWorkflowSourceSettings(); - }, [navigateToWorkflowSourceSettings, workflowSourceName, showConfirmModal, translate]); + }; - const navigateToSubmitWorkspaceApprovalsUpgrade = useCallback(() => { + const navigateToSubmitWorkspaceApprovalsUpgrade = () => { Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvalSubmit.alias, ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID))); - }, [policyID]); + }; // User should be allowed to add new Approval Workflow only if he's upgraded to Control Plan, otherwise redirected to the Upgrade Page - const addApprovalAction = useCallback(() => { + const addApprovalAction = () => { setApprovalWorkflow({ ...INITIAL_APPROVAL_WORKFLOW, availableMembers, @@ -200,7 +200,7 @@ function WorkflowsApprovalsTab({policyID}: WorkflowsApprovalsTabProps) { } Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM.path)); - }, [policy, policyID, availableMembers, usedApproverEmails, isSubmitPolicyWorkspace, navigateToSubmitWorkspaceApprovalsUpgrade]); + }; const isHRAdvancedModeEnabled = isHRAdvancedMode(policy); const isRecruitingAdvancedModeEnabled = isRecruitingAdvancedMode(policy); @@ -256,10 +256,7 @@ function WorkflowsApprovalsTab({policyID}: WorkflowsApprovalsTabProps) { // Trim before deciding so a whitespace-only input doesn't drop pagination while searchFilteredWorkflows is still unfiltered. const isSearchingWorkflows = workflowSearchInput.trim().length > 0; // Memoize so a stable reference reaches the render below; otherwise the slice() allocates a new array each render. - const displayedWorkflows = useMemo( - () => (isWorkflowListExpanded || isSearchingWorkflows ? searchFilteredWorkflows : searchFilteredWorkflows.slice(0, CONST.WORKFLOW_APPROVALS_INITIAL_BATCH)), - [isWorkflowListExpanded, isSearchingWorkflows, searchFilteredWorkflows], - ); + const displayedWorkflows = isWorkflowListExpanded || isSearchingWorkflows ? searchFilteredWorkflows : searchFilteredWorkflows.slice(0, CONST.WORKFLOW_APPROVALS_INITIAL_BATCH); const hiddenWorkflowsCount = searchFilteredWorkflows.length - displayedWorkflows.length; const isDEWEnabled = hasDynamicExternalWorkflow(policy); @@ -271,22 +268,18 @@ function WorkflowsApprovalsTab({policyID}: WorkflowsApprovalsTabProps) { // The info banner below still explains why the section is empty, but nothing else about the workflows is rendered. const shouldHideApprovalWorkflows = shouldHideDynamicExternalWorkflowPeople(policy); const shouldBlockApprovalWorkflowEditing = isApprovalWorkflowLockedByIntegration(policy); - const approvalSubtitle = useMemo(() => { - if (!isWorkflowFromIntegration) { - return translate('workflowsPage.addApprovalsDescription'); - } - - return ( - - {translate('workflowsPage.addApprovalsDescription')}{' '} - - {translate('workflowsPage.configureViaProvider', { - provider: workflowSourceName, - })} - - - ); - }, [isWorkflowFromIntegration, workflowSourceName, navigateToWorkflowSourceSettings, styles.lh20, styles.mr5, styles.mt1, styles.textLabelSupportingEmptyValue, translate]); + const approvalSubtitle = !isWorkflowFromIntegration ? ( + translate('workflowsPage.addApprovalsDescription') + ) : ( + + {translate('workflowsPage.addApprovalsDescription')}{' '} + + {translate('workflowsPage.configureViaProvider', { + provider: workflowSourceName, + })} + + + ); const approvalOptionSubtitle = isWorkflowFromIntegration || !isApprovalsLockedBySmartLimit ? approvalSubtitle : translate('workspace.moreFeatures.workflows.disableApprovalPrompt'); const hasApprovalError = !!policy?.errorFields?.approvalMode; diff --git a/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx b/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx index c32498d52131..7bc80b9a5e9d 100644 --- a/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx +++ b/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx @@ -50,7 +50,7 @@ import ROUTES from '@src/ROUTES'; import type {TupleToUnion} from 'type-fest'; import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; import WorkflowsSectionCard from './WorkflowsSectionCard'; @@ -110,21 +110,21 @@ function WorkflowsPaymentsTab({policyID}: WorkflowsPaymentsTabProps) { const achData = reimbursementAccount?.achData; const shouldShowContinueModal = hasInProgressVBBA(achData, isNonUSDWorkspace, policy?.id); - const showAddBankAccountPermissionModal = useCallback(() => { + const showAddBankAccountPermissionModal = () => { showConfirmModal({ title: translate('workspace.bankAccount.workspaceCurrencyNotSupported'), prompt: translate('workspace.bankAccount.notAllowedToAddBankAccount'), confirmText: translate('common.buttonConfirm'), shouldShowCancelButton: false, }); - }, [showConfirmModal, translate]); + }; - const confirmCurrencyChangeAndHideModal = useCallback(() => { + const confirmCurrencyChangeAndHideModal = () => { if (!policy) { return; } Navigation.navigate(ROUTES.WORKSPACE_OVERVIEW_CURRENCY.getRoute(policy.id, {isForcedToChangeCurrency: true})); - }, [policy]); + }; const workflowsBackTo = ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID); diff --git a/src/pages/workspace/workflows/tabs/WorkflowsSubmissionsTab.tsx b/src/pages/workspace/workflows/tabs/WorkflowsSubmissionsTab.tsx index 666f837eb16e..cef62bb52316 100644 --- a/src/pages/workspace/workflows/tabs/WorkflowsSubmissionsTab.tsx +++ b/src/pages/workspace/workflows/tabs/WorkflowsSubmissionsTab.tsx @@ -20,7 +20,7 @@ import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; import WorkflowsSectionCard from './WorkflowsSectionCard'; @@ -37,7 +37,7 @@ function WorkflowsSubmissionsTab({policyID}: WorkflowsSubmissionsTabProps) { const {canWrite: canWriteWorkflows, showReadOnlyModal, withReadOnlyFallback: withWorkflowsReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.WORKFLOWS); const hasDelayedSubmissionError = !!(policy?.errorFields?.autoReporting ?? policy?.errorFields?.autoReportingFrequency); - const onPressAutoReportingFrequency = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_AUTOREPORTING_FREQUENCY.getRoute(policyID)), [policyID]); + const onPressAutoReportingFrequency = () => Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_AUTOREPORTING_FREQUENCY.getRoute(policyID)); const onToggle = (isEnabled: boolean) => { if (!canWriteWorkflows) { diff --git a/src/stories/ButtonWithDropdownMenu.stories.tsx b/src/stories/ButtonWithDropdownMenu.stories.tsx index cdaed062f868..23341879ba2d 100644 --- a/src/stories/ButtonWithDropdownMenu.stories.tsx +++ b/src/stories/ButtonWithDropdownMenu.stories.tsx @@ -7,7 +7,7 @@ import CONST from '@src/CONST'; import type {StoryFn} from 'storybook-react-rsbuild'; -import React, {useMemo} from 'react'; +import React from 'react'; type ButtonWithDropdownMenuStory = StoryFn; @@ -24,13 +24,10 @@ const story = { function Template(props: ButtonWithDropdownMenuProps) { const icons = useMemoizedLazyExpensifyIcons(['Wallet']); - const options = useMemo( - () => [ - {value: 'One', text: 'One', icon: icons.Wallet}, - {value: 'Two', text: 'Two', icon: icons.Wallet}, - ], - [icons.Wallet], - ); + const options = [ + {value: 'One', text: 'One', icon: icons.Wallet}, + {value: 'Two', text: 'Two', icon: icons.Wallet}, + ]; return ( ) { const [selectedIds, setSelectedIds] = useState(['option-1', 'option-2']); const styles = useThemeStyles(); - const memo = useMemo(() => { - const allIds: string[] = []; - - const sections = props.sections.map((section, sectionIndex) => { - const data = section.data.map((item, itemIndex) => { - if (item.keyForList) { - allIds.push(item.keyForList); - } - const isSelected = item.keyForList ? selectedIds.includes(item.keyForList) : false; - const isAdmin = itemIndex === 0; - - return { - ...item, - isSelected, - alternateText: `${item.keyForList}@email.com`, - accountID: Number(item.keyForList), - login: item.text, - rightElement: isAdmin && ( - - ), - }; - }); - - return {...section, data, title: `Section ${sectionIndex + 1}`}; + const allIds: string[] = []; + const sections = props.sections.map((section, sectionIndex) => { + const data = section.data.map((item, itemIndex) => { + if (item.keyForList) { + allIds.push(item.keyForList); + } + const isSelected = item.keyForList ? selectedIds.includes(item.keyForList) : false; + const isAdmin = itemIndex === 0; + return { + ...item, + isSelected, + alternateText: `${item.keyForList}@email.com`, + accountID: Number(item.keyForList), + login: item.text, + rightElement: isAdmin && ( + + ), + }; }); - - return {sections, allIds}; - }, [props.sections, selectedIds, styles.alignSelfCenter, styles.textStrong]); + return { + ...section, + data, + title: `Section ${sectionIndex + 1}`, + }; + }); + const memo = { + sections, + allIds, + }; const onSelectRow = (item: ListItem) => { if (!item.keyForList) { diff --git a/src/types/env.d.ts b/src/types/env.d.ts index 5c20eabc4a58..e9107b42e266 100644 --- a/src/types/env.d.ts +++ b/src/types/env.d.ts @@ -1,6 +1,7 @@ declare namespace NodeJS { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface ProcessEnv { + TZ?: string; GITHUB_REPOSITORY_OWNER: string; GITHUB_REPOSITORY: string; GITHUB_TOKEN: string | undefined; diff --git a/tests/tooling/lintPipeline.test.ts b/tests/tooling/lintPipeline.test.ts index c8785733fc22..888469f7c5da 100644 --- a/tests/tooling/lintPipeline.test.ts +++ b/tests/tooling/lintPipeline.test.ts @@ -120,6 +120,45 @@ describe('normalizeESLintResults', () => { }); describe('filterReactCompilerMessages', () => { + it('enforces manual memoization only when both compilers memoize the file', async () => { + // Given a diagnostic that depends on automatic memoization being available on both platforms. + const messages = [makeMessage({ruleID: 'rulesdir/no-manual-memoization'})]; + + // When both compilers memoize the file, or one of them does not. + const compiled = await filterReactCompilerMessages(messages, '/tmp', () => true); + const skipped = await filterReactCompilerMessages(messages, '/tmp', () => false); + + // Then manual memoization is forbidden only in the compiled file. + expect(compiled).toEqual(messages); + expect(skipped).toEqual([]); + }); + + it('does not forbid manual memoization when compilation cannot be checked', async () => { + // Given a file whose compiler analysis cannot complete. + const messages = [makeMessage({ruleID: 'rulesdir/no-manual-memoization'})]; + + // When analysis fails or the source cannot be read. + const crashed = await filterReactCompilerMessages(messages, '/tmp', () => { + throw new Error('compiler unavailable'); + }); + const unreadable = await filterReactCompilerMessages(messages, '/tmp'); + + // Then conservatively allow the existing manual memoization. + expect(crashed).toEqual([]); + expect(unreadable).toEqual([]); + }); + + it('does not enforce the compiler-only rule in tests', async () => { + // Given test code, which is excluded from React Compiler. + const messages = [makeMessage({filePath: '/tmp/tests/file.tsx', ruleID: 'rulesdir/no-manual-memoization'})]; + + // When filtering diagnostics, even with a successful compiler stub. + const result = await filterReactCompilerMessages(messages, '/tmp', () => true); + + // Then no compiler-only restriction applies. + expect(result).toEqual([]); + }); + it('skips the compiler for files with no suppressible message', async () => { let called = 0; const messages = [makeMessage({ruleID: 'no-console'})]; diff --git a/tests/tooling/noManualMemoization.test.ts b/tests/tooling/noManualMemoization.test.ts new file mode 100644 index 000000000000..a739e0d60462 --- /dev/null +++ b/tests/tooling/noManualMemoization.test.ts @@ -0,0 +1,89 @@ +import {describe, expect, it} from 'bun:test'; + +import type {Rule} from 'eslint'; + +import {Linter} from 'eslint'; + +import {didBothCompilersMemoizeFile} from '../../config/reactCompiler/checkBoth.mjs'; +import * as rule from '../../eslint-plugin-local-rules/no-manual-memoization'; +import {normalizeESLintResults} from '../../scripts/lint/eslint/ESLintLinter'; +import {filterReactCompilerMessages} from '../../scripts/lint/processors/ReactCompilerFilter'; + +function isRuleModule(value: unknown): value is Rule.RuleModule { + return typeof value === 'object' && value !== null && typeof Reflect.get(value, 'create') === 'function'; +} + +function lint(code: string) { + const ruleModule: unknown = rule; + if (!isRuleModule(ruleModule)) { + throw new TypeError('Expected a local ESLint rule module'); + } + const ruleName = 'no-manual-memoization'; + const ruleID = `rulesdir/${ruleName}`; + return new Linter().verify(code, { + languageOptions: {ecmaVersion: 2022, sourceType: 'module'}, + plugins: {rulesdir: {rules: {[ruleName]: ruleModule}}}, + rules: {[ruleID]: 'error'}, + }); +} + +describe('no-manual-memoization', () => { + it.each([ + {source: "import {useMemo} from 'react'; function useExample(name) { return useMemo(() => ({name}), [name]); }", shouldForbid: true}, + { + source: "import {useMemo, useRef} from 'react'; function useExample(name) { const ref = useRef(); ref.current = name; return useMemo(() => ({name}), [name]); }", + shouldForbid: false, + }, + ])('enforces the real compiler verdict: $shouldForbid', async ({source, shouldForbid}) => { + // Given a hook that either compiles on both platforms or violates the rules for refs. + const filename = '/repo/src/useExample.ts'; + const [{messages}] = normalizeESLintResults([{filePath: filename, messages: lint(source)}]); + expect(messages).toHaveLength(1); + + // When the production compiler helpers determine whether manual memoization is redundant. + const bothMemoized = didBothCompilersMemoizeFile(source, filename); + const filtered = await filterReactCompilerMessages(messages, '/repo', () => bothMemoized); + + // Then the lint pipeline enforces the restriction only for automatically memoized code. + expect(bothMemoized).toBe(shouldForbid); + expect(filtered).toHaveLength(shouldForbid ? 1 : 0); + }); + + it.each([ + "import {useMemo} from 'react'; const value = useMemo(() => 1, []);", + "import {useCallback} from 'react'; const fn = useCallback(() => 1, []);", + "import {memo} from 'react'; const Component = memo(() => null);", + "import {useMemo as cache} from 'react'; const value = cache(() => 1, []);", + "import R from 'react'; const value = R.useMemo(() => 1, []);", + "import * as R from 'react'; const Component = R.memo(() => null);", + "import R from 'react'; const value = R['useMemo'](() => 1, []);", + "import {useCallback} from 'react'; function useFocusEffect(fn) {} useFocusEffect(useCallback(() => {}, []));", + ])('reports React memoization: %s', (code) => { + // Given manual memoization imported from React, including aliases and namespace imports. + // When the rule inspects the call. + const messages = lint(code); + + // Then it reports the wrapper for compiler-aware filtering. + expect(messages).toHaveLength(1); + expect(messages.at(0)?.messageId).toBe('redundant'); + }); + + it.each([ + 'const value = 1;', + "import {memo} from 'other'; const value = memo(() => 1);", + "import React from 'react'; function fn(React) { return React.useMemo(() => 1, []); }", + "import {useMemo} from 'react'; function fn(useMemo) { return useMemo(() => 1, []); }", + "import {useFocusEffect} from '@react-navigation/native'; import {useCallback} from 'react'; useFocusEffect(useCallback(() => {}, []));", + "import {useFocusEffect} from '@react-navigation/core'; import {useCallback} from 'react'; useFocusEffect(useCallback(() => {}, []));", + "import {useFocusEffect as focus} from '@react-navigation/native'; import {useCallback as callback} from 'react'; focus(callback(() => {}, []));", + "import * as Navigation from '@react-navigation/native'; import React from 'react'; Navigation.useFocusEffect(React.useCallback(() => {}, []));", + "import {useFocusEffect} from '@react-navigation/native'; import {useCallback} from 'react'; const callback = useCallback(() => {}, []); useFocusEffect(callback);", + ])('allows plain values and focus callbacks: %s', (code) => { + // Given plain rendering, a non-React function, or a callback React Navigation requires to be stable. + // When inspecting its resolved import rather than just the function name. + const messages = lint(code); + + // Then it is not mistaken for redundant memoization. + expect(messages).toEqual([]); + }); +}); diff --git a/tests/ui/OnyxTabNavigatorTest.tsx b/tests/ui/OnyxTabNavigatorTest.tsx index d00435e14651..4ee5a3db0d6b 100644 --- a/tests/ui/OnyxTabNavigatorTest.tsx +++ b/tests/ui/OnyxTabNavigatorTest.tsx @@ -16,6 +16,7 @@ import Tab from '@userActions/Tab'; import CONST from '@src/CONST'; import KeyboardUtils from '@src/utils/keyboard'; +import type {MaterialTopTabNavigationOptions} from '@react-navigation/material-top-tabs'; import type {NavigationProp, ParamListBase} from '@react-navigation/native'; import type {ValueOf} from 'type-fest'; @@ -44,6 +45,7 @@ const mockOnDiscard = jest.fn, Parameter const mockDispatch = jest.fn['dispatch']>, Parameters['dispatch']>>(); let mockScreenListeners: MockScreenListeners | undefined; let mockInitialRouteName: TabName | undefined; +let mockLazyPlaceholder: MaterialTopTabNavigationOptions['lazyPlaceholder']; jest.mock('@hooks/useConfirmModal', () => ({ __esModule: true, @@ -57,10 +59,17 @@ jest.mock('@hooks/useLocalize', () => ({ jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined, {status: 'loaded'}])); -jest.mock('@hooks/useThemeStyles', () => ({ - __esModule: true, - default: () => ({fullScreenLoading: {}, w100: {}, h100: {}}), -})); +jest.mock('@hooks/useThemeStyles', () => { + const ReactModule = jest.requireActual('react'); + const context = ReactModule.createContext({fullScreenLoading: {}, w100: {}, h100: {}}); + function useMockThemeStyles() { + return ReactModule.useContext(context); + } + return { + __esModule: true, + default: useMockThemeStyles, + }; +}); jest.mock('@userActions/Tab', () => ({setSelectedTab: jest.fn()})); @@ -84,9 +93,20 @@ jest.mock('@libs/getPlatform', () => ({ jest.mock('@react-navigation/material-top-tabs', () => { const ReactModule = jest.requireActual('react'); - const Navigator = ({children, screenListeners, initialRouteName}: {children: React.ReactNode; screenListeners: MockScreenListeners; initialRouteName?: TabName}) => { + const Navigator = ({ + children, + screenListeners, + initialRouteName, + screenOptions, + }: { + children: React.ReactNode; + screenListeners: MockScreenListeners; + initialRouteName?: TabName; + screenOptions: MaterialTopTabNavigationOptions; + }) => { mockScreenListeners = screenListeners; mockInitialRouteName = initialRouteName; + mockLazyPlaceholder = screenOptions.lazyPlaceholder; return ReactModule.createElement(ReactModule.Fragment, null, children); }; const Screen = ({children}: {children?: React.ReactNode | (() => React.ReactNode)}) => { @@ -120,6 +140,55 @@ function renderTabNavigator(hasUnsavedChanges: boolean, shouldDismissKeyboardBef ); } +function LazyScene({isVisible, renderPlaceholder}: {isVisible: boolean; renderPlaceholder: () => React.ReactNode}) { + React.useRef(null); + return isVisible ? renderPlaceholder() : null; +} + +describe('OnyxTabNavigator lazy placeholder', () => { + it('keeps placeholder hooks out of the scene when lazy content becomes visible', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + // Given React Navigation invokes its lazy-placeholder callback directly, and initially hides an unmeasured scene. + renderFiniteNavigator({lazyLoadEnabled: true}); + const renderPlaceholder = mockLazyPlaceholder; + if (!renderPlaceholder) { + throw new Error('Expected a lazy placeholder callback'); + } + const scene = render( + , + ); + + // When layout makes that inactive scene visible, then hides it again. + scene.rerender( + , + ); + expect(scene.toJSON()).not.toBeNull(); + scene.rerender( + , + ); + + // Then its own hook order stays unchanged; the placeholder owns its theme subscription. + expect(scene.toJSON()).toBeNull(); + const hookOrderWarnings = errorSpy.mock.calls.filter((args) => + args.some((arg) => typeof arg === 'string' && /change in the order of Hooks|Rendered (more|fewer) hooks/.test(arg)), + ); + expect(hookOrderWarnings).toHaveLength(0); + } finally { + errorSpy.mockRestore(); + } + }); +}); + function pressTargetTab(): MockTabPressEvent { if (!mockScreenListeners) { throw new Error('Expected the tab navigator to register screen listeners'); diff --git a/tests/ui/components/ExportWithDropdownMenuSelectionTest.tsx b/tests/ui/components/ExportWithDropdownMenuSelectionTest.tsx new file mode 100644 index 000000000000..a294643e49ba --- /dev/null +++ b/tests/ui/components/ExportWithDropdownMenuSelectionTest.tsx @@ -0,0 +1,95 @@ +/** Verifies export selection survives the preferred-method update through the real dropdown and Onyx. */ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import type {ReportExportType} from '@components/ButtonWithDropdownMenu/types'; +import type {PopoverMenuProps} from '@components/PopoverMenu'; +import ExportWithDropdownMenu from '@components/ReportActionItem/ExportWithDropdownMenu'; + +import {exportToIntegration, markAsManuallyExported} from '@libs/actions/Report'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; + +const REPORT_ID = '1001'; +const POLICY_ID = 'policy1'; +const report: Report = {reportID: REPORT_ID, policyID: POLICY_ID, reportName: 'Approved report'}; +const mockPopoverMenu = jest.fn(() => null); + +// Keep the real split button's selection state, replacing only the modal presentation. +jest.mock('@components/PopoverMenu', () => ({ + __esModule: true, + default: (props: PopoverMenuProps) => mockPopoverMenu(props), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string) => key}), +})); + +jest.mock('@libs/actions/Report', () => ({ + exportToIntegration: jest.fn(), + markAsManuallyExported: jest.fn(), +})); + +jest.mock('@libs/ReportUtils', () => ({ + canBeExported: () => true, + isExported: () => false, + getIntegrationIcon: () => undefined, +})); + +describe('ExportWithDropdownMenu selection', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + }); + + it.each([undefined, CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION, CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED])( + 'executes the selected method after updating the saved preference from %s', + async (preferredMethod) => { + // Given a mounted split button that initially selects the workspace's remembered method. + await Onyx.set(ONYXKEYS.LAST_EXPORT_METHOD, preferredMethod ? {[POLICY_ID]: preferredMethod} : {}); + render( + , + ); + await waitForBatchedUpdatesWithAct(); + const initialMethod = preferredMethod ?? CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION; + const selectedMethod = + initialMethod === CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION ? CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED : CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION; + const initialText = initialMethod === CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED ? 'workspace.common.markAsEntered' : 'workspace.common.exportIntegrationSelected'; + expect(screen.getByRole('button', {name: initialText})).toBeOnTheScreen(); + + // When selecting the other option saves it through the real policy action and Onyx subscription. + const selectedText = selectedMethod === CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED ? 'workspace.common.markAsEntered' : 'workspace.common.exportIntegrationSelected'; + const selectedOption = mockPopoverMenu.mock.lastCall?.[0].menuItems.find(({text}) => text === selectedText); + expect(selectedOption).toBeDefined(); + await act(async () => { + selectedOption?.onSelected?.(); + }); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByRole('button', {name: selectedText})); + + // Then the main button executes that selection, not the action formerly at the same array index. + if (selectedMethod === CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED) { + expect(markAsManuallyExported).toHaveBeenCalledWith([REPORT_ID], CONST.POLICY.CONNECTIONS.NAME.QBO, undefined); + expect(exportToIntegration).not.toHaveBeenCalled(); + } else { + expect(exportToIntegration).toHaveBeenCalledWith(REPORT_ID, CONST.POLICY.CONNECTIONS.NAME.QBO, undefined); + expect(markAsManuallyExported).not.toHaveBeenCalled(); + } + }, + ); +}); diff --git a/tests/unit/Search/useSearchSnapshotTest.ts b/tests/unit/Search/useSearchSnapshotTest.ts index f3d2088da7d3..35532a1c065a 100644 --- a/tests/unit/Search/useSearchSnapshotTest.ts +++ b/tests/unit/Search/useSearchSnapshotTest.ts @@ -38,12 +38,14 @@ jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ __esModule: true, default: () => ({accountID: 1, email: 'test@test.com'}), })); +const mockCurrencyListActions = {convertToDisplayString: () => ''}; jest.mock('@hooks/useCurrencyList', () => ({ - useCurrencyListActions: () => ({convertToDisplayString: () => ''}), + useCurrencyListActions: () => mockCurrencyListActions, })); +const mockActionLoadingSet = new Set(); jest.mock('@hooks/useActionLoadingReportIDs', () => ({ __esModule: true, - default: () => new Set(), + default: () => mockActionLoadingSet, })); jest.mock('@hooks/useReportAttributes', () => ({ __esModule: true, @@ -379,15 +381,12 @@ describe('useSearchSnapshot', () => { }); it('keeps the projected data referentially stable across re-renders with unchanged inputs', () => { - // Regression guard for the "Maximum update depth exceeded" crash: the projection stages used to be - // bare IIFEs, which React Compiler does not memoize (it only caches the inline .map callbacks), so - // they allocated a fresh array every render. An unstable `data` reference drove - // useStableOptimisticSortedData's setState effect into an infinite loop on the optimistic-create - // path. The stages must be memoized so the reference only changes when inputs change. + // Given stable inputs and projections that allocate new rows whenever they are evaluated. + // Their compiler-managed references must stay stable for the optimistic-state effect. const searchResults = makeSearchResults(); mockUseOptimisticSearchTracking.mockReturnValue(trackingReturn(searchResults.data)); - mockGetSections.mockReturnValue([[{transactionID: '1'}], 1, false]); - mockGetSortedSections.mockReturnValue([{transactionID: '1', keyForList: '1'}]); + mockGetSections.mockImplementation(() => [[{transactionID: '1'}], 1, false]); + mockGetSortedSections.mockImplementation(() => [{transactionID: '1', keyForList: '1'}]); // Stable props captured from closure so the re-render below passes identical inputs. const props = { @@ -402,10 +401,14 @@ describe('useSearchSnapshot', () => { const firstChartData = result.current.chartData; const firstData = result.current.data; + // When the compiled hook renders again without any input changes. rerender({}); + // Then both projection stages and their output references are reused. expect(result.current.chartData).toBe(firstChartData); expect(result.current.data).toBe(firstData); + expect(mockGetSections).toHaveBeenCalledTimes(1); + expect(mockGetSortedSections).toHaveBeenCalledTimes(1); }); it('caps data at visibleRowLimit while leaving chartData and the row count untouched', () => { diff --git a/tests/unit/hooks/useLazyAsset.test.ts b/tests/unit/hooks/useLazyAsset.test.ts index 09c209785d6a..2ffb4a3ab2e1 100644 --- a/tests/unit/hooks/useLazyAsset.test.ts +++ b/tests/unit/hooks/useLazyAsset.test.ts @@ -413,6 +413,24 @@ describe('useMemoizedLazyExpensifyIcons', () => { // And: The async loader should NOT be called because chunk is already cached expect(mockLoadExpensifyIconsChunk).not.toHaveBeenCalled(); }); + + it('does not reload the chunk after a cold load when icon names are unchanged', async () => { + // Given a cold chunk and callers that recreate their names array on every render. + const mockChunk = createMock({}); + mockChunk.getExpensifyIcon = jest.fn(() => mockAsset); + mockGetExpensifyIconsChunk.mockReturnValue(null); + // Keep unexpected reloads pending so a regression fails without an infinite update loop. + mockLoadExpensifyIconsChunk.mockImplementation(() => new Promise(() => {})).mockResolvedValueOnce(mockChunk); + + // When loading updates the hook's state and the parent renders again with equal names. + const {result, rerender} = renderHook(() => useMemoizedLazyExpensifyIcons(['AddReaction'])); + await waitFor(() => expect(result.current.AddReaction).toBe(mockAsset)); + rerender(undefined); + + // Then the loaded asset is kept without restarting the loading effect. + expect(result.current.AddReaction).toBe(mockAsset); + expect(mockLoadExpensifyIconsChunk).toHaveBeenCalledTimes(1); + }); }); describe('useMemoizedLazyIllustrations', () => { @@ -452,4 +470,21 @@ describe('useMemoizedLazyIllustrations', () => { // And: The async loader should NOT be called because chunk is already cached expect(mockLoadIllustrationsChunk).not.toHaveBeenCalled(); }); + + it('does not reload the chunk after a cold load when illustration names are unchanged', async () => { + // Given the same cold-loading path for illustrations, with freshly allocated names arrays. + const mockChunk = createMock({}); + mockChunk.getIllustration = jest.fn(() => mockAsset); + mockGetIllustrationsChunk.mockReturnValue(null); + mockLoadIllustrationsChunk.mockImplementation(() => new Promise(() => {})).mockResolvedValueOnce(mockChunk); + + // When the chunk resolves and the hook renders again with the same requested illustration. + const {result, rerender} = renderHook(() => useMemoizedLazyIllustrations(['Building'])); + await waitFor(() => expect(result.current.Building).toBe(mockAsset)); + rerender(undefined); + + // Then no second load is started by a new array containing the same name. + expect(result.current.Building).toBe(mockAsset); + expect(mockLoadIllustrationsChunk).toHaveBeenCalledTimes(1); + }); });