-
Notifications
You must be signed in to change notification settings - Fork 3.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add ability to bulk select cards from the same bank in the Card filter #53389
Changes from 10 commits
951f85b
4113519
e0d5bf8
9336e8f
defc543
98b3512
a5545b7
6ebe579
a1ae60e
d9f0e8a
42ecedc
93e23ca
00f3ad8
cfb2417
6f8efac
bd92551
5d93a9d
ab009cc
e9cb9da
cec56f7
9068aa8
313bb46
0f274f9
a48f976
5f21294
b4e5aab
e18af2a
fc3f071
947d918
6ad3f86
d340930
719a972
306f6c6
415ef30
00dc930
a49beaf
4954b15
2a8fe1d
79a5c3d
7a8d927
137ffe8
a3001be
b22f596
69400ad
ded2e05
22dd304
185b375
e7c543b
91b84ef
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
@@ -1,84 +1,232 @@ | ||||||||
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'; | ||||||||
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'; | ||||||||
import type {Section} from '@libs/OptionsListUtils'; | ||||||||
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 {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 CardFilterItem = Partial<OptionData> & {bankIcon?: BankIcon; lastFourPAN?: string; isVirtual?: boolean; isCardFeed?: boolean; correspondingCards?: string[]}; | ||||||||
SzymczakJ marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
|
||||||||
type DomainFeedData = {bank: string; domainName: string; correspospondingCardIDs: string[]}; | ||||||||
|
||||||||
function isCard(item: Card | Record<string, string>): item is Card { | ||||||||
return 'cardID' in item && !!item.cardID && 'bank' in item && !!item.bank; | ||||||||
} | ||||||||
|
||||||||
function buildIndividualCardItem(card: Card, isSelected: boolean, iconStyles: StyleProp<ViewStyle>): CardFilterItem { | ||||||||
const icon = CardUtils.getCardFeedIcon(card?.bank as CompanyCardFeed); | ||||||||
const cardName = card?.nameValuePairs?.cardTitle ?? card?.cardName; | ||||||||
SzymczakJ marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
const text = card.bank === CONST.EXPENSIFY_CARD.BANK ? card.bank : cardName; | ||||||||
|
||||||||
return { | ||||||||
lastFourPAN: card.lastFourPAN, | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Physical cards that have not yet been issued lack a ![]() ![]() I think we should consider either modifying how these cards are displayed or not showing the unissued cards (that do not have a
@luacmartins, what are your thoughts on this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I suppose you cannot pay with a unissued card, so there's no point in searching report with them. We could just filter them out. WDYT @luacmartins There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree. Let's filter out unissued cards. Nice catch! |
||||||||
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<ViewStyle>, | ||||||||
): CardFilterItem { | ||||||||
let isSelected = true; | ||||||||
correspondingCardIDs.forEach((card) => { | ||||||||
if (selectedCards.includes(card)) { | ||||||||
return; | ||||||||
} | ||||||||
isSelected = false; | ||||||||
SzymczakJ marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
}); | ||||||||
|
||||||||
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 [cardList] = useOnyx(ONYXKEYS.CARD_LIST); | ||||||||
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 currentCards = searchAdvancedFiltersForm?.cardID; | ||||||||
const [newCards, setNewCards] = useState(currentCards ?? []); | ||||||||
const initiallySelectedCards = searchAdvancedFiltersForm?.cardID; | ||||||||
const [newSelectedCards, setNewSelectedCards] = useState(initiallySelectedCards ?? []); | ||||||||
SzymczakJ marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
|
||||||||
const sections = useMemo(() => { | ||||||||
const newSections: Section[] = []; | ||||||||
const cards = Object.values(cardList ?? {}) | ||||||||
.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], | ||||||||
}, | ||||||||
}; | ||||||||
const {invidualCardsSectionData, domainFeedsData} = useMemo(() => { | ||||||||
SzymczakJ marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
const individualCards: CardFilterItem[] = []; | ||||||||
const domainFeeds: Record<string, DomainFeedData> = {}; | ||||||||
|
||||||||
Object.values(userCardList ?? {}).forEach((card) => { | ||||||||
const isSelected = newSelectedCards.includes(card.cardID.toString()); | ||||||||
const cardData = buildIndividualCardItem(card, isSelected, styles.cardIcon); | ||||||||
|
||||||||
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)) { | ||||||||
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()]}; | ||||||||
} | ||||||||
} | ||||||||
}); | ||||||||
|
||||||||
// When user is admin of a workspace he sees all the cards of workspace under cards_ Onyx key | ||||||||
filteredWorkspaceCardFeeds.forEach(([, cardFeed]) => { | ||||||||
SzymczakJ marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
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); | ||||||||
}); | ||||||||
}); | ||||||||
return {invidualCardsSectionData: individualCards, domainFeedsData: domainFeeds}; | ||||||||
}, [filteredWorkspaceCardFeeds, newSelectedCards, 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; | ||||||||
} | ||||||||
|
||||||||
handleRepeatingBankNames(bankName); | ||||||||
}); | ||||||||
Object.values(domainFeedsData).forEach((domainFeed) => { | ||||||||
handleRepeatingBankNames(domainFeed.bank); | ||||||||
}); | ||||||||
SzymczakJ marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
|
||||||||
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 ?? {}); | ||||||||
|
||||||||
cardFeedsData.push(buildCardFeedItem(text, policyID, correspondingCards, newSelectedCards, bank as CompanyCardFeed, styles.cardIcon)); | ||||||||
}); | ||||||||
|
||||||||
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); | ||||||||
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 cardFeedsData; | ||||||||
SzymczakJ marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
}, [domainFeedsData, 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.filter((item) => item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())), | ||||||||
shouldShow: cardFeedsSectionData.length > 0, | ||||||||
}); | ||||||||
newSections.push({ | ||||||||
title: undefined, | ||||||||
data: cards, | ||||||||
shouldShow: cards.length > 0, | ||||||||
title: translate('search.filters.card.individualCards'), | ||||||||
data: invidualCardsSectionData.filter((item) => item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())), | ||||||||
shouldShow: invidualCardsSectionData.length > 0, | ||||||||
}); | ||||||||
return newSections; | ||||||||
}, [cardList, styles, newCards]); | ||||||||
}, [translate, cardFeedsSectionData, invidualCardsSectionData, debouncedSearchTerm]); | ||||||||
|
||||||||
const handleConfirmSelection = useCallback(() => { | ||||||||
SearchActions.updateAdvancedFilters({ | ||||||||
cardID: newCards, | ||||||||
cardID: newSelectedCards, | ||||||||
}); | ||||||||
|
||||||||
Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS); | ||||||||
}, [newCards]); | ||||||||
}, [newSelectedCards]); | ||||||||
|
||||||||
const updateNewCards = useCallback( | ||||||||
(item: Partial<OptionData>) => { | ||||||||
(item: CardFilterItem) => { | ||||||||
if (!item.keyForList) { | ||||||||
return; | ||||||||
} | ||||||||
|
||||||||
const isCardFeed = item?.isCardFeed && item?.correspondingCards; | ||||||||
|
||||||||
if (item.isSelected) { | ||||||||
setNewCards(newCards.filter((card) => card !== item.keyForList)); | ||||||||
const newCardsObject = newSelectedCards.filter((card) => (isCardFeed ? !item.correspondingCards?.includes(card) : card !== item.keyForList)); | ||||||||
setNewSelectedCards(newCardsObject); | ||||||||
} else { | ||||||||
setNewCards([...newCards, item.keyForList]); | ||||||||
const newCardsObject = isCardFeed ? [...newSelectedCards, ...(item?.correspondingCards ?? [])] : [...newSelectedCards, item.keyForList]; | ||||||||
setNewSelectedCards(newCardsObject); | ||||||||
} | ||||||||
}, | ||||||||
[newCards], | ||||||||
[newSelectedCards], | ||||||||
); | ||||||||
|
||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
const footerContent = useMemo( | ||||||||
|
@@ -108,14 +256,20 @@ function SearchFiltersCardPage() { | |||||||
}} | ||||||||
/> | ||||||||
<View style={[styles.flex1]}> | ||||||||
<SelectionList | ||||||||
<SelectionList<CardFilterItem> | ||||||||
sections={sections} | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
onSelectRow={updateNewCards} | ||||||||
footerContent={footerContent} | ||||||||
shouldStopPropagation | ||||||||
shouldShowTooltips | ||||||||
canSelectMultiple | ||||||||
ListItem={CardListItem} | ||||||||
shouldShowTextInput={shouldShowSearchInput} | ||||||||
textInputLabel={shouldShowSearchInput ? translate('common.search') : undefined} | ||||||||
textInputValue={searchTerm} | ||||||||
onChangeText={(value) => { | ||||||||
setSearchTerm(value); | ||||||||
}} | ||||||||
/> | ||||||||
</View> | ||||||||
</ScreenWrapper> | ||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BUG: When a feed list item is selected, the list automatically scrolls down to the individual cards. Conversely, selecting an individual card causes the list to automatically scroll up to the corresponding feed list item:
Screen.Recording.2024-12-19.at.9.28.06.PM.mov
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This behaviour is hard coded into SelectionList. Actually the way I handled selected items was not consistent with the rest. Now when I put selected items on top of SelectionList like we do everywhere else, this behaviour is correct and takes us to selected items section.