Skip to content
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

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/hooks/usePaymentMethodState/types.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
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';

type FormattedSelectedPaymentMethodIcon = {
icon: IconAsset;
iconHeight?: number;
iconWidth?: number;
iconStyles?: ViewStyle[];
iconStyles?: StyleProp<ViewStyle>;
iconSize?: number;
};

Expand Down
7 changes: 7 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4570,6 +4570,13 @@ const translations = {
greaterThan: ({amount}: OptionalParam<RequestAmountParams> = {}) => `Greater than ${amount ?? ''}`,
between: ({greaterThan, lessThan}: FiltersAmountBetweenParams) => `Between ${greaterThan} and ${lessThan}`,
},
card: {
expensify: 'Expensify',
individualCards: 'Individual cards',
cardFeeds: 'Card feeds',
cardFeedName: ({cardFeedBankName, cardFeedLabel}: {cardFeedBankName: string; cardFeedLabel?: string}) =>
`All ${cardFeedBankName}${cardFeedLabel ? ` - ${cardFeedLabel}` : ''}`,
},
current: 'Current',
past: 'Past',
},
Expand Down
7 changes: 7 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4614,6 +4614,13 @@ const translations = {
link: 'Enlace',
pinned: 'Fijado',
unread: 'No leído',
card: {
expensify: 'Expensify',
individualCards: 'Tarjetas individuales',
cardFeeds: 'Flujos de tarjetas',
cardFeedName: ({cardFeedBankName, cardFeedLabel}: {cardFeedBankName: string; cardFeedLabel?: string}) =>
`Todo ${cardFeedBankName}${cardFeedLabel ? ` - ${cardFeedLabel}` : ''}`,
},
amount: {
lessThan: ({amount}: OptionalParam<RequestAmountParams> = {}) => `Menos de ${amount ?? ''}`,
greaterThan: ({amount}: OptionalParam<RequestAmountParams> = {}) => `Más que ${amount ?? ''}`,
Expand Down
230 changes: 192 additions & 38 deletions src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersCardPage.tsx
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[]};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe everything inside optionData is optional, the only problem here would be reportID I think? (please confirm).

If yes, then maybe instead let's do Omit< OptionData, reportID> - that would be more explicit

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sadly, we have to adapt to ListItem type, because SelectionList item has to extend it. So we connot use Omit 😞


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,
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],
);

const footerContent = useMemo(
Expand Down Expand Up @@ -108,14 +256,20 @@ function SearchFiltersCardPage() {
}}
/>
<View style={[styles.flex1]}>
<SelectionList
<SelectionList<CardFilterItem>
sections={sections}
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>
Expand Down
4 changes: 2 additions & 2 deletions src/types/onyx/Bank.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -18,7 +18,7 @@ type BankIcon = {
iconWidth?: number;

/** Icon wrapper styles */
iconStyles?: ViewStyle[];
iconStyles?: StyleProp<ViewStyle>;
};

/** Bank names */
Expand Down
4 changes: 2 additions & 2 deletions src/types/onyx/PaymentMethod.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -21,7 +21,7 @@ type PaymentMethod = (BankAccount | Fund) & {
iconWidth?: number;

/** Icon wrapper styles */
iconStyles?: ViewStyle[];
iconStyles?: StyleProp<ViewStyle>;
};

export default PaymentMethod;
Loading