Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
12 changes: 10 additions & 2 deletions src/components/AccountSwitcherSkeletonView/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import {View} from 'react-native';
import type {StyleProp, ViewStyle} from 'react-native';
import {Circle, Rect} from 'react-native-svg';
import type {ValueOf} from 'type-fest';
import SkeletonViewContentLoader from '@components/SkeletonViewContentLoader';
Expand All @@ -15,9 +16,15 @@ type AccountSwitcherSkeletonViewProps = {

/** The size of the avatar */
avatarSize?: ValueOf<typeof CONST.AVATAR_SIZE>;

/** The width of the skeleton view */
width?: number;

/** Additional styles for the skeleton view */
style?: StyleProp<ViewStyle>;
Comment thread
chrispader marked this conversation as resolved.
};

function AccountSwitcherSkeletonView({shouldAnimate = true, avatarSize = CONST.AVATAR_SIZE.DEFAULT}: AccountSwitcherSkeletonViewProps) {
function AccountSwitcherSkeletonView({shouldAnimate = true, avatarSize = CONST.AVATAR_SIZE.DEFAULT, width, style}: AccountSwitcherSkeletonViewProps) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
Expand All @@ -28,11 +35,12 @@ function AccountSwitcherSkeletonView({shouldAnimate = true, avatarSize = CONST.A
const rectXTranslation = startPositionX + avatarPlaceholderRadius + styles.gap3.gap;

return (
<View style={styles.avatarSectionWrapperSkeleton}>
<View style={[width ? undefined : styles.avatarSectionWrapperSkeleton, style]}>
<SkeletonViewContentLoader
animate={shouldAnimate}
backgroundColor={theme.skeletonLHNIn}
foregroundColor={theme.skeletonLHNOut}
width={width}
height={avatarPlaceholderSize}
>
<Circle
Expand Down
10 changes: 9 additions & 1 deletion src/components/FeedSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import CaretWrapper from './CaretWrapper';
import Icon from './Icon';
import {PressableWithFeedback} from './Pressable';
import SearchInputSelectionSkeleton from './Skeletons/SearchInputSelectionSkeleton';
import Text from './Text';

type Props = {
Expand All @@ -23,13 +24,20 @@ type Props = {

/** Whether the RBR indicator should be shown */
shouldShowRBR?: boolean;

/** Whether the feed selector should render a loading skeleton */
isLoading?: boolean;
Comment thread
chrispader marked this conversation as resolved.
};

function FeedSelector({onFeedSelect, CardFeedIcon, feedName, supportingText, shouldShowRBR = false}: Props) {
function FeedSelector({onFeedSelect, CardFeedIcon, feedName, supportingText, shouldShowRBR = false, isLoading = false}: Props) {
const styles = useThemeStyles();
const theme = useTheme();
const expensifyIcons = useMemoizedLazyExpensifyIcons(['DotIndicator'] as const);

if (isLoading) {
return <SearchInputSelectionSkeleton />;
}

return (
<PressableWithFeedback
onPress={onFeedSelect}
Expand Down
26 changes: 19 additions & 7 deletions src/components/Skeletons/SearchFiltersSkeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,34 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useSkeletonSpan from '@libs/telemetry/useSkeletonSpan';

const DEFAULT_CONTAINER_WIDTH = 84;
const DEFAULT_PILL_WIDTH = 60;
const PILL_WIDTH_RATIO = DEFAULT_PILL_WIDTH / DEFAULT_CONTAINER_WIDTH;

const DEFAULT_CONTAINER_HEIGHT = 28;
const DEFAULT_PILL_HEIGHT = 8;
const PILL_HEIGHT_RATIO = DEFAULT_PILL_HEIGHT / DEFAULT_CONTAINER_HEIGHT;

type SearchFiltersSkeletonProps = {
shouldAnimate?: boolean;
itemCount?: number;
width?: number;
height?: number;
};

function SearchFiltersSkeleton({shouldAnimate = true}: SearchFiltersSkeletonProps) {
function SearchFiltersSkeleton({shouldAnimate = true, itemCount = 5, width = 84, height = 28}: SearchFiltersSkeletonProps) {
const theme = useTheme();
const styles = useThemeStyles();
useSkeletonSpan('SearchFiltersSkeleton');

const skeletonCount = new Array(5).fill(0);
const skeletonCount = new Array(itemCount).fill(0);

return (
<View style={[styles.mh5, styles.mb4, styles.mt2]}>
<SkeletonViewContentLoader
animate={shouldAnimate}
height={28}
width={width}
height={height}
backgroundColor={theme.skeletonLHNIn}
foregroundColor={theme.skeletonLHNOut}
>
Expand All @@ -32,8 +44,8 @@ function SearchFiltersSkeleton({shouldAnimate = true}: SearchFiltersSkeletonProp
transform={[{translateX: index * 90}]}
rx={14}
ry={14}
width={84}
height={28}
width={width}
height={height}
/>
))}
</SkeletonViewContentLoader>
Expand All @@ -50,8 +62,8 @@ function SearchFiltersSkeleton({shouldAnimate = true}: SearchFiltersSkeletonProp
// eslint-disable-next-line react/no-array-index-key
key={index}
transform={[{translateX: 12 + index * 90}, {translateY: 10}]}
width={60}
height={8}
width={width * PILL_WIDTH_RATIO}
height={height * PILL_HEIGHT_RATIO}
/>
))}
</SkeletonViewContentLoader>
Expand Down
32 changes: 18 additions & 14 deletions src/hooks/useAssignCard.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {useContext} from 'react';
import {useContext, useRef} from 'react';
import {DelegateNoAccessContext} from '@components/DelegateNoAccessModalProvider';
import {importPlaidAccounts} from '@libs/actions/Plaid';
import {
Expand Down Expand Up @@ -30,25 +30,27 @@ import useOnyx from './useOnyx';
import usePolicy from './usePolicy';

type UseAssignCardProps = {
/** The currently selected card feed (includes domain ID) */
selectedFeed: CompanyCardFeedWithDomainID | undefined;
/** The currently selected card feed */
feedName: CompanyCardFeedWithDomainID | undefined;

/** The ID of the workspace/policy */
policyID: string;

/** Callback to show/hide the offline modal */
setShouldShowOfflineModal: (shouldShow: boolean) => void;
};

function useAssignCard({selectedFeed, policyID, setShouldShowOfflineModal}: UseAssignCardProps) {
function useAssignCard({feedName, policyID, setShouldShowOfflineModal}: UseAssignCardProps) {
const [allFeedsCards] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`, {canBeMissing: false});
const [cardFeeds] = useCardFeeds(policyID);
const companyFeeds = getCompanyFeeds(cardFeeds);
const currentFeedData = selectedFeed ? companyFeeds?.[selectedFeed] : ({} as CombinedCardFeed);
const currentFeedData = feedName ? companyFeeds?.[feedName] : ({} as CombinedCardFeed);

const policy = usePolicy(policyID);
const workspaceAccountID = policy?.workspaceAccountID ?? CONST.DEFAULT_NUMBER_ID;

const companyCards = getCompanyFeeds(cardFeeds);
const selectedFeedData = selectedFeed && companyCards[selectedFeed];
const selectedFeedData = feedName && companyCards[feedName];
const domainOrWorkspaceAccountID = getDomainOrWorkspaceAccountID(workspaceAccountID, selectedFeedData);

const fetchCompanyCards = () => {
Expand All @@ -57,18 +59,18 @@ function useAssignCard({selectedFeed, policyID, setShouldShowOfflineModal}: UseA

const {isOffline} = useNetwork({onReconnect: fetchCompanyCards});

const cardList = allFeedsCards?.[`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${domainOrWorkspaceAccountID}_${selectedFeed}`];
const cardList = allFeedsCards?.[`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${domainOrWorkspaceAccountID}_${feedName}`];

const filteredFeedCards = filterInactiveCards(cardList);
const hasFeedError = selectedFeed ? !!cardFeeds?.[selectedFeed]?.errors : false;
const hasFeedError = feedName ? !!cardFeeds?.[feedName]?.errors : false;
const isSelectedFeedConnectionBroken = checkIfFeedConnectionIsBroken(filteredFeedCards) || hasFeedError;
const isAllowedToIssueCompanyCard = useIsAllowedToIssueCompanyCard({policyID});

const {isActingAsDelegate, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext);

const isAssigningCardDisabled = !currentFeedData || !!currentFeedData?.pending || isSelectedFeedConnectionBroken || !isAllowedToIssueCompanyCard;

const getInitialAssignCardStep = useInitialAssignCardStep({policyID, selectedFeed});
const getInitialAssignCardStep = useInitialAssignCardStep({policyID, selectedFeed: feedName});

const assignCard = (cardID?: string) => {
if (isAssigningCardDisabled) {
Expand All @@ -80,11 +82,11 @@ function useAssignCard({selectedFeed, policyID, setShouldShowOfflineModal}: UseA
return;
}

if (!selectedFeed || !cardID) {
if (!feedName || !cardID) {
return;
}

const isCommercialFeed = isCustomFeed(selectedFeed);
const isCommercialFeed = isCustomFeed(feedName);

// If the feed is a direct feed (not a commercial feed) and the user is offline,
// show the offline alert modal to inform them of the connectivity issue.
Expand All @@ -110,11 +112,11 @@ function useAssignCard({selectedFeed, policyID, setShouldShowOfflineModal}: UseA
switch (initialStep) {
case CONST.COMPANY_CARD.STEP.PLAID_CONNECTION:
case CONST.COMPANY_CARD.STEP.BANK_CONNECTION:
Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_BROKEN_CARD_FEED_CONNECTION.getRoute(policyID, selectedFeed));
Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_BROKEN_CARD_FEED_CONNECTION.getRoute(policyID, feedName));
break;
case CONST.COMPANY_CARD.STEP.ASSIGNEE:
default:
Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD_ASSIGNEE.getRoute({policyID, feed: selectedFeed, cardID}));
Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD_ASSIGNEE.getRoute({policyID, feed: feedName, cardID}));
break;
}
});
Expand Down Expand Up @@ -145,6 +147,7 @@ function useInitialAssignCardStep({policyID, selectedFeed}: UseInitialAssignCard
const bankName = selectedFeed ? getCompanyCardFeed(selectedFeed) : undefined;
const isFeedExpired = isSelectedFeedExpired(feedData);
const plaidAccessToken = feedData?.plaidAccessToken;
const hasImportedPlaidAccounts = useRef(false);

const getInitialAssignCardStep = (cardID: string | undefined): {initialStep: AssignCardStep; cardToAssign: Partial<AssignCardData>} | undefined => {
if (!selectedFeed) {
Expand All @@ -158,9 +161,10 @@ function useInitialAssignCardStep({policyID, selectedFeed}: UseInitialAssignCard
};

// Refetch plaid card list
if (!isFeedExpired && plaidAccessToken) {
if (!isFeedExpired && plaidAccessToken && !hasImportedPlaidAccounts.current) {
const country = feedData?.country ?? '';
importPlaidAccounts('', selectedFeed, '', country, getDomainNameForPolicy(policyID), '', undefined, undefined, plaidAccessToken);
hasImportedPlaidAccounts.current = true;
}

if (isFeedExpired || !cardID) {
Expand Down
4 changes: 2 additions & 2 deletions src/hooks/useCardsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import type {ResultMetadata} from 'react-native-onyx';
import {filterInactiveCards} from '@libs/CardUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {CardList, CompanyCardFeedWithDomainID} from '@src/types/onyx';
import type {CompanyCardFeedWithDomainID, WorkspaceCardsList} from '@src/types/onyx';
import useOnyx from './useOnyx';

/* Custom hook that retrieves a list of company cards for the given selected feed. */
const useCardsList = (selectedFeed: CompanyCardFeedWithDomainID | undefined): [CardList | undefined, ResultMetadata<CardList>] => {
const useCardsList = (selectedFeed: CompanyCardFeedWithDomainID | undefined): [WorkspaceCardsList | undefined, ResultMetadata<WorkspaceCardsList>] => {
const [feed, domainOrWorkspaceAccountID] = selectedFeed?.split(CONST.COMPANY_CARD.FEED_KEY_SEPARATOR) ?? [];
const [cardsList, cardsListMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${domainOrWorkspaceAccountID}_${feed}`, {
selector: filterInactiveCards,
Expand Down
66 changes: 66 additions & 0 deletions src/hooks/useCompanyCards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type {OnyxCollection, ResultMetadata} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import {getCompanyCardFeed, getCompanyFeeds, getPlaidInstitutionId} from '@libs/CardUtils';
import type CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {CardFeeds, CardList} from '@src/types/onyx';
import type {AssignableCardsList, WorkspaceCardsList} from '@src/types/onyx/Card';
import type {CompanyCardFeed, CompanyCardFeedWithDomainID, CompanyFeeds} from '@src/types/onyx/CardFeeds';
import useCardFeeds from './useCardFeeds';
import type {CombinedCardFeed, CombinedCardFeeds} from './useCardFeeds';
import useCardsList from './useCardsList';
import useOnyx from './useOnyx';

type CardFeedType = ValueOf<typeof CONST.COMPANY_CARDS.FEED_TYPE>;

type UsCompanyCardsResult = Partial<{
cardFeedType: CardFeedType;
bankName: CompanyCardFeed;
feedName: CompanyCardFeedWithDomainID;
cardList: AssignableCardsList;
assignedCards: CardList;
cardNames: string[];
allCardFeeds: CombinedCardFeeds;
companyCardFeeds: CompanyFeeds;
selectedFeed: CombinedCardFeed;
}> & {
onyxMetadata: {
cardListMetadata: ResultMetadata<WorkspaceCardsList>;
allCardFeedsMetadata: ResultMetadata<OnyxCollection<CardFeeds>>;
};
};

function useCompanyCards(policyID?: string): UsCompanyCardsResult {
const [lastSelectedFeed] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`, {canBeMissing: true});
const [allCardFeeds, allCardFeedsMetadata] = useCardFeeds(policyID);

const feedName = lastSelectedFeed;
const bankName = feedName ? getCompanyCardFeed(feedName) : undefined;

const [cardsList, cardListMetadata] = useCardsList(feedName);

const companyCardFeeds = getCompanyFeeds(allCardFeeds);
const selectedFeed = feedName && companyCardFeeds[feedName];
const isPlaidCardFeed = !!getPlaidInstitutionId(feedName);

let cardFeedType: CardFeedType = 'customFeed';
if (isPlaidCardFeed) {
cardFeedType = 'directFeed';
}
Comment on lines +46 to +49

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not only plaid connected feeds are direct, we also have native direct connections. This caused this regression https://expensify.slack.com/archives/C05LX9D6E07/p1767116486536099


const {cardList, ...assignedCards} = cardsList ?? {};
const cardNames = cardFeedType === 'directFeed' ? (selectedFeed?.accountList ?? []) : Object.keys(cardList ?? {});

const onyxMetadata = {
cardListMetadata,
allCardFeedsMetadata,
};

if (!policyID) {
return {onyxMetadata};
}

return {allCardFeeds, feedName, companyCardFeeds, cardList, assignedCards, cardNames, selectedFeed, bankName, cardFeedType, onyxMetadata};
}

export default useCompanyCards;
14 changes: 11 additions & 3 deletions src/libs/CardUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ function getAssignedCardSortKey(card: Card): number {
}

/**
* @param card
* Checks if the card is an Expensify card.
* @param card - The card to check.
* @returns boolean
*/
function isExpensifyCard(card?: Card) {
Expand Down Expand Up @@ -388,7 +389,11 @@ function getCardFeedIcon(cardFeed: CompanyCardFeed | typeof CONST.EXPENSIFY_CARD
/**
* Verify if the feed is a custom feed. Those are also referred to as commercial feeds.
*/
function isCustomFeed(feed: CompanyCardFeedWithNumber): boolean {
function isCustomFeed(feed: CompanyCardFeedWithNumber | undefined): boolean {
if (!feed) {
return false;
}

return [CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD, CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX].some((value) => feed.startsWith(value));
}

Expand Down Expand Up @@ -822,7 +827,10 @@ function getFeedConnectionBrokenCard(feedCards: Record<string, Card> | undefined
}

/** Extract feed from feed with domainID */
function getCompanyCardFeed(feedWithDomainID: string): CompanyCardFeed {
function getCompanyCardFeed(feedWithDomainID: string | undefined): CompanyCardFeed {
if (!feedWithDomainID) {
return '' as CompanyCardFeed;
}
const [feed] = feedWithDomainID.split(CONST.COMPANY_CARD.FEED_KEY_SEPARATOR);
return feed as CompanyCardFeed;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,18 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import {hasIssuedExpensifyCard} from '@libs/CardUtils';
import Navigation from '@libs/Navigation/Navigation';
import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
import colors from '@styles/theme/colors';
import {clearAddNewCardFlow} from '@userActions/CompanyCards';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {Policy} from '@src/types/onyx';
import WorkspaceCompanyCardExpensifyCardPromotionBanner from './WorkspaceCompanyCardExpensifyCardPromotionBanner';

type WorkspaceCompanyCardPageEmptyStateProps = {
policy: Policy | undefined;
shouldShowGBDisclaimer?: boolean;
} & WithPolicyAndFullscreenLoadingProps;
};

function WorkspaceCompanyCardPageEmptyState({policy, shouldShowGBDisclaimer}: WorkspaceCompanyCardPageEmptyStateProps) {
const {translate} = useLocalize();
Expand Down Expand Up @@ -94,4 +94,4 @@ function WorkspaceCompanyCardPageEmptyState({policy, shouldShowGBDisclaimer}: Wo
);
}

export default withPolicyAndFullscreenLoading(WorkspaceCompanyCardPageEmptyState);
export default WorkspaceCompanyCardPageEmptyState;
Loading
Loading