From 951f85b9f4d9294a16accbf1a819a3f7e39d1192 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Mon, 2 Dec 2024 16:16:28 +0100 Subject: [PATCH 01/38] add expensify card feeds to card filter --- src/languages/en.ts | 5 ++ src/languages/es.ts | 5 ++ .../SearchFiltersCardPage.tsx | 65 ++++++++++++++++--- 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 855854c58dba..36214587190a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4593,6 +4593,11 @@ const translations = { greaterThan: ({amount}: OptionalParam = {}) => `Greater than ${amount ?? ''}`, between: ({greaterThan, lessThan}: FiltersAmountBetweenParams) => `Between ${greaterThan} and ${lessThan}`, }, + card: { + individualCards: 'Individual cards', + cardFeeds: 'Card feeds', + cardFeedName: (cardFeedBankName: string, cardFeedName?: string) => `All ${cardFeedBankName}${cardFeedName ? ` - ${cardFeedName}` : ''}`, + }, current: 'Current', past: 'Past', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 57ff99f80b6b..c5a661feba07 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4637,6 +4637,11 @@ const translations = { link: 'Enlace', pinned: 'Fijado', unread: 'No leído', + card: { + individualCards: 'Tarjetas individuales', + cardFeeds: 'Flujos de tarjetas', + cardFeedName: (cardFeedBankName: string, cardFeedName?: string) => `Todo ${cardFeedBankName}${cardFeedName ? ` - ${cardFeedName}` : ''}`, + }, amount: { lessThan: ({amount}: OptionalParam = {}) => `Menos de ${amount ?? ''}`, greaterThan: ({amount}: OptionalParam = {}) => `Más que ${amount ?? ''}`, diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index 9a6863f478f7..6b6a6eb146d4 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -9,7 +9,6 @@ import CardListItem from '@components/SelectionList/CardListItem'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CardUtils from '@libs/CardUtils'; -import type {Section} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; import Navigation from '@navigation/Navigation'; import variables from '@styles/variables'; @@ -18,19 +17,61 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {CompanyCardFeed} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type SearchCardItemProps = Partial & {isCardFeed?: boolean; correspondingCards?: string[]}; function SearchFiltersCardPage() { const styles = useThemeStyles(); const {translate} = useLocalize(); const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); + const cardListArray = Object.values(cardList ?? {}); + + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const policyList = Object.values(policies ?? {}); + const [workspaceExpensifyCards] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const currentCards = searchAdvancedFiltersForm?.cardID; const [newCards, setNewCards] = useState(currentCards ?? []); const sections = useMemo(() => { - const newSections: Section[] = []; - const cards = Object.values(cardList ?? {}) + const newSections = []; + + const cardFeedsSection = Object.entries(workspaceExpensifyCards ?? {}) + .filter(([, expensifyCards]) => !isEmptyObject(expensifyCards)) + .map(([expensifyCardListKey, expensifyCards]) => { + const workspaceAccountID = expensifyCardListKey.split('_').at(1) ?? ''; + const correspondingPolicy = policyList.find((policy) => policy?.workspaceAccountID?.toString() === workspaceAccountID); + const text = translate('search.filters.card.cardFeedName', CONST.EXPENSIFY_CARD.BANK, correspondingPolicy?.name); + const correspondingCards = Object.keys(expensifyCards ?? {}).filter((expensifyCardKey) => cardListArray.some((card) => card.cardID.toString() === expensifyCardKey)); + let isSelected = true; + correspondingCards.forEach((card) => { + if (newCards.includes(card)) { + return; + } + isSelected = false; + }); + + const icon = CardUtils.getCardFeedIcon(CONST.EXPENSIFY_CARD.BANK); + return { + text, + keyForList: workspaceAccountID, + isSelected, + bankIcon: { + icon, + iconWidth: variables.cardIconWidth, + iconHeight: variables.cardIconHeight, + iconStyles: [styles.cardIcon], + }, + isCardFeed: true, + correspondingCards, + }; + }); + newSections.push({title: translate('search.filters.card.cardFeeds'), data: cardFeedsSection, shouldShow: cardFeedsSection.length > 0}); + + const cards = cardListArray .sort((a, b) => a.bank.localeCompare(b.bank)) .map((card) => { const icon = CardUtils.getCardFeedIcon(card?.bank as CompanyCardFeed); @@ -49,15 +90,16 @@ function SearchFiltersCardPage() { iconHeight: variables.cardIconHeight, iconStyles: [styles.cardIcon], }, + isCardFeed: false, }; }); newSections.push({ - title: undefined, + title: translate('search.filters.card.individualCards'), data: cards, shouldShow: cards.length > 0, }); return newSections; - }, [cardList, styles, newCards]); + }, [workspaceExpensifyCards, translate, cardListArray, policyList, styles.cardIcon, newCards]); const handleConfirmSelection = useCallback(() => { SearchActions.updateAdvancedFilters({ @@ -68,14 +110,19 @@ function SearchFiltersCardPage() { }, [newCards]); const updateNewCards = useCallback( - (item: Partial) => { + (item: SearchCardItemProps) => { if (!item.keyForList) { return; } + + const isCardFeed = item?.isCardFeed && item?.correspondingCards; + if (item.isSelected) { - setNewCards(newCards.filter((card) => card !== item.keyForList)); + const newCardsObject = newCards.filter((card) => (isCardFeed ? !item.correspondingCards?.includes(card) : card !== item.keyForList)); + setNewCards(newCardsObject); } else { - setNewCards([...newCards, item.keyForList]); + const newCardsObject = isCardFeed ? [...newCards, ...(item?.correspondingCards ?? [])] : [...newCards, item.keyForList]; + setNewCards(newCardsObject); } }, [newCards], @@ -108,7 +155,7 @@ function SearchFiltersCardPage() { }} /> - sections={sections} onSelectRow={updateNewCards} footerContent={footerContent} From 41135196f499e1ee8d7ddc8457216583f9a0f430 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Mon, 2 Dec 2024 17:22:44 +0100 Subject: [PATCH 02/38] add company card feeds to card filter --- src/languages/en.ts | 1 + src/languages/es.ts | 1 + .../SearchFiltersCardPage.tsx | 21 ++++++++++--------- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 36214587190a..fdd67cb5393e 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4594,6 +4594,7 @@ const translations = { between: ({greaterThan, lessThan}: FiltersAmountBetweenParams) => `Between ${greaterThan} and ${lessThan}`, }, card: { + expensify: 'Expensify', individualCards: 'Individual cards', cardFeeds: 'Card feeds', cardFeedName: (cardFeedBankName: string, cardFeedName?: string) => `All ${cardFeedBankName}${cardFeedName ? ` - ${cardFeedName}` : ''}`, diff --git a/src/languages/es.ts b/src/languages/es.ts index c5a661feba07..26f59e36fc41 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4638,6 +4638,7 @@ const translations = { pinned: 'Fijado', unread: 'No leído', card: { + expensify: 'Expensify', individualCards: 'Tarjetas individuales', cardFeeds: 'Flujos de tarjetas', cardFeedName: (cardFeedBankName: string, cardFeedName?: string) => `Todo ${cardFeedBankName}${cardFeedName ? ` - ${cardFeedName}` : ''}`, diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index 6b6a6eb146d4..b229fe5dd460 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -25,12 +25,12 @@ function SearchFiltersCardPage() { const styles = useThemeStyles(); const {translate} = useLocalize(); - const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); - const cardListArray = Object.values(cardList ?? {}); + const [userCardList] = useOnyx(ONYXKEYS.CARD_LIST); + const userCardListArray = Object.values(userCardList ?? {}); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const policyList = Object.values(policies ?? {}); - const [workspaceExpensifyCards] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const [workspaceCardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const currentCards = searchAdvancedFiltersForm?.cardID; @@ -39,13 +39,14 @@ function SearchFiltersCardPage() { const sections = useMemo(() => { const newSections = []; - const cardFeedsSection = Object.entries(workspaceExpensifyCards ?? {}) + const cardFeedsSection = Object.entries(workspaceCardFeeds ?? {}) .filter(([, expensifyCards]) => !isEmptyObject(expensifyCards)) .map(([expensifyCardListKey, expensifyCards]) => { - const workspaceAccountID = expensifyCardListKey.split('_').at(1) ?? ''; + const [, workspaceAccountID, bank] = expensifyCardListKey.split('_'); const correspondingPolicy = policyList.find((policy) => policy?.workspaceAccountID?.toString() === workspaceAccountID); - const text = translate('search.filters.card.cardFeedName', CONST.EXPENSIFY_CARD.BANK, correspondingPolicy?.name); - const correspondingCards = Object.keys(expensifyCards ?? {}).filter((expensifyCardKey) => cardListArray.some((card) => card.cardID.toString() === expensifyCardKey)); + const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); + const text = translate('search.filters.card.cardFeedName', cardFeedBankName, correspondingPolicy?.name); + const correspondingCards = Object.keys(expensifyCards ?? {}).filter((expensifyCardKey) => userCardListArray.some((card) => card.cardID.toString() === expensifyCardKey)); let isSelected = true; correspondingCards.forEach((card) => { if (newCards.includes(card)) { @@ -54,7 +55,7 @@ function SearchFiltersCardPage() { isSelected = false; }); - const icon = CardUtils.getCardFeedIcon(CONST.EXPENSIFY_CARD.BANK); + const icon = CardUtils.getCardFeedIcon(bank as CompanyCardFeed | typeof CONST.EXPENSIFY_CARD.BANK); return { text, keyForList: workspaceAccountID, @@ -71,7 +72,7 @@ function SearchFiltersCardPage() { }); newSections.push({title: translate('search.filters.card.cardFeeds'), data: cardFeedsSection, shouldShow: cardFeedsSection.length > 0}); - const cards = cardListArray + const cards = userCardListArray .sort((a, b) => a.bank.localeCompare(b.bank)) .map((card) => { const icon = CardUtils.getCardFeedIcon(card?.bank as CompanyCardFeed); @@ -99,7 +100,7 @@ function SearchFiltersCardPage() { shouldShow: cards.length > 0, }); return newSections; - }, [workspaceExpensifyCards, translate, cardListArray, policyList, styles.cardIcon, newCards]); + }, [workspaceCardFeeds, translate, userCardListArray, policyList, styles.cardIcon, newCards]); const handleConfirmSelection = useCallback(() => { SearchActions.updateAdvancedFilters({ From 9336e8f9e6a5241bf25212b86202fd6b5a7cee0b Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Thu, 5 Dec 2024 12:23:33 +0100 Subject: [PATCH 03/38] add handling of domain feeds --- .../SearchFiltersCardPage.tsx | 237 ++++++++++++------ src/types/onyx/Bank.ts | 4 +- 2 files changed, 166 insertions(+), 75 deletions(-) diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index b229fe5dd460..d16bc98d2b8b 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -1,5 +1,6 @@ import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -9,6 +10,7 @@ import CardListItem from '@components/SelectionList/CardListItem'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CardUtils from '@libs/CardUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; import Navigation from '@navigation/Navigation'; import variables from '@styles/variables'; @@ -16,102 +18,191 @@ import * as SearchActions from '@userActions/Search'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {CompanyCardFeed} from '@src/types/onyx'; +import type {Card, CompanyCardFeed} from '@src/types/onyx'; +import type {BankIcon} from '@src/types/onyx/Bank'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -type SearchCardItemProps = Partial & {isCardFeed?: boolean; correspondingCards?: string[]}; +type CardFilterItem = Partial & {bankIcon?: BankIcon; lastFourPAN?: string; isVirtual?: boolean; isCardFeed?: boolean; correspondingCards?: string[]}; + +type DomainFeedData = {bank: string; domainName: string; correspospondingCardIDs: string[]}; + +function isCard(item: Card | Record): item is Card { + return 'cardID' in item && !!item.cardID && 'bank' in item && !!item.bank; +} + +function buildIndividualCardItem(card: Card, isSelected: boolean, iconStyles: StyleProp): CardFilterItem { + const icon = CardUtils.getCardFeedIcon(card?.bank as CompanyCardFeed); + const cardName = card?.nameValuePairs?.cardTitle ?? card?.cardName; + const text = card.bank === CONST.EXPENSIFY_CARD.BANK ? card.bank : cardName; + + return { + lastFourPAN: card.lastFourPAN, + isVirtual: card?.nameValuePairs?.isVirtual, + text, + keyForList: card.cardID.toString(), + isSelected, + bankIcon: { + icon, + iconWidth: variables.cardIconWidth, + iconHeight: variables.cardIconHeight, + iconStyles, + }, + isCardFeed: false, + }; +} + +function buildCardFeedItem( + text: string, + keyForList: string, + correspondingCardIDs: string[], + selectedCards: string[], + bank: CompanyCardFeed, + iconStyles: StyleProp, +): CardFilterItem { + let isSelected = true; + correspondingCardIDs.forEach((card) => { + if (selectedCards.includes(card)) { + return; + } + isSelected = false; + }); + + const icon = CardUtils.getCardFeedIcon(bank); + return { + text, + keyForList, + isSelected, + bankIcon: { + icon, + iconWidth: variables.cardIconWidth, + iconHeight: variables.cardIconHeight, + iconStyles, + }, + isCardFeed: true, + correspondingCards: correspondingCardIDs, + }; +} function SearchFiltersCardPage() { const styles = useThemeStyles(); const {translate} = useLocalize(); const [userCardList] = useOnyx(ONYXKEYS.CARD_LIST); - const userCardListArray = Object.values(userCardList ?? {}); - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const policyList = Object.values(policies ?? {}); const [workspaceCardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const filteredWorkspaceCardFeeds = Object.entries(workspaceCardFeeds ?? {}).filter((cardFeed) => !isEmptyObject(cardFeed)); const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); - const currentCards = searchAdvancedFiltersForm?.cardID; - const [newCards, setNewCards] = useState(currentCards ?? []); + const initiallySelectedCards = searchAdvancedFiltersForm?.cardID; + const [newSelectedCards, setNewSelectedCards] = useState(initiallySelectedCards ?? []); const sections = useMemo(() => { const newSections = []; - const cardFeedsSection = Object.entries(workspaceCardFeeds ?? {}) - .filter(([, expensifyCards]) => !isEmptyObject(expensifyCards)) - .map(([expensifyCardListKey, expensifyCards]) => { - const [, workspaceAccountID, bank] = expensifyCardListKey.split('_'); - const correspondingPolicy = policyList.find((policy) => policy?.workspaceAccountID?.toString() === workspaceAccountID); - const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); - const text = translate('search.filters.card.cardFeedName', cardFeedBankName, correspondingPolicy?.name); - const correspondingCards = Object.keys(expensifyCards ?? {}).filter((expensifyCardKey) => userCardListArray.some((card) => card.cardID.toString() === expensifyCardKey)); - let isSelected = true; - correspondingCards.forEach((card) => { - if (newCards.includes(card)) { - return; - } - isSelected = false; - }); - - const icon = CardUtils.getCardFeedIcon(bank as CompanyCardFeed | typeof CONST.EXPENSIFY_CARD.BANK); - return { - text, - keyForList: workspaceAccountID, - isSelected, - bankIcon: { - icon, - iconWidth: variables.cardIconWidth, - iconHeight: variables.cardIconHeight, - iconStyles: [styles.cardIcon], - }, - isCardFeed: true, - correspondingCards, - }; - }); - newSections.push({title: translate('search.filters.card.cardFeeds'), data: cardFeedsSection, shouldShow: cardFeedsSection.length > 0}); - - const cards = userCardListArray - .sort((a, b) => a.bank.localeCompare(b.bank)) - .map((card) => { - const icon = CardUtils.getCardFeedIcon(card?.bank as CompanyCardFeed); - const cardName = card?.nameValuePairs?.cardTitle ?? card?.cardName; - const text = card.bank === CONST.EXPENSIFY_CARD.BANK ? card.bank : cardName; - - return { - lastFourPAN: card.lastFourPAN, - isVirtual: card?.nameValuePairs?.isVirtual, - text, - keyForList: card.cardID.toString(), - isSelected: newCards.includes(card.cardID.toString()), - bankIcon: { - icon, - iconWidth: variables.cardIconWidth, - iconHeight: variables.cardIconHeight, - iconStyles: [styles.cardIcon], - }, - isCardFeed: false, - }; + const invidualCardsSectionData: CardFilterItem[] = []; + const domainFeedsCards: Record = {}; + + Object.values(userCardList ?? {}).forEach((card) => { + const isSelected = newSelectedCards.includes(card.cardID.toString()); + const cardData = buildIndividualCardItem(card, isSelected, styles.cardIcon); + + invidualCardsSectionData.push(cardData); + + // Cards in cardList can also be domain cards, we use them to compute domain feed + if (!card.domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)) { + if (domainFeedsCards[card.domainName]) { + domainFeedsCards[card.domainName].correspospondingCardIDs.push(card.cardID.toString()); + } else { + domainFeedsCards[card.domainName] = {domainName: card.domainName, bank: card.bank, correspospondingCardIDs: [card.cardID.toString()]}; + } + } + }); + + // When user is admin of a workspace he sees all the cards of workspace under cards_ Onyx key + filteredWorkspaceCardFeeds.forEach(([, cardFeed]) => { + Object.values(cardFeed ?? {}).forEach((card) => { + if (!card || !isCard(card) || userCardList?.[card.cardID]) { + return; + } + const isSelected = newSelectedCards.includes(card.cardID.toString()); + const cardData = buildIndividualCardItem(card, isSelected, styles.cardIcon); + + invidualCardsSectionData.push(cardData); }); + }); + + const repeatingBanks: string[] = []; + const banks: string[] = []; + const handleRepeatingBankNames = (bankName: string) => { + if (banks.includes(bankName)) { + repeatingBanks.push(bankName); + } else { + banks.push(bankName); + } + }; + + filteredWorkspaceCardFeeds.forEach(([cardFeedKey]) => { + const bankName = cardFeedKey.split('_').at(2); + if (!bankName) { + return; + } + + handleRepeatingBankNames(bankName); + }); + Object.values(domainFeedsCards).forEach((domainFeed) => { + handleRepeatingBankNames(domainFeed.bank); + }); + + const cardFeedsSectionData: CardFilterItem[] = []; + + filteredWorkspaceCardFeeds.forEach(([, cardFeed]) => { + const representativeCard = Object.values(cardFeed ?? {}).find((cardFeedItem) => isCard(cardFeedItem)); + if (!representativeCard) { + return; + } + const {domainName, bank} = representativeCard; + const isBankRepeating = repeatingBanks.includes(bank); + const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); + const policyID = domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1] ?? ''; + const correspondingPolicy = PolicyUtils.getPolicy(policyID?.toUpperCase()); + const text = translate('search.filters.card.cardFeedName', cardFeedBankName, isBankRepeating ? correspondingPolicy?.name : undefined); + const correspondingCards = Object.keys(cardFeed ?? {}); + + cardFeedsSectionData.push(buildCardFeedItem(text, policyID, correspondingCards, newSelectedCards, bank as CompanyCardFeed, styles.cardIcon)); + }); + + Object.values(domainFeedsCards).forEach((domainFeed) => { + const {domainName, bank, correspospondingCardIDs} = domainFeed; + const isBankRepeating = repeatingBanks.includes(bank); + const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); + const text = translate('search.filters.card.cardFeedName', cardFeedBankName, isBankRepeating ? domainName : undefined); + + cardFeedsSectionData.push(buildCardFeedItem(text, domainName, correspospondingCardIDs, newSelectedCards, bank as CompanyCardFeed, styles.cardIcon)); + }); + newSections.push({ + title: translate('search.filters.card.cardFeeds'), + data: cardFeedsSectionData, + shouldShow: cardFeedsSectionData.length > 0, + }); + newSections.push({ title: translate('search.filters.card.individualCards'), - data: cards, - shouldShow: cards.length > 0, + data: invidualCardsSectionData, + shouldShow: invidualCardsSectionData.length > 0, }); return newSections; - }, [workspaceCardFeeds, translate, userCardListArray, policyList, styles.cardIcon, newCards]); + }, [filteredWorkspaceCardFeeds, translate, newSelectedCards, styles.cardIcon, userCardList]); const handleConfirmSelection = useCallback(() => { SearchActions.updateAdvancedFilters({ - cardID: newCards, + cardID: newSelectedCards, }); Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS); - }, [newCards]); + }, [newSelectedCards]); const updateNewCards = useCallback( - (item: SearchCardItemProps) => { + (item: CardFilterItem) => { if (!item.keyForList) { return; } @@ -119,14 +210,14 @@ function SearchFiltersCardPage() { const isCardFeed = item?.isCardFeed && item?.correspondingCards; if (item.isSelected) { - const newCardsObject = newCards.filter((card) => (isCardFeed ? !item.correspondingCards?.includes(card) : card !== item.keyForList)); - setNewCards(newCardsObject); + const newCardsObject = newSelectedCards.filter((card) => (isCardFeed ? !item.correspondingCards?.includes(card) : card !== item.keyForList)); + setNewSelectedCards(newCardsObject); } else { - const newCardsObject = isCardFeed ? [...newCards, ...(item?.correspondingCards ?? [])] : [...newCards, item.keyForList]; - setNewCards(newCardsObject); + const newCardsObject = isCardFeed ? [...newSelectedCards, ...(item?.correspondingCards ?? [])] : [...newSelectedCards, item.keyForList]; + setNewSelectedCards(newCardsObject); } }, - [newCards], + [newSelectedCards], ); const footerContent = useMemo( @@ -156,7 +247,7 @@ function SearchFiltersCardPage() { }} /> - + sections={sections} onSelectRow={updateNewCards} footerContent={footerContent} diff --git a/src/types/onyx/Bank.ts b/src/types/onyx/Bank.ts index 3eee283da5c6..5e5d45cfa1d5 100644 --- a/src/types/onyx/Bank.ts +++ b/src/types/onyx/Bank.ts @@ -1,4 +1,4 @@ -import type {ViewStyle} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -18,7 +18,7 @@ type BankIcon = { iconWidth?: number; /** Icon wrapper styles */ - iconStyles?: ViewStyle[]; + iconStyles?: StyleProp; }; /** Bank names */ From defc543b5ce161695e504bcce2f6e146510b51a5 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Thu, 5 Dec 2024 14:49:54 +0100 Subject: [PATCH 04/38] add search input --- .../SearchFiltersCardPage.tsx | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index d16bc98d2b8b..323e1b4d4fa0 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -7,6 +7,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import CardListItem from '@components/SelectionList/CardListItem'; +import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CardUtils from '@libs/CardUtils'; @@ -88,25 +89,23 @@ function SearchFiltersCardPage() { const {translate} = useLocalize(); const [userCardList] = useOnyx(ONYXKEYS.CARD_LIST); - const [workspaceCardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); const filteredWorkspaceCardFeeds = Object.entries(workspaceCardFeeds ?? {}).filter((cardFeed) => !isEmptyObject(cardFeed)); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const initiallySelectedCards = searchAdvancedFiltersForm?.cardID; const [newSelectedCards, setNewSelectedCards] = useState(initiallySelectedCards ?? []); - const sections = useMemo(() => { - const newSections = []; - - const invidualCardsSectionData: CardFilterItem[] = []; + const {invidualCardsSectionData, domainCardFeedsData} = useMemo(() => { + const individualCards: CardFilterItem[] = []; const domainFeedsCards: Record = {}; Object.values(userCardList ?? {}).forEach((card) => { const isSelected = newSelectedCards.includes(card.cardID.toString()); const cardData = buildIndividualCardItem(card, isSelected, styles.cardIcon); - invidualCardsSectionData.push(cardData); + individualCards.push(cardData); // Cards in cardList can also be domain cards, we use them to compute domain feed if (!card.domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)) { @@ -127,10 +126,13 @@ function SearchFiltersCardPage() { const isSelected = newSelectedCards.includes(card.cardID.toString()); const cardData = buildIndividualCardItem(card, isSelected, styles.cardIcon); - invidualCardsSectionData.push(cardData); + individualCards.push(cardData); }); }); + return {invidualCardsSectionData: individualCards, domainCardFeedsData: domainFeedsCards}; + }, [filteredWorkspaceCardFeeds, newSelectedCards, styles.cardIcon, userCardList]); + const cardFeedsSectionData = useMemo(() => { const repeatingBanks: string[] = []; const banks: string[] = []; const handleRepeatingBankNames = (bankName: string) => { @@ -149,11 +151,11 @@ function SearchFiltersCardPage() { handleRepeatingBankNames(bankName); }); - Object.values(domainFeedsCards).forEach((domainFeed) => { + Object.values(domainCardFeedsData).forEach((domainFeed) => { handleRepeatingBankNames(domainFeed.bank); }); - const cardFeedsSectionData: CardFilterItem[] = []; + const cardFeedsData: CardFilterItem[] = []; filteredWorkspaceCardFeeds.forEach(([, cardFeed]) => { const representativeCard = Object.values(cardFeed ?? {}).find((cardFeedItem) => isCard(cardFeedItem)); @@ -167,31 +169,44 @@ function SearchFiltersCardPage() { const correspondingPolicy = PolicyUtils.getPolicy(policyID?.toUpperCase()); const text = translate('search.filters.card.cardFeedName', cardFeedBankName, isBankRepeating ? correspondingPolicy?.name : undefined); const correspondingCards = Object.keys(cardFeed ?? {}); + if (debouncedSearchTerm && !text.includes(debouncedSearchTerm)) { + return; + } - cardFeedsSectionData.push(buildCardFeedItem(text, policyID, correspondingCards, newSelectedCards, bank as CompanyCardFeed, styles.cardIcon)); + cardFeedsData.push(buildCardFeedItem(text, policyID, correspondingCards, newSelectedCards, bank as CompanyCardFeed, styles.cardIcon)); }); - Object.values(domainFeedsCards).forEach((domainFeed) => { + Object.values(domainCardFeedsData).forEach((domainFeed) => { const {domainName, bank, correspospondingCardIDs} = domainFeed; const isBankRepeating = repeatingBanks.includes(bank); const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); const text = translate('search.filters.card.cardFeedName', cardFeedBankName, isBankRepeating ? domainName : undefined); + if (debouncedSearchTerm && !text.includes(debouncedSearchTerm)) { + return; + } - cardFeedsSectionData.push(buildCardFeedItem(text, domainName, correspospondingCardIDs, newSelectedCards, bank as CompanyCardFeed, styles.cardIcon)); + cardFeedsData.push(buildCardFeedItem(text, domainName, correspospondingCardIDs, newSelectedCards, bank as CompanyCardFeed, styles.cardIcon)); }); + return cardFeedsData; + }, [debouncedSearchTerm, domainCardFeedsData, filteredWorkspaceCardFeeds, newSelectedCards, styles.cardIcon, translate]); + + const shouldShowSearchInput = cardFeedsSectionData.length + invidualCardsSectionData.length > 8; + + const sections = useMemo(() => { + const newSections = []; + newSections.push({ title: translate('search.filters.card.cardFeeds'), - data: cardFeedsSectionData, + data: debouncedSearchTerm ? cardFeedsSectionData.filter((item) => item.text?.includes(debouncedSearchTerm)) : cardFeedsSectionData, shouldShow: cardFeedsSectionData.length > 0, }); - newSections.push({ title: translate('search.filters.card.individualCards'), - data: invidualCardsSectionData, + data: debouncedSearchTerm ? invidualCardsSectionData.filter((item) => item.text?.includes(debouncedSearchTerm)) : invidualCardsSectionData, shouldShow: invidualCardsSectionData.length > 0, }); return newSections; - }, [filteredWorkspaceCardFeeds, translate, newSelectedCards, styles.cardIcon, userCardList]); + }, [translate, cardFeedsSectionData, invidualCardsSectionData, debouncedSearchTerm]); const handleConfirmSelection = useCallback(() => { SearchActions.updateAdvancedFilters({ @@ -255,6 +270,11 @@ function SearchFiltersCardPage() { shouldShowTooltips canSelectMultiple ListItem={CardListItem} + textInputLabel={shouldShowSearchInput ? translate('common.search') : undefined} + textInputValue={searchTerm} + onChangeText={(value) => { + setSearchTerm(value); + }} /> From 98b35120370bc8c1218565d3b8c3752eea798d8f Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Thu, 5 Dec 2024 15:16:39 +0100 Subject: [PATCH 05/38] improve naming --- .../SearchFiltersCardPage.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index 323e1b4d4fa0..9945514c2c3b 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -97,9 +97,9 @@ function SearchFiltersCardPage() { const initiallySelectedCards = searchAdvancedFiltersForm?.cardID; const [newSelectedCards, setNewSelectedCards] = useState(initiallySelectedCards ?? []); - const {invidualCardsSectionData, domainCardFeedsData} = useMemo(() => { + const {invidualCardsSectionData, domainFeedsData} = useMemo(() => { const individualCards: CardFilterItem[] = []; - const domainFeedsCards: Record = {}; + const domainFeeds: Record = {}; Object.values(userCardList ?? {}).forEach((card) => { const isSelected = newSelectedCards.includes(card.cardID.toString()); @@ -109,10 +109,10 @@ function SearchFiltersCardPage() { // Cards in cardList can also be domain cards, we use them to compute domain feed if (!card.domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)) { - if (domainFeedsCards[card.domainName]) { - domainFeedsCards[card.domainName].correspospondingCardIDs.push(card.cardID.toString()); + if (domainFeeds[card.domainName]) { + domainFeeds[card.domainName].correspospondingCardIDs.push(card.cardID.toString()); } else { - domainFeedsCards[card.domainName] = {domainName: card.domainName, bank: card.bank, correspospondingCardIDs: [card.cardID.toString()]}; + domainFeeds[card.domainName] = {domainName: card.domainName, bank: card.bank, correspospondingCardIDs: [card.cardID.toString()]}; } } }); @@ -129,7 +129,7 @@ function SearchFiltersCardPage() { individualCards.push(cardData); }); }); - return {invidualCardsSectionData: individualCards, domainCardFeedsData: domainFeedsCards}; + return {invidualCardsSectionData: individualCards, domainFeedsData: domainFeeds}; }, [filteredWorkspaceCardFeeds, newSelectedCards, styles.cardIcon, userCardList]); const cardFeedsSectionData = useMemo(() => { @@ -151,7 +151,7 @@ function SearchFiltersCardPage() { handleRepeatingBankNames(bankName); }); - Object.values(domainCardFeedsData).forEach((domainFeed) => { + Object.values(domainFeedsData).forEach((domainFeed) => { handleRepeatingBankNames(domainFeed.bank); }); @@ -176,7 +176,7 @@ function SearchFiltersCardPage() { cardFeedsData.push(buildCardFeedItem(text, policyID, correspondingCards, newSelectedCards, bank as CompanyCardFeed, styles.cardIcon)); }); - Object.values(domainCardFeedsData).forEach((domainFeed) => { + Object.values(domainFeedsData).forEach((domainFeed) => { const {domainName, bank, correspospondingCardIDs} = domainFeed; const isBankRepeating = repeatingBanks.includes(bank); const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); @@ -188,7 +188,7 @@ function SearchFiltersCardPage() { cardFeedsData.push(buildCardFeedItem(text, domainName, correspospondingCardIDs, newSelectedCards, bank as CompanyCardFeed, styles.cardIcon)); }); return cardFeedsData; - }, [debouncedSearchTerm, domainCardFeedsData, filteredWorkspaceCardFeeds, newSelectedCards, styles.cardIcon, translate]); + }, [debouncedSearchTerm, domainFeedsData, filteredWorkspaceCardFeeds, newSelectedCards, styles.cardIcon, translate]); const shouldShowSearchInput = cardFeedsSectionData.length + invidualCardsSectionData.length > 8; From a5545b7f69cbeaf85d8ef1884b242c12a8ff0f59 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Thu, 5 Dec 2024 15:29:10 +0100 Subject: [PATCH 06/38] fix typescript --- src/languages/en.ts | 3 ++- src/languages/es.ts | 3 ++- .../SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx | 4 ++-- src/types/onyx/PaymentMethod.ts | 4 ++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index aaff762e064d..b8a6c2d0dd86 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4574,7 +4574,8 @@ const translations = { expensify: 'Expensify', individualCards: 'Individual cards', cardFeeds: 'Card feeds', - cardFeedName: (cardFeedBankName: string, cardFeedName?: string) => `All ${cardFeedBankName}${cardFeedName ? ` - ${cardFeedName}` : ''}`, + cardFeedName: ({cardFeedBankName, cardFeedLabel}: {cardFeedBankName: string; cardFeedLabel?: string}) => + `All ${cardFeedBankName}${cardFeedLabel ? ` - ${cardFeedLabel}` : ''}`, }, current: 'Current', past: 'Past', diff --git a/src/languages/es.ts b/src/languages/es.ts index 90f957c9e249..7e9764df0aa7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4618,7 +4618,8 @@ const translations = { expensify: 'Expensify', individualCards: 'Tarjetas individuales', cardFeeds: 'Flujos de tarjetas', - cardFeedName: (cardFeedBankName: string, cardFeedName?: string) => `Todo ${cardFeedBankName}${cardFeedName ? ` - ${cardFeedName}` : ''}`, + cardFeedName: ({cardFeedBankName, cardFeedLabel}: {cardFeedBankName: string; cardFeedLabel?: string}) => + `Todo ${cardFeedBankName}${cardFeedLabel ? ` - ${cardFeedLabel}` : ''}`, }, amount: { lessThan: ({amount}: OptionalParam = {}) => `Menos de ${amount ?? ''}`, diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index 9945514c2c3b..e10d99224cd1 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -167,7 +167,7 @@ function SearchFiltersCardPage() { const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); const policyID = domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1] ?? ''; const correspondingPolicy = PolicyUtils.getPolicy(policyID?.toUpperCase()); - const text = translate('search.filters.card.cardFeedName', cardFeedBankName, isBankRepeating ? correspondingPolicy?.name : undefined); + const text = translate('search.filters.card.cardFeedName', {cardFeedBankName, cardFeedLabel: isBankRepeating ? correspondingPolicy?.name : undefined}); const correspondingCards = Object.keys(cardFeed ?? {}); if (debouncedSearchTerm && !text.includes(debouncedSearchTerm)) { return; @@ -180,7 +180,7 @@ function SearchFiltersCardPage() { const {domainName, bank, correspospondingCardIDs} = domainFeed; const isBankRepeating = repeatingBanks.includes(bank); const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); - const text = translate('search.filters.card.cardFeedName', cardFeedBankName, isBankRepeating ? domainName : undefined); + const text = translate('search.filters.card.cardFeedName', {cardFeedBankName, cardFeedLabel: isBankRepeating ? domainName : undefined}); if (debouncedSearchTerm && !text.includes(debouncedSearchTerm)) { return; } diff --git a/src/types/onyx/PaymentMethod.ts b/src/types/onyx/PaymentMethod.ts index b95f890939eb..f473dfb0e8fc 100644 --- a/src/types/onyx/PaymentMethod.ts +++ b/src/types/onyx/PaymentMethod.ts @@ -1,4 +1,4 @@ -import type {ViewStyle} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; import type IconAsset from '@src/types/utils/IconAsset'; import type BankAccount from './BankAccount'; import type Fund from './Fund'; @@ -21,7 +21,7 @@ type PaymentMethod = (BankAccount | Fund) & { iconWidth?: number; /** Icon wrapper styles */ - iconStyles?: ViewStyle[]; + iconStyles?: StyleProp; }; export default PaymentMethod; From 6ebe5791bf36b8384cf6ca5a4e46db46a4a6394b Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Thu, 5 Dec 2024 17:12:04 +0100 Subject: [PATCH 07/38] fix typescript --- src/hooks/usePaymentMethodState/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/usePaymentMethodState/types.ts b/src/hooks/usePaymentMethodState/types.ts index 260a9aec27cf..3bdcc09f3a2c 100644 --- a/src/hooks/usePaymentMethodState/types.ts +++ b/src/hooks/usePaymentMethodState/types.ts @@ -1,4 +1,4 @@ -import type {ViewStyle} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; import type {AccountData} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -6,7 +6,7 @@ type FormattedSelectedPaymentMethodIcon = { icon: IconAsset; iconHeight?: number; iconWidth?: number; - iconStyles?: ViewStyle[]; + iconStyles?: StyleProp; iconSize?: number; }; From a1ae60e0dc659a4cfaab4013a8513aaa9fbbd9fd Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Fri, 6 Dec 2024 08:37:32 +0100 Subject: [PATCH 08/38] add lowercase filtring --- .../SearchFiltersCardPage.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index e10d99224cd1..a3b98c31c3f5 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -169,9 +169,6 @@ function SearchFiltersCardPage() { const correspondingPolicy = PolicyUtils.getPolicy(policyID?.toUpperCase()); const text = translate('search.filters.card.cardFeedName', {cardFeedBankName, cardFeedLabel: isBankRepeating ? correspondingPolicy?.name : undefined}); const correspondingCards = Object.keys(cardFeed ?? {}); - if (debouncedSearchTerm && !text.includes(debouncedSearchTerm)) { - return; - } cardFeedsData.push(buildCardFeedItem(text, policyID, correspondingCards, newSelectedCards, bank as CompanyCardFeed, styles.cardIcon)); }); @@ -181,14 +178,11 @@ function SearchFiltersCardPage() { const isBankRepeating = repeatingBanks.includes(bank); const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); const text = translate('search.filters.card.cardFeedName', {cardFeedBankName, cardFeedLabel: isBankRepeating ? domainName : undefined}); - if (debouncedSearchTerm && !text.includes(debouncedSearchTerm)) { - return; - } cardFeedsData.push(buildCardFeedItem(text, domainName, correspospondingCardIDs, newSelectedCards, bank as CompanyCardFeed, styles.cardIcon)); }); return cardFeedsData; - }, [debouncedSearchTerm, domainFeedsData, filteredWorkspaceCardFeeds, newSelectedCards, styles.cardIcon, translate]); + }, [domainFeedsData, filteredWorkspaceCardFeeds, newSelectedCards, styles.cardIcon, translate]); const shouldShowSearchInput = cardFeedsSectionData.length + invidualCardsSectionData.length > 8; @@ -197,12 +191,12 @@ function SearchFiltersCardPage() { newSections.push({ title: translate('search.filters.card.cardFeeds'), - data: debouncedSearchTerm ? cardFeedsSectionData.filter((item) => item.text?.includes(debouncedSearchTerm)) : cardFeedsSectionData, + data: cardFeedsSectionData.filter((item) => item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())), shouldShow: cardFeedsSectionData.length > 0, }); newSections.push({ title: translate('search.filters.card.individualCards'), - data: debouncedSearchTerm ? invidualCardsSectionData.filter((item) => item.text?.includes(debouncedSearchTerm)) : invidualCardsSectionData, + data: invidualCardsSectionData.filter((item) => item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())), shouldShow: invidualCardsSectionData.length > 0, }); return newSections; @@ -270,6 +264,7 @@ function SearchFiltersCardPage() { shouldShowTooltips canSelectMultiple ListItem={CardListItem} + shouldShowTextInput={shouldShowSearchInput} textInputLabel={shouldShowSearchInput ? translate('common.search') : undefined} textInputValue={searchTerm} onChangeText={(value) => { From d9f0e8a760aa13c44f0291943ddb0024b404facc Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Fri, 6 Dec 2024 10:10:58 +0100 Subject: [PATCH 09/38] wrap workspaceFeeds data in useMemo --- .../Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index a3b98c31c3f5..17db3a219b92 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -90,7 +90,7 @@ function SearchFiltersCardPage() { const [userCardList] = useOnyx(ONYXKEYS.CARD_LIST); const [workspaceCardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); - const filteredWorkspaceCardFeeds = Object.entries(workspaceCardFeeds ?? {}).filter((cardFeed) => !isEmptyObject(cardFeed)); + const filteredWorkspaceCardFeeds = useMemo(() => Object.entries(workspaceCardFeeds ?? {}).filter((cardFeed) => !isEmptyObject(cardFeed)), [workspaceCardFeeds]); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); From 42ecedc8df342dd20abc82ebbf4a74ea9e1c075e Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Fri, 6 Dec 2024 12:37:33 +0100 Subject: [PATCH 10/38] fix filter not appearing bug --- src/pages/Search/AdvancedSearchFilters.tsx | 19 ++++++++++++++++--- .../SearchFiltersCardPage.tsx | 6 +++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index 60c01e6f75f0..737f53218f53 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -1,3 +1,4 @@ +import {CardAnimationContext} from '@react-navigation/stack'; import React, {useMemo} from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'react-native-gesture-handler/lib/typescript/typeUtils'; @@ -30,6 +31,7 @@ import type {SearchAdvancedFiltersForm} from '@src/types/form'; import type {CardList, PersonalDetailsList, Policy, PolicyTagLists, Report} from '@src/types/onyx'; import type {PolicyFeatureName} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {isCard} from './SearchAdvancedFiltersPage/SearchFiltersCardPage'; const baseFilterConfig = { date: { @@ -273,7 +275,18 @@ function AdvancedSearchFilters() { const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); const [searchAdvancedFilters = {} as SearchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const policyID = searchAdvancedFilters.policyID ?? '-1'; - const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const [userCardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const [workspaceCardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const allCards = Object.values(workspaceCardFeeds ?? {}).reduce((cardsAccumulated, currentCardFeed) => { + Object.values(currentCardFeed ?? {}).forEach((card) => { + if (!isCard(card)) { + return; + } + // eslint-disable-next-line no-param-reassign + cardsAccumulated[card.cardID] = card; + }); + return cardsAccumulated; + }, userCardList); const taxRates = getAllTaxRates(); const personalDetails = usePersonalDetails(); @@ -310,7 +323,7 @@ function AdvancedSearchFilters() { const shouldDisplayCategoryFilter = shouldDisplayFilter(nonPersonalPolicyCategoryCount, areCategoriesEnabled, !!singlePolicyCategories); const shouldDisplayTagFilter = shouldDisplayFilter(tagListsUnpacked.length, areTagsEnabled, !!singlePolicyTagLists); - const shouldDisplayCardFilter = shouldDisplayFilter(Object.keys(cardList).length, areCardsEnabled); + const shouldDisplayCardFilter = shouldDisplayFilter(Object.keys(allCards).length, areCardsEnabled); const shouldDisplayTaxFilter = shouldDisplayFilter(Object.keys(taxRates).length, areTaxEnabled); let currentType = searchAdvancedFilters?.type ?? CONST.SEARCH.DATA_TYPES.EXPENSE; @@ -379,7 +392,7 @@ function AdvancedSearchFilters() { if (!shouldDisplayCardFilter) { return; } - filterTitle = baseFilterConfig[key].getTitle(searchAdvancedFilters, cardList); + filterTitle = baseFilterConfig[key].getTitle(searchAdvancedFilters, allCards); } else if (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) { if (!shouldDisplayTaxFilter) { return; diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index 17db3a219b92..6add4b563a5c 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -168,7 +168,7 @@ function SearchFiltersCardPage() { const policyID = domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1] ?? ''; const correspondingPolicy = PolicyUtils.getPolicy(policyID?.toUpperCase()); const text = translate('search.filters.card.cardFeedName', {cardFeedBankName, cardFeedLabel: isBankRepeating ? correspondingPolicy?.name : undefined}); - const correspondingCards = Object.keys(cardFeed ?? {}); + const correspondingCards = Object.keys(cardFeed ?? {}).filter((cardFeedKey) => cardFeedKey !== 'cardList'); cardFeedsData.push(buildCardFeedItem(text, policyID, correspondingCards, newSelectedCards, bank as CompanyCardFeed, styles.cardIcon)); }); @@ -184,7 +184,7 @@ function SearchFiltersCardPage() { return cardFeedsData; }, [domainFeedsData, filteredWorkspaceCardFeeds, newSelectedCards, styles.cardIcon, translate]); - const shouldShowSearchInput = cardFeedsSectionData.length + invidualCardsSectionData.length > 8; + const shouldShowSearchInput = cardFeedsSectionData.length + invidualCardsSectionData.length > CONST.COMPANY_CARDS.CARD_LIST_THRESHOLD; const sections = useMemo(() => { const newSections = []; @@ -278,4 +278,4 @@ function SearchFiltersCardPage() { SearchFiltersCardPage.displayName = 'SearchFiltersCardPage'; -export default SearchFiltersCardPage; +export {SearchFiltersCardPage, isCard}; From 93e23ca1ccb4066588adbc01475a7777b83815c1 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Fri, 6 Dec 2024 12:43:40 +0100 Subject: [PATCH 11/38] fix export bug --- .../Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index 6add4b563a5c..b35f5498d273 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -278,4 +278,5 @@ function SearchFiltersCardPage() { SearchFiltersCardPage.displayName = 'SearchFiltersCardPage'; -export {SearchFiltersCardPage, isCard}; +export default SearchFiltersCardPage; +export {isCard}; From 00f3ad885234b53952bfeb3ecc0d29eb84a7ebc2 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Fri, 6 Dec 2024 13:00:19 +0100 Subject: [PATCH 12/38] fix linter --- src/pages/Search/AdvancedSearchFilters.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index 737f53218f53..3af9b416fee5 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -1,4 +1,3 @@ -import {CardAnimationContext} from '@react-navigation/stack'; import React, {useMemo} from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'react-native-gesture-handler/lib/typescript/typeUtils'; From cfb2417e088631f39e5d70f1a69a0292d1f7c011 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Mon, 9 Dec 2024 11:42:10 +0100 Subject: [PATCH 13/38] fix PR comments --- .../SearchFiltersCardPage.tsx | 163 +++++++++--------- 1 file changed, 80 insertions(+), 83 deletions(-) diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index b35f5498d273..1976c17435de 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -60,13 +60,7 @@ function buildCardFeedItem( bank: CompanyCardFeed, iconStyles: StyleProp, ): CardFilterItem { - let isSelected = true; - correspondingCardIDs.forEach((card) => { - if (selectedCards.includes(card)) { - return; - } - isSelected = false; - }); + const isSelected = correspondingCardIDs.every((card) => selectedCards.includes(card)); const icon = CardUtils.getCardFeedIcon(bank); return { @@ -84,6 +78,31 @@ function buildCardFeedItem( }; } +function getReapeatingBanks(filteredWorkspaceCardFeedsKeys: string[], domainFeedsData: Record) { + const repeatingBanks: string[] = []; + const banks: string[] = []; + const handleRepeatingBankNames = (bankName: string) => { + if (banks.includes(bankName)) { + repeatingBanks.push(bankName); + } else { + banks.push(bankName); + } + }; + + filteredWorkspaceCardFeedsKeys.forEach((cardFeedKey) => { + const bankName = cardFeedKey.split('_').at(2); + if (!bankName) { + return; + } + + handleRepeatingBankNames(bankName); + }); + Object.values(domainFeedsData).forEach((domainFeed) => { + handleRepeatingBankNames(domainFeed.bank); + }); + return repeatingBanks; +} + function SearchFiltersCardPage() { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -95,18 +114,12 @@ function SearchFiltersCardPage() { const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const initiallySelectedCards = searchAdvancedFiltersForm?.cardID; - const [newSelectedCards, setNewSelectedCards] = useState(initiallySelectedCards ?? []); + const [selectedCards, setSelectedCards] = useState(initiallySelectedCards ?? []); - const {invidualCardsSectionData, domainFeedsData} = useMemo(() => { - const individualCards: CardFilterItem[] = []; + const {individualCardsSectionData, domainFeedsData} = useMemo(() => { const domainFeeds: Record = {}; - Object.values(userCardList ?? {}).forEach((card) => { - const isSelected = newSelectedCards.includes(card.cardID.toString()); - const cardData = buildIndividualCardItem(card, isSelected, styles.cardIcon); - - individualCards.push(cardData); - + const userAssignedCards = Object.values(userCardList ?? {}).map((card) => { // Cards in cardList can also be domain cards, we use them to compute domain feed if (!card.domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)) { if (domainFeeds[card.domainName]) { @@ -115,100 +128,84 @@ function SearchFiltersCardPage() { domainFeeds[card.domainName] = {domainName: card.domainName, bank: card.bank, correspospondingCardIDs: [card.cardID.toString()]}; } } + const isSelected = selectedCards.includes(card.cardID.toString()); + const cardData = buildIndividualCardItem(card, isSelected, styles.cardIcon); + + return cardData; }); // When user is admin of a workspace he sees all the cards of workspace under cards_ Onyx key - filteredWorkspaceCardFeeds.forEach(([, cardFeed]) => { - Object.values(cardFeed ?? {}).forEach((card) => { - if (!card || !isCard(card) || userCardList?.[card.cardID]) { - return; - } - const isSelected = newSelectedCards.includes(card.cardID.toString()); - const cardData = buildIndividualCardItem(card, isSelected, styles.cardIcon); - - individualCards.push(cardData); - }); + const allWorkspaceCards = filteredWorkspaceCardFeeds.flatMap(([, cardFeed]) => { + return Object.values(cardFeed ?? {}) + .filter((card) => card && isCard(card) && !userCardList?.[card.cardID]) + .map((card) => { + const isSelected = selectedCards.includes(card.cardID.toString()); + const cardData = buildIndividualCardItem(card as Card, isSelected, styles.cardIcon); + + return cardData; + }); }); - return {invidualCardsSectionData: individualCards, domainFeedsData: domainFeeds}; - }, [filteredWorkspaceCardFeeds, newSelectedCards, styles.cardIcon, userCardList]); + return {individualCardsSectionData: [...userAssignedCards, ...allWorkspaceCards], domainFeedsData: domainFeeds}; + }, [filteredWorkspaceCardFeeds, selectedCards, styles.cardIcon, userCardList]); const cardFeedsSectionData = useMemo(() => { - const repeatingBanks: string[] = []; - const banks: string[] = []; - const handleRepeatingBankNames = (bankName: string) => { - if (banks.includes(bankName)) { - repeatingBanks.push(bankName); - } else { - banks.push(bankName); - } - }; - - filteredWorkspaceCardFeeds.forEach(([cardFeedKey]) => { - const bankName = cardFeedKey.split('_').at(2); - if (!bankName) { - return; - } + const repeatingBanks = getReapeatingBanks(Object.keys(filteredWorkspaceCardFeeds), domainFeedsData); - handleRepeatingBankNames(bankName); - }); - Object.values(domainFeedsData).forEach((domainFeed) => { - handleRepeatingBankNames(domainFeed.bank); - }); - - const cardFeedsData: CardFilterItem[] = []; - - filteredWorkspaceCardFeeds.forEach(([, cardFeed]) => { - const representativeCard = Object.values(cardFeed ?? {}).find((cardFeedItem) => isCard(cardFeedItem)); - if (!representativeCard) { - return; - } - const {domainName, bank} = representativeCard; - const isBankRepeating = repeatingBanks.includes(bank); - const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); - const policyID = domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1] ?? ''; - const correspondingPolicy = PolicyUtils.getPolicy(policyID?.toUpperCase()); - const text = translate('search.filters.card.cardFeedName', {cardFeedBankName, cardFeedLabel: isBankRepeating ? correspondingPolicy?.name : undefined}); - const correspondingCards = Object.keys(cardFeed ?? {}).filter((cardFeedKey) => cardFeedKey !== 'cardList'); - - cardFeedsData.push(buildCardFeedItem(text, policyID, correspondingCards, newSelectedCards, bank as CompanyCardFeed, styles.cardIcon)); - }); - - Object.values(domainFeedsData).forEach((domainFeed) => { + const domainFeeds = Object.values(domainFeedsData).map((domainFeed) => { const {domainName, bank, correspospondingCardIDs} = domainFeed; const isBankRepeating = repeatingBanks.includes(bank); const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); const text = translate('search.filters.card.cardFeedName', {cardFeedBankName, cardFeedLabel: isBankRepeating ? domainName : undefined}); - cardFeedsData.push(buildCardFeedItem(text, domainName, correspospondingCardIDs, newSelectedCards, bank as CompanyCardFeed, styles.cardIcon)); + return buildCardFeedItem(text, domainName, correspospondingCardIDs, selectedCards, bank as CompanyCardFeed, styles.cardIcon); }); - return cardFeedsData; - }, [domainFeedsData, filteredWorkspaceCardFeeds, newSelectedCards, styles.cardIcon, translate]); - const shouldShowSearchInput = cardFeedsSectionData.length + invidualCardsSectionData.length > CONST.COMPANY_CARDS.CARD_LIST_THRESHOLD; + const workspaceFeeds = filteredWorkspaceCardFeeds + .map(([, cardFeed]) => { + const representativeCard = Object.values(cardFeed ?? {}).find((cardFeedItem) => isCard(cardFeedItem)); + if (!representativeCard) { + return; + } + const {domainName, bank} = representativeCard; + const isBankRepeating = repeatingBanks.includes(bank); + const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); + const policyID = domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1] ?? ''; + const correspondingPolicy = PolicyUtils.getPolicy(policyID?.toUpperCase()); + const text = translate('search.filters.card.cardFeedName', {cardFeedBankName, cardFeedLabel: isBankRepeating ? correspondingPolicy?.name : undefined}); + const correspondingCards = Object.keys(cardFeed ?? {}).filter((cardFeedKey) => cardFeedKey !== 'cardList'); + + return buildCardFeedItem(text, policyID, correspondingCards, selectedCards, bank as CompanyCardFeed, styles.cardIcon); + }) + .filter((feed) => feed) as CardFilterItem[]; + + return [...domainFeeds, ...workspaceFeeds]; + }, [domainFeedsData, filteredWorkspaceCardFeeds, selectedCards, styles.cardIcon, translate]); + + const shouldShowSearchInput = cardFeedsSectionData.length + individualCardsSectionData.length > CONST.COMPANY_CARDS.CARD_LIST_THRESHOLD; const sections = useMemo(() => { const newSections = []; newSections.push({ title: translate('search.filters.card.cardFeeds'), - data: cardFeedsSectionData.filter((item) => item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())), + data: cardFeedsSectionData.filter((item) => item && item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())), shouldShow: cardFeedsSectionData.length > 0, }); newSections.push({ title: translate('search.filters.card.individualCards'), - data: invidualCardsSectionData.filter((item) => item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())), - shouldShow: invidualCardsSectionData.length > 0, + data: individualCardsSectionData.filter((item) => item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())), + shouldShow: individualCardsSectionData.length > 0, }); return newSections; - }, [translate, cardFeedsSectionData, invidualCardsSectionData, debouncedSearchTerm]); + }, [translate, cardFeedsSectionData, individualCardsSectionData, debouncedSearchTerm]); const handleConfirmSelection = useCallback(() => { SearchActions.updateAdvancedFilters({ - cardID: newSelectedCards, + cardID: selectedCards, }); Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS); - }, [newSelectedCards]); + }, [selectedCards]); const updateNewCards = useCallback( (item: CardFilterItem) => { @@ -219,14 +216,14 @@ function SearchFiltersCardPage() { const isCardFeed = item?.isCardFeed && item?.correspondingCards; if (item.isSelected) { - const newCardsObject = newSelectedCards.filter((card) => (isCardFeed ? !item.correspondingCards?.includes(card) : card !== item.keyForList)); - setNewSelectedCards(newCardsObject); + const newCardsObject = selectedCards.filter((card) => (isCardFeed ? !item.correspondingCards?.includes(card) : card !== item.keyForList)); + setSelectedCards(newCardsObject); } else { - const newCardsObject = isCardFeed ? [...newSelectedCards, ...(item?.correspondingCards ?? [])] : [...newSelectedCards, item.keyForList]; - setNewSelectedCards(newCardsObject); + const newCardsObject = isCardFeed ? [...selectedCards, ...(item?.correspondingCards ?? [])] : [...selectedCards, item.keyForList]; + setSelectedCards(newCardsObject); } }, - [newSelectedCards], + [selectedCards], ); const footerContent = useMemo( From 6f8efac45bb997faf151208eaf193dba17312b76 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Mon, 9 Dec 2024 16:28:56 +0100 Subject: [PATCH 14/38] fix wrong card names bug --- src/components/Search/SearchPageHeader.tsx | 7 ++-- .../Search/SearchPageHeaderInput.tsx | 10 ++++-- .../Search/SearchRouter/SearchRouterList.tsx | 8 +++-- .../SearchRouter/buildSubstitutionsMap.ts | 3 +- src/libs/CardUtils.ts | 26 ++++++++++++-- src/libs/SearchQueryUtils.ts | 13 +++++-- src/pages/Search/AdvancedSearchFilters.tsx | 17 +++------ .../SearchFiltersCardPage.tsx | 35 +++++++++---------- 8 files changed, 72 insertions(+), 47 deletions(-) diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index a78845f126d2..96a3037f2bab 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -15,6 +15,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as SearchActions from '@libs/actions/Search'; +import * as CardUtils from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; @@ -47,7 +48,9 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { const personalDetails = usePersonalDetails(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const taxRates = getAllTaxRates(); - const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const [userCardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const [workspaceCardFeeds = {}] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const allCards = useMemo(() => CardUtils.mergeCardListWithWorkspaceFeeds(workspaceCardFeeds, userCardList), [userCardList, workspaceCardFeeds]); const [currencyList = {}] = useOnyx(ONYXKEYS.CURRENCY_LIST); const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); const [policyTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); @@ -326,7 +329,7 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { } const onFiltersButtonPress = () => { - const filterFormValues = SearchQueryUtils.buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTagsLists, currencyList, personalDetails, cardList, reports, taxRates); + const filterFormValues = SearchQueryUtils.buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTagsLists, currencyList, personalDetails, allCards, reports, taxRates); SearchActions.updateAdvancedFilters(filterFormValues); Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS); diff --git a/src/components/Search/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeaderInput.tsx index 85ca37b7a79a..1cd3db4494f2 100644 --- a/src/components/Search/SearchPageHeaderInput.tsx +++ b/src/components/Search/SearchPageHeaderInput.tsx @@ -13,6 +13,7 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as SearchActions from '@libs/actions/Search'; +import * as CardUtils from '@libs/CardUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; @@ -68,6 +69,9 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps const personalDetails = usePersonalDetails(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const taxRates = useMemo(() => getAllTaxRates(), []); + const [userCardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const [workspaceCardFeeds = {}] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const allCards = useMemo(() => CardUtils.mergeCardListWithWorkspaceFeeds(workspaceCardFeeds, userCardList), [userCardList, workspaceCardFeeds]); const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({}); // The actual input text that the user sees @@ -81,16 +85,16 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps const {type, inputQuery: originalInputQuery} = queryJSON; const isCannedQuery = SearchQueryUtils.isCannedSearchQuery(queryJSON); const headerText = isCannedQuery ? translate(getHeaderContent(type).titleText) : ''; - const queryText = SearchQueryUtils.buildUserReadableQueryString(queryJSON, personalDetails, reports, taxRates); + const queryText = SearchQueryUtils.buildUserReadableQueryString(queryJSON, personalDetails, reports, taxRates, allCards); useEffect(() => { setTextInputValue(queryText); }, [queryText]); useEffect(() => { - const substitutionsMap = buildSubstitutionsMap(originalInputQuery, personalDetails, reports, taxRates); + const substitutionsMap = buildSubstitutionsMap(originalInputQuery, personalDetails, reports, taxRates, allCards); setAutocompleteSubstitutions(substitutionsMap); - }, [originalInputQuery, personalDetails, reports, taxRates]); + }, [allCards, originalInputQuery, personalDetails, reports, taxRates]); const onSearchQueryChange = useCallback( (userQuery: string) => { diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index 63dd1e6af229..3f1fcf991255 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -132,8 +132,10 @@ function SearchRouterList( const statusAutocompleteList = Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP}); const expenseTypes = Object.values(CONST.SEARCH.TRANSACTION_TYPE); - const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); - const cardAutocompleteList = Object.values(cardList); + const [userCardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const [workspaceCardFeeds = {}] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const allCards = useMemo(() => CardUtils.mergeCardListWithWorkspaceFeeds(workspaceCardFeeds, userCardList), [userCardList, workspaceCardFeeds]); + const cardAutocompleteList = Object.values(allCards); const participantsAutocompleteList = useMemo(() => { if (!areOptionsInitialized) { @@ -326,7 +328,7 @@ function SearchRouterList( return filteredCards.map((card) => ({ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, - text: CardUtils.getCardDescription(card.cardID), + text: CardUtils.getCardDescription(card.cardID, allCards), autocompleteID: card.cardID.toString(), })); } diff --git a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts index a7185f126e55..29d7dbf9d77e 100644 --- a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts +++ b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts @@ -29,6 +29,7 @@ function buildSubstitutionsMap( personalDetails: OnyxTypes.PersonalDetailsList, reports: OnyxCollection, allTaxRates: Record, + cardList: OnyxTypes.CardList, ): SubstitutionMap { const parsedQuery = parser.parse(query) as {ranges: SearchAutocompleteQueryRange[]}; @@ -61,7 +62,7 @@ function buildSubstitutionsMap( filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.IN || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID ) { - const displayValue = SearchQueryUtils.getFilterDisplayValue(filterKey, filterValue, personalDetails, reports); + const displayValue = SearchQueryUtils.getFilterDisplayValue(filterKey, filterValue, personalDetails, reports, cardList); // If displayValue === filterValue, then it means there is nothing to substitute, so we don't add any key to map if (displayValue !== filterValue) { diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 9a71480019a6..080b5afc7aa3 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -63,16 +63,34 @@ function isCorporateCard(cardID: number) { * @param cardID * @returns string in format % - %. */ -function getCardDescription(cardID?: number) { +function getCardDescription(cardID?: number, cards: CardList = allCards) { if (!cardID) { return ''; } - const card = allCards[cardID]; + const card = cards[cardID]; if (!card) { return ''; } const cardDescriptor = card.state === CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED ? Localize.translateLocal('cardTransactions.notActivated') : card.lastFourPAN; - return cardDescriptor ? `${card.bank} - ${cardDescriptor}` : `${card.bank}`; + const humanReadableBankName = card.bank === CONST.EXPENSIFY_CARD.BANK ? CONST.EXPENSIFY_CARD.BANK : getCardFeedName(card.bank as CompanyCardFeed); + return cardDescriptor ? `${humanReadableBankName} - ${cardDescriptor}` : `${humanReadableBankName}`; +} + +function isCard(item: Card | Record): item is Card { + return 'cardID' in item && !!item.cardID && 'bank' in item && !!item.bank; +} + +function mergeCardListWithWorkspaceFeeds(workspaceFeeds: Record, cardList = allCards) { + const feedCards: CardList = {...cardList}; + Object.values(workspaceFeeds ?? {}).forEach((currentCardFeed) => { + Object.values(currentCardFeed ?? {}).forEach((card) => { + if (!isCard(card)) { + return; + } + feedCards[card.cardID] = card; + }); + }); + return feedCards; } /** @@ -415,4 +433,6 @@ export { hasOnlyOneCardToAssign, checkIfNewFeedConnected, getDefaultCardName, + mergeCardListWithWorkspaceFeeds, + isCard, }; diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index f975c575400d..137d162a342e 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -488,7 +488,13 @@ function getPolicyIDFromSearchQuery(queryJSON: SearchQueryJSON) { /** * Returns the human-readable "pretty" string for a specified filter value. */ -function getFilterDisplayValue(filterName: string, filterValue: string, personalDetails: OnyxTypes.PersonalDetailsList, reports: OnyxCollection) { +function getFilterDisplayValue( + filterName: string, + filterValue: string, + personalDetails: OnyxTypes.PersonalDetailsList, + reports: OnyxCollection, + cardList: OnyxTypes.CardList, +) { if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) { // login can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing @@ -499,7 +505,7 @@ function getFilterDisplayValue(filterName: string, filterValue: string, personal if (Number.isNaN(cardID)) { return filterValue; } - return CardUtils.getCardDescription(cardID) || filterValue; + return CardUtils.getCardDescription(cardID, cardList) || filterValue; } if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.IN) { return ReportUtils.getReportName(reports?.[`${ONYXKEYS.COLLECTION.REPORT}${filterValue}`]) || filterValue; @@ -522,6 +528,7 @@ function buildUserReadableQueryString( PersonalDetails: OnyxTypes.PersonalDetailsList, reports: OnyxCollection, taxRates: Record, + cardList: OnyxTypes.CardList, ) { const {type, status} = queryJSON; const filters = queryJSON.flatFilters; @@ -553,7 +560,7 @@ function buildUserReadableQueryString( } else { displayQueryFilters = queryFilter.map((filter) => ({ operator: filter.operator, - value: getFilterDisplayValue(key, filter.value.toString(), PersonalDetails, reports), + value: getFilterDisplayValue(key, filter.value.toString(), PersonalDetails, reports, cardList), })); } title += buildFilterValuesString(key, displayQueryFilters); diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index 3af9b416fee5..57d524d6e3d7 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -14,6 +14,7 @@ import useLocalize from '@hooks/useLocalize'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; +import * as CardUtils from '@libs/CardUtils'; import {convertToDisplayStringWithoutCurrency} from '@libs/CurrencyUtils'; import localeCompare from '@libs/LocaleCompare'; import Navigation from '@libs/Navigation/Navigation'; @@ -30,7 +31,6 @@ import type {SearchAdvancedFiltersForm} from '@src/types/form'; import type {CardList, PersonalDetailsList, Policy, PolicyTagLists, Report} from '@src/types/onyx'; import type {PolicyFeatureName} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {isCard} from './SearchAdvancedFiltersPage/SearchFiltersCardPage'; const baseFilterConfig = { date: { @@ -122,7 +122,7 @@ function getFilterCardDisplayTitle(filters: Partial, return filterValue ? Object.values(cards) .filter((card) => filterValue.includes(card.cardID.toString())) - .map((card) => card.bank) + .map((card) => CardUtils.getCardDescription(card.cardID, cards)) .join(', ') : undefined; } @@ -275,17 +275,8 @@ function AdvancedSearchFilters() { const [searchAdvancedFilters = {} as SearchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const policyID = searchAdvancedFilters.policyID ?? '-1'; const [userCardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); - const [workspaceCardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); - const allCards = Object.values(workspaceCardFeeds ?? {}).reduce((cardsAccumulated, currentCardFeed) => { - Object.values(currentCardFeed ?? {}).forEach((card) => { - if (!isCard(card)) { - return; - } - // eslint-disable-next-line no-param-reassign - cardsAccumulated[card.cardID] = card; - }); - return cardsAccumulated; - }, userCardList); + const [workspaceCardFeeds = {}] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const allCards = useMemo(() => CardUtils.mergeCardListWithWorkspaceFeeds(workspaceCardFeeds, userCardList), [userCardList, workspaceCardFeeds]); const taxRates = getAllTaxRates(); const personalDetails = usePersonalDetails(); diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index 1976c17435de..f06967aafa90 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -27,10 +27,6 @@ type CardFilterItem = Partial & {bankIcon?: BankIcon; lastFourPAN?: type DomainFeedData = {bank: string; domainName: string; correspospondingCardIDs: string[]}; -function isCard(item: Card | Record): item is Card { - return 'cardID' in item && !!item.cardID && 'bank' in item && !!item.bank; -} - function buildIndividualCardItem(card: Card, isSelected: boolean, iconStyles: StyleProp): CardFilterItem { const icon = CardUtils.getCardFeedIcon(card?.bank as CompanyCardFeed); const cardName = card?.nameValuePairs?.cardTitle ?? card?.cardName; @@ -116,18 +112,8 @@ function SearchFiltersCardPage() { const initiallySelectedCards = searchAdvancedFiltersForm?.cardID; const [selectedCards, setSelectedCards] = useState(initiallySelectedCards ?? []); - const {individualCardsSectionData, domainFeedsData} = useMemo(() => { - const domainFeeds: Record = {}; - + const individualCardsSectionData = useMemo(() => { const userAssignedCards = Object.values(userCardList ?? {}).map((card) => { - // Cards in cardList can also be domain cards, we use them to compute domain feed - if (!card.domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)) { - if (domainFeeds[card.domainName]) { - domainFeeds[card.domainName].correspospondingCardIDs.push(card.cardID.toString()); - } else { - domainFeeds[card.domainName] = {domainName: card.domainName, bank: card.bank, correspospondingCardIDs: [card.cardID.toString()]}; - } - } const isSelected = selectedCards.includes(card.cardID.toString()); const cardData = buildIndividualCardItem(card, isSelected, styles.cardIcon); @@ -137,7 +123,7 @@ function SearchFiltersCardPage() { // When user is admin of a workspace he sees all the cards of workspace under cards_ Onyx key const allWorkspaceCards = filteredWorkspaceCardFeeds.flatMap(([, cardFeed]) => { return Object.values(cardFeed ?? {}) - .filter((card) => card && isCard(card) && !userCardList?.[card.cardID]) + .filter((card) => card && CardUtils.isCard(card) && !userCardList?.[card.cardID]) .map((card) => { const isSelected = selectedCards.includes(card.cardID.toString()); const cardData = buildIndividualCardItem(card as Card, isSelected, styles.cardIcon); @@ -145,9 +131,21 @@ function SearchFiltersCardPage() { return cardData; }); }); - return {individualCardsSectionData: [...userAssignedCards, ...allWorkspaceCards], domainFeedsData: domainFeeds}; + return [...userAssignedCards, ...allWorkspaceCards]; }, [filteredWorkspaceCardFeeds, selectedCards, styles.cardIcon, userCardList]); + const domainFeedsData = Object.values(userCardList ?? {}).reduce((accumulator, currentCard) => { + // Cards in cardList can also be domain cards, we use them to compute domain feed + if (!currentCard.domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)) { + if (accumulator[currentCard.domainName]) { + accumulator[currentCard.domainName].correspospondingCardIDs.push(currentCard.cardID.toString()); + } else { + accumulator[currentCard.domainName] = {domainName: currentCard.domainName, bank: currentCard.bank, correspospondingCardIDs: [currentCard.cardID.toString()]}; + } + } + return accumulator; + }, {} as Record); + const cardFeedsSectionData = useMemo(() => { const repeatingBanks = getReapeatingBanks(Object.keys(filteredWorkspaceCardFeeds), domainFeedsData); @@ -162,7 +160,7 @@ function SearchFiltersCardPage() { const workspaceFeeds = filteredWorkspaceCardFeeds .map(([, cardFeed]) => { - const representativeCard = Object.values(cardFeed ?? {}).find((cardFeedItem) => isCard(cardFeedItem)); + const representativeCard = Object.values(cardFeed ?? {}).find((cardFeedItem) => CardUtils.isCard(cardFeedItem)); if (!representativeCard) { return; } @@ -276,4 +274,3 @@ function SearchFiltersCardPage() { SearchFiltersCardPage.displayName = 'SearchFiltersCardPage'; export default SearchFiltersCardPage; -export {isCard}; From 5d93a9d91b804fa45444ef5a90bd52d880c339a7 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Wed, 11 Dec 2024 14:09:50 +0100 Subject: [PATCH 15/38] add tests to card filter data generation --- .../SearchFiltersCardPage.tsx | 269 ++++++++++-------- tests/unit/Search/buildCardFilterDataTest.ts | 235 +++++++++++++++ 2 files changed, 387 insertions(+), 117 deletions(-) create mode 100644 tests/unit/Search/buildCardFilterDataTest.ts diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index f06967aafa90..a4171953e2ea 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -4,6 +4,7 @@ import type {StyleProp, ViewStyle} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import CardListItem from '@components/SelectionList/CardListItem'; @@ -19,73 +20,26 @@ import * as SearchActions from '@userActions/Search'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Card, CompanyCardFeed} from '@src/types/onyx'; +import type {Card, CardList, CompanyCardFeed, WorkspaceCardsList} from '@src/types/onyx'; import type {BankIcon} from '@src/types/onyx/Bank'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; type CardFilterItem = Partial & {bankIcon?: BankIcon; lastFourPAN?: string; isVirtual?: boolean; isCardFeed?: boolean; correspondingCards?: string[]}; -type DomainFeedData = {bank: string; domainName: string; correspospondingCardIDs: string[]}; - -function buildIndividualCardItem(card: Card, isSelected: boolean, iconStyles: StyleProp): CardFilterItem { - const icon = CardUtils.getCardFeedIcon(card?.bank as CompanyCardFeed); - const cardName = card?.nameValuePairs?.cardTitle ?? card?.cardName; - const text = card.bank === CONST.EXPENSIFY_CARD.BANK ? card.bank : cardName; - - return { - lastFourPAN: card.lastFourPAN, - isVirtual: card?.nameValuePairs?.isVirtual, - text, - keyForList: card.cardID.toString(), - isSelected, - bankIcon: { - icon, - iconWidth: variables.cardIconWidth, - iconHeight: variables.cardIconHeight, - iconStyles, - }, - isCardFeed: false, - }; -} +type DomainFeedData = {bank: string; domainName: string; correspondingCardIDs: string[]}; -function buildCardFeedItem( - text: string, - keyForList: string, - correspondingCardIDs: string[], - selectedCards: string[], - bank: CompanyCardFeed, - iconStyles: StyleProp, -): CardFilterItem { - const isSelected = correspondingCardIDs.every((card) => selectedCards.includes(card)); - - const icon = CardUtils.getCardFeedIcon(bank); - return { - text, - keyForList, - isSelected, - bankIcon: { - icon, - iconWidth: variables.cardIconWidth, - iconHeight: variables.cardIconHeight, - iconStyles, - }, - isCardFeed: true, - correspondingCards: correspondingCardIDs, - }; -} - -function getReapeatingBanks(filteredWorkspaceCardFeedsKeys: string[], domainFeedsData: Record) { +function getReapeatingBanks(workspaceCardFeedsKeys: string[], domainFeedsData: Record) { const repeatingBanks: string[] = []; const banks: string[] = []; const handleRepeatingBankNames = (bankName: string) => { - if (banks.includes(bankName)) { + if (banks.includes(bankName) && !repeatingBanks.includes(bankName)) { repeatingBanks.push(bankName); } else { banks.push(bankName); } }; - filteredWorkspaceCardFeedsKeys.forEach((cardFeedKey) => { + workspaceCardFeedsKeys.forEach((cardFeedKey) => { const bankName = cardFeedKey.split('_').at(2); if (!bankName) { return; @@ -99,85 +53,165 @@ function getReapeatingBanks(filteredWorkspaceCardFeedsKeys: string[], domainFeed return repeatingBanks; } -function SearchFiltersCardPage() { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - - const [userCardList] = useOnyx(ONYXKEYS.CARD_LIST); - const [workspaceCardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); - const filteredWorkspaceCardFeeds = useMemo(() => Object.entries(workspaceCardFeeds ?? {}).filter((cardFeed) => !isEmptyObject(cardFeed)), [workspaceCardFeeds]); - - const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); - const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); - const initiallySelectedCards = searchAdvancedFiltersForm?.cardID; - const [selectedCards, setSelectedCards] = useState(initiallySelectedCards ?? []); - - const individualCardsSectionData = useMemo(() => { - const userAssignedCards = Object.values(userCardList ?? {}).map((card) => { - const isSelected = selectedCards.includes(card.cardID.toString()); - const cardData = buildIndividualCardItem(card, isSelected, styles.cardIcon); - - return cardData; - }); +function buildIndividualCardsData(workspaceCardFeeds: Record, userCardList: CardList, selectedCards: string[], iconStyles: StyleProp) { + const userAssignedCards = Object.values(userCardList ?? {}).map((card) => { + const isSelected = selectedCards.includes(card.cardID.toString()); + const icon = CardUtils.getCardFeedIcon(card?.bank as CompanyCardFeed); + const cardName = card?.nameValuePairs?.cardTitle ?? card?.cardName; + const text = card.bank === CONST.EXPENSIFY_CARD.BANK ? card.bank : cardName; + + return { + lastFourPAN: card.lastFourPAN, + isVirtual: card?.nameValuePairs?.isVirtual, + text, + keyForList: card.cardID.toString(), + isSelected, + bankIcon: { + icon, + iconWidth: variables.cardIconWidth, + iconHeight: variables.cardIconHeight, + iconStyles, + }, + isCardFeed: false, + }; + }); - // When user is admin of a workspace he sees all the cards of workspace under cards_ Onyx key - const allWorkspaceCards = filteredWorkspaceCardFeeds.flatMap(([, cardFeed]) => { - return Object.values(cardFeed ?? {}) + // When user is admin of a workspace he sees all the cards of workspace under cards_ Onyx key + const allWorkspaceCards = Object.values(workspaceCardFeeds) + .filter((cardFeed) => !isEmptyObject(cardFeed)) + .flatMap((cardFeed) => { + return Object.values(cardFeed as Record) .filter((card) => card && CardUtils.isCard(card) && !userCardList?.[card.cardID]) .map((card) => { const isSelected = selectedCards.includes(card.cardID.toString()); - const cardData = buildIndividualCardItem(card as Card, isSelected, styles.cardIcon); - - return cardData; + const icon = CardUtils.getCardFeedIcon(card?.bank as CompanyCardFeed); + const cardName = card?.nameValuePairs?.cardTitle ?? card?.cardName; + const text = card.bank === CONST.EXPENSIFY_CARD.BANK ? card.bank : cardName; + + return { + lastFourPAN: card.lastFourPAN, + isVirtual: card?.nameValuePairs?.isVirtual, + text, + keyForList: card.cardID.toString(), + isSelected, + bankIcon: { + icon, + iconWidth: variables.cardIconWidth, + iconHeight: variables.cardIconHeight, + iconStyles, + }, + isCardFeed: false, + }; }); }); - return [...userAssignedCards, ...allWorkspaceCards]; - }, [filteredWorkspaceCardFeeds, selectedCards, styles.cardIcon, userCardList]); - - const domainFeedsData = Object.values(userCardList ?? {}).reduce((accumulator, currentCard) => { - // Cards in cardList can also be domain cards, we use them to compute domain feed - if (!currentCard.domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)) { - if (accumulator[currentCard.domainName]) { - accumulator[currentCard.domainName].correspospondingCardIDs.push(currentCard.cardID.toString()); - } else { - accumulator[currentCard.domainName] = {domainName: currentCard.domainName, bank: currentCard.bank, correspospondingCardIDs: [currentCard.cardID.toString()]}; - } - } - return accumulator; - }, {} as Record); + return [...userAssignedCards, ...allWorkspaceCards]; +} - const cardFeedsSectionData = useMemo(() => { - const repeatingBanks = getReapeatingBanks(Object.keys(filteredWorkspaceCardFeeds), domainFeedsData); +function buildCardFeedsData( + workspaceCardFeeds: Record, + domainFeedsData: Record, + selectedCards: string[], + iconStyles: StyleProp, + translate: LocaleContextProps['translate'], +) { + const repeatingBanks = getReapeatingBanks(Object.keys(workspaceCardFeeds), domainFeedsData); + const domainFeeds = Object.values(domainFeedsData).map((domainFeed) => { + const {domainName, bank, correspondingCardIDs} = domainFeed; + const isBankRepeating = repeatingBanks.includes(bank); + const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); + const text = translate('search.filters.card.cardFeedName', {cardFeedBankName, cardFeedLabel: isBankRepeating ? domainName : undefined}); + + const isSelected = correspondingCardIDs.every((card) => selectedCards.includes(card)); + + const icon = CardUtils.getCardFeedIcon(bank as CompanyCardFeed); + return { + text, + keyForList: `${domainName}-${bank}`, + isSelected, + bankIcon: { + icon, + iconWidth: variables.cardIconWidth, + iconHeight: variables.cardIconHeight, + iconStyles, + }, + isCardFeed: true, + correspondingCards: correspondingCardIDs, + }; + }); - const domainFeeds = Object.values(domainFeedsData).map((domainFeed) => { - const {domainName, bank, correspospondingCardIDs} = domainFeed; + const workspaceFeeds = Object.entries(workspaceCardFeeds) + .filter(([, cardFeed]) => !isEmptyObject(cardFeed)) + .map(([cardFeedKey, cardFeed]) => { + const representativeCard = Object.values(cardFeed ?? {}).find((cardFeedItem) => CardUtils.isCard(cardFeedItem)); + if (!representativeCard) { + return; + } + const {domainName, bank} = representativeCard; const isBankRepeating = repeatingBanks.includes(bank); const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); - const text = translate('search.filters.card.cardFeedName', {cardFeedBankName, cardFeedLabel: isBankRepeating ? domainName : undefined}); + const policyID = domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1] ?? ''; + const correspondingPolicy = PolicyUtils.getPolicy(policyID?.toUpperCase()); + const text = translate('search.filters.card.cardFeedName', {cardFeedBankName, cardFeedLabel: isBankRepeating ? correspondingPolicy?.name : undefined}); + const correspondingCardIDs = Object.keys(cardFeed ?? {}).filter((cardKey) => cardKey !== 'cardList'); + + const isSelected = correspondingCardIDs.every((card) => selectedCards.includes(card)); + + const icon = CardUtils.getCardFeedIcon(bank as CompanyCardFeed); + return { + text, + keyForList: cardFeedKey, + isSelected, + bankIcon: { + icon, + iconWidth: variables.cardIconWidth, + iconHeight: variables.cardIconHeight, + iconStyles, + }, + isCardFeed: true, + correspondingCards: correspondingCardIDs, + }; + }) + .filter((feed) => feed) as CardFilterItem[]; + + return [...domainFeeds, ...workspaceFeeds]; +} - return buildCardFeedItem(text, domainName, correspospondingCardIDs, selectedCards, bank as CompanyCardFeed, styles.cardIcon); - }); +function SearchFiltersCardPage() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); - const workspaceFeeds = filteredWorkspaceCardFeeds - .map(([, cardFeed]) => { - const representativeCard = Object.values(cardFeed ?? {}).find((cardFeedItem) => CardUtils.isCard(cardFeedItem)); - if (!representativeCard) { - return; - } - const {domainName, bank} = representativeCard; - const isBankRepeating = repeatingBanks.includes(bank); - const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); - const policyID = domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1] ?? ''; - const correspondingPolicy = PolicyUtils.getPolicy(policyID?.toUpperCase()); - const text = translate('search.filters.card.cardFeedName', {cardFeedBankName, cardFeedLabel: isBankRepeating ? correspondingPolicy?.name : undefined}); - const correspondingCards = Object.keys(cardFeed ?? {}).filter((cardFeedKey) => cardFeedKey !== 'cardList'); + const [userCardList] = useOnyx(ONYXKEYS.CARD_LIST); + const [workspaceCardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); + const initiallySelectedCards = searchAdvancedFiltersForm?.cardID; + const [selectedCards, setSelectedCards] = useState(initiallySelectedCards ?? []); + + const individualCardsSectionData = useMemo( + () => buildIndividualCardsData(workspaceCardFeeds ?? {}, userCardList ?? {}, selectedCards, styles.cardIcon), + [workspaceCardFeeds, selectedCards, styles.cardIcon, userCardList], + ); - return buildCardFeedItem(text, policyID, correspondingCards, selectedCards, bank as CompanyCardFeed, styles.cardIcon); - }) - .filter((feed) => feed) as CardFilterItem[]; + const domainFeedsData = useMemo( + () => + Object.values(userCardList ?? {}).reduce((accumulator, currentCard) => { + // Cards in cardList can also be domain cards, we use them to compute domain feed + if (!currentCard.domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)) { + if (accumulator[currentCard.domainName]) { + accumulator[currentCard.domainName].correspondingCardIDs.push(currentCard.cardID.toString()); + } else { + accumulator[currentCard.domainName] = {domainName: currentCard.domainName, bank: currentCard.bank, correspondingCardIDs: [currentCard.cardID.toString()]}; + } + } + return accumulator; + }, {} as Record), + [userCardList], + ); - return [...domainFeeds, ...workspaceFeeds]; - }, [domainFeedsData, filteredWorkspaceCardFeeds, selectedCards, styles.cardIcon, translate]); + const cardFeedsSectionData = useMemo( + () => buildCardFeedsData(workspaceCardFeeds ?? {}, domainFeedsData, selectedCards, styles.cardIcon, translate), + [domainFeedsData, workspaceCardFeeds, selectedCards, styles.cardIcon, translate], + ); const shouldShowSearchInput = cardFeedsSectionData.length + individualCardsSectionData.length > CONST.COMPANY_CARDS.CARD_LIST_THRESHOLD; @@ -274,3 +308,4 @@ function SearchFiltersCardPage() { SearchFiltersCardPage.displayName = 'SearchFiltersCardPage'; export default SearchFiltersCardPage; +export {buildIndividualCardsData, buildCardFeedsData}; diff --git a/tests/unit/Search/buildCardFilterDataTest.ts b/tests/unit/Search/buildCardFilterDataTest.ts new file mode 100644 index 000000000000..b03f499d4b38 --- /dev/null +++ b/tests/unit/Search/buildCardFilterDataTest.ts @@ -0,0 +1,235 @@ +// The cards_ object keys don't follow normal naming convention, so to test this reliably we have to disable liner + +/* eslint-disable @typescript-eslint/naming-convention */ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import {buildCardFeedsData, buildIndividualCardsData} from '@pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage'; +import type {CardList, WorkspaceCardsList} from '@src/types/onyx'; + +jest.mock('@libs/PolicyUtils', () => { + return { + getPolicy(policyID: string) { + switch (policyID) { + case '1': + return {name: ''}; + case '2': + return {name: 'test1'}; + case '3': + return {name: 'test2'}; + default: + return {name: ''}; + } + }, + }; +}); + +const workspaceCardFeeds = { + cards_18680694_vcf: { + '21593492': { + accountID: 1, + bank: 'vcf', + cardID: 21593492, + cardName: '480801XXXXXX9411', + domainName: 'expensify-policy1.exfy', + lastFourPAN: '9411', + }, + '21604933': { + accountID: 1, + bank: 'vcf', + cardID: 21604933, + cardName: '480801XXXXXX1601', + domainName: 'expensify-policy1.exfy', + lastFourPAN: '1601', + }, + '21638320': { + accountID: 1, + bank: 'vcf', + cardID: 21638320, + cardName: '480801XXXXXX2617', + domainName: 'expensify-policy1.exfy', + lastFourPAN: '2617', + }, + '21638598': { + accountID: 1, + bank: 'vcf', + cardID: 21638598, + cardName: '480801XXXXXX2111', + domainName: 'expensify-policy1.exfy', + lastFourPAN: '2111', + }, + cardList: { + test: '231:1111111', + }, + }, + 'cards_18755165_Expensify Card': { + '21588678': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21588678, + cardName: '455594XXXXXX1138', + domainName: 'expensify-policy2.exfy', + lastFourPAN: '1138', + }, + '21588684': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21588684, + cardName: '', + domainName: 'expensify-policy2.exfy', + lastFourPAN: '', + }, + }, + 'cards_11111_Expensify Card': { + '21589168': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21589168, + cardName: '455594XXXXXX4163', + domainName: 'expensify-policy3.exfy', + lastFourPAN: '4163', + }, + '21589182': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21589182, + cardName: '', + domainName: 'expensify-policy3.exfy', + lastFourPAN: '', + }, + '21589202': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21589202, + cardName: '455594XXXXXX6232', + domainName: 'expensify-policy3.exfy', + lastFourPAN: '6232', + }, + '21638322': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21638322, + cardName: '', + domainName: 'expensify-policy3.exfy', + lastFourPAN: '', + }, + }, +}; + +const cardList = { + '21588678': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21588678, + cardName: '455594XXXXXX1138', + domainName: 'expensify-policy2.exfy', + lastFourPAN: '1138', + }, + '21588684': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21588684, + cardName: '', + domainName: 'expensify-policy2.exfy', + lastFourPAN: '', + }, + '21589202': { + accountID: 1, + bank: 'Expensify Card', + cardID: 21589202, + cardName: '455594XXXXXX6232', + domainName: 'expensify-policy3.exfy', + lastFourPAN: '6232', + }, + '21604933': { + accountID: 1, + bank: 'vcf', + cardID: 21604933, + cardName: '480801XXXXXX1601', + domainName: 'expensify-policy1.exfy', + lastFourPAN: '1601', + }, + '11111111': { + accountID: 1, + bank: 'Expensify Card', + cardID: 11111111, + cardName: '455594XXXXXX1138', + domainName: 'testDomain', + lastFourPAN: '1138', + }, +}; + +const domainFeedData = {testDomain: {domainName: 'testDomain', bank: 'Expensify Card', correspondingCardIDs: ['11111111']}}; + +function translateMock(key: string, obj: {cardFeedBankName: string; cardFeedLabel: string}) { + if (key === 'search.filters.card.expensify') { + return 'Expensify'; + } + return `All ${obj.cardFeedBankName}${obj.cardFeedLabel ? ` - ${obj.cardFeedLabel}` : ''}`; +} + +describe('Build individual cards data from given cardList and workspaceCardFeeds objects', () => { + const result = buildIndividualCardsData(workspaceCardFeeds as unknown as Record, cardList as unknown as CardList, ['21588678'], {}); + + it("Builds all individual cards and doesn't generate duplicates", () => { + expect(result.length).toEqual(11); + }); + + it('Builds expensify card data properly', () => { + const expensifyCard = result.find((card) => card.keyForList === '21588678'); + expect(expensifyCard).toMatchObject({ + text: 'Expensify Card', + lastFourPAN: '1138', + isSelected: true, + }); + }); + + it('Builds company card data properly', () => { + const companyCard = result.find((card) => card.keyForList === '21604933'); + expect(companyCard).toMatchObject({ + text: '480801XXXXXX1601', + lastFourPAN: '1601', + isSelected: false, + }); + }); +}); + +describe('Build card feed data from given domainFeedData and workspaceCardFeeds objects', () => { + const result = buildCardFeedsData( + workspaceCardFeeds as unknown as Record, + domainFeedData, + [], + {}, + translateMock as LocaleContextProps['translate'], + ); + + it('Buids domain card feed properly', () => { + expect(result.at(0)).toMatchObject({ + text: 'All Expensify - testDomain', + isCardFeed: true, + correspondingCards: ['11111111'], + }); + }); + + it('Buids workspace card feed from company card feed properly', () => { + expect(result.at(1)).toMatchObject({ + text: 'All Visa', + isCardFeed: true, + correspondingCards: ['21593492', '21604933', '21638320', '21638598'], + }); + }); + + it('Buids "test1" workspace card feed from expensify card feed(there are two expensify card feeds) properly', () => { + expect(result.at(2)).toMatchObject({ + text: 'All Expensify - test1', + isCardFeed: true, + correspondingCards: ['21588678', '21588684'], + }); + }); + + it('Buids "test2" workspace card feed from expensify card feed(there are tow expensify card feeds) properly', () => { + expect(result.at(3)).toMatchObject({ + text: 'All Expensify - test2', + isCardFeed: true, + correspondingCards: ['21589168', '21589182', '21589202', '21638322'], + }); + }); +}); From ab009ccd52c300171e545ca7c7f5f6b7ee3523a9 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Wed, 11 Dec 2024 14:30:56 +0100 Subject: [PATCH 16/38] fix buildSubstitutionsMapTest tests --- .../unit/Search/buildSubstitutionsMapTest.ts | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/tests/unit/Search/buildSubstitutionsMapTest.ts b/tests/unit/Search/buildSubstitutionsMapTest.ts index 03f4f13645df..ca0f35aec8d7 100644 --- a/tests/unit/Search/buildSubstitutionsMapTest.ts +++ b/tests/unit/Search/buildSubstitutionsMapTest.ts @@ -5,13 +5,13 @@ import {buildSubstitutionsMap} from '@src/components/Search/SearchRouter/buildSu import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -jest.mock('@libs/CardUtils', () => { - return { - getCardDescription(cardID: number) { - return cardID; - }, - }; -}); +// jest.mock('@libs/CardUtils', () => { +// return { +// getCardDescription(cardID: number) { +// return 'Visa - 1234'; +// }, +// }; +// }); jest.mock('@libs/ReportUtils', () => { return { @@ -53,18 +53,26 @@ const taxRatesMock = { TAX_1: ['id_TAX_1'], } as Record; +const cardListMock = { + '11223344': { + state: 1, + bank: 'vcf', + lastFourPAN: '1234', + }, +} as unknown as OnyxTypes.CardList; + describe('buildSubstitutionsMap should return correct substitutions map', () => { test('when there were no substitutions', () => { const userQuery = 'foo bar'; - const result = buildSubstitutionsMap(userQuery, personalDetailsMock, reportsMock, taxRatesMock); + const result = buildSubstitutionsMap(userQuery, personalDetailsMock, reportsMock, taxRatesMock, {}); expect(result).toStrictEqual({}); }); test('when query has a single substitution', () => { const userQuery = 'foo from:12345'; - const result = buildSubstitutionsMap(userQuery, personalDetailsMock, reportsMock, taxRatesMock); + const result = buildSubstitutionsMap(userQuery, personalDetailsMock, reportsMock, taxRatesMock, {}); expect(result).toStrictEqual({ 'from:John Doe': '12345', @@ -74,13 +82,13 @@ describe('buildSubstitutionsMap should return correct substitutions map', () => test('when query has multiple substitutions of different types', () => { const userQuery = 'from:78901,12345 to:nonExistingGuy@mail.com cardID:11223344 in:rep123 taxRate:id_TAX_1'; - const result = buildSubstitutionsMap(userQuery, personalDetailsMock, reportsMock, taxRatesMock); + const result = buildSubstitutionsMap(userQuery, personalDetailsMock, reportsMock, taxRatesMock, cardListMock); expect(result).toStrictEqual({ 'from:Jane Doe': '78901', 'from:John Doe': '12345', 'in:Report 1': 'rep123', - 'cardID:11223344': '11223344', + 'cardID:Visa - 1234': '11223344', 'taxRate:TAX_1': 'id_TAX_1', }); }); From cec56f77f120fe18f26b68dd15d2192bed1305f8 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Wed, 11 Dec 2024 15:02:19 +0100 Subject: [PATCH 17/38] clean up Search tests --- src/components/Search/SearchPageHeaderInput.tsx | 4 ---- .../Search/SearchRouter/buildSubstitutionsMap.ts | 16 ---------------- tests/unit/Search/buildCardFilterDataTest.ts | 2 +- 3 files changed, 1 insertion(+), 21 deletions(-) diff --git a/src/components/Search/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeaderInput.tsx index 6ec60daadfd8..b6cfcec86d29 100644 --- a/src/components/Search/SearchPageHeaderInput.tsx +++ b/src/components/Search/SearchPageHeaderInput.tsx @@ -110,10 +110,6 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps setTextInputValue(queryText); }, [queryText]); - useEffect(() => { - setTextInputValue(queryText); - }, [queryText]); - useEffect(() => { const substitutionsMap = buildSubstitutionsMap(originalInputQuery, personalDetails, reports, taxRates, allCards); setAutocompleteSubstitutions(substitutionsMap); diff --git a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts index ce2bd6106b22..7bbbfcec2873 100644 --- a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts +++ b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts @@ -8,22 +8,6 @@ import type {SubstitutionMap} from './getQueryWithSubstitutions'; const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`; -/** - * Given a plaintext query and specific entities data, - * this function will build the substitutions map from scratch for this query - * - * Ex: - * query: `Test from:12345 to:9876` - * personalDetails: { - * 12345: JohnDoe - * 98765: SomeoneElse - * } - * - * return: { - * from:JohnDoe: 12345, - * to:SomeoneElse: 98765, - * } - */ function buildSubstitutionsMap( query: string, personalDetails: OnyxTypes.PersonalDetailsList | undefined, diff --git a/tests/unit/Search/buildCardFilterDataTest.ts b/tests/unit/Search/buildCardFilterDataTest.ts index b03f499d4b38..e014adb8d5d1 100644 --- a/tests/unit/Search/buildCardFilterDataTest.ts +++ b/tests/unit/Search/buildCardFilterDataTest.ts @@ -225,7 +225,7 @@ describe('Build card feed data from given domainFeedData and workspaceCardFeeds }); }); - it('Buids "test2" workspace card feed from expensify card feed(there are tow expensify card feeds) properly', () => { + it('Buids "test2" workspace card feed from expensify card feed(there are two expensify card feeds) properly', () => { expect(result.at(3)).toMatchObject({ text: 'All Expensify - test2', isCardFeed: true, From 9068aa80d2595ba41d72b430764c8ccb9ea501c8 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Wed, 11 Dec 2024 16:28:08 +0100 Subject: [PATCH 18/38] delete redundant comments --- .../Search/SearchRouter/buildSubstitutionsMap.ts | 16 ++++++++++++++++ tests/unit/Search/buildSubstitutionsMapTest.ts | 8 -------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts index 7bbbfcec2873..ce2bd6106b22 100644 --- a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts +++ b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts @@ -8,6 +8,22 @@ import type {SubstitutionMap} from './getQueryWithSubstitutions'; const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`; +/** + * Given a plaintext query and specific entities data, + * this function will build the substitutions map from scratch for this query + * + * Ex: + * query: `Test from:12345 to:9876` + * personalDetails: { + * 12345: JohnDoe + * 98765: SomeoneElse + * } + * + * return: { + * from:JohnDoe: 12345, + * to:SomeoneElse: 98765, + * } + */ function buildSubstitutionsMap( query: string, personalDetails: OnyxTypes.PersonalDetailsList | undefined, diff --git a/tests/unit/Search/buildSubstitutionsMapTest.ts b/tests/unit/Search/buildSubstitutionsMapTest.ts index ca0f35aec8d7..4015435d3c12 100644 --- a/tests/unit/Search/buildSubstitutionsMapTest.ts +++ b/tests/unit/Search/buildSubstitutionsMapTest.ts @@ -5,14 +5,6 @@ import {buildSubstitutionsMap} from '@src/components/Search/SearchRouter/buildSu import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -// jest.mock('@libs/CardUtils', () => { -// return { -// getCardDescription(cardID: number) { -// return 'Visa - 1234'; -// }, -// }; -// }); - jest.mock('@libs/ReportUtils', () => { return { parseReportRouteParams: jest.fn(() => ({})), From 313bb465b160e3e3072938fa0f32d935b6d06540 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Wed, 11 Dec 2024 17:56:02 +0100 Subject: [PATCH 19/38] fix PR comments --- tests/unit/Search/buildCardFilterDataTest.ts | 51 ++++++++++++-------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/tests/unit/Search/buildCardFilterDataTest.ts b/tests/unit/Search/buildCardFilterDataTest.ts index e014adb8d5d1..3c9e92348c06 100644 --- a/tests/unit/Search/buildCardFilterDataTest.ts +++ b/tests/unit/Search/buildCardFilterDataTest.ts @@ -157,7 +157,7 @@ const cardList = { }, }; -const domainFeedData = {testDomain: {domainName: 'testDomain', bank: 'Expensify Card', correspondingCardIDs: ['11111111']}}; +const domainFeedDataMock = {testDomain: {domainName: 'testDomain', bank: 'Expensify Card', correspondingCardIDs: ['11111111']}}; function translateMock(key: string, obj: {cardFeedBankName: string; cardFeedLabel: string}) { if (key === 'search.filters.card.expensify') { @@ -165,24 +165,23 @@ function translateMock(key: string, obj: {cardFeedBankName: string; cardFeedLabe } return `All ${obj.cardFeedBankName}${obj.cardFeedLabel ? ` - ${obj.cardFeedLabel}` : ''}`; } +const translateMock1 = jest.fn(); -describe('Build individual cards data from given cardList and workspaceCardFeeds objects', () => { +describe('buildIndividualCardsData', () => { const result = buildIndividualCardsData(workspaceCardFeeds as unknown as Record, cardList as unknown as CardList, ['21588678'], {}); it("Builds all individual cards and doesn't generate duplicates", () => { expect(result.length).toEqual(11); - }); - it('Builds expensify card data properly', () => { + // Check if Expensify card was built correctly const expensifyCard = result.find((card) => card.keyForList === '21588678'); expect(expensifyCard).toMatchObject({ text: 'Expensify Card', lastFourPAN: '1138', isSelected: true, }); - }); - it('Builds company card data properly', () => { + // Check if company card was built correctly const companyCard = result.find((card) => card.keyForList === '21604933'); expect(companyCard).toMatchObject({ text: '480801XXXXXX1601', @@ -192,44 +191,54 @@ describe('Build individual cards data from given cardList and workspaceCardFeeds }); }); -describe('Build card feed data from given domainFeedData and workspaceCardFeeds objects', () => { +describe('buildIndividualCardsData with empty argument objects', () => { + it('Returns empty array when cardList and workspaceCardFeeds are empty', () => { + const result = buildIndividualCardsData({}, {}, [], {}); + expect(result).toEqual([]); + }); +}); + +describe('buildCardFeedsData', () => { const result = buildCardFeedsData( workspaceCardFeeds as unknown as Record, - domainFeedData, + domainFeedDataMock, [], {}, - translateMock as LocaleContextProps['translate'], + translateMock1 as LocaleContextProps['translate'], ); it('Buids domain card feed properly', () => { + // Check if domain card feed was built properly expect(result.at(0)).toMatchObject({ - text: 'All Expensify - testDomain', isCardFeed: true, correspondingCards: ['11111111'], }); - }); - - it('Buids workspace card feed from company card feed properly', () => { + expect(translateMock1).toHaveBeenCalledWith('search.filters.card.cardFeedName', {cardFeedBankName: undefined, cardFeedLabel: 'testDomain'}); + // Check if workspace card feed that comes from company cards was built properly. expect(result.at(1)).toMatchObject({ - text: 'All Visa', isCardFeed: true, correspondingCards: ['21593492', '21604933', '21638320', '21638598'], }); - }); - - it('Buids "test1" workspace card feed from expensify card feed(there are two expensify card feeds) properly', () => { + expect(translateMock1).toHaveBeenCalledWith('search.filters.card.cardFeedName', {cardFeedBankName: 'Visa', cardFeedLabel: undefined}); + // Check if workspace card feed that comes from expensify cards was built properly expect(result.at(2)).toMatchObject({ - text: 'All Expensify - test1', isCardFeed: true, correspondingCards: ['21588678', '21588684'], }); - }); + expect(translateMock1).toHaveBeenCalledWith('search.filters.card.cardFeedName', {cardFeedBankName: undefined, cardFeedLabel: 'test1'}); - it('Buids "test2" workspace card feed from expensify card feed(there are two expensify card feeds) properly', () => { + // Check if workspace card feed that comes from expensify cards was built properly. expect(result.at(3)).toMatchObject({ - text: 'All Expensify - test2', isCardFeed: true, correspondingCards: ['21589168', '21589182', '21589202', '21638322'], }); + expect(translateMock1).toHaveBeenCalledWith('search.filters.card.cardFeedName', {cardFeedBankName: undefined, cardFeedLabel: 'test2'}); + }); +}); + +describe('buildIndividualCardsData with empty argument objects', () => { + it('Return empty array when domainCardFeeds and workspaceCardFeeds are empty', () => { + const result = buildCardFeedsData({}, {}, [], {}, translateMock as LocaleContextProps['translate']); + expect(result).toEqual([]); }); }); From a48f976ca634abc93e4179a364a5dee3a4917bac Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Tue, 17 Dec 2024 11:16:09 +0100 Subject: [PATCH 20/38] fix typo --- src/components/Search/SearchRouter/SearchRouterList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index 79c4d201992c..21a210b95903 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -344,7 +344,7 @@ function SearchRouterList( return filteredCards.map((card) => ({ filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.CARD_ID, - text: CardUtils.getCardDescription(card.cardID, allCard), + text: CardUtils.getCardDescription(card.cardID, allCards), autocompleteID: card.cardID.toString(), mapKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, })); From 5f21294a6281d8be49d73a34e51268e00fc4b241 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Tue, 17 Dec 2024 11:36:23 +0100 Subject: [PATCH 21/38] fix linter warnings --- src/components/Search/SearchPageHeader.tsx | 9 +++------ src/components/Search/SearchRouter/SearchRouterList.tsx | 2 +- src/libs/API/parameters/ExportSearchItemsToCSVParams.ts | 2 +- src/libs/CardUtils.ts | 4 ++-- src/libs/actions/Search.ts | 2 +- src/pages/Search/AdvancedSearchFilters.tsx | 2 +- src/pages/Search/SearchTypeMenu.tsx | 1 + 7 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index 9ef84d0c5c87..0e143a97242f 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -191,12 +191,9 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { } const reportIDList = selectedReports?.filter((report) => !!report).map((report) => report.reportID) ?? []; - SearchActions.exportSearchItemsToCSV( - {query: status, jsonQuery: JSON.stringify(queryJSON), reportIDList, transactionIDList: selectedTransactionsKeys, policyIDs: [activeWorkspaceID ?? '']}, - () => { - setIsDownloadErrorModalVisible(true); - }, - ); + SearchActions.exportSearchItemsToCSV({query: status, jsonQuery: JSON.stringify(queryJSON), reportIDList, transactionIDList: selectedTransactionsKeys}, () => { + setIsDownloadErrorModalVisible(true); + }); }, }); diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index 21a210b95903..fceccb0e8ed7 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -182,7 +182,7 @@ function SearchRouterList( if (currentUser) { autocompleteOptions.push({ name: currentUser.displayName ?? Str.removeSMSDomain(currentUser.login ?? ''), - accountID: currentUser.accountID?.toString() ?? '-1', + accountID: currentUser.accountID.toString(), }); } diff --git a/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts b/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts index 057b6188e3ea..ac1a90ca1ada 100644 --- a/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts +++ b/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts @@ -5,7 +5,7 @@ type ExportSearchItemsToCSVParams = { jsonQuery: SearchQueryString; reportIDList: string[]; transactionIDList: string[]; - policyIDs: string[]; + policyIDs?: string[]; }; export default ExportSearchItemsToCSVParams; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 080b5afc7aa3..3c857bea615c 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -221,8 +221,8 @@ function getEligibleBankAccountsForCard(bankAccountsList: OnyxEntry, personalDetails: OnyxEntry): Card[] { const {cardList, ...cards} = cardsList ?? {}; return Object.values(cards).sort((cardA: Card, cardB: Card) => { - const userA = personalDetails?.[cardA.accountID ?? '-1'] ?? {}; - const userB = personalDetails?.[cardB.accountID ?? '-1'] ?? {}; + const userA = cardA.accountID ? personalDetails?.[cardA.accountID] : {}; + const userB = cardB.accountID ? personalDetails?.[cardB.accountID] : {}; const aName = PersonalDetailsUtils.getDisplayNameOrDefault(userA); const bName = PersonalDetailsUtils.getDisplayNameOrDefault(userB); diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 50e37ba6afe5..1070aaa057e3 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -334,7 +334,7 @@ function deleteMoneyRequestOnSearch(hash: number, transactionIDList: string[]) { type Params = Record; -function exportSearchItemsToCSV({query, jsonQuery, reportIDList, transactionIDList, policyIDs}: ExportSearchItemsToCSVParams, onDownloadFailed: () => void) { +function exportSearchItemsToCSV({query, jsonQuery, reportIDList, transactionIDList, policyIDs = ['']}: ExportSearchItemsToCSVParams, onDownloadFailed: () => void) { const finalParameters = enhanceParameters(WRITE_COMMANDS.EXPORT_SEARCH_ITEMS_TO_CSV, { query, jsonQuery, diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index 9a41cefbae89..5ca09ee82036 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -373,7 +373,7 @@ function AdvancedSearchFilters() { const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); const [searchAdvancedFilters = {} as SearchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); - const policyID = searchAdvancedFilters.policyID ?? '-1'; + const policyID = searchAdvancedFilters.policyID; const [userCardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); const [workspaceCardFeeds = {}] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); const allCards = useMemo(() => CardUtils.mergeCardListWithWorkspaceFeeds(workspaceCardFeeds, userCardList), [userCardList, workspaceCardFeeds]); diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index 87c40046737c..b7d554127ec2 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -185,6 +185,7 @@ function SearchTypeMenu({queryJSON, searchName}: SearchTypeMenuProps) { personalDetails, reports, taxRates, + allCards, shouldShowProductTrainingTooltip, hideProductTrainingTooltip, renderProductTrainingTooltip, From b4e5aab5aefca0ac6ff60efaa4f8e126db953efc Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Tue, 17 Dec 2024 13:11:15 +0100 Subject: [PATCH 22/38] add api call --- src/libs/API/types.ts | 2 ++ src/libs/CardUtils.ts | 2 +- src/libs/actions/Search.ts | 12 +++++++++++- .../SearchFiltersCardPage.tsx | 6 +++++- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 47c19c158ff6..0d5b8af46b10 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -942,6 +942,7 @@ const READ_COMMANDS = { OPEN_POLICY_COMPANY_CARDS_FEED: 'OpenPolicyCompanyCardsFeed', OPEN_POLICY_COMPANY_CARDS_PAGE: 'OpenPolicyCompanyCardsPage', OPEN_POLICY_EDIT_CARD_LIMIT_TYPE_PAGE: 'OpenPolicyEditCardLimitTypePage', + OPEN_SEARCH_FILTERS_CARD_PAGE: 'OpenSearchFiltersCardPage', OPEN_WORKSPACE_INVITE_PAGE: 'OpenWorkspaceInvitePage', OPEN_DRAFT_WORKSPACE_REQUEST: 'OpenDraftWorkspaceRequest', OPEN_POLICY_WORKFLOWS_PAGE: 'OpenPolicyWorkflowsPage', @@ -1012,6 +1013,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_POLICY_COMPANY_CARDS_PAGE]: Parameters.OpenPolicyExpensifyCardsPageParams; [READ_COMMANDS.OPEN_POLICY_COMPANY_CARDS_FEED]: Parameters.OpenPolicyCompanyCardsFeedParams; [READ_COMMANDS.OPEN_POLICY_EDIT_CARD_LIMIT_TYPE_PAGE]: Parameters.OpenPolicyEditCardLimitTypePageParams; + [READ_COMMANDS.OPEN_SEARCH_FILTERS_CARD_PAGE]: null; [READ_COMMANDS.OPEN_POLICY_PROFILE_PAGE]: Parameters.OpenPolicyProfilePageParams; [READ_COMMANDS.OPEN_POLICY_INITIAL_PAGE]: Parameters.OpenPolicyInitialPageParams; [READ_COMMANDS.OPEN_SUBSCRIPTION_PAGE]: null; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 3c857bea615c..73de56d0865d 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -77,7 +77,7 @@ function getCardDescription(cardID?: number, cards: CardList = allCards) { } function isCard(item: Card | Record): item is Card { - return 'cardID' in item && !!item.cardID && 'bank' in item && !!item.bank; + return typeof item === 'object' && 'cardID' in item && !!item.cardID && 'bank' in item && !!item.bank; } function mergeCardListWithWorkspaceFeeds(workspaceFeeds: Record, cardList = allCards) { diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 1070aaa057e3..6eda45174c11 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -6,7 +6,7 @@ import type {PaymentData, SearchQueryJSON} from '@components/Search/types'; import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import * as API from '@libs/API'; import type {ExportSearchItemsToCSVParams, SubmitReportParams} from '@libs/API/parameters'; -import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ApiUtils from '@libs/ApiUtils'; import fileDownload from '@libs/fileDownload'; import enhanceParameters from '@libs/Network/enhanceParameters'; @@ -208,6 +208,15 @@ function deleteSavedSearch(hash: number) { API.write(WRITE_COMMANDS.DELETE_SAVED_SEARCH, {hash}, {optimisticData, failureData, successData}); } +function openSearchFiltersCardPage() { + const optimisticData: OnyxUpdate[] = [{onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`, value: null}]; + + const successData: OnyxUpdate[] = [{onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`, value: null}]; + + const failureData: OnyxUpdate[] = [{onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`, value: null}]; + API.read(READ_COMMANDS.OPEN_SEARCH_FILTERS_CARD_PAGE, null, {optimisticData, successData, failureData}); +} + function search({queryJSON, offset}: {queryJSON: SearchQueryJSON; offset?: number}) { const {optimisticData, finallyData} = getOnyxLoadingData(queryJSON.hash); const {flatFilters, ...queryJSONWithoutFlatFilters} = queryJSON; @@ -396,4 +405,5 @@ export { approveMoneyRequestOnSearch, handleActionButtonPress, submitMoneyRequestOnSearch, + openSearchFiltersCardPage, }; diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index a4171953e2ea..576ca42df21d 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; import {useOnyx} from 'react-native-onyx'; @@ -187,6 +187,10 @@ function SearchFiltersCardPage() { const initiallySelectedCards = searchAdvancedFiltersForm?.cardID; const [selectedCards, setSelectedCards] = useState(initiallySelectedCards ?? []); + useEffect(() => { + SearchActions.openSearchFiltersCardPage(); + }, []); + const individualCardsSectionData = useMemo( () => buildIndividualCardsData(workspaceCardFeeds ?? {}, userCardList ?? {}, selectedCards, styles.cardIcon), [workspaceCardFeeds, selectedCards, styles.cardIcon, userCardList], From e18af2a21e65e4ee5356878da68d3b3f4457b75c Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Tue, 17 Dec 2024 14:16:57 +0100 Subject: [PATCH 23/38] fix linter warning --- src/components/Search/SearchPageHeader.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index 0e143a97242f..ed6db09e3452 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -283,7 +283,6 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { styles.colorMuted, styles.fontWeightNormal, isOffline, - activeWorkspaceID, selectedReports, styles.textWrap, lastPaymentMethods, From fc3f0719d68f52d9fd8fa4a1ae9eeafc4bbc6c9c Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Tue, 17 Dec 2024 14:20:58 +0100 Subject: [PATCH 24/38] fix linter --- src/components/Search/SearchPageHeader.tsx | 26 +++++++++++++++------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index ed6db09e3452..e2cc3335ab6a 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -191,9 +191,18 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { } const reportIDList = selectedReports?.filter((report) => !!report).map((report) => report.reportID) ?? []; - SearchActions.exportSearchItemsToCSV({query: status, jsonQuery: JSON.stringify(queryJSON), reportIDList, transactionIDList: selectedTransactionsKeys}, () => { - setIsDownloadErrorModalVisible(true); - }); + SearchActions.exportSearchItemsToCSV( + { + query: status, + jsonQuery: JSON.stringify(queryJSON), + reportIDList, + transactionIDList: selectedTransactionsKeys, + policyIDs: activeWorkspaceID ? [activeWorkspaceID] : [''], + }, + () => { + setIsDownloadErrorModalVisible(true); + }, + ); }, }); @@ -273,19 +282,20 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { return options; }, [ - queryJSON, - status, selectedTransactionsKeys, selectedTransactions, + isOffline, + selectedReports, translate, hash, + lastPaymentMethods, + status, + queryJSON, + activeWorkspaceID, theme.icon, styles.colorMuted, styles.fontWeightNormal, - isOffline, - selectedReports, styles.textWrap, - lastPaymentMethods, ]); if (shouldUseNarrowLayout) { From 6ad3f86fc39b569b431484a8e5ff5611bc42c49b Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Thu, 19 Dec 2024 12:45:10 +0100 Subject: [PATCH 25/38] fix PR comments --- src/components/Search/SearchPageHeader.tsx | 2 +- .../Search/SearchPageHeaderInput.tsx | 2 +- .../Search/SearchRouter/SearchRouterList.tsx | 2 +- .../SearchFiltersCardPage.tsx | 203 +++++++++--------- 4 files changed, 103 insertions(+), 106 deletions(-) diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index 61cd8f650bf7..a46149d6db36 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -49,7 +49,7 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const taxRates = getAllTaxRates(); const [userCardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); - const [workspaceCardFeeds = {}] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const [workspaceCardFeeds = {}] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); const allCards = useMemo(() => CardUtils.mergeCardListWithWorkspaceFeeds(workspaceCardFeeds, userCardList), [userCardList, workspaceCardFeeds]); const [currencyList = {}] = useOnyx(ONYXKEYS.CURRENCY_LIST); const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); diff --git a/src/components/Search/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeaderInput.tsx index f848f2d219ff..afd8b2a2f8be 100644 --- a/src/components/Search/SearchPageHeaderInput.tsx +++ b/src/components/Search/SearchPageHeaderInput.tsx @@ -73,7 +73,7 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const taxRates = useMemo(() => getAllTaxRates(), []); const [userCardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); - const [workspaceCardFeeds = {}] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const [workspaceCardFeeds = {}] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); const allCards = useMemo(() => CardUtils.mergeCardListWithWorkspaceFeeds(workspaceCardFeeds, userCardList), [userCardList, workspaceCardFeeds]); const {type, inputQuery: originalInputQuery} = queryJSON; diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index fceccb0e8ed7..b93430f3db36 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -146,7 +146,7 @@ function SearchRouterList( const expenseTypes = Object.values(CONST.SEARCH.TRANSACTION_TYPE); const [userCardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); - const [workspaceCardFeeds = {}] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const [workspaceCardFeeds = {}] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); const allCards = useMemo(() => CardUtils.mergeCardListWithWorkspaceFeeds(workspaceCardFeeds, userCardList), [userCardList, workspaceCardFeeds]); const cardAutocompleteList = Object.values(allCards); diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index 576ca42df21d..031dea8035d8 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -28,118 +28,124 @@ type CardFilterItem = Partial & {bankIcon?: BankIcon; lastFourPAN?: type DomainFeedData = {bank: string; domainName: string; correspondingCardIDs: string[]}; -function getReapeatingBanks(workspaceCardFeedsKeys: string[], domainFeedsData: Record) { - const repeatingBanks: string[] = []; - const banks: string[] = []; - const handleRepeatingBankNames = (bankName: string) => { - if (banks.includes(bankName) && !repeatingBanks.includes(bankName)) { - repeatingBanks.push(bankName); - } else { - banks.push(bankName); - } - }; - - workspaceCardFeedsKeys.forEach((cardFeedKey) => { - const bankName = cardFeedKey.split('_').at(2); - if (!bankName) { - return; +function getRepeatingBanks(workspaceCardFeedsKeys: string[], domainFeedsData: Record) { + const bankFrequency: Record = {}; + for (const key of workspaceCardFeedsKeys) { + // Example: "cards_18755165_Expensify Card" -> "Expensify Card" + const bankName = key.split('_').at(2); + if (bankName) { + bankFrequency[bankName] = (bankFrequency[bankName] || 0) + 1; } + } + for (const domainFeed of Object.values(domainFeedsData)) { + bankFrequency[domainFeed.bank] = (bankFrequency[domainFeed.bank] || 0) + 1; + } + return Object.keys(bankFrequency).filter((bank) => bankFrequency[bank] > 1); +} - handleRepeatingBankNames(bankName); - }); - Object.values(domainFeedsData).forEach((domainFeed) => { - handleRepeatingBankNames(domainFeed.bank); - }); - return repeatingBanks; +function createIndividualCardFilterItem(card: Card, selectedCards: string[], iconStyles: StyleProp) { + const isSelected = selectedCards.includes(card.cardID.toString()); + const icon = CardUtils.getCardFeedIcon(card?.bank as CompanyCardFeed); + const cardName = card?.nameValuePairs?.cardTitle ?? card?.cardName; + const text = card.bank === CONST.EXPENSIFY_CARD.BANK ? card.bank : cardName; + + return { + lastFourPAN: card.lastFourPAN, + isVirtual: card?.nameValuePairs?.isVirtual, + text, + keyForList: card.cardID.toString(), + isSelected, + bankIcon: { + icon, + iconWidth: variables.cardIconWidth, + iconHeight: variables.cardIconHeight, + iconStyles, + }, + isCardFeed: false, + }; } -function buildIndividualCardsData(workspaceCardFeeds: Record, userCardList: CardList, selectedCards: string[], iconStyles: StyleProp) { - const userAssignedCards = Object.values(userCardList ?? {}).map((card) => { - const isSelected = selectedCards.includes(card.cardID.toString()); - const icon = CardUtils.getCardFeedIcon(card?.bank as CompanyCardFeed); - const cardName = card?.nameValuePairs?.cardTitle ?? card?.cardName; - const text = card.bank === CONST.EXPENSIFY_CARD.BANK ? card.bank : cardName; - - return { - lastFourPAN: card.lastFourPAN, - isVirtual: card?.nameValuePairs?.isVirtual, - text, - keyForList: card.cardID.toString(), - isSelected, - bankIcon: { - icon, - iconWidth: variables.cardIconWidth, - iconHeight: variables.cardIconHeight, - iconStyles, - }, - isCardFeed: false, - }; - }); +function buildIndividualCardsData( + workspaceCardFeeds: Record, + userCardList: CardList, + selectedCards: string[], + iconStyles: StyleProp, +): CardFilterItem[] { + const userAssignedCards: CardFilterItem[] = Object.values(userCardList ?? {}).map((card) => createIndividualCardFilterItem(card, selectedCards, iconStyles)); // When user is admin of a workspace he sees all the cards of workspace under cards_ Onyx key - const allWorkspaceCards = Object.values(workspaceCardFeeds) + const allWorkspaceCards: CardFilterItem[] = Object.values(workspaceCardFeeds) .filter((cardFeed) => !isEmptyObject(cardFeed)) .flatMap((cardFeed) => { return Object.values(cardFeed as Record) .filter((card) => card && CardUtils.isCard(card) && !userCardList?.[card.cardID]) - .map((card) => { - const isSelected = selectedCards.includes(card.cardID.toString()); - const icon = CardUtils.getCardFeedIcon(card?.bank as CompanyCardFeed); - const cardName = card?.nameValuePairs?.cardTitle ?? card?.cardName; - const text = card.bank === CONST.EXPENSIFY_CARD.BANK ? card.bank : cardName; - - return { - lastFourPAN: card.lastFourPAN, - isVirtual: card?.nameValuePairs?.isVirtual, - text, - keyForList: card.cardID.toString(), - isSelected, - bankIcon: { - icon, - iconWidth: variables.cardIconWidth, - iconHeight: variables.cardIconHeight, - iconStyles, - }, - isCardFeed: false, - }; - }); + .map((card) => createIndividualCardFilterItem(card, selectedCards, iconStyles)); }); return [...userAssignedCards, ...allWorkspaceCards]; } +function createCardFeedItem({ + bank, + cardFeedLabel, + keyForList, + correspondingCardIDs, + selectedCards, + iconStyles, + translate, +}: { + bank: string; + cardFeedLabel: string | undefined; + keyForList: string; + correspondingCardIDs: string[]; + selectedCards: string[]; + iconStyles: StyleProp; + translate: LocaleContextProps['translate']; +}) { + const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); + const text = translate('search.filters.card.cardFeedName', {cardFeedBankName, cardFeedLabel}); + const isSelected = correspondingCardIDs.every((card) => selectedCards.includes(card)); + + const icon = CardUtils.getCardFeedIcon(bank as CompanyCardFeed); + return { + text, + keyForList, + isSelected, + bankIcon: { + icon, + iconWidth: variables.cardIconWidth, + iconHeight: variables.cardIconHeight, + iconStyles, + }, + isCardFeed: true, + correspondingCards: correspondingCardIDs, + }; +} + function buildCardFeedsData( workspaceCardFeeds: Record, domainFeedsData: Record, selectedCards: string[], iconStyles: StyleProp, translate: LocaleContextProps['translate'], -) { - const repeatingBanks = getReapeatingBanks(Object.keys(workspaceCardFeeds), domainFeedsData); - const domainFeeds = Object.values(domainFeedsData).map((domainFeed) => { +): CardFilterItem[] { + const repeatingBanks = getRepeatingBanks(Object.keys(workspaceCardFeeds), domainFeedsData); + + const domainFeeds: CardFilterItem[] = Object.values(domainFeedsData).map((domainFeed) => { const {domainName, bank, correspondingCardIDs} = domainFeed; const isBankRepeating = repeatingBanks.includes(bank); - const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); - const text = translate('search.filters.card.cardFeedName', {cardFeedBankName, cardFeedLabel: isBankRepeating ? domainName : undefined}); - const isSelected = correspondingCardIDs.every((card) => selectedCards.includes(card)); - - const icon = CardUtils.getCardFeedIcon(bank as CompanyCardFeed); - return { - text, + return createCardFeedItem({ + bank, + correspondingCardIDs, + iconStyles, + cardFeedLabel: isBankRepeating ? domainName : undefined, + translate, keyForList: `${domainName}-${bank}`, - isSelected, - bankIcon: { - icon, - iconWidth: variables.cardIconWidth, - iconHeight: variables.cardIconHeight, - iconStyles, - }, - isCardFeed: true, - correspondingCards: correspondingCardIDs, - }; + selectedCards, + }); }); - const workspaceFeeds = Object.entries(workspaceCardFeeds) + const workspaceFeeds: CardFilterItem[] = Object.entries(workspaceCardFeeds) .filter(([, cardFeed]) => !isEmptyObject(cardFeed)) .map(([cardFeedKey, cardFeed]) => { const representativeCard = Object.values(cardFeed ?? {}).find((cardFeedItem) => CardUtils.isCard(cardFeedItem)); @@ -148,28 +154,19 @@ function buildCardFeedsData( } const {domainName, bank} = representativeCard; const isBankRepeating = repeatingBanks.includes(bank); - const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); const policyID = domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1] ?? ''; const correspondingPolicy = PolicyUtils.getPolicy(policyID?.toUpperCase()); - const text = translate('search.filters.card.cardFeedName', {cardFeedBankName, cardFeedLabel: isBankRepeating ? correspondingPolicy?.name : undefined}); const correspondingCardIDs = Object.keys(cardFeed ?? {}).filter((cardKey) => cardKey !== 'cardList'); - const isSelected = correspondingCardIDs.every((card) => selectedCards.includes(card)); - - const icon = CardUtils.getCardFeedIcon(bank as CompanyCardFeed); - return { - text, + return createCardFeedItem({ + bank, + correspondingCardIDs, + iconStyles, + cardFeedLabel: isBankRepeating ? correspondingPolicy?.name : undefined, + translate, keyForList: cardFeedKey, - isSelected, - bankIcon: { - icon, - iconWidth: variables.cardIconWidth, - iconHeight: variables.cardIconHeight, - iconStyles, - }, - isCardFeed: true, - correspondingCards: correspondingCardIDs, - }; + selectedCards, + }); }) .filter((feed) => feed) as CardFilterItem[]; From d34093077597b8a6346e583f3ac78046929913bb Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Fri, 20 Dec 2024 16:22:45 +0100 Subject: [PATCH 26/38] fix PR comments --- src/libs/CardUtils.ts | 12 ++++++++++++ src/libs/actions/Search.ts | 6 +++--- src/pages/Search/AdvancedSearchFilters.tsx | 2 +- .../SearchFiltersCardPage.tsx | 7 +++++-- src/pages/Search/SearchTypeMenu.tsx | 2 +- src/pages/settings/Wallet/PaymentMethodList.tsx | 15 ++------------- 6 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 73de56d0865d..fba5f6b5f81a 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -16,6 +16,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; import localeCompare from './LocaleCompare'; import * as Localize from './Localize'; import * as PersonalDetailsUtils from './PersonalDetailsUtils'; +import * as PolicyUtils from './PolicyUtils'; let allCards: OnyxValues[typeof ONYXKEYS.CARD_LIST] = {}; Onyx.connect({ @@ -404,6 +405,16 @@ function checkIfNewFeedConnected(prevFeedsData: CompanyFeeds, currentFeedsData: }; } +const getDescriptionForPolicyDomainCard = (domainName: string): string => { + // A domain name containing a policyID indicates that this is a workspace feed + const policyID = domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1]; + if (policyID) { + const policy = PolicyUtils.getPolicy(policyID.toUpperCase()); + return policy?.name ?? domainName; + } + return domainName; +}; + export { isExpensifyCard, isCorporateCard, @@ -435,4 +446,5 @@ export { getDefaultCardName, mergeCardListWithWorkspaceFeeds, isCard, + getDescriptionForPolicyDomainCard, }; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 742dd3efbac6..2dc51564e637 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -209,11 +209,11 @@ function deleteSavedSearch(hash: number) { } function openSearchFiltersCardPage() { - const optimisticData: OnyxUpdate[] = [{onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`, value: null}]; + const optimisticData: OnyxUpdate[] = [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, value: null}]; - const successData: OnyxUpdate[] = [{onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`, value: null}]; + const successData: OnyxUpdate[] = [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, value: null}]; - const failureData: OnyxUpdate[] = [{onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`, value: null}]; + const failureData: OnyxUpdate[] = [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, value: null}]; API.read(READ_COMMANDS.OPEN_SEARCH_FILTERS_CARD_PAGE, null, {optimisticData, successData, failureData}); } diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index fadd2e40b629..81eaa80fee19 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -375,7 +375,7 @@ function AdvancedSearchFilters() { const [searchAdvancedFilters = {} as SearchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const policyID = searchAdvancedFilters.policyID; const [userCardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); - const [workspaceCardFeeds = {}] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const [workspaceCardFeeds = {}] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); const allCards = useMemo(() => CardUtils.mergeCardListWithWorkspaceFeeds(workspaceCardFeeds, userCardList), [userCardList, workspaceCardFeeds]); const taxRates = getAllTaxRates(); const personalDetails = usePersonalDetails(); diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index 031dea8035d8..303e8ef7f087 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -138,7 +138,7 @@ function buildCardFeedsData( bank, correspondingCardIDs, iconStyles, - cardFeedLabel: isBankRepeating ? domainName : undefined, + cardFeedLabel: isBankRepeating ? CardUtils.getDescriptionForPolicyDomainCard(domainName) : undefined, translate, keyForList: `${domainName}-${bank}`, selectedCards, @@ -178,7 +178,7 @@ function SearchFiltersCardPage() { const {translate} = useLocalize(); const [userCardList] = useOnyx(ONYXKEYS.CARD_LIST); - const [workspaceCardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const [workspaceCardFeeds] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const initiallySelectedCards = searchAdvancedFiltersForm?.cardID; @@ -293,6 +293,9 @@ function SearchFiltersCardPage() { shouldStopPropagation shouldShowTooltips canSelectMultiple + shouldPreventDefaultFocusOnSelectRow={false} + shouldKeepFocusedItemAtTopOfViewableArea={false} + shouldScrollToFocusedIndex={false} ListItem={CardListItem} shouldShowTextInput={shouldShowSearchInput} textInputLabel={shouldShowSearchInput ? translate('common.search') : undefined} diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index d5dca8348f54..cb4653f2a2e1 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -70,7 +70,7 @@ function SearchTypeMenu({queryJSON, searchName}: SearchTypeMenuProps) { const personalDetails = usePersonalDetails(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [userCardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); - const [workspaceCardFeeds = {}] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); + const [workspaceCardFeeds = {}] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); const allCards = useMemo(() => CardUtils.mergeCardListWithWorkspaceFeeds(workspaceCardFeeds, userCardList), [userCardList, workspaceCardFeeds]); const taxRates = getAllTaxRates(); const {isOffline} = useNetwork(); diff --git a/src/pages/settings/Wallet/PaymentMethodList.tsx b/src/pages/settings/Wallet/PaymentMethodList.tsx index a16bad99d2cc..2443ce9abab2 100644 --- a/src/pages/settings/Wallet/PaymentMethodList.tsx +++ b/src/pages/settings/Wallet/PaymentMethodList.tsx @@ -22,7 +22,6 @@ import * as CardUtils from '@libs/CardUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import * as PaymentUtils from '@libs/PaymentUtils'; -import * as PolicyUtils from '@libs/PolicyUtils'; import variables from '@styles/variables'; import * as PaymentMethods from '@userActions/PaymentMethods'; import CONST from '@src/CONST'; @@ -214,16 +213,6 @@ function PaymentMethodList({ const [isLoadingPaymentMethods = true, isLoadingPaymentMethodsResult] = useOnyx(ONYXKEYS.IS_LOADING_PAYMENT_METHODS); const isLoadingPaymentMethodsOnyx = isLoadingOnyxValue(isLoadingPaymentMethodsResult); - const getDescriptionForPolicyDomainCard = (domainName: string): string => { - // A domain name containing a policyID indicates that this is a workspace feed - const policyID = domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1]; - if (policyID) { - const policy = PolicyUtils.getPolicy(policyID.toUpperCase()); - return policy?.name ?? domainName; - } - return domainName; - }; - const filteredPaymentMethods = useMemo(() => { if (shouldShowAssignedCards) { const assignedCards = Object.values(isLoadingCardList ? {} : cardList ?? {}) @@ -239,7 +228,7 @@ function PaymentMethodList({ assignedCardsGrouped.push({ key: card.cardID.toString(), title: CardUtils.maskCardNumber(card.cardName ?? '', card.bank), - description: getDescriptionForPolicyDomainCard(card.domainName), + description: CardUtils.getDescriptionForPolicyDomainCard(card.domainName), shouldShowRightIcon: false, interactive: false, canDismissError: false, @@ -277,7 +266,7 @@ function PaymentMethodList({ key: card.cardID.toString(), // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing title: card?.nameValuePairs?.cardTitle || card.bank, - description: getDescriptionForPolicyDomainCard(card.domainName), + description: CardUtils.getDescriptionForPolicyDomainCard(card.domainName), onPress: () => Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(String(card.cardID))), cardID: card.cardID, isGroupedCardDomain: !isAdminIssuedVirtualCard, From 306f6c68ecdf475e763cd50d33e8e51c8b57400f Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Tue, 7 Jan 2025 11:15:21 +0100 Subject: [PATCH 27/38] fix card filter SelectionList selection behaviour --- .../SearchFiltersCardPage.tsx | 67 ++++++++++++++----- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index 303e8ef7f087..0e2a5e52d478 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -25,6 +25,7 @@ import type {BankIcon} from '@src/types/onyx/Bank'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; type CardFilterItem = Partial & {bankIcon?: BankIcon; lastFourPAN?: string; isVirtual?: boolean; isCardFeed?: boolean; correspondingCards?: string[]}; +type ItemsGroupedBySelection = {selected: CardFilterItem[]; unselected: CardFilterItem[]}; type DomainFeedData = {bank: string; domainName: string; correspondingCardIDs: string[]}; @@ -70,7 +71,7 @@ function buildIndividualCardsData( userCardList: CardList, selectedCards: string[], iconStyles: StyleProp, -): CardFilterItem[] { +): ItemsGroupedBySelection { const userAssignedCards: CardFilterItem[] = Object.values(userCardList ?? {}).map((card) => createIndividualCardFilterItem(card, selectedCards, iconStyles)); // When user is admin of a workspace he sees all the cards of workspace under cards_ Onyx key @@ -81,7 +82,18 @@ function buildIndividualCardsData( .filter((card) => card && CardUtils.isCard(card) && !userCardList?.[card.cardID]) .map((card) => createIndividualCardFilterItem(card, selectedCards, iconStyles)); }); - return [...userAssignedCards, ...allWorkspaceCards]; + + const allCardItems = [...userAssignedCards, ...allWorkspaceCards]; + const selectedCardItems: CardFilterItem[] = []; + const unselectedCardItems: CardFilterItem[] = []; + allCardItems.forEach((card) => { + if (card.isSelected) { + selectedCardItems.push(card); + } else { + unselectedCardItems.push(card); + } + }); + return {selected: selectedCardItems, unselected: unselectedCardItems}; } function createCardFeedItem({ @@ -100,7 +112,7 @@ function createCardFeedItem({ selectedCards: string[]; iconStyles: StyleProp; translate: LocaleContextProps['translate']; -}) { +}): CardFilterItem { const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); const text = translate('search.filters.card.cardFeedName', {cardFeedBankName, cardFeedLabel}); const isSelected = correspondingCardIDs.every((card) => selectedCards.includes(card)); @@ -127,14 +139,16 @@ function buildCardFeedsData( selectedCards: string[], iconStyles: StyleProp, translate: LocaleContextProps['translate'], -): CardFilterItem[] { +): ItemsGroupedBySelection { const repeatingBanks = getRepeatingBanks(Object.keys(workspaceCardFeeds), domainFeedsData); + const selectedFeeds: CardFilterItem[] = []; + const unselectedFeeds: CardFilterItem[] = []; - const domainFeeds: CardFilterItem[] = Object.values(domainFeedsData).map((domainFeed) => { + Object.values(domainFeedsData).forEach((domainFeed) => { const {domainName, bank, correspondingCardIDs} = domainFeed; const isBankRepeating = repeatingBanks.includes(bank); - return createCardFeedItem({ + const feedItem = createCardFeedItem({ bank, correspondingCardIDs, iconStyles, @@ -143,11 +157,16 @@ function buildCardFeedsData( keyForList: `${domainName}-${bank}`, selectedCards, }); + if (feedItem.isSelected) { + selectedFeeds.push(feedItem); + } else { + unselectedFeeds.push(feedItem); + } }); - const workspaceFeeds: CardFilterItem[] = Object.entries(workspaceCardFeeds) + Object.entries(workspaceCardFeeds) .filter(([, cardFeed]) => !isEmptyObject(cardFeed)) - .map(([cardFeedKey, cardFeed]) => { + .forEach(([cardFeedKey, cardFeed]) => { const representativeCard = Object.values(cardFeed ?? {}).find((cardFeedItem) => CardUtils.isCard(cardFeedItem)); if (!representativeCard) { return; @@ -158,7 +177,7 @@ function buildCardFeedsData( const correspondingPolicy = PolicyUtils.getPolicy(policyID?.toUpperCase()); const correspondingCardIDs = Object.keys(cardFeed ?? {}).filter((cardKey) => cardKey !== 'cardList'); - return createCardFeedItem({ + const feedItem = createCardFeedItem({ bank, correspondingCardIDs, iconStyles, @@ -167,10 +186,14 @@ function buildCardFeedsData( keyForList: cardFeedKey, selectedCards, }); - }) - .filter((feed) => feed) as CardFilterItem[]; + if (feedItem.isSelected) { + selectedFeeds.push(feedItem); + } else { + unselectedFeeds.push(feedItem); + } + }); - return [...domainFeeds, ...workspaceFeeds]; + return {selected: selectedFeeds, unselected: unselectedFeeds}; } function SearchFiltersCardPage() { @@ -214,23 +237,31 @@ function SearchFiltersCardPage() { [domainFeedsData, workspaceCardFeeds, selectedCards, styles.cardIcon, translate], ); - const shouldShowSearchInput = cardFeedsSectionData.length + individualCardsSectionData.length > CONST.COMPANY_CARDS.CARD_LIST_THRESHOLD; + const shouldShowSearchInput = + cardFeedsSectionData.selected.length + cardFeedsSectionData.unselected.length + individualCardsSectionData.selected.length + individualCardsSectionData.unselected.length > + CONST.COMPANY_CARDS.CARD_LIST_THRESHOLD; const sections = useMemo(() => { const newSections = []; + const selectedItems = [...cardFeedsSectionData.selected, ...individualCardsSectionData.selected]; + newSections.push({ + title: undefined, + data: selectedItems.filter((item) => item && item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())), + shouldShow: selectedItems.length > 0, + }); newSections.push({ title: translate('search.filters.card.cardFeeds'), - data: cardFeedsSectionData.filter((item) => item && item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())), - shouldShow: cardFeedsSectionData.length > 0, + data: cardFeedsSectionData.unselected.filter((item) => item && item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())), + shouldShow: cardFeedsSectionData.unselected.length > 0, }); newSections.push({ title: translate('search.filters.card.individualCards'), - data: individualCardsSectionData.filter((item) => item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())), - shouldShow: individualCardsSectionData.length > 0, + data: individualCardsSectionData.unselected.filter((item) => item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())), + shouldShow: individualCardsSectionData.unselected.length > 0, }); return newSections; - }, [translate, cardFeedsSectionData, individualCardsSectionData, debouncedSearchTerm]); + }, [cardFeedsSectionData.selected, cardFeedsSectionData.unselected, individualCardsSectionData.selected, individualCardsSectionData.unselected, translate, debouncedSearchTerm]); const handleConfirmSelection = useCallback(() => { SearchActions.updateAdvancedFilters({ From 415ef30061a13bcd1a1a37c5d23a457766e6b9ba Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Tue, 7 Jan 2025 12:30:42 +0100 Subject: [PATCH 28/38] filter out unissued cards in card filter page --- .../SearchFiltersCardPage.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index 0e2a5e52d478..0518d1c4a46a 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -29,6 +29,10 @@ type ItemsGroupedBySelection = {selected: CardFilterItem[]; unselected: CardFilt type DomainFeedData = {bank: string; domainName: string; correspondingCardIDs: string[]}; +function isCardIssued(card: Card) { + return !!card?.nameValuePairs?.isVirtual || card?.state !== CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED; +} + function getRepeatingBanks(workspaceCardFeedsKeys: string[], domainFeedsData: Record) { const bankFrequency: Record = {}; for (const key of workspaceCardFeedsKeys) { @@ -72,14 +76,16 @@ function buildIndividualCardsData( selectedCards: string[], iconStyles: StyleProp, ): ItemsGroupedBySelection { - const userAssignedCards: CardFilterItem[] = Object.values(userCardList ?? {}).map((card) => createIndividualCardFilterItem(card, selectedCards, iconStyles)); + const userAssignedCards: CardFilterItem[] = Object.values(userCardList ?? {}) + .filter((card) => isCardIssued(card)) + .map((card) => createIndividualCardFilterItem(card, selectedCards, iconStyles)); // When user is admin of a workspace he sees all the cards of workspace under cards_ Onyx key const allWorkspaceCards: CardFilterItem[] = Object.values(workspaceCardFeeds) .filter((cardFeed) => !isEmptyObject(cardFeed)) .flatMap((cardFeed) => { return Object.values(cardFeed as Record) - .filter((card) => card && CardUtils.isCard(card) && !userCardList?.[card.cardID]) + .filter((card) => card && CardUtils.isCard(card) && !userCardList?.[card.cardID] && isCardIssued(card)) .map((card) => createIndividualCardFilterItem(card, selectedCards, iconStyles)); }); @@ -167,8 +173,9 @@ function buildCardFeedsData( Object.entries(workspaceCardFeeds) .filter(([, cardFeed]) => !isEmptyObject(cardFeed)) .forEach(([cardFeedKey, cardFeed]) => { - const representativeCard = Object.values(cardFeed ?? {}).find((cardFeedItem) => CardUtils.isCard(cardFeedItem)); - if (!representativeCard) { + const cardFeedArray = Object.values(cardFeed ?? {}); + const representativeCard = cardFeedArray.find((cardFeedItem) => CardUtils.isCard(cardFeedItem)); + if (!representativeCard || !cardFeedArray.some((cardFeedItem) => CardUtils.isCard(cardFeedItem) && isCardIssued(cardFeedItem))) { return; } const {domainName, bank} = representativeCard; @@ -220,7 +227,7 @@ function SearchFiltersCardPage() { () => Object.values(userCardList ?? {}).reduce((accumulator, currentCard) => { // Cards in cardList can also be domain cards, we use them to compute domain feed - if (!currentCard.domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)) { + if (!currentCard.domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME) && isCardIssued(currentCard)) { if (accumulator[currentCard.domainName]) { accumulator[currentCard.domainName].correspondingCardIDs.push(currentCard.cardID.toString()); } else { From 00dc9307862ab25f36b9e7b4b1a6af8449c6c4f9 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Tue, 7 Jan 2025 12:49:20 +0100 Subject: [PATCH 29/38] include lastFourPan in card filter search --- .../SearchFiltersCardPage.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index 0518d1c4a46a..f1d32d4005ae 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -248,27 +248,33 @@ function SearchFiltersCardPage() { cardFeedsSectionData.selected.length + cardFeedsSectionData.unselected.length + individualCardsSectionData.selected.length + individualCardsSectionData.unselected.length > CONST.COMPANY_CARDS.CARD_LIST_THRESHOLD; + const searchFunction = useCallback( + (item: CardFilterItem) => + !!item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase()) || item.lastFourPAN?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase()), + [debouncedSearchTerm], + ); + const sections = useMemo(() => { const newSections = []; const selectedItems = [...cardFeedsSectionData.selected, ...individualCardsSectionData.selected]; newSections.push({ title: undefined, - data: selectedItems.filter((item) => item && item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())), + data: selectedItems.filter(searchFunction), shouldShow: selectedItems.length > 0, }); newSections.push({ title: translate('search.filters.card.cardFeeds'), - data: cardFeedsSectionData.unselected.filter((item) => item && item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())), + data: cardFeedsSectionData.unselected.filter(searchFunction), shouldShow: cardFeedsSectionData.unselected.length > 0, }); newSections.push({ title: translate('search.filters.card.individualCards'), - data: individualCardsSectionData.unselected.filter((item) => item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())), + data: individualCardsSectionData.unselected.filter(searchFunction), shouldShow: individualCardsSectionData.unselected.length > 0, }); return newSections; - }, [cardFeedsSectionData.selected, cardFeedsSectionData.unselected, individualCardsSectionData.selected, individualCardsSectionData.unselected, translate, debouncedSearchTerm]); + }, [cardFeedsSectionData.selected, cardFeedsSectionData.unselected, individualCardsSectionData.selected, individualCardsSectionData.unselected, searchFunction, translate]); const handleConfirmSelection = useCallback(() => { SearchActions.updateAdvancedFilters({ From a49beaf502db20cfb7e806041925e7723e51a4e2 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Wed, 8 Jan 2025 15:16:12 +0100 Subject: [PATCH 30/38] change cardList item design --- .../{ => Search}/CardListItem.tsx | 76 ++++++++++++++++--- src/libs/CardUtils.ts | 8 +- .../SearchFiltersCardPage.tsx | 43 +++++------ .../addNew/CardInstructionsStep.tsx | 2 +- .../assignCard/CardSelectionStep.tsx | 2 +- src/styles/index.ts | 15 ++++ src/styles/variables.ts | 3 + tests/unit/CardUtilsTest.ts | 6 +- 8 files changed, 111 insertions(+), 44 deletions(-) rename src/components/SelectionList/{ => Search}/CardListItem.tsx (52%) diff --git a/src/components/SelectionList/CardListItem.tsx b/src/components/SelectionList/Search/CardListItem.tsx similarity index 52% rename from src/components/SelectionList/CardListItem.tsx rename to src/components/SelectionList/Search/CardListItem.tsx index 0e887d1d30db..136196854d62 100644 --- a/src/components/SelectionList/CardListItem.tsx +++ b/src/components/SelectionList/Search/CardListItem.tsx @@ -1,18 +1,26 @@ import {Str} from 'expensify-common'; import React, {useCallback} from 'react'; import {View} from 'react-native'; +import Avatar from '@components/Avatar'; import Icon from '@components/Icon'; +import {FallbackAvatar} from '@components/Icon/Expensicons'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import SelectCircle from '@components/SelectCircle'; +import BaseListItem from '@components/SelectionList/BaseListItem'; +import type {BaseListItemProps, ListItem} from '@components/SelectionList/types'; import TextWithTooltip from '@components/TextWithTooltip'; +import UserDetailsTooltip from '@components/UserDetailsTooltip'; import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {PersonalDetails} from '@src/types/onyx'; import type {BankIcon} from '@src/types/onyx/Bank'; -import BaseListItem from './BaseListItem'; -import type {BaseListItemProps, ListItem} from './types'; -type CardListItemProps = BaseListItemProps; +type AdditionalCardProps = {shouldShowOwnersAvatar?: boolean; cardOwnerPersonalDetails?: PersonalDetails; bankIcon?: BankIcon; lastFourPAN?: string; isVirtual?: boolean; cardName?: string}; +type CardListItemProps = BaseListItemProps; function CardListItem({ item, @@ -28,7 +36,9 @@ function CardListItem({ shouldSyncFocus, }: CardListItemProps) { const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); + const theme = useTheme(); const handleCheckboxPress = useCallback(() => { if (onCheckboxPress) { @@ -38,10 +48,18 @@ function CardListItem({ } }, [item, onCheckboxPress, onSelectRow]); + const ownersAvatar = { + source: item.cardOwnerPersonalDetails?.avatar ?? FallbackAvatar, + id: item.cardOwnerPersonalDetails?.accountID ?? -1, + type: CONST.ICON_TYPE_AVATAR, + name: item.cardOwnerPersonalDetails?.displayName ?? '', + fallbackIcon: item.cardOwnerPersonalDetails?.fallbackIcon, + }; + const subtitleText = - `${item.lastFourPAN ? `${translate('paymentMethodList.accountLastFour')} ${item.lastFourPAN}` : ''}` + - `${item.lastFourPAN && item.isVirtual ? ` ${CONST.DOT_SEPARATOR} ` : ''}` + - `${item.isVirtual ? translate('workspace.expensifyCard.virtual') : ''}`; + `${item.cardName ? `${item.cardName}` : ''}` + + `${item.lastFourPAN ? ` ${CONST.DOT_SEPARATOR} ${item.lastFourPAN}` : ''}` + + `${item.isVirtual ? ` ${CONST.DOT_SEPARATOR} ${translate('workspace.expensifyCard.virtual')}` : ''}`; return ( ({ <> {!!item.bankIcon && ( - + {item.shouldShowOwnersAvatar ? ( + + + + + + + + + + + ) : ( + + )} )} @@ -115,3 +166,4 @@ function CardListItem({ CardListItem.displayName = 'CardListItem'; export default CardListItem; +export type {AdditionalCardProps}; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 0c26ae1e0f7e..1de7f2af4dbc 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -73,7 +73,7 @@ function getCardDescription(cardID?: number, cards: CardList = allCards) { return ''; } const cardDescriptor = card.state === CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED ? Localize.translateLocal('cardTransactions.notActivated') : card.lastFourPAN; - const humanReadableBankName = card.bank === CONST.EXPENSIFY_CARD.BANK ? CONST.EXPENSIFY_CARD.BANK : getCardFeedName(card.bank as CompanyCardFeed); + const humanReadableBankName = card.bank === CONST.EXPENSIFY_CARD.BANK ? CONST.EXPENSIFY_CARD.BANK : getBankName(card.bank as CompanyCardFeed); return cardDescriptor ? `${humanReadableBankName} - ${cardDescriptor}` : `${humanReadableBankName}`; } @@ -281,7 +281,7 @@ function getCompanyFeeds(cardFeeds: OnyxEntry, shouldFilterOutRemoved ); } -function getCardFeedName(feedType: CompanyCardFeed): string { +function getBankName(feedType: CompanyCardFeed): string { const feedNamesMapping = { [CONST.COMPANY_CARD.FEED_BANK_NAME.VISA]: 'Visa', [CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD]: 'Mastercard', @@ -327,7 +327,7 @@ function getCustomOrFormattedFeedName(feed?: CompanyCardFeed, companyCardNicknam } const customFeedName = companyCardNicknames?.[feed]; - const formattedFeedName = Localize.translateLocal('workspace.companyCards.feedName', {feedName: getCardFeedName(feed)}); + const formattedFeedName = Localize.translateLocal('workspace.companyCards.feedName', {feedName: getBankName(feed)}); return customFeedName ?? formattedFeedName; } @@ -430,7 +430,7 @@ export { getEligibleBankAccountsForCard, sortCardsByCardholderName, getCardFeedIcon, - getCardFeedName, + getBankName, getCompanyFeeds, isCustomFeed, getBankCardDetailsImage, diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index f1d32d4005ae..8756db33cf05 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -5,9 +5,11 @@ import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import {usePersonalDetails} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; -import CardListItem from '@components/SelectionList/CardListItem'; +import CardListItem from '@components/SelectionList/Search/CardListItem'; +import type {AdditionalCardProps} from '@components/SelectionList/Search/CardListItem'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -15,16 +17,14 @@ import * as CardUtils from '@libs/CardUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; import Navigation from '@navigation/Navigation'; -import variables from '@styles/variables'; import * as SearchActions from '@userActions/Search'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Card, CardList, CompanyCardFeed, WorkspaceCardsList} from '@src/types/onyx'; -import type {BankIcon} from '@src/types/onyx/Bank'; +import type {Card, CardList, CompanyCardFeed, PersonalDetailsList, WorkspaceCardsList} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -type CardFilterItem = Partial & {bankIcon?: BankIcon; lastFourPAN?: string; isVirtual?: boolean; isCardFeed?: boolean; correspondingCards?: string[]}; +type CardFilterItem = Partial & AdditionalCardProps & {isCardFeed?: boolean; correspondingCards?: string[]}; type ItemsGroupedBySelection = {selected: CardFilterItem[]; unselected: CardFilterItem[]}; type DomainFeedData = {bank: string; domainName: string; correspondingCardIDs: string[]}; @@ -48,23 +48,25 @@ function getRepeatingBanks(workspaceCardFeedsKeys: string[], domainFeedsData: Re return Object.keys(bankFrequency).filter((bank) => bankFrequency[bank] > 1); } -function createIndividualCardFilterItem(card: Card, selectedCards: string[], iconStyles: StyleProp) { +function createIndividualCardFilterItem(card: Card, personalDetailsList: PersonalDetailsList, selectedCards: string[]): CardFilterItem { + const personalDetails = personalDetailsList[card?.accountID ?? '']; const isSelected = selectedCards.includes(card.cardID.toString()); const icon = CardUtils.getCardFeedIcon(card?.bank as CompanyCardFeed); const cardName = card?.nameValuePairs?.cardTitle ?? card?.cardName; - const text = card.bank === CONST.EXPENSIFY_CARD.BANK ? card.bank : cardName; + const text1 = card.bank === CONST.EXPENSIFY_CARD.BANK ? card.bank : cardName; + const text = personalDetails?.displayName ?? text1; return { lastFourPAN: card.lastFourPAN, isVirtual: card?.nameValuePairs?.isVirtual, + shouldShowOwnersAvatar: true, + cardName, + cardOwnerPersonalDetails: personalDetails ?? undefined, text, keyForList: card.cardID.toString(), isSelected, bankIcon: { icon, - iconWidth: variables.cardIconWidth, - iconHeight: variables.cardIconHeight, - iconStyles, }, isCardFeed: false, }; @@ -73,12 +75,12 @@ function createIndividualCardFilterItem(card: Card, selectedCards: string[], ico function buildIndividualCardsData( workspaceCardFeeds: Record, userCardList: CardList, + personalDetailsList: PersonalDetailsList, selectedCards: string[], - iconStyles: StyleProp, ): ItemsGroupedBySelection { const userAssignedCards: CardFilterItem[] = Object.values(userCardList ?? {}) .filter((card) => isCardIssued(card)) - .map((card) => createIndividualCardFilterItem(card, selectedCards, iconStyles)); + .map((card) => createIndividualCardFilterItem(card, personalDetailsList, selectedCards)); // When user is admin of a workspace he sees all the cards of workspace under cards_ Onyx key const allWorkspaceCards: CardFilterItem[] = Object.values(workspaceCardFeeds) @@ -86,7 +88,7 @@ function buildIndividualCardsData( .flatMap((cardFeed) => { return Object.values(cardFeed as Record) .filter((card) => card && CardUtils.isCard(card) && !userCardList?.[card.cardID] && isCardIssued(card)) - .map((card) => createIndividualCardFilterItem(card, selectedCards, iconStyles)); + .map((card) => createIndividualCardFilterItem(card, personalDetailsList, selectedCards)); }); const allCardItems = [...userAssignedCards, ...allWorkspaceCards]; @@ -108,7 +110,6 @@ function createCardFeedItem({ keyForList, correspondingCardIDs, selectedCards, - iconStyles, translate, }: { bank: string; @@ -116,10 +117,9 @@ function createCardFeedItem({ keyForList: string; correspondingCardIDs: string[]; selectedCards: string[]; - iconStyles: StyleProp; translate: LocaleContextProps['translate']; }): CardFilterItem { - const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getCardFeedName(bank as CompanyCardFeed); + const cardFeedBankName = bank === CONST.EXPENSIFY_CARD.BANK ? translate('search.filters.card.expensify') : CardUtils.getBankName(bank as CompanyCardFeed); const text = translate('search.filters.card.cardFeedName', {cardFeedBankName, cardFeedLabel}); const isSelected = correspondingCardIDs.every((card) => selectedCards.includes(card)); @@ -128,11 +128,9 @@ function createCardFeedItem({ text, keyForList, isSelected, + shouldShowOwnersAvatar: false, bankIcon: { icon, - iconWidth: variables.cardIconWidth, - iconHeight: variables.cardIconHeight, - iconStyles, }, isCardFeed: true, correspondingCards: correspondingCardIDs, @@ -157,7 +155,6 @@ function buildCardFeedsData( const feedItem = createCardFeedItem({ bank, correspondingCardIDs, - iconStyles, cardFeedLabel: isBankRepeating ? CardUtils.getDescriptionForPolicyDomainCard(domainName) : undefined, translate, keyForList: `${domainName}-${bank}`, @@ -187,7 +184,6 @@ function buildCardFeedsData( const feedItem = createCardFeedItem({ bank, correspondingCardIDs, - iconStyles, cardFeedLabel: isBankRepeating ? correspondingPolicy?.name : undefined, translate, keyForList: cardFeedKey, @@ -213,14 +209,15 @@ function SearchFiltersCardPage() { const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const initiallySelectedCards = searchAdvancedFiltersForm?.cardID; const [selectedCards, setSelectedCards] = useState(initiallySelectedCards ?? []); + const personalDetails = usePersonalDetails(); useEffect(() => { SearchActions.openSearchFiltersCardPage(); }, []); const individualCardsSectionData = useMemo( - () => buildIndividualCardsData(workspaceCardFeeds ?? {}, userCardList ?? {}, selectedCards, styles.cardIcon), - [workspaceCardFeeds, selectedCards, styles.cardIcon, userCardList], + () => buildIndividualCardsData(workspaceCardFeeds ?? {}, userCardList ?? {}, personalDetails ?? {}, selectedCards), + [workspaceCardFeeds, userCardList, personalDetails, selectedCards], ); const domainFeedsData = useMemo( diff --git a/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx b/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx index 040c3ede8b57..44874ad61b78 100644 --- a/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx +++ b/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx @@ -98,7 +98,7 @@ function CardInstructionsStep({policyID}: CardInstructionsStepProps) { contentContainerStyle={styles.flexGrow1} > - {translate('workspace.companyCards.addNewCard.enableFeed.title', {provider: CardUtils.getCardFeedName(feedProvider)})} + {translate('workspace.companyCards.addNewCard.enableFeed.title', {provider: CardUtils.getBankName(feedProvider)})} {translate(translationKey)} diff --git a/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx b/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx index e537bbc3a625..9ae3ed06116a 100644 --- a/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx @@ -151,7 +151,7 @@ function CardSelectionStep({feed, policyID}: CardSelectionStepProps) { {translate('workspace.companyCards.chooseCardFor', { assignee: assigneeDisplayName, - feed: CardUtils.getCardFeedName(feed), + feed: CardUtils.getBankName(feed), })} diff --git a/src/styles/index.ts b/src/styles/index.ts index 139207835685..1913bcbbd060 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4773,6 +4773,15 @@ const styles = (theme: ThemeColors) => borderRadius: 8, }, + cardItemSecondaryIconStyle: { + position: 'absolute', + bottom: -4, + right: -4, + borderWidth: 2, + borderRadius: 2, + backgroundColor: theme.componentBG, + }, + selectionListStickyHeader: { backgroundColor: theme.appBG, }, @@ -5232,6 +5241,12 @@ const styles = (theme: ThemeColors) => alignSelf: 'center', }, + cardMiniature: { + overflow: 'hidden', + borderRadius: variables.cardMiniatureBorderRadius, + alignSelf: 'center', + }, + tripReservationIconContainer: { width: variables.avatarSizeNormal, height: variables.avatarSizeNormal, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 174e23fe64a9..56921f7dc488 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -239,6 +239,9 @@ export default { cardIconWidth: 40, cardIconHeight: 26, cardBorderRadius: 4, + cardMiniatureWidth: 20, + cardMiniatureHeight: 13, + cardMiniatureBorderRadius: 2, cardNameWidth: 156, holdMenuIconSize: 64, diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index 2685a77836b3..f9b7f05dab98 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -335,19 +335,19 @@ describe('CardUtils', () => { describe('getCardFeedName', () => { it('Should return a valid name if a valid feed was provided', () => { const feed = 'vcf'; - const feedName = CardUtils.getCardFeedName(feed); + const feedName = CardUtils.getBankName(feed); expect(feedName).toBe('Visa'); }); it('Should return a valid name if an OldDot feed variation was provided', () => { const feed = 'oauth.americanexpressfdx.com 2003' as OnyxTypes.CompanyCardFeed; - const feedName = CardUtils.getCardFeedName(feed); + const feedName = CardUtils.getBankName(feed); expect(feedName).toBe('American Express'); }); it('Should return empty string if invalid feed was provided', () => { const feed = 'vvcf' as OnyxTypes.CompanyCardFeed; - const feedName = CardUtils.getCardFeedName(feed); + const feedName = CardUtils.getBankName(feed); expect(feedName).toBe(''); }); }); From 4954b15281542e7fbccb76b822792824675ba3b5 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Wed, 8 Jan 2025 15:34:32 +0100 Subject: [PATCH 31/38] fix tests and eslint --- src/libs/CardUtils.ts | 2 +- .../SearchFiltersCardPage.tsx | 6 +-- .../settings/Wallet/PaymentMethodList.tsx | 4 +- tests/unit/Search/buildCardFilterDataTest.ts | 41 ++++++++----------- 4 files changed, 21 insertions(+), 32 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 1de7f2af4dbc..0fb5d73d4948 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -158,7 +158,7 @@ function maskCard(lastFour = ''): string { * @param feed - card feed. * @returns - The masked card string. */ -function maskCardNumber(cardName: string, feed: string | undefined): string { +function maskCardNumber(cardName: string | undefined, feed: string | undefined): string { if (!cardName || cardName === '') { return ''; } diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index 8756db33cf05..a2b2f18ccf2c 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -1,6 +1,5 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; -import type {StyleProp, ViewStyle} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -141,7 +140,6 @@ function buildCardFeedsData( workspaceCardFeeds: Record, domainFeedsData: Record, selectedCards: string[], - iconStyles: StyleProp, translate: LocaleContextProps['translate'], ): ItemsGroupedBySelection { const repeatingBanks = getRepeatingBanks(Object.keys(workspaceCardFeeds), domainFeedsData); @@ -237,8 +235,8 @@ function SearchFiltersCardPage() { ); const cardFeedsSectionData = useMemo( - () => buildCardFeedsData(workspaceCardFeeds ?? {}, domainFeedsData, selectedCards, styles.cardIcon, translate), - [domainFeedsData, workspaceCardFeeds, selectedCards, styles.cardIcon, translate], + () => buildCardFeedsData(workspaceCardFeeds ?? {}, domainFeedsData, selectedCards, translate), + [domainFeedsData, workspaceCardFeeds, selectedCards, translate], ); const shouldShowSearchInput = diff --git a/src/pages/settings/Wallet/PaymentMethodList.tsx b/src/pages/settings/Wallet/PaymentMethodList.tsx index 2443ce9abab2..4a9fe5c89ec3 100644 --- a/src/pages/settings/Wallet/PaymentMethodList.tsx +++ b/src/pages/settings/Wallet/PaymentMethodList.tsx @@ -227,7 +227,7 @@ function PaymentMethodList({ if (!CardUtils.isExpensifyCard(card.cardID)) { assignedCardsGrouped.push({ key: card.cardID.toString(), - title: CardUtils.maskCardNumber(card.cardName ?? '', card.bank), + title: CardUtils.maskCardNumber(card.cardName, card.bank), description: CardUtils.getDescriptionForPolicyDomainCard(card.domainName), shouldShowRightIcon: false, interactive: false, @@ -410,7 +410,7 @@ function PaymentMethodList({ shouldShowDefaultBadge( filteredPaymentMethods, item, - userWallet?.walletLinkedAccountID ?? 0, + userWallet?.walletLinkedAccountID ?? CONST.DEFAULT_NUMBER_ID, invoiceTransferBankAccountID ? invoiceTransferBankAccountID === item.methodID : item.isDefault, ) ? translate('paymentMethodList.defaultPaymentMethod') diff --git a/tests/unit/Search/buildCardFilterDataTest.ts b/tests/unit/Search/buildCardFilterDataTest.ts index 3c9e92348c06..225c4207d2e7 100644 --- a/tests/unit/Search/buildCardFilterDataTest.ts +++ b/tests/unit/Search/buildCardFilterDataTest.ts @@ -159,32 +159,24 @@ const cardList = { const domainFeedDataMock = {testDomain: {domainName: 'testDomain', bank: 'Expensify Card', correspondingCardIDs: ['11111111']}}; -function translateMock(key: string, obj: {cardFeedBankName: string; cardFeedLabel: string}) { - if (key === 'search.filters.card.expensify') { - return 'Expensify'; - } - return `All ${obj.cardFeedBankName}${obj.cardFeedLabel ? ` - ${obj.cardFeedLabel}` : ''}`; -} -const translateMock1 = jest.fn(); +const translateMock = jest.fn(); describe('buildIndividualCardsData', () => { - const result = buildIndividualCardsData(workspaceCardFeeds as unknown as Record, cardList as unknown as CardList, ['21588678'], {}); + const result = buildIndividualCardsData(workspaceCardFeeds as unknown as Record, cardList as unknown as CardList, {}, ['21588678']); it("Builds all individual cards and doesn't generate duplicates", () => { - expect(result.length).toEqual(11); + expect(result.unselected.length + result.selected.length).toEqual(11); // Check if Expensify card was built correctly - const expensifyCard = result.find((card) => card.keyForList === '21588678'); + const expensifyCard = result.selected.find((card) => card.keyForList === '21588678'); expect(expensifyCard).toMatchObject({ - text: 'Expensify Card', lastFourPAN: '1138', isSelected: true, }); // Check if company card was built correctly - const companyCard = result.find((card) => card.keyForList === '21604933'); + const companyCard = result.unselected.find((card) => card.keyForList === '21604933'); expect(companyCard).toMatchObject({ - text: '480801XXXXXX1601', lastFourPAN: '1601', isSelected: false, }); @@ -193,7 +185,7 @@ describe('buildIndividualCardsData', () => { describe('buildIndividualCardsData with empty argument objects', () => { it('Returns empty array when cardList and workspaceCardFeeds are empty', () => { - const result = buildIndividualCardsData({}, {}, [], {}); + const result = buildIndividualCardsData({}, {}, {}, []); expect(result).toEqual([]); }); }); @@ -203,42 +195,41 @@ describe('buildCardFeedsData', () => { workspaceCardFeeds as unknown as Record, domainFeedDataMock, [], - {}, - translateMock1 as LocaleContextProps['translate'], + translateMock as LocaleContextProps['translate'], ); it('Buids domain card feed properly', () => { // Check if domain card feed was built properly - expect(result.at(0)).toMatchObject({ + expect(result.unselected.at(0)).toMatchObject({ isCardFeed: true, correspondingCards: ['11111111'], }); - expect(translateMock1).toHaveBeenCalledWith('search.filters.card.cardFeedName', {cardFeedBankName: undefined, cardFeedLabel: 'testDomain'}); + expect(translateMock).toHaveBeenCalledWith('search.filters.card.cardFeedName', {cardFeedBankName: undefined, cardFeedLabel: 'testDomain'}); // Check if workspace card feed that comes from company cards was built properly. - expect(result.at(1)).toMatchObject({ + expect(result.unselected.at(1)).toMatchObject({ isCardFeed: true, correspondingCards: ['21593492', '21604933', '21638320', '21638598'], }); - expect(translateMock1).toHaveBeenCalledWith('search.filters.card.cardFeedName', {cardFeedBankName: 'Visa', cardFeedLabel: undefined}); + expect(translateMock).toHaveBeenCalledWith('search.filters.card.cardFeedName', {cardFeedBankName: 'Visa', cardFeedLabel: undefined}); // Check if workspace card feed that comes from expensify cards was built properly - expect(result.at(2)).toMatchObject({ + expect(result.unselected.at(2)).toMatchObject({ isCardFeed: true, correspondingCards: ['21588678', '21588684'], }); - expect(translateMock1).toHaveBeenCalledWith('search.filters.card.cardFeedName', {cardFeedBankName: undefined, cardFeedLabel: 'test1'}); + expect(translateMock).toHaveBeenCalledWith('search.filters.card.cardFeedName', {cardFeedBankName: undefined, cardFeedLabel: 'test1'}); // Check if workspace card feed that comes from expensify cards was built properly. - expect(result.at(3)).toMatchObject({ + expect(result.unselected.at(3)).toMatchObject({ isCardFeed: true, correspondingCards: ['21589168', '21589182', '21589202', '21638322'], }); - expect(translateMock1).toHaveBeenCalledWith('search.filters.card.cardFeedName', {cardFeedBankName: undefined, cardFeedLabel: 'test2'}); + expect(translateMock).toHaveBeenCalledWith('search.filters.card.cardFeedName', {cardFeedBankName: undefined, cardFeedLabel: 'test2'}); }); }); describe('buildIndividualCardsData with empty argument objects', () => { it('Return empty array when domainCardFeeds and workspaceCardFeeds are empty', () => { - const result = buildCardFeedsData({}, {}, [], {}, translateMock as LocaleContextProps['translate']); + const result = buildCardFeedsData({}, {}, [], translateMock as LocaleContextProps['translate']); expect(result).toEqual([]); }); }); From 79a5c3dcc2ac089d7e953764184fdd570a3f65b5 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Wed, 8 Jan 2025 16:24:22 +0100 Subject: [PATCH 32/38] fix typescript error --- src/libs/CardUtils.ts | 2 +- .../companyCards/assignCard/BankConnection/index.native.tsx | 2 +- .../workspace/companyCards/assignCard/BankConnection/index.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 5024c9919663..b7ce7cb93b99 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -333,7 +333,7 @@ function getCustomOrFormattedFeedName(feed?: CompanyCardFeed, companyCardNicknam return ''; } - const formattedFeedName = Localize.translateLocal('workspace.companyCards.feedName', {feedName: getCardFeedName(feed)}); + const formattedFeedName = Localize.translateLocal('workspace.companyCards.feedName', {feedName: getBankName(feed)}); return customFeedName ?? formattedFeedName; } diff --git a/src/pages/workspace/companyCards/assignCard/BankConnection/index.native.tsx b/src/pages/workspace/companyCards/assignCard/BankConnection/index.native.tsx index 32e9c03ea8d0..ade9dd4b8926 100644 --- a/src/pages/workspace/companyCards/assignCard/BankConnection/index.native.tsx +++ b/src/pages/workspace/companyCards/assignCard/BankConnection/index.native.tsx @@ -29,7 +29,7 @@ function BankConnection({policyID, feed}: BankConnectionStepProps) { const webViewRef = useRef(null); const [session] = useOnyx(ONYXKEYS.SESSION); const authToken = session?.authToken ?? null; - const bankName = CardUtils.getCardFeedName(feed); + const bankName = CardUtils.getBankName(feed); const url = getCompanyCardBankConnection(policyID, bankName); const workspaceAccountID = PolicyUtils.getWorkspaceAccountID(policyID); const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`); diff --git a/src/pages/workspace/companyCards/assignCard/BankConnection/index.tsx b/src/pages/workspace/companyCards/assignCard/BankConnection/index.tsx index b714229752cf..1874a788b1e4 100644 --- a/src/pages/workspace/companyCards/assignCard/BankConnection/index.tsx +++ b/src/pages/workspace/companyCards/assignCard/BankConnection/index.tsx @@ -30,7 +30,7 @@ type BankConnectionStepProps = { function BankConnection({policyID, feed}: BankConnectionStepProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const bankName = CardUtils.getCardFeedName(feed); + const bankName = CardUtils.getBankName(feed); const currentUrl = getCurrentUrl(); const isBankConnectionCompleteRoute = currentUrl.includes(ROUTES.BANK_CONNECTION_COMPLETE); const url = getCompanyCardBankConnection(policyID, bankName); From 7a8d927a885aa182d2c566aa654b84282a406ed7 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Thu, 9 Jan 2025 17:51:18 +0100 Subject: [PATCH 33/38] merge main --- Mobile-Expensify | 2 +- android/app/build.gradle | 4 +- .../Deactivate-or-cancel-an-Expensify-Card.md | 34 ++- .../Quickbooks-Online-Troubleshooting.md | 196 +++++++++++++- .../travel/manage-travel-member-roles.md | 2 +- ios/NewExpensify/Info.plist | 2 +- ios/NewExpensifyTests/Info.plist | 2 +- ios/NotificationServiceExtension/Info.plist | 2 +- ios/Podfile.lock | 4 +- package-lock.json | 12 +- package.json | 4 +- ...ing-fixes-keyboard-flicker-in-modals.patch | 17 -- ...027+measureText-full-width-if-wraps.patch} | 0 ...oll-the-cursor-into-view-when-focus.patch} | 0 ...029+fix-crash-when-deleting-expense.patch} | 0 src/App.tsx | 2 +- src/CONST.ts | 16 +- src/ONYXKEYS.ts | 4 + src/components/Breadcrumbs.tsx | 1 + src/components/ExplanationModal.tsx | 8 - src/components/Header.tsx | 7 +- src/components/OnyxProvider.tsx | 3 +- .../SelectionList/BaseSelectionList.tsx | 7 +- .../SelectionList/Search/CardListItem.tsx | 4 +- .../VideoPlayer/BaseVideoPlayer.tsx | 18 +- src/components/withCurrentReportID.tsx | 89 ------ src/hooks/useCurrentReportID.tsx | 62 ++++- src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/libs/Fullstory/index.native.ts | 45 +++- src/libs/Fullstory/index.ts | 77 +++++- src/libs/Middleware/SaveResponseInOnyx.ts | 9 +- .../BottomTabBar.tsx | 2 +- src/libs/Navigation/NavigationRoot.tsx | 10 +- .../LocalNotification/BrowserNotifications.ts | 8 +- .../PushNotification/NotificationType.ts | 1 + .../subscribePushNotification/index.ts | 62 +++-- src/libs/ReportUtils.ts | 48 ++++ src/libs/ValidationUtils.ts | 4 +- src/libs/actions/OnyxUpdateManager/index.ts | 117 +++++--- .../utils/DeferredOnyxUpdates.ts | 3 +- .../utils/__mocks__/applyUpdates.ts | 33 ++- .../utils/__mocks__/index.ts | 16 +- .../actions/OnyxUpdateManager/utils/index.ts | 15 +- src/libs/actions/OnyxUpdates.ts | 18 +- src/libs/actions/Policy/Policy.ts | 6 - src/libs/actions/Session/index.ts | 6 +- src/libs/actions/User.ts | 6 +- src/libs/actions/__mocks__/App.ts | 34 ++- src/libs/actions/__mocks__/OnyxUpdates.ts | 44 +++ src/libs/actions/applyOnyxUpdatesReliably.ts | 41 ++- src/libs/onboardingSelectors.ts | 13 +- src/pages/ConciergePage.tsx | 21 +- src/pages/GroupChatNameEditPage.tsx | 30 +-- .../PrivateNotes/PrivateNotesEditPage.tsx | 2 +- src/pages/RoomDescriptionPage.tsx | 2 +- src/pages/Search/AdvancedSearchFilters.tsx | 4 +- src/pages/home/ReportScreen.tsx | 11 +- .../home/report/PureReportActionItem.tsx | 12 +- src/pages/home/report/ReportActionsList.tsx | 13 +- .../step/IOURequestStepScan/index.native.tsx | 15 +- .../request/step/IOURequestStepScan/index.tsx | 24 +- src/pages/settings/Report/RoomNamePage.tsx | 25 +- src/pages/workspace/WorkspaceNamePage.tsx | 2 +- .../WorkspaceProfileDescriptionPage.tsx | 4 +- .../categories/WorkspaceCategoriesPage.tsx | 22 +- .../companyCards/addNew/SelectBankStep.tsx | 1 + .../distanceRates/PolicyDistanceRatesPage.tsx | 43 ++- .../ReportFieldsListValuesPage.tsx | 26 +- .../workspace/tags/WorkspaceTagsPage.tsx | 69 +++-- .../workspace/tags/WorkspaceViewTagsPage.tsx | 22 +- .../workspace/taxes/WorkspaceTaxesPage.tsx | 44 ++- src/stories/SelectionList.stories.tsx | 8 + src/types/onyx/OnyxUpdatesFromServer.ts | 5 +- tests/actions/OnyxUpdateManagerTest.ts | 27 +- tests/actions/PolicyTest.ts | 32 +++ .../perf-test/ReportActionsList.perf-test.tsx | 9 + tests/ui/WorkspaceCategoriesTest.tsx | 2 +- tests/unit/OnyxUpdateManagerTest.ts | 254 +++++++++++++----- tests/utils/LHNTestUtils.tsx | 2 +- tests/utils/OnyxUpdateMockUtils.ts | 36 +++ tests/utils/createOnyxMockUpdate.ts | 23 -- 82 files changed, 1394 insertions(+), 518 deletions(-) delete mode 100644 patches/react-native+0.76.3+027+disable-status-bar-hiding-fixes-keyboard-flicker-in-modals.patch rename patches/{react-native+0.76.3+028+measureText-full-width-if-wraps.patch => react-native+0.76.3+027+measureText-full-width-if-wraps.patch} (100%) rename patches/{react-native+0.76.3+029+fix-scroll-the-cursor-into-view-when-focus.patch => react-native+0.76.3+028+fix-scroll-the-cursor-into-view-when-focus.patch} (100%) rename patches/{react-native+0.76.3+030+fix-crash-when-deleting-expense.patch => react-native+0.76.3+029+fix-crash-when-deleting-expense.patch} (100%) delete mode 100644 src/components/withCurrentReportID.tsx create mode 100644 src/libs/actions/__mocks__/OnyxUpdates.ts create mode 100644 tests/utils/OnyxUpdateMockUtils.ts delete mode 100644 tests/utils/createOnyxMockUpdate.ts diff --git a/Mobile-Expensify b/Mobile-Expensify index 81dda91b1b0b..9dd1eb09dfa4 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 81dda91b1b0bfad66474b58657e181293528db4b +Subproject commit 9dd1eb09dfa47da8bcbe6ab0d4bad62d1a628719 diff --git a/android/app/build.gradle b/android/app/build.gradle index afac56fba643..67f55fb61836 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009008201 - versionName "9.0.82-1" + versionCode 1009008204 + versionName "9.0.82-4" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/docs/articles/expensify-classic/expensify-card/Deactivate-or-cancel-an-Expensify-Card.md b/docs/articles/expensify-classic/expensify-card/Deactivate-or-cancel-an-Expensify-Card.md index d7fa33221834..7a704f024ce7 100644 --- a/docs/articles/expensify-classic/expensify-card/Deactivate-or-cancel-an-Expensify-Card.md +++ b/docs/articles/expensify-classic/expensify-card/Deactivate-or-cancel-an-Expensify-Card.md @@ -4,25 +4,41 @@ description: Close an Expensify Card ---
-A cardholder or a Domain Admin can cancel an Expensify Card. You may want to cancel a card: -- To cancel an old Expensify Card after upgrading to the new Expensify Visa® Commercial Card +A cardholder can cancel an Expensify Card themselves, or a Domain Admin can deactivate it. You may want to cancel or deactivate a card: - After a fraudulent or suspicious charge +- When an Expensify Card is lost or damaged - After an employee leaves the company +# Cardholders + +To cancel an Expensify Card assigned to you, + +1. Hover over Settings, then click **Account**. +2. Click the **Credit Card Import** tab. +3. Click **Request a New Card** next to the card. +4. Choose a reason. +5. Confirm your address details for shipping a new card. +6. Consult this [guide](https://help.expensify.com/articles/expensify-classic/expensify-card/Dispute-A-Transaction) for how to dispute fraudulent transactions (where relevant). + # Domain Admins -To cancel an employee's Expensify Card as a Domain Admin, +To deactivate an employee's Expensify Card as a Domain Admin, 1. Hover over Settings, then click **Domains**. 2. Click the name of the domain. -3. Next to the card, click **Terminate**. +3. Next to the card, click **Edit Limit**. +4. Ensure the Custom Smart Limit toggle is enabled to be able to set a specific card limit. Otherwise, the card limit will be determined by the limit set for the group that the employee is in. +5. In the Limit Amount field, set the limit to $0. The card will be disabled for use until the limit is increased. +6. Click **Save**. -# Cardholders +Note: If you have concerns about fraudulent access to a Domain Admin's user account, please message Concierge or email concierge@expensify.com immediately. If necessary, our support team can manually suspend Expensify cards outside of the Expensify Domain as a temporary measure if your account is compromised. -To cancel an Expensify Card assigned to you, +# Terminating an old Expensify Card after upgrading to the new Expensify Visa® Commercial Card -1. Hover over Settings, then click **Account**. -2. Click the **Credit Card Import** tab. -3. Click **Cancel** next to the card. +To terminate old Expensify Cards that have since been upgraded, + +1. Hover over Settings, then click **Domains**. +2. Click the name of the domain. +3. Next to the card, click **Terminate**.
diff --git a/docs/articles/new-expensify/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md b/docs/articles/new-expensify/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md index 497c618442b1..3b32f33266e7 100644 --- a/docs/articles/new-expensify/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md +++ b/docs/articles/new-expensify/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md @@ -3,7 +3,9 @@ title: QuickBooks Online Troubleshooting description: A list of common QuickBooks Online errors and how to resolve them --- -## Report won’t automatically export to QuickBooks Online +Occasionally, you might run into errors when exporting reports or syncing QuickBooks Online with Expensify. Below, you'll find detailed instructions to help you troubleshoot and resolve the most common connection and export issues quickly. + +# Issue: Report won’t automatically export to QuickBooks Online If an error occurs during an automatic export to QuickBooks Online: @@ -13,7 +15,7 @@ If an error occurs during an automatic export to QuickBooks Online: An error on a report will prevent it from automatically exporting. -### How to resolve +## How to resolve Open the expense and make the required changes. Then an admin must manually export the report to QuickBooks Online by clicking Details > Export. @@ -21,32 +23,210 @@ Open the expense and make the required changes. Then an admin must manually expo ![Select QuickBooks Online in the Export tab](https://help.expensify.com/assets/images/QBO_help_03.png){:width="100%"} -## Unable to manually export a report +# Issue: Unable to manually export a report To export a report, it must be in the Approved, Closed, or Reimbursed state. If it is in the Open state, clicking “Export” will lead to an empty page, as the data is not yet available for export: ![If the Report is in the Open status, the Not Ready to Export message shows](https://help.expensify.com/assets/images/QBO_help_04.png){:width="100%"} -### How to resolve +## How to resolve Open the report and make the required changes: 1. If the report is in the Open status, ensure that it is submitted. 2. If the Report is in the Processing status, an admin or approver will need to approve it. -Once this is done, an admin must manually export the report to QuickBooks Online. +Once this is done, Workspace Admins must manually export the report to QuickBooks Online. + +# Error: When exporting billable expenses, please make sure the account in QuickBooks Online has been marked as billable + +**Why does this happen?** + +This error occurs when the account applied as a category to the expense in Expensify is not marked as a billable type account. + +## How to resolve +1. Log in to QuickBooks Online. +2. Click the Gear in the upper right-hand corner. +3. Under Company Settings, click Expenses. +4. Enable the option “Make expenses and items billable.” +5. Click on the pencil icon on the right to check if you have "In multiple accounts" selected: +6. If "In multiple accounts" is selected, go to Chart of Accounts and click Edit for the account in question. +7. Check the billable option and select an income account within your Chart of Accounts. +8. Sync your QuickBooks Online connection in **Settings > Workspaces > Workspace Name > Accounting**. +9. Open the report and click on Details, then the Export button to re-export the data to QuickBooks Online. + +# Error: Feature Not Included in Subscription + +**Why does this happen?** + +This error occurs when your version of QuickBooks Online doesn’t support the feature you are using in Expensify. + +## How to resolve + +Though you will see all of these features available in Expensify, you will receive an error trying to export to QuickBooks if you have a feature enabled that isn't available with your QuickBooks Online subscription. + +**Here is a list of the features supported by each version:** +![QuickBooks Online - Subscription types]({{site.url}}/assets/images/QBO1.png){:width="100%"} + +_Please note: Self-employed is not supported._ + +# Error: Expenses are not categorized with a QuickBooks Online account + +**Why does this happen?** + +QuickBooks Online requires all expenses exported from Expensify to use a category matching an account in your Chart of Accounts. If a category from another source is used, QuickBooks Online will reject the expense. This error occurs when an expense on the report has a category applied that is not valid in QuickBooks Online. + +## How to resolve + +1. Sync your QuickBooks Online connection in Expensify from **Settings > Workspaces > Workspace Name > Accounting**, and click the **Sync Now** button. +2. Review your expenses. If any appear with a red _Category no longer valid_ violation, recategorize the expense until all expenses are violation-free. +3. Click the **Details** tab, then the **Export** button to export the data to QuickBooks Online. + - If you receive the same error, continue to the next step. +4. Note the categories used on the expenses and check the **Settings > Workspaces > Workspace Name > Categories** page to confirm the exact categories used on the report are enabled and connected to QuickBooks Online (you'll see a green QB icon next to all connected categories). +5. Confirm the categories used on the expenses in the report match exactly the accounts in your QuickBooks Online chart of accounts. +6. If you make any changes in QuickBooks Online or in Expensify, always sync the connection and then try to export again. + +# Error: Error Creating Vendor + +**Why does this happen?** + +This error occurs when you have an Employee Record set up with the employee's name. This prevents the Expensify integration from automatically creating the Vendor Record with the same name since QuickBooks Online won't allow you to have an employee and vendor with the same name. + +## How to resolve + +There are two different ways you can resolve this error. + +**Option 1**: +1. Log into QuickBooks Online. +2. Access the Employee Records for your submitters. +3. Edit the name to differentiate them from the name they have on their account in Expensify. +4. Sync your QuickBooks Online connection in **Settings > Workspaces > Workspace Name > Accounting**. +5. Open the report and click on the Details tab, then the Export button to export the data to QuickBooks Online. + +**Option 2**: +1. Log into QuickBooks Online. +2. Manually create all of your Vendor Records, making sure that the email matches the email address associated with the user in Expensify. + +With this option, we recommend disabling _Automatically Create Entities_ under **Settings > Workspaces > Workspace Name > Accounting > Configure > Advanced**. That way, you will receive the corresponding error messages if a vendor record doesn't exist. + +# Error: When You Use Accounts Payable, You Must Choose a Vendor in the Name Field + +**Why does this happen?** + +This error occurs when you are exporting reimbursable expenses as Journal Entries against an A/P account and also use Employee Records in QuickBooks Online. + +## How to resolve + +There are three different ways you can resolve this error: +- **Option 1**: Under **Settings > Workspaces > Workspace Name > Accounting > Configure > Export tab**, select a different type of export for reimbursable expenses. +- **Option 2**: Enable _Automatically Create Entities_ under **Settings > Workspaces > Workspace Name > Accounting > Configure > Advanced** to create vendor records automatically. +- **Option 3**: Manually create vendor records in QuickBooks Online for each employee. + +# Error: Items marked as billable must have sales information checked + +**Why does this happen?** + +This error occurs when an Item category on an expense does not have sales information in QuickBooks Online. + +## How to resolve + +1. Log into QuickBooks Online. +2. Navigate to your items list. +3. Click **Edit** to the right of the item used on the report with the error. Here you will see an option to check either "Sales" or "Purchasing". +4. Check the option for **Sales**. +5. Select an income account. +6. Save your changes. +7. Sync your QuickBooks Online connection in **Settings > Workspaces > Workspace Name > Accounting**. +8. Open the report, click on Details, and then click the Export button to re-export the data to QuickBooks Online. + +# Error: Couldn't Connect to QuickBooks Online + +**Why does this happen?** + +This error occurs when the QuickBooks Online credentials used to make the connection have changed. + +_Note: This error message can also show up as, "QuickBooks Reconnect error: OAuth Token rejected.”_ + +## How to resolve + +1. Navigate to **Settings > Workspaces > Workspace Name > Accounting**. +2. Click the **Sync Now** button. +3. In the pop-up window, click **Reconnect** and enter your current QuickBooks Online credentials. + +If you are connecting with new credentials, you will need to reconfigure your settings and re-select the categories and tags you want enabled. We recommend taking a screenshot of your configuration settings beforehand so that you can reset the connection with those settings. + +# Error: Duplicate Document Number, This bill number has already been used. + +**Why does this happen?** + +This error happens when QuickBooks Online is set to flag duplicate document numbers. + +## How to resolve + +1. Log into QuickBooks Online. +2. Navigate to Settings > Advanced. +3. Under the Other Preferences section, make sure "Warn if duplicate bill number is used" is set to "Off." +4. Sync your QuickBooks Online connection in **Settings > Workspaces > Workspace Name > Accounting**. +5. Open the report and click on Details, then the Export button to re-export the data to QuickBooks Online + +# Error: The transaction needs to be in the same currency as the A/R and A/P accounts + +**Why does this happen?** + +This error occurs because the currency on the Vendor record in QuickBooks Online doesn't match the currency on the A/P account. + +## How to resolve + +1. Log into QuickBooks Online. +2. Open the vendor record. +3. Update the record to use with the correct A/P account, currency, and email matching their Expensify email. + +_Note: You can find the correct Vendor record by exporting your QuickBooks Online vendor list to a spreadsheet (click the export icon on the right-hand side of the page), and searching for the email address of the person who submitted the report._ + +If you have multiple vendors with different currencies with the same email, Expensify is likely trying to export to the wrong one. + +**In that case, run through the following steps**: +1. Try removing the email address from the vendor in QuickBooks Online that you aren't trying to export to. +2. Sync your QuickBooks Online connection in **Settings > Workspaces > Workspace Name > Accounting**. +3. Open the report and click on Details, then the Export button to re-export the data to QuickBooks Online. + +**If this still fails, you'll need to confirm that the A/P account selected in Expensify is set to the correct currency for the export**: +1. Navigate to **Settings > Workspaces > Workspace Name > Accounting**. +2. Under the Exports tab check that both A/P accounts are the correct currency. + {% include faq-begin.md %} -**How do I disconnect the QuickBooks Online connection?** +# Why are company card expenses exported to the wrong account in QuickBooks Online? +Multiple factors could be causing your company card transactions to export to the wrong place in your accounting system, but the best place to start is always the same. + +1. Confirm that the company cards have been mapped to the correct accounts in Settings > Domains > Company Cards > click the **Edit Export** button for the card to view the account. +2. Make sure the expenses in question have been imported from the company card. + - Only expenses with the Card+Lock icon next to them will export according to the mapping settings that you configure in the domain settings. + +It’s important to note that expenses imported from a card linked at the individual account level, expenses created from a SmartScanned receipt, and manually created cash expenses will export to the default bank account selected in your accounting connection's configuration settings. + +**Is the report exporter a domain admin?** + +The user exporting the report must be a domain admin. You can check the history and comment section at the bottom of the report to see who exported the report: +- If your reports are being exported automatically by Concierge, the user listed as the Preferred Exporter under **Settings > Workspaces > Workspace Name > Accounting > Export** must also be a domain admin. +- If the report exporter is not a domain admin, all company card expenses will be exported to the account set in **Settings > Workspaces > Workspace Name > Accounting > Export Company Card Expenses As**. + +# How do I disconnect the QuickBooks Online connection? + +You can disconnect QuickBooks Online from Expensify by running through the following steps: 1. Click your profile image or icon in the bottom left menu. 2. Scroll down and click **Workspaces** in the left menu. 3. Select the workspace you want to disconnect from QuickBooks Online. 4. Click **Accounting** in the left menu. -5. Click the three dot menu icon to the right of QuickBooks Online and select **Disconnect**. +5. Click the three-dot menu icon to the right of QuickBooks Online and select **Disconnect**. 6. Click **Disconnect** to confirm. -You will no longer see the imported options from QuickBooks Online. +Once you disconnect from QuickBooks, that will clear all of the previously imported options from Expensify. + +# Can I export negative expenses to QuickBooks Online? + +Yes, in general, you can export negative expenses successfully to QuickBooks Online regardless of which export method you choose. {% include faq-end.md %} diff --git a/docs/articles/new-expensify/travel/manage-travel-member-roles.md b/docs/articles/new-expensify/travel/manage-travel-member-roles.md index 954e24550f05..059c367ba8ca 100644 --- a/docs/articles/new-expensify/travel/manage-travel-member-roles.md +++ b/docs/articles/new-expensify/travel/manage-travel-member-roles.md @@ -14,7 +14,7 @@ To assign a role to a travel member, 1. Click the + icon in the bottom left menu and select **Book travel**. 2. Click **Book or manage travel**. -3. Click the **Program** tab at the top and select Users. +3. Click the **Program** tab at the top and select **Users**. 4. Click the name of the member whose role you wish to update. 5. Click the **Roles** tab and select a role. - **Traveler**: Can only book travel for themselves. diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 5142f61f355e..ff4c9ff8c999 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.82.1 + 9.0.82.4 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index d748108d361f..aab81695759c 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 9.0.82.1 + 9.0.82.4 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 466e18b62984..c6c8a20f6285 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.0.82 CFBundleVersion - 9.0.82.1 + 9.0.82.4 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 1ec4c0cd21a0..f516193d5246 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1775,7 +1775,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-keyboard-controller (1.15.0): + - react-native-keyboard-controller (1.15.2): - DoubleConversion - glog - hermes-engine @@ -3307,7 +3307,7 @@ SPEC CHECKSUMS: react-native-geolocation: b9bd12beaf0ebca61a01514517ca8455bd26fa06 react-native-image-picker: ba5067f7d833b9081102c0a33dd0188eb21d92dc react-native-key-command: aae312752fcdfaa2240be9a015fc41ce54087546 - react-native-keyboard-controller: 3428e4761623fd6a242d9bf3573112f8ebe92238 + react-native-keyboard-controller: dbd7fb6a233505f937c9242d6d8bb5ebe659ec32 react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d react-native-netinfo: fb5112b1fa754975485884ae85a3fb6a684f49d5 react-native-pager-view: abc5ef92699233eb726442c7f452cac82f73d0cb diff --git a/package-lock.json b/package-lock.json index d220e0ea3d94..dda4462daaa2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.82-1", + "version": "9.0.82-4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.82-1", + "version": "9.0.82-4", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -93,7 +93,7 @@ "react-native-image-picker": "^7.1.2", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#cb392140db4953a283590d7cf93b4d0461baa2a9", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "1.15.0", + "react-native-keyboard-controller": "1.15.2", "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", @@ -32249,9 +32249,9 @@ "license": "MIT" }, "node_modules/react-native-keyboard-controller": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.15.0.tgz", - "integrity": "sha512-Laqszs0Uciu9MFkHurLwaHs9kftzUueew75HVOndbdcGR3MbKs2MqKdQEg1AgXSHcGoGg5nKafMOLVIoYjK6kA==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.15.2.tgz", + "integrity": "sha512-ZN151OyMJ2GQkhebARY/5G9rXgSlNCKy+WjS6p4o7S+5ulb4nGzl6UkpEuT7/C6bHDeAjDupdrET9tyyTee3nA==", "license": "MIT", "dependencies": { "react-native-is-edge-to-edge": "^1.1.6" diff --git a/package.json b/package.json index abc1076e55d5..db2a31db91d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.82-1", + "version": "9.0.82-4", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -158,7 +158,7 @@ "react-native-image-picker": "^7.1.2", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#cb392140db4953a283590d7cf93b4d0461baa2a9", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "1.15.0", + "react-native-keyboard-controller": "1.15.2", "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", diff --git a/patches/react-native+0.76.3+027+disable-status-bar-hiding-fixes-keyboard-flicker-in-modals.patch b/patches/react-native+0.76.3+027+disable-status-bar-hiding-fixes-keyboard-flicker-in-modals.patch deleted file mode 100644 index dd55ed2c88e8..000000000000 --- a/patches/react-native+0.76.3+027+disable-status-bar-hiding-fixes-keyboard-flicker-in-modals.patch +++ /dev/null @@ -1,17 +0,0 @@ -diff --git a/node_modules/react-native/Libraries/Components/StatusBar/StatusBar.js b/node_modules/react-native/Libraries/Components/StatusBar/StatusBar.js -index 53a01ef..d4de477 100644 ---- a/node_modules/react-native/Libraries/Components/StatusBar/StatusBar.js -+++ b/node_modules/react-native/Libraries/Components/StatusBar/StatusBar.js -@@ -456,9 +456,9 @@ class StatusBar extends React.Component { - mergedProps.backgroundColor.animated, - ); - } -- if (!oldProps || oldProps.hidden.value !== mergedProps.hidden.value) { -- NativeStatusBarManagerAndroid.setHidden(mergedProps.hidden.value); -- } -+ // if (!oldProps || oldProps.hidden.value !== mergedProps.hidden.value) { -+ // NativeStatusBarManagerAndroid.setHidden(mergedProps.hidden.value); -+ // } - // Activities are not translucent by default, so always set if true. - if ( - (oldProps && oldProps.translucent !== mergedProps.translucent) || diff --git a/patches/react-native+0.76.3+028+measureText-full-width-if-wraps.patch b/patches/react-native+0.76.3+027+measureText-full-width-if-wraps.patch similarity index 100% rename from patches/react-native+0.76.3+028+measureText-full-width-if-wraps.patch rename to patches/react-native+0.76.3+027+measureText-full-width-if-wraps.patch diff --git a/patches/react-native+0.76.3+029+fix-scroll-the-cursor-into-view-when-focus.patch b/patches/react-native+0.76.3+028+fix-scroll-the-cursor-into-view-when-focus.patch similarity index 100% rename from patches/react-native+0.76.3+029+fix-scroll-the-cursor-into-view-when-focus.patch rename to patches/react-native+0.76.3+028+fix-scroll-the-cursor-into-view-when-focus.patch diff --git a/patches/react-native+0.76.3+030+fix-crash-when-deleting-expense.patch b/patches/react-native+0.76.3+029+fix-crash-when-deleting-expense.patch similarity index 100% rename from patches/react-native+0.76.3+030+fix-crash-when-deleting-expense.patch rename to patches/react-native+0.76.3+029+fix-crash-when-deleting-expense.patch diff --git a/src/App.tsx b/src/App.tsx index cc824b78fa4c..de209d6f6631 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,11 +29,11 @@ import {FullScreenContextProvider} from './components/VideoPlayerContexts/FullSc import {PlaybackContextProvider} from './components/VideoPlayerContexts/PlaybackContext'; import {VideoPopoverMenuContextProvider} from './components/VideoPlayerContexts/VideoPopoverMenuContext'; import {VolumeContextProvider} from './components/VideoPlayerContexts/VolumeContext'; -import {CurrentReportIDContextProvider} from './components/withCurrentReportID'; import {EnvironmentProvider} from './components/withEnvironment'; import {KeyboardStateProvider} from './components/withKeyboardState'; import CONFIG from './CONFIG'; import Expensify from './Expensify'; +import {CurrentReportIDContextProvider} from './hooks/useCurrentReportID'; import useDefaultDragAndDrop from './hooks/useDefaultDragAndDrop'; import {ReportIDsContextProvider} from './hooks/useReportIDs'; import OnyxUpdateManager from './libs/actions/OnyxUpdateManager'; diff --git a/src/CONST.ts b/src/CONST.ts index 3242921fad6b..ea5923ddb706 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -866,6 +866,7 @@ const CONST = { EMPTY_ARRAY, EMPTY_OBJECT, DEFAULT_NUMBER_ID: 0, + EMPTY_STRING: '', USE_EXPENSIFY_URL, EXPENSIFY_URL, GOOGLE_MEET_URL_ANDROID: 'https://meet.google.com', @@ -926,6 +927,7 @@ const CONST = { ADMIN_TOUR_STAGING: 'https://expensify.navattic.com/3i300k18', EMPLOYEE_TOUR_PRODUCTION: 'https://expensify.navattic.com/35609gb', EMPLOYEE_TOUR_STAGING: 'https://expensify.navattic.com/cf15002s', + COMPLETED: 'completed', }, OLD_DOT_PUBLIC_URLS: { TERMS_URL: `${EXPENSIFY_URL}/terms`, @@ -1469,8 +1471,8 @@ const CONST = { // at least 8 characters, 1 capital letter, 1 lowercase number, 1 number PASSWORD_COMPLEXITY_REGEX_STRING: '^(?=.*[A-Z])(?=.*[0-9])(?=.*[a-z]).{8,}$', - // 6 numeric digits - VALIDATE_CODE_REGEX_STRING: /^\d{6}$/, + // We allow either 6 digits for validated users or 9-character base26 for unvalidated users + VALIDATE_CODE_REGEX_STRING: /^\d{6}$|^[A-Z]{9}$/, // 8 alphanumeric characters RECOVERY_CODE_REGEX_STRING: /^[a-zA-Z0-9]{8}$/, @@ -1676,6 +1678,16 @@ const CONST = { STUDENT_AMBASSADOR: 'studentambassadors@expensify.com', SVFG: 'svfg@expensify.com', EXPENSIFY_EMAIL_DOMAIN: '@expensify.com', + EXPENSIFY_TEAM_EMAIL_DOMAIN: '@team.expensify.com', + }, + + FULL_STORY: { + MASK: 'fs-mask', + UNMASK: 'fs-unmask', + CUSTOMER: 'customer', + CONCIERGE: 'concierge', + OTHER: 'other', + WEB_PROP_ATTR: 'data-testid', }, CONCIERGE_DISPLAY_NAME: 'Concierge', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 020eb5262200..6b26ecd73700 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -115,6 +115,9 @@ const ONYXKEYS = { STASHED_SESSION: 'stashedSession', BETAS: 'betas', + /** Whether the user is a member of a policy other than their personal */ + HAS_NON_PERSONAL_POLICY: 'hasNonPersonalPolicy', + /** NVP keys */ /** This NVP contains list of at most 5 recent attendees */ @@ -944,6 +947,7 @@ type OnyxValuesMapping = { [ONYXKEYS.LAST_EXPORT_METHOD]: OnyxTypes.LastExportMethod; [ONYXKEYS.NVP_RECENT_WAYPOINTS]: OnyxTypes.RecentWaypoint[]; [ONYXKEYS.NVP_INTRO_SELECTED]: OnyxTypes.IntroSelected; + [ONYXKEYS.HAS_NON_PERSONAL_POLICY]: boolean; [ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES]: OnyxTypes.LastSelectedDistanceRates; [ONYXKEYS.NVP_SEEN_NEW_USER_MODAL]: boolean; [ONYXKEYS.PUSH_NOTIFICATIONS_ENABLED]: boolean; diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx index 4cbf85cb0014..3f1d78eae06b 100644 --- a/src/components/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs.tsx @@ -52,6 +52,7 @@ function Breadcrumbs({breadcrumbs, style}: BreadcrumbsProps) { height={variables.lhnLogoHeight * fontScale} /> } + style={styles.justifyContentCenter} shouldShowEnvironmentBadge />
diff --git a/src/components/ExplanationModal.tsx b/src/components/ExplanationModal.tsx index 9c44bc4d0fd3..d846dd4d28ba 100644 --- a/src/components/ExplanationModal.tsx +++ b/src/components/ExplanationModal.tsx @@ -1,19 +1,11 @@ import React from 'react'; -import {useOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import * as Welcome from '@userActions/Welcome'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import FeatureTrainingModal from './FeatureTrainingModal'; function ExplanationModal() { const {translate} = useLocalize(); - const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRYNEWDOT); - const hasBeenAddedToNudgeMigration = !!tryNewDot?.nudgeMigration?.timestamp; - - if (hasBeenAddedToNudgeMigration) { - return null; - } return ( ; + /** Additional header styles */ + style?: StyleProp; + /** Additional header container styles */ containerStyles?: StyleProp; @@ -27,7 +30,7 @@ type HeaderProps = { subTitleLink?: string; }; -function Header({title = '', subtitle = '', textStyles = [], containerStyles = [], shouldShowEnvironmentBadge = false, subTitleLink = ''}: HeaderProps) { +function Header({title = '', subtitle = '', textStyles = [], style, containerStyles = [], shouldShowEnvironmentBadge = false, subTitleLink = ''}: HeaderProps) { const styles = useThemeStyles(); const renderedSubtitle = useMemo( () => ( @@ -65,7 +68,7 @@ function Header({title = '', subtitle = '', textStyles = [], containerStyles = [ return ( - + {typeof title === 'string' ? !!title && ( ( initialFocusedIndex: flattenedSections.allOptions.findIndex((option) => option.keyForList === initiallyFocusedOptionKey), maxIndex: Math.min(flattenedSections.allOptions.length - 1, CONST.MAX_SELECTION_LIST_PAGE_LENGTH * currentPage - 1), disabledIndexes: disabledArrowKeyIndexes, - isActive: isFocused, + isActive: true, onFocusedIndexChange: (index: number) => { const focusedItem = flattenedSections.allOptions.at(index); if (focusedItem) { @@ -333,14 +333,15 @@ function BaseSelectionList( isFocused, }); + const selectedItemIndex = useMemo(() => flattenedSections.allOptions.findIndex((option) => option.isSelected), [flattenedSections.allOptions]); + useEffect(() => { - const selectedItemIndex = flattenedSections.allOptions.findIndex((option) => option.isSelected); if (selectedItemIndex === -1 || selectedItemIndex === focusedIndex) { return; } setFocusedIndex(selectedItemIndex); // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [flattenedSections]); + }, [selectedItemIndex]); const clearInputAfterSelect = useCallback(() => { onChangeText?.(''); diff --git a/src/components/SelectionList/Search/CardListItem.tsx b/src/components/SelectionList/Search/CardListItem.tsx index 136196854d62..5c40d11b2ddb 100644 --- a/src/components/SelectionList/Search/CardListItem.tsx +++ b/src/components/SelectionList/Search/CardListItem.tsx @@ -57,8 +57,8 @@ function CardListItem({ }; const subtitleText = - `${item.cardName ? `${item.cardName}` : ''}` + - `${item.lastFourPAN ? ` ${CONST.DOT_SEPARATOR} ${item.lastFourPAN}` : ''}` + + `${item.lastFourPAN ? `${item.lastFourPAN}` : ''}` + + `${item.cardName ? ` ${CONST.DOT_SEPARATOR} ${item.cardName}` : ''}` + `${item.isVirtual ? ` ${CONST.DOT_SEPARATOR} ${translate('workspace.expensifyCard.virtual')}` : ''}`; return ( diff --git a/src/components/VideoPlayer/BaseVideoPlayer.tsx b/src/components/VideoPlayer/BaseVideoPlayer.tsx index 3df4a914f2d9..b0d9bcc44485 100644 --- a/src/components/VideoPlayer/BaseVideoPlayer.tsx +++ b/src/components/VideoPlayer/BaseVideoPlayer.tsx @@ -342,8 +342,21 @@ function BaseVideoPlayer({ shareVideoPlayerElements(videoPlayerRef.current, videoPlayerElementParentRef.current, videoPlayerElementRef.current, isUploading || isFullScreenRef.current); }, [currentlyPlayingURL, shouldUseSharedVideoElement, shareVideoPlayerElements, url, isUploading, isFullScreenRef]); + // Call bindFunctions() through the refs to avoid adding it to the dependency array of the DOM mutation effect, as doing so would change the DOM when the functions update. + const bindFunctionsRef = useRef<(() => void) | null>(null); + const shouldBindFunctionsRef = useRef(false); + + useEffect(() => { + bindFunctionsRef.current = bindFunctions; + if (shouldBindFunctionsRef.current) { + bindFunctions(); + } + }, [bindFunctions]); + // append shared video element to new parent (used for example in attachment modal) useEffect(() => { + shouldBindFunctionsRef.current = false; + if (url !== currentlyPlayingURL || !sharedElement || isFullScreenRef.current) { return; } @@ -360,7 +373,8 @@ function BaseVideoPlayer({ videoPlayerRef.current = currentVideoPlayerRef.current; if (currentlyPlayingURL === url && newParentRef && 'appendChild' in newParentRef) { newParentRef.appendChild(sharedElement as HTMLDivElement); - bindFunctions(); + bindFunctionsRef.current?.(); + shouldBindFunctionsRef.current = true; } return () => { if (!originalParent || !('appendChild' in originalParent)) { @@ -373,7 +387,7 @@ function BaseVideoPlayer({ } newParentRef.childNodes[0]?.remove(); }; - }, [bindFunctions, currentVideoPlayerRef, currentlyPlayingURL, isFullScreenRef, originalParent, sharedElement, shouldUseSharedVideoElement, url]); + }, [currentVideoPlayerRef, currentlyPlayingURL, isFullScreenRef, originalParent, sharedElement, shouldUseSharedVideoElement, url]); useEffect(() => { if (!shouldPlay) { diff --git a/src/components/withCurrentReportID.tsx b/src/components/withCurrentReportID.tsx deleted file mode 100644 index b551d321eb6c..000000000000 --- a/src/components/withCurrentReportID.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import type {NavigationState} from '@react-navigation/native'; -import type {ComponentType, ForwardedRef, RefAttributes} from 'react'; -import React, {createContext, forwardRef, useCallback, useMemo, useState} from 'react'; -import getComponentDisplayName from '@libs/getComponentDisplayName'; -import Navigation from '@libs/Navigation/Navigation'; - -type CurrentReportIDContextValue = { - updateCurrentReportID: (state: NavigationState) => void; - currentReportID: string; -}; - -type CurrentReportIDContextProviderProps = { - /** Actual content wrapped by this component */ - children: React.ReactNode; -}; - -const CurrentReportIDContext = createContext(null); - -const withCurrentReportIDDefaultProps = { - currentReportID: '', -}; - -function CurrentReportIDContextProvider(props: CurrentReportIDContextProviderProps) { - const [currentReportID, setCurrentReportID] = useState(''); - - /** - * This function is used to update the currentReportID - * @param state root navigation state - */ - const updateCurrentReportID = useCallback( - (state: NavigationState) => { - const reportID = Navigation.getTopmostReportId(state) ?? '-1'; - - /* - * 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; - } - setCurrentReportID(reportID); - }, - [setCurrentReportID], - ); - - /** - * The context this component exposes to child components - * @returns currentReportID to share between central pane and LHN - */ - const contextValue = useMemo( - (): CurrentReportIDContextValue => ({ - updateCurrentReportID, - currentReportID, - }), - [updateCurrentReportID, currentReportID], - ); - - return {props.children}; -} - -CurrentReportIDContextProvider.displayName = 'CurrentReportIDContextProvider'; - -export default function withCurrentReportID( - WrappedComponent: ComponentType>, -): (props: Omit & React.RefAttributes) => React.ReactElement | null { - function WithCurrentReportID(props: Omit, ref: ForwardedRef) { - return ( - - {(currentReportIDUtils) => ( - - )} - - ); - } - - WithCurrentReportID.displayName = `withCurrentReportID(${getComponentDisplayName(WrappedComponent)})`; - - return forwardRef(WithCurrentReportID); -} - -export {withCurrentReportIDDefaultProps, CurrentReportIDContextProvider, CurrentReportIDContext}; -export type {CurrentReportIDContextValue}; diff --git a/src/hooks/useCurrentReportID.tsx b/src/hooks/useCurrentReportID.tsx index d2934cd65b62..9b6f25f834cd 100644 --- a/src/hooks/useCurrentReportID.tsx +++ b/src/hooks/useCurrentReportID.tsx @@ -1,7 +1,63 @@ -import {useContext} from 'react'; -import type {CurrentReportIDContextValue} from '@components/withCurrentReportID'; -import {CurrentReportIDContext} from '@components/withCurrentReportID'; +import type {NavigationState} from '@react-navigation/native'; +import React, {createContext, useCallback, useContext, useMemo, useState} from 'react'; +import Navigation from '@libs/Navigation/Navigation'; + +type CurrentReportIDContextValue = { + updateCurrentReportID: (state: NavigationState) => void; + currentReportID: string | undefined; +}; + +type CurrentReportIDContextProviderProps = { + /** Actual content wrapped by this component */ + children: React.ReactNode; +}; + +const CurrentReportIDContext = createContext(null); + +function CurrentReportIDContextProvider(props: CurrentReportIDContextProviderProps) { + const [currentReportID, setCurrentReportID] = useState(''); + + /** + * This function is used to update the currentReportID + * @param state root navigation state + */ + const updateCurrentReportID = useCallback( + (state: NavigationState) => { + const reportID = Navigation.getTopmostReportId(state); + + /* + * 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; + } + setCurrentReportID(reportID); + }, + [setCurrentReportID], + ); + + /** + * The context this component exposes to child components + * @returns currentReportID to share between central pane and LHN + */ + const contextValue = useMemo( + (): CurrentReportIDContextValue => ({ + updateCurrentReportID, + currentReportID, + }), + [updateCurrentReportID, currentReportID], + ); + + return {props.children}; +} + +CurrentReportIDContextProvider.displayName = 'CurrentReportIDContextProvider'; export default function useCurrentReportID(): CurrentReportIDContextValue | null { return useContext(CurrentReportIDContext); } + +export {CurrentReportIDContextProvider}; +export type {CurrentReportIDContextValue}; diff --git a/src/languages/en.ts b/src/languages/en.ts index 90d97737bead..d44eb3e79cbe 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -523,6 +523,7 @@ const translations = { chooseDocument: 'Choose file', attachmentTooLarge: 'Attachment is too large', sizeExceeded: 'Attachment size is larger than 24 MB limit', + sizeExceededWithLimit: ({maxUploadSizeInMB}: SizeExceededParams) => `Attachment size is larger than ${maxUploadSizeInMB} MB limit`, attachmentTooSmall: 'Attachment is too small', sizeNotMet: 'Attachment size must be greater than 240 bytes', wrongFileType: 'Invalid file type', diff --git a/src/languages/es.ts b/src/languages/es.ts index fcabf024b646..5eb8fd4cff78 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -518,6 +518,7 @@ const translations = { chooseDocument: 'Elegir un archivo', attachmentTooLarge: 'Archivo adjunto demasiado grande', sizeExceeded: 'El archivo adjunto supera el límite de 24 MB.', + sizeExceededWithLimit: ({maxUploadSizeInMB}: SizeExceededParams) => `El archivo adjunto supera el límite de ${maxUploadSizeInMB} MB.`, attachmentTooSmall: 'Archivo adjunto demasiado pequeño', sizeNotMet: 'El archivo adjunto debe ser más grande que 240 bytes.', wrongFileType: 'Tipo de archivo inválido', diff --git a/src/libs/Fullstory/index.native.ts b/src/libs/Fullstory/index.native.ts index 30a5a77ae9f3..ad4cfb31f4d3 100644 --- a/src/libs/Fullstory/index.native.ts +++ b/src/libs/Fullstory/index.native.ts @@ -1,8 +1,9 @@ import FullStory, {FSPage} from '@fullstory/react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import {isConciergeChatReport, shouldUnmaskChat} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import * as Environment from '@src/libs/Environment/Environment'; -import type {UserMetadata} from '@src/types/onyx'; +import type {OnyxInputOrEntry, PersonalDetailsList, Report, UserMetadata} from '@src/types/onyx'; /** * Fullstory React-Native lib adapter @@ -63,5 +64,45 @@ const FS = { }, }; +/** + * Placeholder function for Mobile-Web compatibility. + */ +function parseFSAttributes(): void { + // pass +} + +/* + prefix? if component name should be used as a prefix, + in case data-test-id attribute usage, + clean component name should be preserved in data-test-id. +*/ +function getFSAttributes(name: string, mask: boolean, prefix: boolean): string { + if (!name && !prefix) { + return `${mask ? CONST.FULL_STORY.MASK : CONST.FULL_STORY.UNMASK}`; + } + // prefixed for Native apps should contain only component name + if (prefix) { + return name; + } + + return `${name},${mask ? CONST.FULL_STORY.MASK : CONST.FULL_STORY.UNMASK}`; +} + +function getChatFSAttributes(context: OnyxEntry, name: string, report: OnyxInputOrEntry): string[] { + if (!name) { + return ['', '']; + } + if (isConciergeChatReport(report)) { + const formattedName = `${CONST.FULL_STORY.CONCIERGE}-${name}`; + return [`${formattedName}`, `${CONST.FULL_STORY.UNMASK},${formattedName}`]; + } + if (shouldUnmaskChat(context, report)) { + const formattedName = `${CONST.FULL_STORY.CUSTOMER}-${name}`; + return [`${formattedName}`, `${CONST.FULL_STORY.UNMASK},${formattedName}`]; + } + const formattedName = `${CONST.FULL_STORY.OTHER}-${name}`; + return [`${formattedName}`, `${CONST.FULL_STORY.MASK},${formattedName}`]; +} + export default FS; -export {FSPage}; +export {FSPage, parseFSAttributes, getFSAttributes, getChatFSAttributes}; diff --git a/src/libs/Fullstory/index.ts b/src/libs/Fullstory/index.ts index 0aa0b2094591..39d2d7e310e5 100644 --- a/src/libs/Fullstory/index.ts +++ b/src/libs/Fullstory/index.ts @@ -1,10 +1,79 @@ import {FullStory, init, isInitialized} from '@fullstory/browser'; import type {OnyxEntry} from 'react-native-onyx'; +import {isConciergeChatReport, shouldUnmaskChat} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import * as Environment from '@src/libs/Environment/Environment'; -import type {UserMetadata} from '@src/types/onyx'; +import type {OnyxInputOrEntry, PersonalDetailsList, Report, UserMetadata} from '@src/types/onyx'; import type NavigationProperties from './types'; +/** + * Extract values from non-scraped at build time attribute WEB_PROP_ATTR, + * reevaluate "fs-class". + */ +function parseFSAttributes(): void { + window?.document?.querySelectorAll(`[${CONST.FULL_STORY.WEB_PROP_ATTR}]`).forEach((o) => { + const attr = o.getAttribute(CONST.FULL_STORY.WEB_PROP_ATTR) ?? ''; + if (!/fs-/gim.test(attr)) { + return; + } + + const fsAttrs = attr.match(/fs-[a-zA-Z0-9_-]+/g) ?? []; + o.setAttribute('fs-class', fsAttrs.join(',')); + + let cleanedAttrs = attr; + fsAttrs.forEach((fsAttr) => { + cleanedAttrs = cleanedAttrs.replace(fsAttr, ''); + }); + + cleanedAttrs = cleanedAttrs + .replace(/,+/g, ',') + .replace(/\s*,\s*/g, ',') + .replace(/^,+|,+$/g, '') + .replace(/\s+/g, ' ') + .trim(); + + if (cleanedAttrs) { + o.setAttribute(CONST.FULL_STORY.WEB_PROP_ATTR, cleanedAttrs); + } else { + o.removeAttribute(CONST.FULL_STORY.WEB_PROP_ATTR); + } + }); +} + +/* + prefix? if component name should be used as a prefix, + in case data-test-id attribute usage, + clean component name should be preserved in data-test-id. +*/ +function getFSAttributes(name: string, mask: boolean, prefix: boolean): string { + if (!name) { + return `${mask ? CONST.FULL_STORY.MASK : CONST.FULL_STORY.UNMASK}`; + } + + if (prefix) { + return `${name},${mask ? CONST.FULL_STORY.MASK : CONST.FULL_STORY.UNMASK}`; + } + + return `${name}`; +} + +function getChatFSAttributes(context: OnyxEntry, name: string, report: OnyxInputOrEntry): string[] { + if (!name) { + return ['', '']; + } + if (isConciergeChatReport(report)) { + const formattedName = `${CONST.FULL_STORY.CONCIERGE}-${name}`; + return [`${formattedName},${CONST.FULL_STORY.UNMASK}`, `${formattedName}`]; + } + if (shouldUnmaskChat(context, report)) { + const formattedName = `${CONST.FULL_STORY.CUSTOMER}-${name}`; + return [`${formattedName},${CONST.FULL_STORY.UNMASK}`, `${formattedName}`]; + } + + const formattedName = `${CONST.FULL_STORY.OTHER}-${name}`; + return [`${formattedName},${CONST.FULL_STORY.MASK}`, `${formattedName}`]; +} + // Placeholder Browser API does not support Manual Page definition class FSPage { private pageName; @@ -16,7 +85,9 @@ class FSPage { this.properties = properties; } - start() {} + start() { + parseFSAttributes(); + } } /** @@ -93,4 +164,4 @@ const FS = { }; export default FS; -export {FSPage}; +export {FSPage, parseFSAttributes, getFSAttributes, getChatFSAttributes}; diff --git a/src/libs/Middleware/SaveResponseInOnyx.ts b/src/libs/Middleware/SaveResponseInOnyx.ts index 12c1931b0199..1a092f131e04 100644 --- a/src/libs/Middleware/SaveResponseInOnyx.ts +++ b/src/libs/Middleware/SaveResponseInOnyx.ts @@ -25,13 +25,16 @@ const SaveResponseInOnyx: Middleware = (requestResponse, request) => const responseToApply = { type: CONST.ONYX_UPDATE_TYPES.HTTPS, - lastUpdateID: Number(response?.lastUpdateID ?? 0), - previousUpdateID: Number(response?.previousUpdateID ?? 0), + lastUpdateID: Number(response?.lastUpdateID ?? CONST.DEFAULT_NUMBER_ID), + previousUpdateID: Number(response?.previousUpdateID ?? CONST.DEFAULT_NUMBER_ID), request, response: response ?? {}, }; - if (requestsToIgnoreLastUpdateID.includes(request.command) || !OnyxUpdates.doesClientNeedToBeUpdated(Number(response?.previousUpdateID ?? 0))) { + if ( + requestsToIgnoreLastUpdateID.includes(request.command) || + !OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: Number(response?.previousUpdateID ?? CONST.DEFAULT_NUMBER_ID)}) + ) { return OnyxUpdates.apply(responseToApply); } diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx index 74fb83aa7e01..80165073fda8 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx @@ -70,7 +70,7 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {activeWorkspaceID} = useActiveWorkspace(); - const {currentReportID} = useCurrentReportID() ?? {currentReportID: null}; + const {currentReportID = null} = useCurrentReportID() ?? {}; const [user] = useOnyx(ONYXKEYS.USER); const [betas] = useOnyx(ONYXKEYS.BETAS); const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index b19635a77fdb..c044422a7cc7 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -12,7 +12,7 @@ import useThemePreference from '@hooks/useThemePreference'; import Firebase from '@libs/Firebase'; import {FSPage} from '@libs/Fullstory'; import Log from '@libs/Log'; -import {hasCompletedGuidedSetupFlowSelector} from '@libs/onboardingSelectors'; +import {hasCompletedGuidedSetupFlowSelector, wasInvitedToNewDotSelector} from '@libs/onboardingSelectors'; import {getPathFromURL} from '@libs/Url'; import {updateLastVisitedPath} from '@userActions/App'; import * as Session from '@userActions/Session'; @@ -98,6 +98,10 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh const [isOnboardingCompleted = true] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { selector: hasCompletedGuidedSetupFlowSelector, }); + const [wasInvitedToNewDot = false] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, { + selector: wasInvitedToNewDotSelector, + }); + const [hasNonPersonalPolicy] = useOnyx(ONYXKEYS.HAS_NON_PERSONAL_POLICY); const initialState = useMemo(() => { if (!user || user.isFromPublicDomain) { @@ -105,8 +109,8 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh } // If the user haven't completed the flow, we want to always redirect them to the onboarding flow. - // We also make sure that the user is authenticated. - if (!NativeModules.HybridAppModule && !isOnboardingCompleted && authenticated && !shouldShowRequire2FAModal) { + // We also make sure that the user is authenticated, isn't part of a group workspace, & wasn't invited to NewDot. + if (!NativeModules.HybridAppModule && !hasNonPersonalPolicy && !isOnboardingCompleted && !wasInvitedToNewDot && authenticated && !shouldShowRequire2FAModal) { const {adaptedState} = getAdaptedStateFromPath(getOnboardingInitialPath(isPrivateDomain), linkingConfig.config); return adaptedState; } diff --git a/src/libs/Notification/LocalNotification/BrowserNotifications.ts b/src/libs/Notification/LocalNotification/BrowserNotifications.ts index 2a963b8bc6c9..04f45411dce1 100644 --- a/src/libs/Notification/LocalNotification/BrowserNotifications.ts +++ b/src/libs/Notification/LocalNotification/BrowserNotifications.ts @@ -6,6 +6,7 @@ import * as AppUpdate from '@libs/actions/AppUpdate'; import ModifiedExpenseMessage from '@libs/ModifiedExpenseMessage'; import {getTextFromHtml} from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import type {Report, ReportAction} from '@src/types/onyx'; import focusApp from './focusApp'; import type {LocalNotificationClickHandler, LocalNotificationData} from './types'; @@ -65,9 +66,12 @@ function push( body, icon: String(icon), data, - silent, + silent: true, tag, }); + if (!silent) { + playSound(SOUNDS.RECEIVE); + } notificationCache[notificationID].onclick = () => { onClick(); window.parent.focus(); @@ -122,7 +126,7 @@ export default { reportID: report.reportID, }; - push(title, body, icon, data, onClick, true); + push(title, body, icon, data, onClick); }, pushModifiedExpenseNotification(report: Report, reportAction: ReportAction, onClick: LocalNotificationClickHandler, usesIcon = false) { diff --git a/src/libs/Notification/PushNotification/NotificationType.ts b/src/libs/Notification/PushNotification/NotificationType.ts index 0ae8d9cc488e..28d98c7aec42 100644 --- a/src/libs/Notification/PushNotification/NotificationType.ts +++ b/src/libs/Notification/PushNotification/NotificationType.ts @@ -19,6 +19,7 @@ type BasePushNotificationData = { onyxData?: OnyxServerUpdate[]; lastUpdateID?: number; previousUpdateID?: number; + hasPendingOnyxUpdates?: boolean; }; type ReportActionPushNotificationData = BasePushNotificationData & { diff --git a/src/libs/Notification/PushNotification/subscribePushNotification/index.ts b/src/libs/Notification/PushNotification/subscribePushNotification/index.ts index 61af079f9ed1..237a615b570a 100644 --- a/src/libs/Notification/PushNotification/subscribePushNotification/index.ts +++ b/src/libs/Notification/PushNotification/subscribePushNotification/index.ts @@ -43,12 +43,12 @@ function getLastUpdateIDAppliedToClient(): Promise { return new Promise((resolve) => { Onyx.connect({ key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, - callback: (value) => resolve(value ?? 0), + callback: (value) => resolve(value ?? CONST.DEFAULT_NUMBER_ID), }); }); } -function applyOnyxData({reportID, reportActionID, onyxData, lastUpdateID, previousUpdateID}: ReportActionPushNotificationData): Promise { +function applyOnyxData({reportID, reportActionID, onyxData, lastUpdateID, previousUpdateID, hasPendingOnyxUpdates = false}: ReportActionPushNotificationData): Promise { Log.info(`[PushNotification] Applying onyx data in the ${Visibility.isVisible() ? 'foreground' : 'background'}`, false, {reportID, reportActionID}); if (!ActiveClientManager.isClientTheLeader()) { @@ -56,30 +56,56 @@ function applyOnyxData({reportID, reportActionID, onyxData, lastUpdateID, previo return Promise.resolve(); } - if (!onyxData || !lastUpdateID || !previousUpdateID) { - Log.hmmm("[PushNotification] didn't apply onyx updates because some data is missing", {lastUpdateID, previousUpdateID, onyxDataCount: onyxData?.length ?? 0}); - return Promise.resolve(); - } + const logMissingOnyxDataInfo = (isDataMissing: boolean): boolean => { + if (isDataMissing) { + Log.hmmm("[PushNotification] didn't apply onyx updates because some data is missing", {lastUpdateID, previousUpdateID, onyxDataCount: onyxData?.length ?? 0}); + return false; + } - Log.info('[PushNotification] reliable onyx update received', false, {lastUpdateID, previousUpdateID, onyxDataCount: onyxData?.length ?? 0}); - const updates: OnyxUpdatesFromServer = { - type: CONST.ONYX_UPDATE_TYPES.AIRSHIP, - lastUpdateID, - previousUpdateID, - updates: [ - { - eventType: '', // This is only needed for Pusher events - data: onyxData, - }, - ], + Log.info('[PushNotification] reliable onyx update received', false, {lastUpdateID, previousUpdateID, onyxDataCount: onyxData?.length ?? 0}); + return true; }; + let updates: OnyxUpdatesFromServer; + if (hasPendingOnyxUpdates) { + const isDataMissing = !lastUpdateID; + logMissingOnyxDataInfo(isDataMissing); + if (isDataMissing) { + return Promise.resolve(); + } + + updates = { + type: CONST.ONYX_UPDATE_TYPES.AIRSHIP, + lastUpdateID, + shouldFetchPendingUpdates: true, + updates: [], + }; + } else { + const isDataMissing = !lastUpdateID || !onyxData || !previousUpdateID; + logMissingOnyxDataInfo(isDataMissing); + if (isDataMissing) { + return Promise.resolve(); + } + + updates = { + type: CONST.ONYX_UPDATE_TYPES.AIRSHIP, + lastUpdateID, + previousUpdateID, + updates: [ + { + eventType: '', // This is only needed for Pusher events + data: onyxData, + }, + ], + }; + } + /** * When this callback runs in the background on Android (via Headless JS), no other Onyx.connect callbacks will run. This means that * lastUpdateIDAppliedToClient will NOT be populated in other libs. To workaround this, we manually read the value here * and pass it as a param */ - return getLastUpdateIDAppliedToClient().then((lastUpdateIDAppliedToClient) => applyOnyxUpdatesReliably(updates, true, lastUpdateIDAppliedToClient)); + return getLastUpdateIDAppliedToClient().then((lastUpdateIDAppliedToClient) => applyOnyxUpdatesReliably(updates, {shouldRunSync: true, clientLastUpdateID: lastUpdateIDAppliedToClient})); } function navigateToReport({reportID, reportActionID}: ReportActionPushNotificationData): Promise { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 54e7e6a330a7..8a5ae8b1d102 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -8637,6 +8637,53 @@ function hasInvoiceReports() { return reports.some((report) => isInvoiceReport(report)); } +function shouldUnmaskChat(participantsContext: OnyxEntry, report: OnyxInputOrEntry): boolean { + if (!report?.participants) { + return true; + } + + if (isThread(report) && report?.chatType && report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT) { + return true; + } + + if (isThread(report) && report?.type === CONST.REPORT.TYPE.EXPENSE) { + return true; + } + + const participantAccountIDs = Object.keys(report.participants); + + if (participantAccountIDs.length > 2) { + return false; + } + + if (participantsContext) { + let teamInChat = false; + let userInChat = false; + + for (const participantAccountID of participantAccountIDs) { + const id = Number(participantAccountID); + const contextAccountData = participantsContext[id]; + + if (contextAccountData) { + const login = contextAccountData.login ?? ''; + + if (login.endsWith(CONST.EMAIL.EXPENSIFY_EMAIL_DOMAIN) || login.endsWith(CONST.EMAIL.EXPENSIFY_TEAM_EMAIL_DOMAIN)) { + teamInChat = true; + } else { + userInChat = true; + } + } + } + + // exclude teamOnly chat + if (teamInChat && userInChat) { + return true; + } + } + + return false; +} + function getReportMetadata(reportID: string | undefined) { return reportID ? allReportMetadataKeyValue[reportID] : undefined; } @@ -8963,6 +9010,7 @@ export { getAllReportErrors, getAllReportActionsErrorsAndReportActionThatRequiresAttention, hasInvoiceReports, + shouldUnmaskChat, getReportMetadata, buildOptimisticSelfDMReport, isHiddenForCurrentUser, diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index fac02bd2b4ca..ea1ecf319cc2 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -390,8 +390,8 @@ function isReservedRoomName(roomName: string): boolean { /** * Checks if the room name already exists. */ -function isExistingRoomName(roomName: string, reports: OnyxCollection, policyID: string): boolean { - return Object.values(reports ?? {}).some((report) => report && report.policyID === policyID && report.reportName === roomName); +function isExistingRoomName(roomName: string, reports: OnyxCollection, policyID: string | undefined): boolean { + return Object.values(reports ?? {}).some((report) => report && policyID && report.policyID === policyID && report.reportName === roomName); } /** diff --git a/src/libs/actions/OnyxUpdateManager/index.ts b/src/libs/actions/OnyxUpdateManager/index.ts index 085e05b0a449..dad3e4b15f35 100644 --- a/src/libs/actions/OnyxUpdateManager/index.ts +++ b/src/libs/actions/OnyxUpdateManager/index.ts @@ -6,6 +6,7 @@ import * as NetworkStore from '@libs/Network/NetworkStore'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; import * as App from '@userActions/App'; import updateSessionAuthTokens from '@userActions/Session/updateSessionAuthTokens'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OnyxUpdatesFromServer, Session} from '@src/types/onyx'; import {isValidOnyxUpdateFromServer} from '@src/types/onyx/OnyxUpdatesFromServer'; @@ -27,10 +28,10 @@ import * as DeferredOnyxUpdates from './utils/DeferredOnyxUpdates'; // The circular dependency happens because this file calls API.GetMissingOnyxUpdates() which uses the SaveResponseInOnyx.js file (as a middleware). // Therefore, SaveResponseInOnyx.js can't import and use this file directly. -let lastUpdateIDAppliedToClient = 0; +let lastUpdateIDAppliedToClient: number = CONST.DEFAULT_NUMBER_ID; Onyx.connect({ key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, - callback: (value) => (lastUpdateIDAppliedToClient = value ?? 0), + callback: (value) => (lastUpdateIDAppliedToClient = value ?? CONST.DEFAULT_NUMBER_ID), }); let isLoadingApp = false; @@ -48,6 +49,7 @@ const createQueryPromiseWrapper = () => }); // eslint-disable-next-line import/no-mutable-exports let queryPromiseWrapper = createQueryPromiseWrapper(); +let isFetchingForPendingUpdates = false; const resetDeferralLogicVariables = () => { DeferredOnyxUpdates.clear({shouldUnpauseSequentialQueue: false}); @@ -61,18 +63,19 @@ function finalizeUpdatesAndResumeQueue() { queryPromiseWrapper = createQueryPromiseWrapper(); DeferredOnyxUpdates.clear(); + isFetchingForPendingUpdates = false; } /** - * - * @param onyxUpdatesFromServer + * Triggers the fetching process of either pending or missing updates. + * @param onyxUpdatesFromServer the current update that is supposed to be applied * @param clientLastUpdateID an optional override for the lastUpdateIDAppliedToClient * @returns */ -function handleOnyxUpdateGap(onyxUpdatesFromServer: OnyxEntry, clientLastUpdateID?: number) { +function handleMissingOnyxUpdates(onyxUpdatesFromServer: OnyxEntry, clientLastUpdateID?: number) { // If isLoadingApp is positive it means that OpenApp command hasn't finished yet, and in that case - // we don't have base state of the app (reports, policies, etc) setup. If we apply this update, - // we'll only have them overriten by the openApp response. So let's skip it and return. + // we don't have base state of the app (reports, policies, etc.) setup. If we apply this update, + // we'll only have them overwritten by the openApp response. So let's skip it and return. if (isLoadingApp) { // When ONYX_UPDATES_FROM_SERVER is set, we pause the queue. Let's unpause // it so the app is not stuck forever without processing requests. @@ -96,48 +99,81 @@ function handleOnyxUpdateGap(onyxUpdatesFromServer: OnyxEntry { + // The OnyxUpdateManager can handle different types of re-fetch processes. Either there are pending updates, + // that we need to fetch manually, or we detected gaps in the previously fetched updates. + // Each of the flows below sets a promise through `DeferredOnyxUpdates.setMissingOnyxUpdatesQueryPromise`, which we further process. + if (shouldFetchPendingUpdates) { + // This flow handles the case where the server didn't send updates because the payload was too big. + // We need to call the GetMissingOnyxUpdates query to fetch the missing updates up to the pendingLastUpdateID. + const pendingUpdateID = Number(lastUpdateIDFromServer); + + isFetchingForPendingUpdates = true; + + // If the pendingUpdateID is not newer than the last locally applied update, we don't need to fetch the missing updates. + if (pendingUpdateID <= lastUpdateIDFromClient) { + DeferredOnyxUpdates.setMissingOnyxUpdatesQueryPromise(Promise.resolve()); + return true; + } + + console.debug(`[OnyxUpdateManager] Client is fetching pending updates from the server, from updates ${lastUpdateIDFromClient} to ${Number(pendingUpdateID)}`); + Log.info('There are pending updates from the server, so fetching incremental updates', true, { + pendingUpdateID, + lastUpdateIDFromClient, + }); + + // Get the missing Onyx updates from the server and afterward validate and apply the deferred updates. + // This will trigger recursive calls to "validateAndApplyDeferredUpdates" if there are gaps in the deferred updates. + DeferredOnyxUpdates.setMissingOnyxUpdatesQueryPromise( + App.getMissingOnyxUpdates(lastUpdateIDFromClient, lastUpdateIDFromServer).then(() => OnyxUpdateManagerUtils.validateAndApplyDeferredUpdates(clientLastUpdateID)), + ); + + return true; } - Log.info('Client has not gotten reliable updates before so reconnecting the app to start the process'); + if (!lastUpdateIDFromClient) { + // This is the first time we're receiving an lastUpdateID, so we need to do a final ReconnectApp query before + // This flow is setting the promise to a ReconnectApp query. + + // If there is a ReconnectApp query in progress, we should not start another one. + if (DeferredOnyxUpdates.getMissingOnyxUpdatesQueryPromise()) { + return false; + } + + Log.info('Client has not gotten reliable updates before so reconnecting the app to start the process'); + + // Since this is a full reconnectApp, we'll not apply the updates we received - those will come in the reconnect app request. + DeferredOnyxUpdates.setMissingOnyxUpdatesQueryPromise(App.finalReconnectAppAfterActivatingReliableUpdates()); + + return true; + } - // Since this is a full reconnectApp, we'll not apply the updates we received - those will come in the reconnect app request. - DeferredOnyxUpdates.setMissingOnyxUpdatesQueryPromise(App.finalReconnectAppAfterActivatingReliableUpdates()); - } else { - // The flow below is setting the promise to a getMissingOnyxUpdates to address flow (2) explained above. + // This client already has the reliable updates mode enabled, but it's missing some updates and it needs to fetch those. + // Therefore, we are calling the GetMissingOnyxUpdates query, to fetch the missing updates. const areDeferredUpdatesQueued = !DeferredOnyxUpdates.isEmpty(); // Add the new update to the deferred updates - DeferredOnyxUpdates.enqueue(updateParams, {shouldPauseSequentialQueue: false}); + DeferredOnyxUpdates.enqueue(onyxUpdatesFromServer, {shouldPauseSequentialQueue: false}); // If there are deferred updates already, we don't need to fetch the missing updates again. - if (areDeferredUpdatesQueued) { - return; + if (areDeferredUpdatesQueued || isFetchingForPendingUpdates) { + return false; } - console.debug(`[OnyxUpdateManager] Client is behind the server by ${Number(previousUpdateIDFromServer) - lastUpdateIDFromClient} so fetching incremental updates`); - Log.info('Gap detected in update IDs from server so fetching incremental updates', true, { + console.debug(`[OnyxUpdateManager] Client is fetching missing updates from the server, from updates ${lastUpdateIDFromClient} to ${Number(previousUpdateIDFromServer)}`); + Log.info('Gap detected in update IDs from the server so fetching incremental updates', true, { + lastUpdateIDFromClient, lastUpdateIDFromServer, previousUpdateIDFromServer, - lastUpdateIDFromClient, }); // Get the missing Onyx updates from the server and afterwards validate and apply the deferred updates. @@ -145,9 +181,14 @@ function handleOnyxUpdateGap(onyxUpdatesFromServer: OnyxEntry OnyxUpdateManagerUtils.validateAndApplyDeferredUpdates(clientLastUpdateID)), ); - } - DeferredOnyxUpdates.getMissingOnyxUpdatesQueryPromise()?.finally(finalizeUpdatesAndResumeQueue); + return true; + }; + const shouldFinalizeAndResume = checkIfClientNeedsToBeUpdated(); + + if (shouldFinalizeAndResume) { + DeferredOnyxUpdates.getMissingOnyxUpdatesQueryPromise()?.finally(finalizeUpdatesAndResumeQueue); + } } function updateAuthTokenIfNecessary(onyxUpdatesFromServer: OnyxEntry): void { @@ -177,8 +218,8 @@ export default () => { console.debug('[OnyxUpdateManager] Listening for updates from the server'); Onyx.connect({ key: ONYXKEYS.ONYX_UPDATES_FROM_SERVER, - callback: (value) => handleOnyxUpdateGap(value), + callback: (value) => handleMissingOnyxUpdates(value), }); }; -export {handleOnyxUpdateGap, queryPromiseWrapper as queryPromise, resetDeferralLogicVariables}; +export {handleMissingOnyxUpdates, queryPromiseWrapper as queryPromise, resetDeferralLogicVariables}; diff --git a/src/libs/actions/OnyxUpdateManager/utils/DeferredOnyxUpdates.ts b/src/libs/actions/OnyxUpdateManager/utils/DeferredOnyxUpdates.ts index 8a7b67db30c6..8b3bf5d9af86 100644 --- a/src/libs/actions/OnyxUpdateManager/utils/DeferredOnyxUpdates.ts +++ b/src/libs/actions/OnyxUpdateManager/utils/DeferredOnyxUpdates.ts @@ -1,6 +1,7 @@ import Onyx from 'react-native-onyx'; import type {DeferredUpdatesDictionary} from '@libs/actions/OnyxUpdateManager/types'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OnyxUpdatesFromServer, Response} from '@src/types/onyx'; import {isValidOnyxUpdateFromServer} from '@src/types/onyx/OnyxUpdatesFromServer'; @@ -40,7 +41,7 @@ function getUpdates(options?: GetDeferredOnyxUpdatesOptiosn) { } return Object.entries(deferredUpdates).reduce((acc, [lastUpdateID, update]) => { - if (Number(lastUpdateID) > (options.minUpdateID ?? 0)) { + if (Number(lastUpdateID) > (options.minUpdateID ?? CONST.DEFAULT_NUMBER_ID)) { acc[Number(lastUpdateID)] = update; } return acc; diff --git a/src/libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates.ts b/src/libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates.ts index 5cd66df6b0b0..019821c7f215 100644 --- a/src/libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates.ts +++ b/src/libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates.ts @@ -1,16 +1,11 @@ -import Onyx from 'react-native-onyx'; import type {DeferredUpdatesDictionary} from '@libs/actions/OnyxUpdateManager/types'; -import ONYXKEYS from '@src/ONYXKEYS'; +import * as OnyxUpdates from '@userActions/OnyxUpdates'; import createProxyForObject from '@src/utils/createProxyForObject'; -let lastUpdateIDAppliedToClient = 0; -Onyx.connect({ - key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, - callback: (value) => (lastUpdateIDAppliedToClient = value ?? 0), -}); +jest.mock('@userActions/OnyxUpdates'); type ApplyUpdatesMockValues = { - onApplyUpdates: ((updates: DeferredUpdatesDictionary) => Promise) | undefined; + beforeApplyUpdates: ((updates: DeferredUpdatesDictionary) => Promise) | undefined; }; type ApplyUpdatesMock = { @@ -19,15 +14,27 @@ type ApplyUpdatesMock = { }; const mockValues: ApplyUpdatesMockValues = { - onApplyUpdates: undefined, + beforeApplyUpdates: undefined, }; const mockValuesProxy = createProxyForObject(mockValues); const applyUpdates = jest.fn((updates: DeferredUpdatesDictionary) => { - const lastUpdateIdFromUpdates = Math.max(...Object.keys(updates).map(Number)); - return (mockValuesProxy.onApplyUpdates === undefined ? Promise.resolve() : mockValuesProxy.onApplyUpdates(updates)).then(() => - Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, Math.max(lastUpdateIDAppliedToClient, lastUpdateIdFromUpdates)), - ); + const createChain = () => { + let chain = Promise.resolve(); + Object.values(updates).forEach((update) => { + chain = chain.then(() => { + return OnyxUpdates.apply(update).then(() => undefined); + }); + }); + + return chain; + }; + + if (mockValuesProxy.beforeApplyUpdates === undefined) { + return createChain(); + } + + return mockValuesProxy.beforeApplyUpdates(updates).then(() => createChain()); }); export {applyUpdates, mockValuesProxy as mockValues}; diff --git a/src/libs/actions/OnyxUpdateManager/utils/__mocks__/index.ts b/src/libs/actions/OnyxUpdateManager/utils/__mocks__/index.ts index f66e059ff7f6..a10c265cf569 100644 --- a/src/libs/actions/OnyxUpdateManager/utils/__mocks__/index.ts +++ b/src/libs/actions/OnyxUpdateManager/utils/__mocks__/index.ts @@ -6,7 +6,7 @@ import {applyUpdates} from './applyUpdates'; const UtilsImplementation = jest.requireActual('@libs/actions/OnyxUpdateManager/utils'); type OnyxUpdateManagerUtilsMockValues = { - onValidateAndApplyDeferredUpdates: ((clientLastUpdateID?: number) => Promise) | undefined; + beforeValidateAndApplyDeferredUpdates: ((clientLastUpdateID?: number) => Promise) | undefined; }; type OnyxUpdateManagerUtilsMock = typeof UtilsImplementation & { @@ -16,17 +16,19 @@ type OnyxUpdateManagerUtilsMock = typeof UtilsImplementation & { }; const mockValues: OnyxUpdateManagerUtilsMockValues = { - onValidateAndApplyDeferredUpdates: undefined, + beforeValidateAndApplyDeferredUpdates: undefined, }; const mockValuesProxy = createProxyForObject(mockValues); const detectGapsAndSplit = jest.fn(UtilsImplementation.detectGapsAndSplit); -const validateAndApplyDeferredUpdates = jest.fn((clientLastUpdateID?: number) => - (mockValuesProxy.onValidateAndApplyDeferredUpdates === undefined ? Promise.resolve() : mockValuesProxy.onValidateAndApplyDeferredUpdates(clientLastUpdateID)).then(() => - UtilsImplementation.validateAndApplyDeferredUpdates(clientLastUpdateID), - ), -); +const validateAndApplyDeferredUpdates = jest.fn((clientLastUpdateID?: number) => { + if (mockValuesProxy.beforeValidateAndApplyDeferredUpdates === undefined) { + return UtilsImplementation.validateAndApplyDeferredUpdates(clientLastUpdateID); + } + + return mockValuesProxy.beforeValidateAndApplyDeferredUpdates(clientLastUpdateID).then(() => UtilsImplementation.validateAndApplyDeferredUpdates(clientLastUpdateID)); +}); export {applyUpdates, detectGapsAndSplit, validateAndApplyDeferredUpdates, mockValuesProxy as mockValues}; export type {OnyxUpdateManagerUtilsMock}; diff --git a/src/libs/actions/OnyxUpdateManager/utils/index.ts b/src/libs/actions/OnyxUpdateManager/utils/index.ts index 9a527308034e..bf9be862c029 100644 --- a/src/libs/actions/OnyxUpdateManager/utils/index.ts +++ b/src/libs/actions/OnyxUpdateManager/utils/index.ts @@ -2,15 +2,16 @@ import Onyx from 'react-native-onyx'; import Log from '@libs/Log'; import * as App from '@userActions/App'; import type {DeferredUpdatesDictionary, DetectGapAndSplitResult} from '@userActions/OnyxUpdateManager/types'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {applyUpdates} from './applyUpdates'; // eslint-disable-next-line import/no-cycle import * as DeferredOnyxUpdates from './DeferredOnyxUpdates'; -let lastUpdateIDAppliedToClient = 0; +let lastUpdateIDAppliedToClient: number = CONST.DEFAULT_NUMBER_ID; Onyx.connect({ key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, - callback: (value) => (lastUpdateIDAppliedToClient = value ?? 0), + callback: (value) => (lastUpdateIDAppliedToClient = value ?? CONST.DEFAULT_NUMBER_ID), }); /** @@ -114,13 +115,13 @@ function detectGapsAndSplit(lastUpdateIDFromClient: number): DetectGapAndSplitRe * apply the updates in order after the missing updates are fetched and applied */ function validateAndApplyDeferredUpdates(clientLastUpdateID?: number, previousParams?: {newLastUpdateIDFromClient: number; latestMissingUpdateID: number}): Promise { - const lastUpdateIDFromClient = clientLastUpdateID ?? lastUpdateIDAppliedToClient ?? 0; + const lastUpdateIDFromClient = clientLastUpdateID ?? lastUpdateIDAppliedToClient ?? CONST.DEFAULT_NUMBER_ID; Log.info('[DeferredUpdates] Processing deferred updates', false, {lastUpdateIDFromClient, previousParams}); const {applicableUpdates, updatesAfterGaps, latestMissingUpdateID} = detectGapsAndSplit(lastUpdateIDFromClient); - // If there are no applicable deferred updates and no missing deferred updates, + // If there are no applicably deferred updates and no missing deferred updates, // we don't need to apply or re-fetch any updates. We can just unpause the queue by resolving. if (Object.values(applicableUpdates).length === 0 && latestMissingUpdateID === undefined) { return Promise.resolve(); @@ -138,13 +139,13 @@ function validateAndApplyDeferredUpdates(clientLastUpdateID?: number, previousPa // After we have applied the applicable updates, there might have been new deferred updates added. // In the next (recursive) call of "validateAndApplyDeferredUpdates", // the initial "updatesAfterGaps" and all new deferred updates will be applied in order, - // as long as there was no new gap detected. Otherwise repeat the process. + // as long as there was no new gap detected. Otherwise, repeat the process. - const newLastUpdateIDFromClient = clientLastUpdateID ?? lastUpdateIDAppliedToClient ?? 0; + const newLastUpdateIDFromClient = clientLastUpdateID ?? lastUpdateIDAppliedToClient ?? CONST.DEFAULT_NUMBER_ID; DeferredOnyxUpdates.enqueue(updatesAfterGaps, {shouldPauseSequentialQueue: false}); - // If lastUpdateIDAppliedToClient got updated, we will just retrigger the validation + // If lastUpdateIDAppliedToClient got updated, we will just re-trigger the validation // and application of the current deferred updates. if (latestMissingUpdateID <= newLastUpdateIDFromClient) { validateAndApplyDeferredUpdates(undefined, {newLastUpdateIDFromClient, latestMissingUpdateID}) diff --git a/src/libs/actions/OnyxUpdates.ts b/src/libs/actions/OnyxUpdates.ts index 52dfa9dfd742..a09159993ad8 100644 --- a/src/libs/actions/OnyxUpdates.ts +++ b/src/libs/actions/OnyxUpdates.ts @@ -2,7 +2,6 @@ import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {Merge} from 'type-fest'; import Log from '@libs/Log'; -import * as SequentialQueue from '@libs/Network/SequentialQueue'; import Performance from '@libs/Performance'; import PusherUtils from '@libs/PusherUtils'; import CONST from '@src/CONST'; @@ -154,28 +153,28 @@ function apply({lastUpdateID, type, request, response, updates}: OnyxUpdatesFrom * @param [updateParams.updates] Exists if updateParams.type === 'pusher' */ function saveUpdateInformation(updateParams: OnyxUpdatesFromServer) { - // If we got here, that means we are missing some updates on our local storage. To - // guarantee that we're not fetching more updates before our local data is up to date, - // let's stop the sequential queue from running until we're done catching up. - SequentialQueue.pause(); - // Always use set() here so that the updateParams are never merged and always unique to the request that came in Onyx.set(ONYXKEYS.ONYX_UPDATES_FROM_SERVER, updateParams); } +type DoesClientNeedToBeUpdatedParams = { + clientLastUpdateID?: number; + previousUpdateID?: number; +}; + /** * This function will receive the previousUpdateID from any request/pusher update that has it, compare to our current app state * and return if an update is needed * @param previousUpdateID The previousUpdateID contained in the response object * @param clientLastUpdateID an optional override for the lastUpdateIDAppliedToClient */ -function doesClientNeedToBeUpdated(previousUpdateID = 0, clientLastUpdateID = 0): boolean { +function doesClientNeedToBeUpdated({previousUpdateID, clientLastUpdateID}: DoesClientNeedToBeUpdatedParams): boolean { // If no previousUpdateID is sent, this is not a WRITE request so we don't need to update our current state if (!previousUpdateID) { return false; } - const lastUpdateIDFromClient = clientLastUpdateID || lastUpdateIDAppliedToClient; + const lastUpdateIDFromClient = clientLastUpdateID ?? lastUpdateIDAppliedToClient; // If we don't have any value in lastUpdateIDFromClient, this is the first time we're receiving anything, so we need to do a last reconnectApp if (!lastUpdateIDFromClient) { @@ -191,4 +190,5 @@ function doesClientNeedToBeUpdated(previousUpdateID = 0, clientLastUpdateID = 0) } // eslint-disable-next-line import/prefer-default-export -export {apply, doesClientNeedToBeUpdated, saveUpdateInformation}; +export {apply, doesClientNeedToBeUpdated, saveUpdateInformation, applyHTTPSOnyxUpdates as INTERNAL_DO_NOT_USE_applyHTTPSOnyxUpdates}; +export type {DoesClientNeedToBeUpdatedParams as ManualOnyxUpdateCheckIds}; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index f18fac18aca2..c04648c04104 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -3466,10 +3466,6 @@ function upgradeToCorporate(policyID: string, featureName?: string) { maxExpenseAmount: CONST.POLICY.DEFAULT_MAX_EXPENSE_AMOUNT, maxExpenseAmountNoReceipt: CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_RECEIPT, glCodes: true, - ...(PolicyUtils.isInstantSubmitEnabled(policy) && { - autoReporting: true, - autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE, - }), harvesting: { enabled: false, }, @@ -3498,8 +3494,6 @@ function upgradeToCorporate(policyID: string, featureName?: string) { maxExpenseAmount: policy?.maxExpenseAmount ?? null, maxExpenseAmountNoReceipt: policy?.maxExpenseAmountNoReceipt ?? null, glCodes: policy?.glCodes ?? null, - autoReporting: policy?.autoReporting ?? null, - autoReportingFrequency: policy?.autoReportingFrequency ?? null, harvesting: policy?.harvesting ?? null, }, }, diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index c854fbae401d..0699058b6fdd 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -522,6 +522,7 @@ function signInAfterTransitionFromOldDot(transitionURL: string) { autoGeneratedPassword, clearOnyxOnStart, completedHybridAppOnboarding, + nudgeMigrationTimestamp, isSingleNewDotEntry, primaryLogin, shouldRemoveDelegatedAccess, @@ -554,7 +555,10 @@ function signInAfterTransitionFromOldDot(transitionURL: string) { [ONYXKEYS.ACCOUNT]: {primaryLogin}, [ONYXKEYS.CREDENTIALS]: {autoGeneratedLogin, autoGeneratedPassword}, [ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY]: isSingleNewDotEntry === 'true', - [ONYXKEYS.NVP_TRYNEWDOT]: {classicRedirect: {completedHybridAppOnboarding: completedHybridAppOnboarding === 'true'}}, + [ONYXKEYS.NVP_TRYNEWDOT]: { + classicRedirect: {completedHybridAppOnboarding: completedHybridAppOnboarding === 'true'}, + nudgeMigration: nudgeMigrationTimestamp ? {timestamp: new Date(nudgeMigrationTimestamp)} : undefined, + }, }), ) .then(() => { diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 71973a5adbc3..80d04a4617bd 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -58,7 +58,7 @@ let currentEmail = ''; Onyx.connect({ key: ONYXKEYS.SESSION, callback: (value) => { - currentUserAccountID = value?.accountID ?? -1; + currentUserAccountID = value?.accountID ?? CONST.DEFAULT_NUMBER_ID; currentEmail = value?.email ?? ''; }, }); @@ -910,9 +910,9 @@ function subscribeToUserEvents() { // Example: {lastUpdateID: 1, previousUpdateID: 0, updates: [{onyxMethod: 'whatever', key: 'foo', value: 'bar'}]} const updates = { type: CONST.ONYX_UPDATE_TYPES.PUSHER, - lastUpdateID: Number(pushJSON.lastUpdateID || 0), + lastUpdateID: Number(pushJSON.lastUpdateID ?? CONST.DEFAULT_NUMBER_ID), updates: pushJSON.updates ?? [], - previousUpdateID: Number(pushJSON.previousUpdateID || 0), + previousUpdateID: Number(pushJSON.previousUpdateID ?? CONST.DEFAULT_NUMBER_ID), }; applyOnyxUpdatesReliably(updates); }); diff --git a/src/libs/actions/__mocks__/App.ts b/src/libs/actions/__mocks__/App.ts index 09fd553a87f3..0b2b098feefb 100644 --- a/src/libs/actions/__mocks__/App.ts +++ b/src/libs/actions/__mocks__/App.ts @@ -1,10 +1,11 @@ -import Onyx from 'react-native-onyx'; import type * as AppImport from '@libs/actions/App'; -import type * as ApplyUpdatesImport from '@libs/actions/OnyxUpdateManager/utils/applyUpdates'; -import ONYXKEYS from '@src/ONYXKEYS'; +import * as OnyxUpdates from '@userActions/OnyxUpdates'; import type {OnyxUpdatesFromServer} from '@src/types/onyx'; import createProxyForObject from '@src/utils/createProxyForObject'; +jest.mock('@libs/actions/OnyxUpdates'); +jest.mock('@libs/actions/OnyxUpdateManager/utils/applyUpdates'); + const AppImplementation = jest.requireActual('@libs/actions/App'); const { setLocale, @@ -39,13 +40,30 @@ const mockValues: AppMockValues = { }; const mockValuesProxy = createProxyForObject(mockValues); -const ApplyUpdatesImplementation = jest.requireActual('@libs/actions/OnyxUpdateManager/utils/applyUpdates'); -const getMissingOnyxUpdates = jest.fn((_fromID: number, toID: number) => { - if (mockValuesProxy.missingOnyxUpdatesToBeApplied === undefined) { - return Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, toID); +const getMissingOnyxUpdates = jest.fn((updateIDFrom: number, updateIDTo: number) => { + const updates = mockValuesProxy.missingOnyxUpdatesToBeApplied ?? []; + if (updates.length === 0) { + for (let i = updateIDFrom + 1; i <= updateIDTo; i++) { + updates.push({ + lastUpdateID: i, + previousUpdateID: i - 1, + } as OnyxUpdatesFromServer); + } } - return ApplyUpdatesImplementation.applyUpdates(mockValuesProxy.missingOnyxUpdatesToBeApplied); + let chain = Promise.resolve(); + updates.forEach((update) => { + chain = chain.then(() => { + if (!OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: Number(update.previousUpdateID)})) { + return OnyxUpdates.apply(update).then(() => undefined); + } + + OnyxUpdates.saveUpdateInformation(update); + return Promise.resolve(); + }); + }); + + return chain; }); export { diff --git a/src/libs/actions/__mocks__/OnyxUpdates.ts b/src/libs/actions/__mocks__/OnyxUpdates.ts new file mode 100644 index 000000000000..3e4cb10d7f9e --- /dev/null +++ b/src/libs/actions/__mocks__/OnyxUpdates.ts @@ -0,0 +1,44 @@ +import Onyx from 'react-native-onyx'; +import type * as OnyxUpdatesImport from '@userActions/OnyxUpdates'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxUpdatesFromServer, Response} from '@src/types/onyx'; + +jest.mock('@libs/actions/OnyxUpdateManager/utils/applyUpdates'); + +const OnyxUpdatesImplementation = jest.requireActual('@libs/actions/OnyxUpdates'); +const {doesClientNeedToBeUpdated, saveUpdateInformation, INTERNAL_DO_NOT_USE_applyHTTPSOnyxUpdates: applyHTTPSOnyxUpdates} = OnyxUpdatesImplementation; + +type OnyxUpdatesMock = typeof OnyxUpdatesImport & { + apply: jest.Mock, [OnyxUpdatesFromServer]>; +}; + +let lastUpdateIDAppliedToClient: number | undefined = 0; +Onyx.connect({ + key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, + callback: (val) => (lastUpdateIDAppliedToClient = val), +}); + +const apply = jest.fn(({lastUpdateID, request, response}: OnyxUpdatesFromServer): Promise | undefined => { + if (lastUpdateID && (lastUpdateIDAppliedToClient === undefined || Number(lastUpdateID) > lastUpdateIDAppliedToClient)) { + Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, Number(lastUpdateID)); + } + + if (request && response) { + return applyHTTPSOnyxUpdates(request, response).then(() => undefined); + } + + return Promise.resolve(); +}); + +export { + // Mocks + apply, + + // Actual OnyxUpdates implementation + doesClientNeedToBeUpdated, + saveUpdateInformation, +}; + +type ManualOnyxUpdateCheckIds = OnyxUpdatesImport.ManualOnyxUpdateCheckIds; +export type {ManualOnyxUpdateCheckIds}; +export type {OnyxUpdatesMock}; diff --git a/src/libs/actions/applyOnyxUpdatesReliably.ts b/src/libs/actions/applyOnyxUpdatesReliably.ts index 17754712cdc8..d8475c55042a 100644 --- a/src/libs/actions/applyOnyxUpdatesReliably.ts +++ b/src/libs/actions/applyOnyxUpdatesReliably.ts @@ -1,7 +1,14 @@ +import * as SequentialQueue from '@libs/Network/SequentialQueue'; +import CONST from '@src/CONST'; import type {OnyxUpdatesFromServer} from '@src/types/onyx'; -import {handleOnyxUpdateGap} from './OnyxUpdateManager'; +import {handleMissingOnyxUpdates} from './OnyxUpdateManager'; import * as OnyxUpdates from './OnyxUpdates'; +type ApplyOnyxUpdatesReliablyOptions = { + clientLastUpdateID?: number; + shouldRunSync?: boolean; +}; + /** * Checks for and handles gaps of onyx updates between the client and the given server updates before applying them * @@ -11,16 +18,32 @@ import * as OnyxUpdates from './OnyxUpdates'; * @param shouldRunSync * @returns */ -export default function applyOnyxUpdatesReliably(updates: OnyxUpdatesFromServer, shouldRunSync = false, clientLastUpdateID = 0) { - const previousUpdateID = Number(updates.previousUpdateID) || 0; - if (!OnyxUpdates.doesClientNeedToBeUpdated(previousUpdateID, clientLastUpdateID)) { - OnyxUpdates.apply(updates); +export default function applyOnyxUpdatesReliably(updates: OnyxUpdatesFromServer, {shouldRunSync = false, clientLastUpdateID}: ApplyOnyxUpdatesReliablyOptions = {}) { + const fetchMissingUpdates = () => { + // If we got here, that means we are missing some updates on our local storage. To + // guarantee that we're not fetching more updates before our local data is up to date, + // let's stop the sequential queue from running until we're done catching up. + SequentialQueue.pause(); + + if (shouldRunSync) { + handleMissingOnyxUpdates(updates, clientLastUpdateID); + } else { + OnyxUpdates.saveUpdateInformation(updates); + } + }; + + // If a pendingLastUpdateID is was provided, it means that the backend didn't send updates because the payload was too big. + // In this case, we need to fetch the missing updates up to the pendingLastUpdateID. + if (updates.shouldFetchPendingUpdates) { + fetchMissingUpdates(); return; } - if (shouldRunSync) { - handleOnyxUpdateGap(updates, clientLastUpdateID); - } else { - OnyxUpdates.saveUpdateInformation(updates); + const previousUpdateID = Number(updates.previousUpdateID) ?? CONST.DEFAULT_NUMBER_ID; + if (!OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID, clientLastUpdateID})) { + OnyxUpdates.apply(updates); + return; } + + fetchMissingUpdates(); } diff --git a/src/libs/onboardingSelectors.ts b/src/libs/onboardingSelectors.ts index b21626cf8a07..b578a6a36942 100644 --- a/src/libs/onboardingSelectors.ts +++ b/src/libs/onboardingSelectors.ts @@ -56,4 +56,15 @@ function hasSeenTourSelector(onboarding: OnyxValue): boolean | undefined { + return introSelected?.inviteType !== undefined; +} + +export {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector, hasSeenTourSelector, wasInvitedToNewDotSelector}; diff --git a/src/pages/ConciergePage.tsx b/src/pages/ConciergePage.tsx index 40e2a6094ac7..0ce089787230 100644 --- a/src/pages/ConciergePage.tsx +++ b/src/pages/ConciergePage.tsx @@ -1,4 +1,4 @@ -import {useFocusEffect} from '@react-navigation/native'; +import {useFocusEffect, useRoute} from '@react-navigation/native'; import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; @@ -10,6 +10,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import * as App from '@userActions/App'; import * as Report from '@userActions/Report'; +import * as Task from '@userActions/Task'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; /* @@ -23,6 +25,11 @@ function ConciergePage() { const {shouldUseNarrowLayout} = useResponsiveLayout(); const [session] = useOnyx(ONYXKEYS.SESSION); const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA, {initialValue: true}); + const route = useRoute(); + + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const viewTourTaskReportID = introSelected?.viewTour; + const [viewTourTaskReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${viewTourTaskReportID}`); useFocusEffect( useCallback(() => { @@ -32,12 +39,22 @@ function ConciergePage() { if (isUnmounted.current || isLoadingReportData === undefined || !!isLoadingReportData) { return; } + + // Mark the viewTourTask as complete if we are redirected to Concierge after finishing the Navattic tour + const {navattic} = route.params as {navattic?: string}; + if (navattic === CONST.NAVATTIC.COMPLETED) { + if (viewTourTaskReport) { + if (viewTourTaskReport.stateNum !== CONST.REPORT.STATE_NUM.APPROVED || viewTourTaskReport.statusNum !== CONST.REPORT.STATUS_NUM.APPROVED) { + Task.completeTask(viewTourTaskReport); + } + } + } Report.navigateToConciergeChat(true, () => !isUnmounted.current); }); } else { Navigation.navigate(); } - }, [session, isLoadingReportData]), + }, [session, isLoadingReportData, route.params, viewTourTaskReport]), ); useEffect(() => { diff --git a/src/pages/GroupChatNameEditPage.tsx b/src/pages/GroupChatNameEditPage.tsx index 69d7f6c6f8af..66cc4b0a2329 100644 --- a/src/pages/GroupChatNameEditPage.tsx +++ b/src/pages/GroupChatNameEditPage.tsx @@ -1,6 +1,5 @@ import React, {useCallback, useMemo} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormOnyxValues} from '@components/Form/types'; @@ -22,23 +21,18 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/NewChatNameForm'; import type {Report as ReportOnyxType} from '@src/types/onyx'; -import type NewGroupChatDraft from '@src/types/onyx/NewGroupChatDraft'; import type {Errors} from '@src/types/onyx/OnyxCommon'; -type GroupChatNameEditPageOnyxProps = { - groupChatDraft: OnyxEntry; +type GroupChatNameEditPageProps = Partial> & { + report?: ReportOnyxType; }; -type GroupChatNameEditPageProps = GroupChatNameEditPageOnyxProps & - Partial> & { - report?: ReportOnyxType; - }; - -function GroupChatNameEditPage({groupChatDraft, report}: GroupChatNameEditPageProps) { +function GroupChatNameEditPage({report}: GroupChatNameEditPageProps) { // If we have a report this means we are using this page to update an existing Group Chat name // In this case its better to use empty string as the reportID if there is no reportID - const reportID = report?.reportID ?? ''; + const reportID = report?.reportID; const isUpdatingExistingReport = !!reportID; + const [groupChatDraft] = useOnyx(ONYXKEYS.NEW_GROUP_CHAT_DRAFT); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -69,13 +63,15 @@ function GroupChatNameEditPage({groupChatDraft, report}: GroupChatNameEditPagePr if (values[INPUT_IDS.NEW_CHAT_NAME] !== currentChatName) { Report.updateGroupChatName(reportID, values[INPUT_IDS.NEW_CHAT_NAME] ?? ''); } - Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(reportID)); + + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(reportID))); + return; } if (values[INPUT_IDS.NEW_CHAT_NAME] !== currentChatName) { Report.setGroupDraft({reportName: values[INPUT_IDS.NEW_CHAT_NAME]}); } - Navigation.goBack(ROUTES.NEW_CHAT_CONFIRM); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.NEW_CHAT_CONFIRM)); }, [isUpdatingExistingReport, reportID, currentChatName], ); @@ -117,8 +113,4 @@ function GroupChatNameEditPage({groupChatDraft, report}: GroupChatNameEditPagePr GroupChatNameEditPage.displayName = 'GroupChatNameEditPage'; -export default withOnyx({ - groupChatDraft: { - key: ONYXKEYS.NEW_GROUP_CHAT_DRAFT, - }, -})(GroupChatNameEditPage); +export default GroupChatNameEditPage; diff --git a/src/pages/PrivateNotes/PrivateNotesEditPage.tsx b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx index 4d084cfa924d..deab122e3006 100644 --- a/src/pages/PrivateNotes/PrivateNotesEditPage.tsx +++ b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx @@ -96,7 +96,7 @@ function PrivateNotesEditPage({route, report, accountID}: PrivateNotesEditPagePr if (!Object.values({...report.privateNotes, [route.params.accountID]: {note: editedNote}}).some((item) => item.note)) { ReportUtils.navigateToDetailsPage(report, backTo); } else { - Navigation.goBack(ROUTES.PRIVATE_NOTES_LIST.getRoute(report.reportID, backTo)); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.PRIVATE_NOTES_LIST.getRoute(report.reportID, backTo))); } }; diff --git a/src/pages/RoomDescriptionPage.tsx b/src/pages/RoomDescriptionPage.tsx index 28920f581681..69faef68f766 100644 --- a/src/pages/RoomDescriptionPage.tsx +++ b/src/pages/RoomDescriptionPage.tsx @@ -51,7 +51,7 @@ function RoomDescriptionPage({report, policies}: RoomDescriptionPageProps) { }, []); const goBack = useCallback(() => { - Navigation.goBack(backTo ?? ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID)); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(backTo ?? ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID))); }, [report.reportID, backTo]); const submitForm = useCallback(() => { diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index ce666cc46200..6f845726f6d3 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -437,8 +437,8 @@ function AdvancedSearchFilters() { const onSaveSearch = () => { const savedSearchKeys = Object.keys(savedSearches ?? {}); if (!queryJSON || (savedSearches && savedSearchKeys.includes(String(queryJSON.hash)))) { - // If the search is already saved, return early to prevent unnecessary API calls - Navigation.dismissModal(); + // If the search is already saved, we only display the results as we don't need to save it. + applyFiltersAndNavigate(); return; } diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 73fed14af87c..ef3137a8c7d2 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -16,10 +16,10 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import ScreenWrapper from '@components/ScreenWrapper'; import TaskHeaderActionButton from '@components/TaskHeaderActionButton'; -import type {CurrentReportIDContextValue} from '@components/withCurrentReportID'; -import withCurrentReportID from '@components/withCurrentReportID'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useAppFocusEvent from '@hooks/useAppFocusEvent'; +import type {CurrentReportIDContextValue} from '@hooks/useCurrentReportID'; +import useCurrentReportID from '@hooks/useCurrentReportID'; import useDeepCompareRef from '@hooks/useDeepCompareRef'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -97,7 +97,7 @@ function getParentReportAction(parentReportActions: OnyxEntry ReportUtils.canEditReportAction(action) && !ReportActionsUtils.isMoneyRequestAction(action)); const isSingleTransactionView = ReportUtils.isMoneyRequest(report) || ReportUtils.isTrackExpenseReport(report); const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? '-1'}`]; - const isTopMostReportId = currentReportID === reportIDFromRoute; + const isTopMostReportId = currentReportIDValue?.currentReportID === reportIDFromRoute; const didSubscribeToReportLeavingEvents = useRef(false); const [showSoftInputOnFocus, setShowSoftInputOnFocus] = useState(false); @@ -870,4 +871,4 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro } ReportScreen.displayName = 'ReportScreen'; -export default withCurrentReportID(memo(ReportScreen, (prevProps, nextProps) => prevProps.currentReportID === nextProps.currentReportID && lodashIsEqual(prevProps.route, nextProps.route))); +export default memo(ReportScreen, (prevProps, nextProps) => lodashIsEqual(prevProps.route, nextProps.route)); diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index bb3e04a90b84..dd44558f83bf 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -1,7 +1,7 @@ import lodashIsEqual from 'lodash/isEqual'; import React, {memo, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import type {GestureResponderEvent, TextInput} from 'react-native'; -import {InteractionManager, View} from 'react-native'; +import {InteractionManager, Keyboard, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type {Emoji} from '@assets/emojis/types'; @@ -1075,7 +1075,15 @@ function PureReportActionItem({ return ( { + if (draftMessage === undefined) { + onPress?.(); + } + if (!Keyboard.isVisible()) { + return; + } + Keyboard.dismiss(); + }} style={[action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isDeletedParentAction ? styles.pointerEventsNone : styles.pointerEventsAuto]} onPressIn={() => shouldUseNarrowLayout && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 7fd5bb21f57d..eda19dde71a5 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -2,7 +2,7 @@ import type {ListRenderItemInfo} from '@react-native/virtualized-lists/Lists/Vir import {useIsFocused, useRoute} from '@react-navigation/native'; // eslint-disable-next-line lodash/import-scope import type {DebouncedFunc} from 'lodash'; -import React, {memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; import {useOnyx} from 'react-native-onyx'; @@ -19,6 +19,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import DateUtils from '@libs/DateUtils'; +import {getChatFSAttributes} from '@libs/Fullstory'; import isReportScreenTopmostCentralPane from '@libs/Navigation/isReportScreenTopmostCentralPane'; import isSearchTopmostCentralPane from '@libs/Navigation/isSearchTopmostCentralPane'; import Navigation from '@libs/Navigation/Navigation'; @@ -29,6 +30,7 @@ import Visibility from '@libs/Visibility'; import type {AuthScreensParamList} from '@navigation/types'; import variables from '@styles/variables'; import * as Report from '@userActions/Report'; +import {PersonalDetailsContext} from '@src/components/OnyxProvider'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -171,6 +173,7 @@ function ReportActionsList({ const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`); const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.accountID}); + const participantsContext = useContext(PersonalDetailsContext); useEffect(() => { const unsubscriber = Visibility.onVisibilityChange(() => { @@ -724,13 +727,19 @@ function ReportActionsList({ // When performing comment linking, initially 25 items are added to the list. Subsequent fetches add 15 items from the cache or 50 items from the server. // This is to ensure that the user is able to see the 'scroll to newer comments' button when they do comment linking and have not reached the end of the list yet. const canScrollToNewerComments = !isLoadingInitialReportActions && !hasNewestReportAction && sortedReportActions.length > 25 && !isLastPendingActionIsDelete; + const [reportActionsListTestID, reportActionsListFSClass] = getChatFSAttributes(participantsContext, 'ReportActionsList', report); + return ( <> - + (null); const [fileSource, setFileSource] = useState(''); - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? CONST.DEFAULT_NUMBER_ID}`); const policy = usePolicy(report?.policyID); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? -1}`); + const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? CONST.DEFAULT_NUMBER_ID}`); const platform = getPlatform(true); const [mutedPlatforms = {}] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS); const isPlatformMuted = mutedPlatforms[platform]; @@ -198,7 +198,10 @@ function IOURequestStepScan({ } if (!Str.isImage(file.name ?? '') && (file?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) { - Alert.alert(translate('attachmentPicker.attachmentTooLarge'), translate('attachmentPicker.sizeExceeded')); + Alert.alert( + translate('attachmentPicker.attachmentTooLarge'), + translate('attachmentPicker.sizeExceededWithLimit', {maxUploadSizeInMB: CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE / (1024 * 1024)}), + ); return false; } @@ -295,7 +298,7 @@ function IOURequestStepScan({ // be added to the transaction (taken from the chat report participants) and then the person is taken to the confirmation step. const selectedParticipants = IOU.setMoneyRequestParticipantsFromReport(transactionID, report); const participants = selectedParticipants.map((participant) => { - const participantAccountID = participant?.accountID ?? -1; + const participantAccountID = participant?.accountID ?? CONST.DEFAULT_NUMBER_ID; return participantAccountID ? OptionsListUtils.getParticipantsOption(participant, personalDetails) : OptionsListUtils.getReportOption(participant); }); @@ -308,10 +311,10 @@ function IOURequestStepScan({ IOU.startSplitBill({ participants, currentUserLogin: currentUserPersonalDetails?.login ?? '', - currentUserAccountID: currentUserPersonalDetails?.accountID ?? -1, + currentUserAccountID: currentUserPersonalDetails.accountID, comment: '', receipt, - existingSplitChatReportID: reportID ?? -1, + existingSplitChatReportID: reportID, billable: false, category: '', tag: '', diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index b2d9fd5e78b1..fcf734781b3a 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -86,10 +86,10 @@ function IOURequestStepScan({ const [isQueriedPermissionState, setIsQueriedPermissionState] = useState(false); const getScreenshotTimeoutRef = useRef(null); - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? CONST.DEFAULT_NUMBER_ID}`); const policy = usePolicy(report?.policyID); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? -1}`); + const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? CONST.DEFAULT_NUMBER_ID}`); const [isLoadingReceipt, setIsLoadingReceipt] = useState(false); const [videoConstraints, setVideoConstraints] = useState(); @@ -221,7 +221,7 @@ function IOURequestStepScan({ } if (!Str.isImage(file.name ?? '') && (file?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) { - setUploadReceiptError(true, 'attachmentPicker.attachmentTooLarge', 'attachmentPicker.sizeExceeded'); + setUploadReceiptError(true, 'attachmentPicker.attachmentTooLarge', 'attachmentPicker.sizeExceededWithLimit'); return false; } @@ -324,7 +324,7 @@ function IOURequestStepScan({ // be added to the transaction (taken from the chat report participants) and then the person is taken to the confirmation step. const selectedParticipants = IOU.setMoneyRequestParticipantsFromReport(transactionID, report); const participants = selectedParticipants.map((participant) => { - const participantAccountID = participant?.accountID ?? -1; + const participantAccountID = participant?.accountID ?? CONST.DEFAULT_NUMBER_ID; return participantAccountID ? OptionsListUtils.getParticipantsOption(participant, personalDetails) : OptionsListUtils.getReportOption(participant); }); @@ -337,10 +337,10 @@ function IOURequestStepScan({ IOU.startSplitBill({ participants, currentUserLogin: currentUserPersonalDetails?.login ?? '', - currentUserAccountID: currentUserPersonalDetails?.accountID ?? -1, + currentUserAccountID: currentUserPersonalDetails.accountID, comment: '', receipt, - existingSplitChatReportID: reportID ?? -1, + existingSplitChatReportID: reportID, billable: false, category: '', tag: '', @@ -618,6 +618,16 @@ function IOURequestStepScan({ /> ) : null; + const getConfirmModalPrompt = () => { + if (!attachmentInvalidReason) { + return ''; + } + if (attachmentInvalidReason === 'attachmentPicker.sizeExceededWithLimit') { + return translate(attachmentInvalidReason, {maxUploadSizeInMB: CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE / (1024 * 1024)}); + } + return translate(attachmentInvalidReason); + }; + const mobileCameraView = () => ( <> @@ -794,7 +804,7 @@ function IOURequestStepScan({ onConfirm={hideRecieptModal} onCancel={hideRecieptModal} isVisible={isAttachmentInvalid} - prompt={attachmentInvalidReason ? translate(attachmentInvalidReason) : ''} + prompt={getConfirmModalPrompt()} confirmText={translate('common.close')} shouldShowCancelButton={false} /> diff --git a/src/pages/settings/Report/RoomNamePage.tsx b/src/pages/settings/Report/RoomNamePage.tsx index bc83b2ad0ba1..d7ad1c51f22c 100644 --- a/src/pages/settings/Report/RoomNamePage.tsx +++ b/src/pages/settings/Report/RoomNamePage.tsx @@ -1,8 +1,7 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import React, {useCallback, useRef} from 'react'; import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import type {OnyxCollection} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; @@ -27,25 +26,21 @@ import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/RoomNameForm'; import type {Report} from '@src/types/onyx'; -type RoomNamePageOnyxProps = { - /** All reports shared with the user */ - reports: OnyxCollection; -}; - -type RoomNamePageProps = RoomNamePageOnyxProps & { +type RoomNamePageProps = { report: Report; }; -function RoomNamePage({report, reports}: RoomNamePageProps) { +function RoomNamePage({report}: RoomNamePageProps) { const route = useRoute>(); const styles = useThemeStyles(); const roomNameInputRef = useRef(null); const isFocused = useIsFocused(); const {translate} = useLocalize(); - const reportID = report?.reportID ?? '-1'; + const reportID = report?.reportID; + const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const goBack = useCallback(() => { - Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(reportID, route.params.backTo)); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(reportID, route.params.backTo))); }, [reportID, route.params.backTo]); const validate = useCallback( @@ -66,7 +61,7 @@ function RoomNamePage({report, reports}: RoomNamePageProps) { } else if (ValidationUtils.isReservedRoomName(values.roomName)) { // Certain names are reserved for default rooms and should not be used for policy rooms. ErrorUtils.addErrorMessage(errors, 'roomName', translate('newRoomPage.roomNameReservedError', {reservedName: values.roomName})); - } else if (ValidationUtils.isExistingRoomName(values.roomName, reports, report?.policyID ?? '-1')) { + } else if (ValidationUtils.isExistingRoomName(values.roomName, reports, report?.policyID)) { // The room name can't be set to one that already exists on the policy ErrorUtils.addErrorMessage(errors, 'roomName', translate('newRoomPage.roomAlreadyExistsError')); } else if (values.roomName.length > CONST.TITLE_CHARACTER_LIMIT) { @@ -122,8 +117,4 @@ function RoomNamePage({report, reports}: RoomNamePageProps) { RoomNamePage.displayName = 'RoomNamePage'; -export default withOnyx({ - reports: { - key: ONYXKEYS.COLLECTION.REPORT, - }, -})(RoomNamePage); +export default RoomNamePage; diff --git a/src/pages/workspace/WorkspaceNamePage.tsx b/src/pages/workspace/WorkspaceNamePage.tsx index af97c7c03c1f..12ef91d918d9 100644 --- a/src/pages/workspace/WorkspaceNamePage.tsx +++ b/src/pages/workspace/WorkspaceNamePage.tsx @@ -33,7 +33,7 @@ function WorkspaceNamePage({policy}: Props) { Policy.updateGeneralSettings(policy.id, values.name.trim(), policy.outputCurrency); Keyboard.dismiss(); - Navigation.goBack(); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack()); }, [policy], ); diff --git a/src/pages/workspace/WorkspaceProfileDescriptionPage.tsx b/src/pages/workspace/WorkspaceProfileDescriptionPage.tsx index a72daf2e907e..f6a9e3649b22 100644 --- a/src/pages/workspace/WorkspaceProfileDescriptionPage.tsx +++ b/src/pages/workspace/WorkspaceProfileDescriptionPage.tsx @@ -65,14 +65,14 @@ function WorkspaceProfileDescriptionPage({policy}: Props) { Policy.updateWorkspaceDescription(policy.id, values.description.trim(), policy.description ?? ''); Keyboard.dismiss(); - Navigation.goBack(); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack()); }, [policy], ); return ( { + Category.setWorkspaceCategoryEnabled(policyId, {[categoryName]: {name: categoryName, enabled: value}}); + }, + [policyId], + ); + const categoryList = useMemo(() => { const categories = lodashSortBy(Object.values(policyCategories ?? {}), 'name', localeCompare) as PolicyCategory[]; return categories.reduce((acc, value) => { @@ -121,12 +128,19 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { isDisabled, pendingAction: value.pendingAction, errors: value.errors ?? undefined, - rightElement: , + rightElement: ( + updateWorkspaceRequiresCategory(newValue, value.name)} + /> + ), }); return acc; }, []); - }, [policyCategories, isOffline, selectedCategories, canSelectMultiple, translate]); + }, [policyCategories, isOffline, selectedCategories, canSelectMultiple, translate, updateWorkspaceRequiresCategory]); useAutoTurnSelectionModeOffWhenHasNoActiveOption(categoryList); diff --git a/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx b/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx index 2c6745fabe14..15ead5b9a323 100644 --- a/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx +++ b/src/pages/workspace/companyCards/addNew/SelectBankStep.tsx @@ -94,6 +94,7 @@ function SelectBankStep() { showConfirmButton confirmButtonText={translate('common.next')} onConfirm={submit} + confirmButtonStyles={styles.mt5} > {hasError && ( diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index 19878036030b..5b21f4a5307e 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -8,11 +8,11 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import ScreenWrapper from '@components/ScreenWrapper'; -import ListItemRightCaretWithLabel from '@components/SelectionList/ListItemRightCaretWithLabel'; import TableListItem from '@components/SelectionList/TableListItem'; import type {ListItem} from '@components/SelectionList/types'; import SelectionListWithModal from '@components/SelectionListWithModal'; import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; +import Switch from '@components/Switch'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -74,11 +74,11 @@ function PolicyDistanceRatesPage({ const dismissError = useCallback( (item: RateForList) => { if (customUnitRates[item.value].errors) { - DistanceRate.clearDeleteDistanceRateError(policyID, customUnit?.customUnitID ?? '', item.value); + DistanceRate.clearDeleteDistanceRateError(policyID, customUnit?.customUnitID ?? CONST.EMPTY_STRING, item.value); return; } - DistanceRate.clearCreateDistanceRateItemAndError(policyID, customUnit?.customUnitID ?? '', item.value); + DistanceRate.clearCreateDistanceRateItemAndError(policyID, customUnit?.customUnitID ?? CONST.EMPTY_STRING, item.value); }, [customUnit?.customUnitID, customUnitRates, policyID], ); @@ -98,16 +98,36 @@ function PolicyDistanceRatesPage({ setSelectedDistanceRates([]); }, [isFocused]); + const updateDistanceRateEnabled = useCallback( + (value: boolean, rateID: string) => { + if (!customUnit) { + return; + } + const rate = customUnit?.rates?.[rateID]; + // Rates can be disabled or deleted as long as in the remaining rates there is always at least one enabled rate and there are no pending delete action + const canDisableOrDeleteRate = Object.values(customUnit?.rates ?? {}).some( + (distanceRate: Rate) => distanceRate?.enabled && rateID !== distanceRate?.customUnitRateID && distanceRate?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + ); + + if (!rate?.enabled || canDisableOrDeleteRate) { + DistanceRate.setPolicyDistanceRatesEnabled(policyID, customUnit, [{...rate, enabled: value}]); + } else { + setIsWarningModalVisible(true); + } + }, + [customUnit, policyID], + ); + const distanceRatesList = useMemo( () => Object.values(customUnitRates) .sort((rateA, rateB) => (rateA?.rate ?? 0) - (rateB?.rate ?? 0)) .map((value) => ({ - value: value.customUnitRateID ?? '', + value: value.customUnitRateID ?? CONST.EMPTY_STRING, text: `${CurrencyUtils.convertAmountToDisplayString(value.rate, value.currency ?? CONST.CURRENCY.USD)} / ${translate( `common.${customUnit?.attributes?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}`, )}`, - keyForList: value.customUnitRateID ?? '', + keyForList: value.customUnitRateID ?? CONST.EMPTY_STRING, isSelected: selectedDistanceRates.find((rate) => rate.customUnitRateID === value.customUnitRateID) !== undefined && canSelectMultiple, isDisabled: value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, pendingAction: @@ -119,9 +139,16 @@ function PolicyDistanceRatesPage({ value.pendingFields?.taxClaimablePercentage ?? (policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD ? policy?.pendingAction : undefined), errors: value.errors ?? undefined, - rightElement: , + rightElement: ( + updateDistanceRateEnabled(newValue, value.customUnitRateID)} + disabled={value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE} + /> + ), })), - [customUnit?.attributes?.unit, customUnitRates, selectedDistanceRates, translate, policy?.pendingAction, canSelectMultiple], + [customUnitRates, translate, customUnit, selectedDistanceRates, canSelectMultiple, policy?.pendingAction, updateDistanceRateEnabled], ); const addRate = () => { @@ -170,7 +197,7 @@ function PolicyDistanceRatesPage({ DistanceRate.deletePolicyDistanceRates( policyID, customUnit, - selectedDistanceRates.map((rate) => rate.customUnitRateID ?? ''), + selectedDistanceRates.map((rate) => rate.customUnitRateID ?? CONST.EMPTY_STRING), ); setSelectedDistanceRates([]); setIsDeleteModalVisible(false); diff --git a/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx b/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx index 7203c37ca704..908888fc887e 100644 --- a/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx +++ b/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx @@ -1,4 +1,4 @@ -import React, {useMemo, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -10,12 +10,12 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import ScreenWrapper from '@components/ScreenWrapper'; -import ListItemRightCaretWithLabel from '@components/SelectionList/ListItemRightCaretWithLabel'; import TableListItem from '@components/SelectionList/TableListItem'; import type {ListItem} from '@components/SelectionList/types'; import SelectionListWithModal from '@components/SelectionListWithModal'; import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; import TableListItemSkeleton from '@components/Skeletons/TableRowSkeleton'; +import Switch from '@components/Switch'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -90,6 +90,18 @@ function ReportFieldsListValuesPage({ return [reportFieldValues, reportFieldDisabledValues]; }, [formDraft?.disabledListValues, formDraft?.listValues, policy?.fieldList, reportFieldID]); + const updateReportFieldListValueEnabled = useCallback( + (value: boolean, valueIndex: number) => { + if (reportFieldID) { + ReportField.updateReportFieldListValueEnabled(policyID, reportFieldID, [Number(valueIndex)], value); + return; + } + + ReportField.setReportFieldsListValueEnabled([valueIndex], value); + }, + [policyID, reportFieldID], + ); + const listValuesSections = useMemo(() => { const data = listValues .map((value, index) => ({ @@ -99,11 +111,17 @@ function ReportFieldsListValuesPage({ keyForList: value, isSelected: selectedValues[value] && canSelectMultiple, enabled: !disabledListValues.at(index) ?? true, - rightElement: , + rightElement: ( + updateReportFieldListValueEnabled(newValue, index)} + /> + ), })) .sort((a, b) => localeCompare(a.value, b.value)); return [{data, isDisabled: false}]; - }, [canSelectMultiple, disabledListValues, listValues, selectedValues, translate]); + }, [canSelectMultiple, disabledListValues, listValues, selectedValues, translate, updateReportFieldListValueEnabled]); const shouldShowEmptyState = Object.values(listValues ?? {}).length <= 0; const selectedValuesArray = Object.keys(selectedValues).filter((key) => selectedValues[key]); diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index cd3510cfffb5..fb94af11481c 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -15,11 +15,11 @@ import * as Illustrations from '@components/Icon/Illustrations'; import LottieAnimations from '@components/LottieAnimations'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import ScreenWrapper from '@components/ScreenWrapper'; -import ListItemRightCaretWithLabel from '@components/SelectionList/ListItemRightCaretWithLabel'; import TableListItem from '@components/SelectionList/TableListItem'; import SelectionListWithModal from '@components/SelectionListWithModal'; import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; import TableListItemSkeleton from '@components/Skeletons/TableRowSkeleton'; +import Switch from '@components/Switch'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import useEnvironment from '@hooks/useEnvironment'; @@ -103,23 +103,49 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { : undefined; }; + const updateWorkspaceTagEnabled = useCallback( + (value: boolean, tagName: string) => { + Tag.setWorkspaceTagEnabled(policyID, {[tagName]: {name: tagName, enabled: value}}, 0); + }, + [policyID], + ); + + const updateWorkspaceRequiresTag = useCallback( + (value: boolean, orderWeight: number) => { + Tag.setPolicyTagsRequired(policyID, value, orderWeight); + }, + [policyID], + ); const tagList = useMemo(() => { if (isMultiLevelTags) { - return policyTagLists.map((policyTagList) => ({ - value: policyTagList.name, - orderWeight: policyTagList.orderWeight, - text: PolicyUtils.getCleanedTagName(policyTagList.name), - keyForList: String(policyTagList.orderWeight), - isSelected: selectedTags[policyTagList.name] && canSelectMultiple, - pendingAction: getPendingAction(policyTagList), - enabled: true, - required: policyTagList.required, - rightElement: ( - tag.enabled) ? translate('common.required') : undefined} - /> - ), - })); + return policyTagLists.map((policyTagList) => { + const areTagsEnabled = !!Object.values(policyTagList?.tags ?? {}).some((tag) => tag.enabled); + const isSwitchDisabled = !policyTagList.required && !areTagsEnabled; + const isSwitchEnabled = policyTagList.required && areTagsEnabled; + + if (policyTagList.required && !areTagsEnabled) { + updateWorkspaceRequiresTag(false, policyTagList.orderWeight); + } + + return { + value: policyTagList.name, + orderWeight: policyTagList.orderWeight, + text: PolicyUtils.getCleanedTagName(policyTagList.name), + keyForList: String(policyTagList.orderWeight), + isSelected: selectedTags[policyTagList.name] && canSelectMultiple, + pendingAction: getPendingAction(policyTagList), + enabled: true, + required: policyTagList.required, + rightElement: ( + updateWorkspaceRequiresTag(newValue, policyTagList.orderWeight)} + disabled={isSwitchDisabled} + /> + ), + }; + }); } const sortedTags = lodashSortBy(Object.values(policyTagLists.at(0)?.tags ?? {}), 'name', localeCompare) as PolicyTag[]; return sortedTags.map((tag) => ({ @@ -131,9 +157,16 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { errors: tag.errors ?? undefined, enabled: tag.enabled, isDisabled: tag.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - rightElement: , + rightElement: ( + updateWorkspaceTagEnabled(newValue, tag.name)} + /> + ), })); - }, [isMultiLevelTags, policyTagLists, selectedTags, canSelectMultiple, translate]); + }, [isMultiLevelTags, policyTagLists, selectedTags, canSelectMultiple, translate, updateWorkspaceRequiresTag, updateWorkspaceTagEnabled]); const tagListKeyedByName = useMemo( () => diff --git a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx index 173c89c41551..a69dccc2f606 100644 --- a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx @@ -10,10 +10,10 @@ import * as Expensicons from '@components/Icon/Expensicons'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; -import ListItemRightCaretWithLabel from '@components/SelectionList/ListItemRightCaretWithLabel'; import TableListItem from '@components/SelectionList/TableListItem'; import SelectionListWithModal from '@components/SelectionListWithModal'; import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; +import Switch from '@components/Switch'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; @@ -52,7 +52,7 @@ function WorkspaceViewTagsPage({route}: WorkspaceViewTagsProps) { const dropdownButtonRef = useRef(null); const [isDeleteTagsConfirmModalVisible, setIsDeleteTagsConfirmModalVisible] = useState(false); const isFocused = useIsFocused(); - const policyID = route.params.policyID ?? '-1'; + const policyID = route.params.policyID; const backTo = route.params.backTo; const policy = usePolicy(policyID); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); @@ -80,6 +80,13 @@ function WorkspaceViewTagsPage({route}: WorkspaceViewTagsProps) { }; }, [isFocused]); + const updateWorkspaceTagEnabled = useCallback( + (value: boolean, tagName: string) => { + Tag.setWorkspaceTagEnabled(policyID, {[tagName]: {name: tagName, enabled: value}}, route.params.orderWeight); + }, + [policyID, route.params.orderWeight], + ); + const tagList = useMemo( () => Object.values(currentPolicyTag?.tags ?? {}) @@ -93,9 +100,16 @@ function WorkspaceViewTagsPage({route}: WorkspaceViewTagsProps) { errors: tag.errors ?? undefined, enabled: tag.enabled, isDisabled: tag.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - rightElement: , + rightElement: ( + updateWorkspaceTagEnabled(newValue, tag.name)} + /> + ), })), - [currentPolicyTag, selectedTags, canSelectMultiple, translate], + [currentPolicyTag?.tags, selectedTags, canSelectMultiple, translate, updateWorkspaceTagEnabled], ); const hasDependentTags = useMemo(() => PolicyUtils.hasDependentTags(policy, policyTags), [policy, policyTags]); diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx index e064c04878a1..deb304808707 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx @@ -10,11 +10,11 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import ScreenWrapper from '@components/ScreenWrapper'; -import ListItemRightCaretWithLabel from '@components/SelectionList/ListItemRightCaretWithLabel'; import TableListItem from '@components/SelectionList/TableListItem'; import type {ListItem} from '@components/SelectionList/types'; import SelectionListWithModal from '@components/SelectionListWithModal'; import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; +import Switch from '@components/Switch'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import useEnvironment from '@hooks/useEnvironment'; @@ -111,24 +111,42 @@ function WorkspaceTaxesPage({ [defaultExternalID, foreignTaxDefault, translate], ); + const updateWorkspaceTaxEnabled = useCallback( + (value: boolean, taxID: string) => { + setPolicyTaxesEnabled(policyID, [taxID], value); + }, + [policyID], + ); + const taxesList = useMemo(() => { if (!policy) { return []; } return Object.entries(policy.taxRates?.taxes ?? {}) - .map(([key, value]) => ({ - text: value.name, - alternateText: textForDefault(key, value), - keyForList: key, - isSelected: !!selectedTaxesIDs.includes(key) && canSelectMultiple, - isDisabledCheckbox: !PolicyUtils.canEditTaxRate(policy, key), - isDisabled: value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - pendingAction: value.pendingAction ?? (Object.keys(value.pendingFields ?? {}).length > 0 ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : null), - errors: value.errors ?? ErrorUtils.getLatestErrorFieldForAnyField(value), - rightElement: , - })) + .map(([key, value]) => { + const canEditTaxRate = policy && PolicyUtils.canEditTaxRate(policy, key); + + return { + text: value.name, + alternateText: textForDefault(key, value), + keyForList: key, + isSelected: !!selectedTaxesIDs.includes(key) && canSelectMultiple, + isDisabledCheckbox: !PolicyUtils.canEditTaxRate(policy, key), + isDisabled: value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + pendingAction: value.pendingAction ?? (Object.keys(value.pendingFields ?? {}).length > 0 ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : null), + errors: value.errors ?? ErrorUtils.getLatestErrorFieldForAnyField(value), + rightElement: ( + updateWorkspaceTaxEnabled(newValue, key)} + /> + ), + }; + }) .sort((a, b) => (a.text ?? a.keyForList ?? '').localeCompare(b.text ?? b.keyForList ?? '')); - }, [policy, textForDefault, selectedTaxesIDs, canSelectMultiple, translate]); + }, [policy, textForDefault, selectedTaxesIDs, canSelectMultiple, translate, updateWorkspaceTaxEnabled]); const isLoading = !isOffline && taxesList === undefined; diff --git a/src/stories/SelectionList.stories.tsx b/src/stories/SelectionList.stories.tsx index 0e87fdbb4239..b3dc4c5ae2d2 100644 --- a/src/stories/SelectionList.stories.tsx +++ b/src/stories/SelectionList.stories.tsx @@ -19,6 +19,13 @@ const SelectionListWithNavigation = withNavigationFallback(SelectionList); const story: Meta = { title: 'Components/SelectionList', component: SelectionList, + parameters: { + docs: { + source: { + type: 'code', + }, + }, + }, }; const SECTIONS = [ @@ -417,6 +424,7 @@ WithConfirmButton.args = { ...MultipleSelection.args, onConfirm: () => {}, confirmButtonText: 'Confirm', + showConfirmButton: true, }; export {Default, WithTextInput, WithHeaderMessage, WithAlternateText, MultipleSelection, WithSectionHeader, WithConfirmButton}; diff --git a/src/types/onyx/OnyxUpdatesFromServer.ts b/src/types/onyx/OnyxUpdatesFromServer.ts index 99d565ede08f..ef82ab013e39 100644 --- a/src/types/onyx/OnyxUpdatesFromServer.ts +++ b/src/types/onyx/OnyxUpdatesFromServer.ts @@ -30,7 +30,10 @@ type OnyxUpdatesFromServer = { lastUpdateID: number | string; /** Previous update ID from server */ - previousUpdateID: number | string; + previousUpdateID?: number | string; + + /** Whether the client should fetch pending updates from the server */ + shouldFetchPendingUpdates?: boolean; /** Request data sent to the server */ request?: Request; diff --git a/tests/actions/OnyxUpdateManagerTest.ts b/tests/actions/OnyxUpdateManagerTest.ts index e1008064163b..7366c13fa8b3 100644 --- a/tests/actions/OnyxUpdateManagerTest.ts +++ b/tests/actions/OnyxUpdateManagerTest.ts @@ -15,12 +15,13 @@ import CONST from '@src/CONST'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -import createOnyxMockUpdate from '../utils/createOnyxMockUpdate'; +import OnyxUpdateMockUtils from '../utils/OnyxUpdateMockUtils'; -jest.mock('@libs/actions/App'); -jest.mock('@libs/actions/OnyxUpdateManager/utils'); -jest.mock('@libs/actions/OnyxUpdateManager/utils/applyUpdates', () => { - const ApplyUpdatesImplementation = jest.requireActual('@libs/actions/OnyxUpdateManager/utils/applyUpdates'); +jest.mock('@userActions/OnyxUpdates'); +jest.mock('@userActions/App'); +jest.mock('@userActions/OnyxUpdateManager/utils'); +jest.mock('@userActions/OnyxUpdateManager/utils/applyUpdates', () => { + const ApplyUpdatesImplementation = jest.requireActual('@userActions/OnyxUpdateManager/utils/applyUpdates'); return { applyUpdates: jest.fn((updates: DeferredUpdatesDictionary) => ApplyUpdatesImplementation.applyUpdates(updates)), @@ -53,14 +54,14 @@ const exampleReportAction = { const initialData = {report1: exampleReportAction, report2: exampleReportAction, report3: exampleReportAction} as unknown as OnyxTypes.ReportActions; -const mockUpdate1 = createOnyxMockUpdate(1, [ +const mockUpdate1 = OnyxUpdateMockUtils.createUpdate(1, [ { onyxMethod: OnyxUtils.METHOD.SET, key: ONYX_KEY, value: initialData, }, ]); -const mockUpdate2 = createOnyxMockUpdate(2, [ +const mockUpdate2 = OnyxUpdateMockUtils.createUpdate(2, [ { onyxMethod: OnyxUtils.METHOD.MERGE, key: ONYX_KEY, @@ -79,7 +80,7 @@ const report2PersonDiff = { const report3AvatarDiff: Partial = { avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_5.png', }; -const mockUpdate3 = createOnyxMockUpdate(3, [ +const mockUpdate3 = OnyxUpdateMockUtils.createUpdate(3, [ { onyxMethod: OnyxUtils.METHOD.MERGE, key: ONYX_KEY, @@ -89,7 +90,7 @@ const mockUpdate3 = createOnyxMockUpdate(3, [ }, }, ]); -const mockUpdate4 = createOnyxMockUpdate(4, [ +const mockUpdate4 = OnyxUpdateMockUtils.createUpdate(4, [ { onyxMethod: OnyxUtils.METHOD.MERGE, key: ONYX_KEY, @@ -106,7 +107,7 @@ const report4 = { ...exampleReportAction, automatic: true, } satisfies Partial; -const mockUpdate5 = createOnyxMockUpdate(5, [ +const mockUpdate5 = OnyxUpdateMockUtils.createUpdate(5, [ { onyxMethod: OnyxUtils.METHOD.MERGE, key: ONYX_KEY, @@ -135,7 +136,7 @@ describe('actions/OnyxUpdateManager', () => { await Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 1); await Onyx.set(ONYX_KEY, initialData); - OnyxUpdateManagerUtils.mockValues.onValidateAndApplyDeferredUpdates = undefined; + OnyxUpdateManagerUtils.mockValues.beforeValidateAndApplyDeferredUpdates = undefined; App.mockValues.missingOnyxUpdatesToBeApplied = undefined; OnyxUpdateManagerExports.resetDeferralLogicVariables(); }); @@ -194,7 +195,7 @@ describe('actions/OnyxUpdateManager', () => { finishFirstCall = resolve; }); - OnyxUpdateManagerUtils.mockValues.onValidateAndApplyDeferredUpdates = () => { + OnyxUpdateManagerUtils.mockValues.beforeValidateAndApplyDeferredUpdates = () => { // After the first GetMissingOnyxUpdates call has been resolved, // we have to set the mocked results of for the second call. App.mockValues.missingOnyxUpdatesToBeApplied = [mockUpdate3, mockUpdate4]; @@ -261,7 +262,7 @@ describe('actions/OnyxUpdateManager', () => { }; let firstCallFinished = false; - OnyxUpdateManagerUtils.mockValues.onValidateAndApplyDeferredUpdates = () => { + OnyxUpdateManagerUtils.mockValues.beforeValidateAndApplyDeferredUpdates = () => { if (firstCallFinished) { assertAfterSecondGetMissingOnyxUpdates(); return Promise.resolve(); diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index 0bcd22e05bb7..ead8dca198a1 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -190,4 +190,36 @@ describe('actions/Policy', () => { }); }); }); + + describe('upgradeToCorporate', () => { + it('upgradeToCorporate should not alter initial values of autoReporting and autoReportingFrequency', async () => { + const autoReporting = true; + const autoReportingFrequency = CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT; + // Given that a policy has autoReporting initially set to true and autoReportingFrequency set to instant. + const fakePolicy: PolicyType = { + ...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), + autoReporting, + autoReportingFrequency, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + + // When a policy is upgradeToCorporate + Policy.upgradeToCorporate(fakePolicy.id); + await waitForBatchedUpdates(); + + const policy: OnyxEntry = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, + callback: (workspace) => { + Onyx.disconnect(connection); + resolve(workspace); + }, + }); + }); + + // Then the policy autoReporting and autoReportingFrequency should equal the initial value. + expect(policy?.autoReporting).toBe(autoReporting); + expect(policy?.autoReportingFrequency).toBe(autoReportingFrequency); + }); + }); }); diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx index 1aacaaa5471f..a8cae6db0203 100644 --- a/tests/perf-test/ReportActionsList.perf-test.tsx +++ b/tests/perf-test/ReportActionsList.perf-test.tsx @@ -25,6 +25,15 @@ type LazyLoadLHNTestUtils = { const mockedNavigate = jest.fn(); +// Mock Fullstory library dependency +jest.mock('@libs/Fullstory', () => ({ + default: { + consentAndIdentify: jest.fn(), + }, + getFSAttributes: jest.fn(), + getChatFSAttributes: jest.fn().mockReturnValue(['mockTestID', 'mockFSClass']), +})); + jest.mock('@components/withCurrentUserPersonalDetails', () => { // Lazy loading of LHNTestUtils const lazyLoadLHNTestUtils = () => require('../utils/LHNTestUtils'); diff --git a/tests/ui/WorkspaceCategoriesTest.tsx b/tests/ui/WorkspaceCategoriesTest.tsx index eca2f803f70e..e01ea073ace2 100644 --- a/tests/ui/WorkspaceCategoriesTest.tsx +++ b/tests/ui/WorkspaceCategoriesTest.tsx @@ -6,7 +6,7 @@ import Onyx from 'react-native-onyx'; import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxProvider from '@components/OnyxProvider'; -import {CurrentReportIDContextProvider} from '@components/withCurrentReportID'; +import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; import * as Localize from '@libs/Localize'; diff --git a/tests/unit/OnyxUpdateManagerTest.ts b/tests/unit/OnyxUpdateManagerTest.ts index 7b0446641458..cd0922416a62 100644 --- a/tests/unit/OnyxUpdateManagerTest.ts +++ b/tests/unit/OnyxUpdateManagerTest.ts @@ -1,17 +1,20 @@ import Onyx from 'react-native-onyx'; -import type {AppActionsMock} from '@libs/actions/__mocks__/App'; -import * as AppImport from '@libs/actions/App'; -import * as OnyxUpdateManager from '@libs/actions/OnyxUpdateManager'; -import * as OnyxUpdateManagerUtilsImport from '@libs/actions/OnyxUpdateManager/utils'; -import type {OnyxUpdateManagerUtilsMock} from '@libs/actions/OnyxUpdateManager/utils/__mocks__'; -import type {ApplyUpdatesMock} from '@libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates'; -import * as ApplyUpdatesImport from '@libs/actions/OnyxUpdateManager/utils/applyUpdates'; +import type {AppActionsMock} from '@userActions/__mocks__/App'; +import type {OnyxUpdatesMock} from '@userActions/__mocks__/OnyxUpdates'; +import * as AppImport from '@userActions/App'; +import * as OnyxUpdateManager from '@userActions/OnyxUpdateManager'; +import * as OnyxUpdateManagerUtilsImport from '@userActions/OnyxUpdateManager/utils'; +import type {OnyxUpdateManagerUtilsMock} from '@userActions/OnyxUpdateManager/utils/__mocks__'; +import type {ApplyUpdatesMock} from '@userActions/OnyxUpdateManager/utils/__mocks__/applyUpdates'; +import * as ApplyUpdatesImport from '@userActions/OnyxUpdateManager/utils/applyUpdates'; +import * as OnyxUpdatesImport from '@userActions/OnyxUpdates'; import ONYXKEYS from '@src/ONYXKEYS'; -import createOnyxMockUpdate from '../utils/createOnyxMockUpdate'; +import OnyxUpdateMockUtils from '../utils/OnyxUpdateMockUtils'; -jest.mock('@libs/actions/App'); -jest.mock('@libs/actions/OnyxUpdateManager/utils'); -jest.mock('@libs/actions/OnyxUpdateManager/utils/applyUpdates'); +jest.mock('@userActions/OnyxUpdates'); +jest.mock('@userActions/App'); +jest.mock('@userActions/OnyxUpdateManager/utils'); +jest.mock('@userActions/OnyxUpdateManager/utils/applyUpdates'); jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ default: () => ({ @@ -19,17 +22,23 @@ jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ }), })); +const OnyxUpdates = OnyxUpdatesImport as OnyxUpdatesMock; const App = AppImport as AppActionsMock; const ApplyUpdates = ApplyUpdatesImport as ApplyUpdatesMock; const OnyxUpdateManagerUtils = OnyxUpdateManagerUtilsImport as OnyxUpdateManagerUtilsMock; -const mockUpdate3 = createOnyxMockUpdate(3); -const offsetedMockUpdate3 = createOnyxMockUpdate(3, undefined, 2); -const mockUpdate4 = createOnyxMockUpdate(4); -const mockUpdate5 = createOnyxMockUpdate(5); -const mockUpdate6 = createOnyxMockUpdate(6); -const mockUpdate7 = createOnyxMockUpdate(7); -const mockUpdate8 = createOnyxMockUpdate(8); +const update2 = OnyxUpdateMockUtils.createUpdate(2); +const pendingUpdateUpTo2 = OnyxUpdateMockUtils.createPendingUpdate(2); + +const update3 = OnyxUpdateMockUtils.createUpdate(3); +const pendingUpdateUpTo3 = OnyxUpdateMockUtils.createPendingUpdate(3); +const offsetUpdate3 = OnyxUpdateMockUtils.createUpdate(3, undefined, 1); + +const update4 = OnyxUpdateMockUtils.createUpdate(4); +const update5 = OnyxUpdateMockUtils.createUpdate(5); +const update6 = OnyxUpdateMockUtils.createUpdate(6); +const update7 = OnyxUpdateMockUtils.createUpdate(7); +const update8 = OnyxUpdateMockUtils.createUpdate(8); describe('OnyxUpdateManager', () => { let lastUpdateIDAppliedToClient = 1; @@ -43,20 +52,26 @@ describe('OnyxUpdateManager', () => { beforeEach(async () => { jest.clearAllMocks(); await Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 1); - OnyxUpdateManagerUtils.mockValues.onValidateAndApplyDeferredUpdates = undefined; - ApplyUpdates.mockValues.onApplyUpdates = undefined; + OnyxUpdateManagerUtils.mockValues.beforeValidateAndApplyDeferredUpdates = undefined; + ApplyUpdates.mockValues.beforeApplyUpdates = undefined; + App.mockValues.missingOnyxUpdatesToBeApplied = undefined; OnyxUpdateManager.resetDeferralLogicVariables(); }); it('should fetch missing Onyx updates once, defer updates and apply after missing updates', () => { - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate3); - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate4); - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate5); + OnyxUpdateManager.handleMissingOnyxUpdates(update3); + OnyxUpdateManager.handleMissingOnyxUpdates(update4); + OnyxUpdateManager.handleMissingOnyxUpdates(update5); return OnyxUpdateManager.queryPromise.then(() => { // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 6. expect(lastUpdateIDAppliedToClient).toBe(5); + // OnyxUpdates.apply should have been called 4 times, + // - once for the fetched missing update (2) + // - three times for the deferred updates (3-5) + expect(OnyxUpdates.apply).toHaveBeenCalledTimes(4); + // There are no gaps in the deferred updates, therefore only one call to getMissingOnyxUpdates should be triggered expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(1); @@ -70,27 +85,32 @@ describe('OnyxUpdateManager', () => { // since the locally applied updates have changed in the meantime. expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(1); // eslint-disable-next-line @typescript-eslint/naming-convention - expect(ApplyUpdates.applyUpdates).toHaveBeenCalledWith({3: mockUpdate3, 4: mockUpdate4, 5: mockUpdate5}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: update3, 4: update4, 5: update5}); }); }); - it('should only apply deferred updates that are newer than the last locally applied update (pending updates)', async () => { - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate4); - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate5); - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate6); + it('should only apply deferred updates that are newer than the last locally applied update (pending deferred updates)', async () => { + OnyxUpdateManager.handleMissingOnyxUpdates(update4); + OnyxUpdateManager.handleMissingOnyxUpdates(update5); + OnyxUpdateManager.handleMissingOnyxUpdates(update6); - OnyxUpdateManagerUtils.mockValues.onValidateAndApplyDeferredUpdates = async () => { + OnyxUpdateManagerUtils.mockValues.beforeValidateAndApplyDeferredUpdates = async () => { // We manually update the lastUpdateIDAppliedToClient to 5, to simulate local updates being applied, // while we are waiting for the missing updates to be fetched. // Only the deferred updates after the lastUpdateIDAppliedToClient should be applied. await Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 5); - OnyxUpdateManagerUtils.mockValues.onValidateAndApplyDeferredUpdates = undefined; + OnyxUpdateManagerUtils.mockValues.beforeValidateAndApplyDeferredUpdates = undefined; }; return OnyxUpdateManager.queryPromise.then(() => { // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 6. expect(lastUpdateIDAppliedToClient).toBe(6); + // OnyxUpdates.apply should have been called 2 times, + // - twice for the fetched missing updates (2-3) + // - once for the deferred update (6) + expect(OnyxUpdates.apply).toHaveBeenCalledTimes(3); + // There are no gaps in the deferred updates, therefore only one call to getMissingOnyxUpdates should be triggered expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(1); @@ -108,19 +128,25 @@ describe('OnyxUpdateManager', () => { expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(1); // eslint-disable-next-line @typescript-eslint/naming-convention - expect(ApplyUpdates.applyUpdates).toHaveBeenCalledWith({6: mockUpdate6}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {6: update6}); }); }); it('should re-fetch missing updates if the deferred updates have a gap', async () => { - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate3); - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate5); - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate6); + OnyxUpdateManager.handleMissingOnyxUpdates(update3); + OnyxUpdateManager.handleMissingOnyxUpdates(update5); + OnyxUpdateManager.handleMissingOnyxUpdates(update6); return OnyxUpdateManager.queryPromise.then(() => { // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 6. expect(lastUpdateIDAppliedToClient).toBe(6); + // OnyxUpdates.apply should have been called 5 times, + // - once for the first fetched missing update (2) + // - once for the second fetched missing update (4) + // - three times for the deferred updates (3, 5, 6) + expect(OnyxUpdates.apply).toHaveBeenCalledTimes(5); + // Even though there is a gap in the deferred updates, we only want to fetch missing updates once per batch. expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(2); expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(2); @@ -136,27 +162,34 @@ describe('OnyxUpdateManager', () => { // After the initial missing updates have been applied, the applicable updates (3) should be applied. // eslint-disable-next-line @typescript-eslint/naming-convention - expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: mockUpdate3}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: update3}); // The second call to getMissingOnyxUpdates should fetch the missing updates from the gap in the deferred updates. 3-4 expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(2, 3, 4); // After the gap in the deferred updates has been resolved, the remaining deferred updates (5, 6) should be applied. // eslint-disable-next-line @typescript-eslint/naming-convention - expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(2, {5: mockUpdate5, 6: mockUpdate6}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(2, {5: update5, 6: update6}); }); }); it('should re-fetch missing deferred updates only once per batch', async () => { - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate3); - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate4); - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate6); - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate8); + OnyxUpdateManager.handleMissingOnyxUpdates(update3); + OnyxUpdateManager.handleMissingOnyxUpdates(update4); + OnyxUpdateManager.handleMissingOnyxUpdates(update6); + OnyxUpdateManager.handleMissingOnyxUpdates(update8); return OnyxUpdateManager.queryPromise.then(() => { // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 6. expect(lastUpdateIDAppliedToClient).toBe(8); + // OnyxUpdates.apply should have been called 7 times, + // - once for the first fetched missing update (2) + // - once for the second fetched missing update (5) + // - once for the third fetched missing update (7) + // - four times for the deferred updates (3, 4, 6, 8) + expect(OnyxUpdates.apply).toHaveBeenCalledTimes(7); + // Even though there are multiple gaps in the deferred updates, we only want to fetch missing updates once per batch. expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(2); expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(2); @@ -167,36 +200,42 @@ describe('OnyxUpdateManager', () => { // After the initial missing updates have been applied, the applicable updates (3-4) should be applied. // eslint-disable-next-line @typescript-eslint/naming-convention - expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: mockUpdate3, 4: mockUpdate4}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: update3, 4: update4}); // The second call to getMissingOnyxUpdates should fetch the missing updates from the gap (4-7) in the deferred updates. expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(2, 4, 7); // After the gap in the deferred updates has been resolved, the remaining deferred updates (8) should be applied. // eslint-disable-next-line @typescript-eslint/naming-convention - expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(2, {8: mockUpdate8}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(2, {8: update8}); }); }); it('should not re-fetch missing updates if the lastUpdateIDFromClient has been updated', async () => { - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate3); - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate5); - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate6); - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate7); + OnyxUpdateManager.handleMissingOnyxUpdates(update3); + OnyxUpdateManager.handleMissingOnyxUpdates(update5); + OnyxUpdateManager.handleMissingOnyxUpdates(update6); + OnyxUpdateManager.handleMissingOnyxUpdates(update7); - ApplyUpdates.mockValues.onApplyUpdates = async () => { + ApplyUpdates.mockValues.beforeApplyUpdates = async () => { // We manually update the lastUpdateIDAppliedToClient to 5, to simulate local updates being applied, // while the applicable updates have been applied. // When this happens, the OnyxUpdateManager should trigger another validation of the deferred updates, // without triggering another re-fetching of missing updates from the server. await Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 5); - ApplyUpdates.mockValues.onApplyUpdates = undefined; + ApplyUpdates.mockValues.beforeApplyUpdates = undefined; }; return OnyxUpdateManager.queryPromise.then(() => { // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 6. expect(lastUpdateIDAppliedToClient).toBe(7); + // OnyxUpdates.apply should have been called 4 times, + // - once for the first fetched missing update (2) + // - updates 3-5 are not fetched, because they are set externally + // - two times for the remaining deferred updates (6, 7) + expect(OnyxUpdates.apply).toHaveBeenCalledTimes(4); + // Even though there are multiple gaps in the deferred updates, we only want to fetch missing updates once per batch. expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); @@ -211,31 +250,38 @@ describe('OnyxUpdateManager', () => { // After the initial missing updates have been applied, the applicable updates (3) should be applied. // eslint-disable-next-line @typescript-eslint/naming-convention - expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: mockUpdate3}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: update3}); // Since the lastUpdateIDAppliedToClient has changed to 5 in the meantime, we only need to apply the remaining deferred updates (6-7). // eslint-disable-next-line @typescript-eslint/naming-convention - expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(2, {6: mockUpdate6, 7: mockUpdate7}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(2, {6: update6, 7: update7}); }); }); it('should re-fetch missing updates if the lastUpdateIDFromClient has increased, but there are still gaps after the locally applied update', async () => { - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate3); - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate7); + OnyxUpdateManager.handleMissingOnyxUpdates(update3); + OnyxUpdateManager.handleMissingOnyxUpdates(update7); - ApplyUpdates.mockValues.onApplyUpdates = async () => { + ApplyUpdates.mockValues.beforeApplyUpdates = async () => { // We manually update the lastUpdateIDAppliedToClient to 4, to simulate local updates being applied, // while the applicable updates have been applied. // When this happens, the OnyxUpdateManager should trigger another validation of the deferred updates, // without triggering another re-fetching of missing updates from the server. await Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, 4); - ApplyUpdates.mockValues.onApplyUpdates = undefined; + ApplyUpdates.mockValues.beforeApplyUpdates = undefined; }; return OnyxUpdateManager.queryPromise.then(() => { // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 6. expect(lastUpdateIDAppliedToClient).toBe(7); + // OnyxUpdates.apply should have been called 6 times, + // - once for the first fetched missing update (2) + // - updates 3-4 are not fetched, because they are set externally + // - three times for the second fetched missing updates (5-6) + // - once for the remaining deferred update (7) + expect(OnyxUpdates.apply).toHaveBeenCalledTimes(5); + // Even though there are multiple gaps in the deferred updates, we only want to fetch missing updates once per batch. expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(2); @@ -250,26 +296,32 @@ describe('OnyxUpdateManager', () => { // After the initial missing updates have been applied, the applicable updates (3) should be applied. // eslint-disable-next-line @typescript-eslint/naming-convention - expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: mockUpdate3}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: update3}); // The second call to getMissingOnyxUpdates should fetch the missing updates from the gap in the deferred updates, // that are later than the locally applied update (4-6). (including the last locally applied update) expect(App.getMissingOnyxUpdates).toHaveBeenNthCalledWith(2, 4, 6); - // Since the lastUpdateIDAppliedToClient has changed to 4 in the meantime and we're fetching updates 5-6 we only need to apply the remaining deferred updates (7). + // Since the lastUpdateIDAppliedToClient has changed to 4 in the meantime, and we're fetching updates 5-6 we only need to apply the remaining deferred updates (7). // eslint-disable-next-line @typescript-eslint/naming-convention - expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(2, {7: mockUpdate7}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(2, {7: update7}); }); }); it('should only fetch missing updates that are not outdated (older than already locally applied update)', () => { - OnyxUpdateManager.handleOnyxUpdateGap(offsetedMockUpdate3); - OnyxUpdateManager.handleOnyxUpdateGap(mockUpdate4); + OnyxUpdateManager.handleMissingOnyxUpdates(offsetUpdate3); + OnyxUpdateManager.handleMissingOnyxUpdates(update4); return OnyxUpdateManager.queryPromise.then(() => { // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 4. expect(lastUpdateIDAppliedToClient).toBe(4); + // OnyxUpdates.apply should have been called 2 times, + // - once for the first fetched missing update (2) + // - the offset update is omitted + // - three times for the deferred update (4) + expect(OnyxUpdates.apply).toHaveBeenCalledTimes(2); + // validateAndApplyDeferredUpdates should be called once for the initial deferred updates // Unfortunately, we cannot easily count the calls of this function with Jest, since it recursively calls itself. // The intended assertion would look like this: @@ -279,7 +331,89 @@ describe('OnyxUpdateManager', () => { // since the locally applied updates have changed in the meantime. expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(1); // eslint-disable-next-line @typescript-eslint/naming-convention - expect(ApplyUpdates.applyUpdates).toHaveBeenCalledWith({3: offsetedMockUpdate3, 4: mockUpdate4}); + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {3: offsetUpdate3, 4: update4}); + + // There are no gaps in the deferred updates, therefore only one call to getMissingOnyxUpdates should be triggered + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); + }); + }); + + it('should fetch a single pending update if `hasPendingOnyxUpdates flag is true`', () => { + App.mockValues.missingOnyxUpdatesToBeApplied = [update2]; + + OnyxUpdateManager.handleMissingOnyxUpdates(pendingUpdateUpTo2); + + return OnyxUpdateManager.queryPromise.then(() => { + // After all the pending update has been applied, the lastUpdateIDAppliedToClient should be 2. + expect(lastUpdateIDAppliedToClient).toBe(2); + + // OnyxUpdates.apply should have been called 1 times, + // - once for the pending update (2) + expect(OnyxUpdates.apply).toHaveBeenCalledTimes(1); + + // validateAndApplyDeferredUpdates should be called once for the initial deferred updates + // Unfortunately, we cannot easily count the calls of this function with Jest, since it recursively calls itself. + // The intended assertion would look like this: + // expect(OnyxUpdateManagerUtils.validateAndApplyDeferredUpdates).toHaveBeenCalledTimes(1); + + // There should be no call to applyUpdates, because there are no deferred updates + expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(0); + + // There are no deferred updates, so this should only be called once for the pending update. + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); + }); + }); + + it('should fetch multiple pending updates if `hasPendingOnyxUpdates flag is true`', () => { + App.mockValues.missingOnyxUpdatesToBeApplied = [update2, update3]; + + OnyxUpdateManager.handleMissingOnyxUpdates(pendingUpdateUpTo3); + + return OnyxUpdateManager.queryPromise.then(() => { + // After all the pending update has been applied, the lastUpdateIDAppliedToClient should be 3. + expect(lastUpdateIDAppliedToClient).toBe(3); + + // OnyxUpdates.apply should have been called 2 times, + // - twice for the pending updates (2, 3) + expect(OnyxUpdates.apply).toHaveBeenCalledTimes(2); + + // validateAndApplyDeferredUpdates should be called once for the initial deferred updates + // Unfortunately, we cannot easily count the calls of this function with Jest, since it recursively calls itself. + // The intended assertion would look like this: + // expect(OnyxUpdateManagerUtils.validateAndApplyDeferredUpdates).toHaveBeenCalledTimes(1); + + // There should be no call to applyUpdates, because there are no deferred updates + expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(0); + + // There are no deferred updates, so this should only be called once for the pending update. + expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); + }); + }); + + it('should apply deferred updates after fetching pending updates', () => { + App.mockValues.missingOnyxUpdatesToBeApplied = [update2, update3]; + + OnyxUpdateManager.handleMissingOnyxUpdates(pendingUpdateUpTo3); + OnyxUpdateManager.handleMissingOnyxUpdates(update4); + + return OnyxUpdateManager.queryPromise.then(() => { + // After all missing and deferred updates have been applied, the lastUpdateIDAppliedToClient should be 4. + expect(lastUpdateIDAppliedToClient).toBe(4); + + // OnyxUpdates.apply should have been called 3 times, + // - twice for the pending updates (2, 3) + // - once for the deferred update (4) + expect(OnyxUpdates.apply).toHaveBeenCalledTimes(3); + + // validateAndApplyDeferredUpdates should be called once for the initial deferred updates + // Unfortunately, we cannot easily count the calls of this function with Jest, since it recursively calls itself. + // The intended assertion would look like this: + // expect(OnyxUpdateManagerUtils.validateAndApplyDeferredUpdates).toHaveBeenCalledTimes(1); + + // There should be only one call to applyUpdates. The call should contain the deferred updates. + expect(ApplyUpdates.applyUpdates).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/naming-convention + expect(ApplyUpdates.applyUpdates).toHaveBeenNthCalledWith(1, {4: update4}); // There are no gaps in the deferred updates, therefore only one call to getMissingOnyxUpdates should be triggered expect(App.getMissingOnyxUpdates).toHaveBeenCalledTimes(1); diff --git a/tests/utils/LHNTestUtils.tsx b/tests/utils/LHNTestUtils.tsx index 1a3fc3d07f28..0d2613ea7739 100644 --- a/tests/utils/LHNTestUtils.tsx +++ b/tests/utils/LHNTestUtils.tsx @@ -6,8 +6,8 @@ import React from 'react'; import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxProvider from '@components/OnyxProvider'; -import {CurrentReportIDContextProvider} from '@components/withCurrentReportID'; import {EnvironmentProvider} from '@components/withEnvironment'; +import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; import {ReportIDsContextProvider} from '@hooks/useReportIDs'; import DateUtils from '@libs/DateUtils'; import * as ReportUtils from '@libs/ReportUtils'; diff --git a/tests/utils/OnyxUpdateMockUtils.ts b/tests/utils/OnyxUpdateMockUtils.ts new file mode 100644 index 000000000000..90d738a413cc --- /dev/null +++ b/tests/utils/OnyxUpdateMockUtils.ts @@ -0,0 +1,36 @@ +import type {OnyxUpdate} from 'react-native-onyx'; +import CONST from '@src/CONST'; +import type {OnyxUpdatesFromServer} from '@src/types/onyx'; + +const createUpdate = (lastUpdateID: number, successData: OnyxUpdate[] = [], previousUpdateID?: number): OnyxUpdatesFromServer => ({ + type: CONST.ONYX_UPDATE_TYPES.HTTPS, + lastUpdateID, + previousUpdateID: previousUpdateID ?? lastUpdateID - 1, + request: { + command: 'TestCommand', + successData, + failureData: [], + finallyData: [], + optimisticData: [], + }, + response: { + jsonCode: 200, + lastUpdateID, + previousUpdateID: previousUpdateID ?? lastUpdateID - 1, + onyxData: successData, + }, +}); + +const createPendingUpdate = (lastUpdateID: number): OnyxUpdatesFromServer => ({ + type: CONST.ONYX_UPDATE_TYPES.AIRSHIP, + lastUpdateID, + shouldFetchPendingUpdates: true, + updates: [], +}); + +const OnyxUpdateMockUtils = { + createUpdate, + createPendingUpdate, +}; + +export default OnyxUpdateMockUtils; diff --git a/tests/utils/createOnyxMockUpdate.ts b/tests/utils/createOnyxMockUpdate.ts deleted file mode 100644 index e50918b01faf..000000000000 --- a/tests/utils/createOnyxMockUpdate.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type {OnyxUpdate} from 'react-native-onyx'; -import type {OnyxUpdatesFromServer} from '@src/types/onyx'; - -const createOnyxMockUpdate = (lastUpdateID: number, successData: OnyxUpdate[] = [], previousUpdateIDOffset = 1): OnyxUpdatesFromServer => ({ - type: 'https', - lastUpdateID, - previousUpdateID: lastUpdateID - previousUpdateIDOffset, - request: { - command: 'TestCommand', - successData, - failureData: [], - finallyData: [], - optimisticData: [], - }, - response: { - jsonCode: 200, - lastUpdateID, - previousUpdateID: lastUpdateID - previousUpdateIDOffset, - onyxData: successData, - }, -}); - -export default createOnyxMockUpdate; From a3001be708414c3c137e3d775a9a2aaa137a4526 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Fri, 10 Jan 2025 17:29:33 +0100 Subject: [PATCH 34/38] apply design changes --- .../SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx | 4 ++-- src/pages/settings/Wallet/PaymentMethodList.tsx | 2 +- .../workspace/companyCards/addNew/CardInstructionsStep.tsx | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index a2b2f18ccf2c..aff8d9ef4c17 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -48,10 +48,10 @@ function getRepeatingBanks(workspaceCardFeedsKeys: string[], domainFeedsData: Re } function createIndividualCardFilterItem(card: Card, personalDetailsList: PersonalDetailsList, selectedCards: string[]): CardFilterItem { - const personalDetails = personalDetailsList[card?.accountID ?? '']; + const personalDetails = personalDetailsList[card?.accountID ?? CONST.DEFAULT_NUMBER_ID]; const isSelected = selectedCards.includes(card.cardID.toString()); const icon = CardUtils.getCardFeedIcon(card?.bank as CompanyCardFeed); - const cardName = card?.nameValuePairs?.cardTitle ?? card?.cardName; + const cardName = card?.nameValuePairs?.cardTitle; const text1 = card.bank === CONST.EXPENSIFY_CARD.BANK ? card.bank : cardName; const text = personalDetails?.displayName ?? text1; diff --git a/src/pages/settings/Wallet/PaymentMethodList.tsx b/src/pages/settings/Wallet/PaymentMethodList.tsx index 4a9fe5c89ec3..ee8035622029 100644 --- a/src/pages/settings/Wallet/PaymentMethodList.tsx +++ b/src/pages/settings/Wallet/PaymentMethodList.tsx @@ -126,7 +126,7 @@ function dismissError(item: PaymentMethodItem) { const isBankAccount = item.accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT; const paymentList = isBankAccount ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST; - const paymentID = isBankAccount ? item.accountData?.bankAccountID ?? '' : item.accountData?.fundID ?? ''; + const paymentID = isBankAccount ? item.accountData?.bankAccountID ?? CONST.DEFAULT_NUMBER_ID : item.accountData?.fundID ?? CONST.DEFAULT_NUMBER_ID; if (!paymentID) { Log.info('Unable to clear payment method error: ', undefined, item); diff --git a/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx b/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx index 44874ad61b78..4aab5acfbb0c 100644 --- a/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx +++ b/src/pages/workspace/companyCards/addNew/CardInstructionsStep.tsx @@ -52,8 +52,8 @@ function CardInstructionsStep({policyID}: CardInstructionsStepProps) { const buttonTranslation = isStripeFeedProvider ? translate('common.submit') : translate('common.next'); const submit = () => { - if (isStripeFeedProvider) { - Card.updateSelectedFeed(feedProvider, policyID ?? '-1'); + if (isStripeFeedProvider && policyID) { + Card.updateSelectedFeed(feedProvider, policyID); Navigation.goBack(); return; } From 69400adfd4516f87de438b15b801c3ba7224b7ba Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Fri, 10 Jan 2025 17:52:49 +0100 Subject: [PATCH 35/38] clean up code --- src/libs/API/parameters/ExportSearchItemsToCSVParams.ts | 2 +- src/libs/actions/Search.ts | 2 +- .../SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts b/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts index ac1a90ca1ada..057b6188e3ea 100644 --- a/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts +++ b/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts @@ -5,7 +5,7 @@ type ExportSearchItemsToCSVParams = { jsonQuery: SearchQueryString; reportIDList: string[]; transactionIDList: string[]; - policyIDs?: string[]; + policyIDs: string[]; }; export default ExportSearchItemsToCSVParams; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 83965d62c789..0662b705d83e 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -358,7 +358,7 @@ function deleteMoneyRequestOnSearch(hash: number, transactionIDList: string[]) { type Params = Record; -function exportSearchItemsToCSV({query, jsonQuery, reportIDList, transactionIDList, policyIDs = ['']}: ExportSearchItemsToCSVParams, onDownloadFailed: () => void) { +function exportSearchItemsToCSV({query, jsonQuery, reportIDList, transactionIDList, policyIDs}: ExportSearchItemsToCSVParams, onDownloadFailed: () => void) { const finalParameters = enhanceParameters(WRITE_COMMANDS.EXPORT_SEARCH_ITEMS_TO_CSV, { query, jsonQuery, diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index aff8d9ef4c17..585fb09f15af 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -52,8 +52,7 @@ function createIndividualCardFilterItem(card: Card, personalDetailsList: Persona const isSelected = selectedCards.includes(card.cardID.toString()); const icon = CardUtils.getCardFeedIcon(card?.bank as CompanyCardFeed); const cardName = card?.nameValuePairs?.cardTitle; - const text1 = card.bank === CONST.EXPENSIFY_CARD.BANK ? card.bank : cardName; - const text = personalDetails?.displayName ?? text1; + const text = personalDetails?.displayName ?? cardName; return { lastFourPAN: card.lastFourPAN, @@ -177,7 +176,9 @@ function buildCardFeedsData( const isBankRepeating = repeatingBanks.includes(bank); const policyID = domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1] ?? ''; const correspondingPolicy = PolicyUtils.getPolicy(policyID?.toUpperCase()); - const correspondingCardIDs = Object.keys(cardFeed ?? {}).filter((cardKey) => cardKey !== 'cardList'); + const correspondingCardIDs = Object.entries(cardFeed ?? {}) + .filter(([cardKey, card]) => cardKey !== 'cardList' && CardUtils.isCard(card) && isCardIssued(card)) + .map(([cardKey]) => cardKey); const feedItem = createCardFeedItem({ bank, From 22dd304589c1a1e4f34129ec28624201627d9690 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Mon, 13 Jan 2025 13:25:02 +0100 Subject: [PATCH 36/38] fix card page bugs --- .../Search/SearchRouter/SearchRouterList.tsx | 1 + .../SelectionList/Search/CardListItem.tsx | 1 - src/libs/CardUtils.ts | 5 ++++ .../SearchFiltersCardPage.tsx | 24 ++++++++++--------- src/pages/Search/SearchTypeMenuNarrow.tsx | 7 ++++-- 5 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index c6cda8a76788..f196a4f98597 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -336,6 +336,7 @@ function SearchRouterList( } case CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID: { const filteredCards = cardAutocompleteList + .filter((card) => CardUtils.isCard(card) && CardUtils.isCardIssued(card)) .filter( (card) => card.bank.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(CardUtils.getCardDescription(card.cardID).toLowerCase()), diff --git a/src/components/SelectionList/Search/CardListItem.tsx b/src/components/SelectionList/Search/CardListItem.tsx index 5c40d11b2ddb..a11da62979cd 100644 --- a/src/components/SelectionList/Search/CardListItem.tsx +++ b/src/components/SelectionList/Search/CardListItem.tsx @@ -108,7 +108,6 @@ function CardListItem({ width={variables.cardMiniatureWidth} height={variables.cardMiniatureHeight} additionalStyles={styles.cardMiniature} - fill={theme.componentBG} /> diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 74a561c85e20..115453ac3091 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -91,6 +91,10 @@ function isCard(item: Card | Record): item is Card { return typeof item === 'object' && 'cardID' in item && !!item.cardID && 'bank' in item && !!item.bank; } +function isCardIssued(card: Card) { + return !!card?.nameValuePairs?.isVirtual || card?.state !== CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED; +} + function mergeCardListWithWorkspaceFeeds(workspaceFeeds: Record, cardList = allCards) { const feedCards: CardList = {...cardList}; Object.values(workspaceFeeds ?? {}).forEach((currentCardFeed) => { @@ -480,4 +484,5 @@ export { isCard, getDescriptionForPolicyDomainCard, getAllCardsForWorkspace, + isCardIssued, }; diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx index 585fb09f15af..876ec032ca21 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx @@ -28,10 +28,6 @@ type ItemsGroupedBySelection = {selected: CardFilterItem[]; unselected: CardFilt type DomainFeedData = {bank: string; domainName: string; correspondingCardIDs: string[]}; -function isCardIssued(card: Card) { - return !!card?.nameValuePairs?.isVirtual || card?.state !== CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED; -} - function getRepeatingBanks(workspaceCardFeedsKeys: string[], domainFeedsData: Record) { const bankFrequency: Record = {}; for (const key of workspaceCardFeedsKeys) { @@ -77,7 +73,7 @@ function buildIndividualCardsData( selectedCards: string[], ): ItemsGroupedBySelection { const userAssignedCards: CardFilterItem[] = Object.values(userCardList ?? {}) - .filter((card) => isCardIssued(card)) + .filter((card) => CardUtils.isCardIssued(card)) .map((card) => createIndividualCardFilterItem(card, personalDetailsList, selectedCards)); // When user is admin of a workspace he sees all the cards of workspace under cards_ Onyx key @@ -85,7 +81,7 @@ function buildIndividualCardsData( .filter((cardFeed) => !isEmptyObject(cardFeed)) .flatMap((cardFeed) => { return Object.values(cardFeed as Record) - .filter((card) => card && CardUtils.isCard(card) && !userCardList?.[card.cardID] && isCardIssued(card)) + .filter((card) => card && CardUtils.isCard(card) && !userCardList?.[card.cardID] && CardUtils.isCardIssued(card)) .map((card) => createIndividualCardFilterItem(card, personalDetailsList, selectedCards)); }); @@ -169,7 +165,7 @@ function buildCardFeedsData( .forEach(([cardFeedKey, cardFeed]) => { const cardFeedArray = Object.values(cardFeed ?? {}); const representativeCard = cardFeedArray.find((cardFeedItem) => CardUtils.isCard(cardFeedItem)); - if (!representativeCard || !cardFeedArray.some((cardFeedItem) => CardUtils.isCard(cardFeedItem) && isCardIssued(cardFeedItem))) { + if (!representativeCard || !cardFeedArray.some((cardFeedItem) => CardUtils.isCard(cardFeedItem) && CardUtils.isCardIssued(cardFeedItem))) { return; } const {domainName, bank} = representativeCard; @@ -177,7 +173,7 @@ function buildCardFeedsData( const policyID = domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1] ?? ''; const correspondingPolicy = PolicyUtils.getPolicy(policyID?.toUpperCase()); const correspondingCardIDs = Object.entries(cardFeed ?? {}) - .filter(([cardKey, card]) => cardKey !== 'cardList' && CardUtils.isCard(card) && isCardIssued(card)) + .filter(([cardKey, card]) => cardKey !== 'cardList' && CardUtils.isCard(card) && CardUtils.isCardIssued(card)) .map(([cardKey]) => cardKey); const feedItem = createCardFeedItem({ @@ -223,7 +219,7 @@ function SearchFiltersCardPage() { () => Object.values(userCardList ?? {}).reduce((accumulator, currentCard) => { // Cards in cardList can also be domain cards, we use them to compute domain feed - if (!currentCard.domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME) && isCardIssued(currentCard)) { + if (!currentCard.domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME) && CardUtils.isCardIssued(currentCard)) { if (accumulator[currentCard.domainName]) { accumulator[currentCard.domainName].correspondingCardIDs.push(currentCard.cardID.toString()); } else { @@ -246,8 +242,11 @@ function SearchFiltersCardPage() { const searchFunction = useCallback( (item: CardFilterItem) => - !!item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase()) || item.lastFourPAN?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase()), - [debouncedSearchTerm], + !!item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase()) || + !!item.lastFourPAN?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase()) || + !!item.cardName?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase()) || + (item.isVirtual && translate('workspace.expensifyCard.virtual').toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())), + [debouncedSearchTerm, translate], ); const sections = useMemo(() => { @@ -299,6 +298,8 @@ function SearchFiltersCardPage() { [selectedCards], ); + const headerMessage = debouncedSearchTerm.trim() && sections.every((section) => !section.data.length) ? translate('common.noResultsFound') : ''; + const footerContent = useMemo( () => (