From 64a1fc34a4b39d8be7bdca7c7aa72305275c77bf Mon Sep 17 00:00:00 2001 From: Federico Mastrini Date: Wed, 11 Dec 2024 19:11:08 +0100 Subject: [PATCH] fix(IT Wallet): [SIW-1806] Fix state inconsistencies in wallet home screen (#6532) --- .../components/ItwEidLifecycleAlert.tsx | 5 +- .../components/ItwWalletReadyBanner.tsx | 17 +- .../ItwDiscoveryBannerStandalone.tsx | 2 +- .../__tests__/ItwDiscoveryBanner.test.tsx | 88 +- .../ItwDiscoveryBannerStandalone.test.tsx | 41 + .../ItwDiscoveryBanner.test.tsx.snap | 789 ------------ ...ItwDiscoveryBannerStandalone.test.tsx.snap | 1135 +++++++++++++++++ .../itwallet/common/store/selectors/index.ts | 18 +- .../details/saga/handleDeleteWalletDetails.ts | 60 +- .../WalletCardsCategoryRetryErrorBanner.tsx | 5 +- .../components/WalletCardsContainer.tsx | 142 ++- .../components/WalletCategoryFilterTabs.tsx | 31 +- ...lletCardsCategoryRetryErrorBanner.test.tsx | 63 + .../__tests__/WalletCardsContainer.test.tsx | 416 +++--- .../WalletCategoryFilterTabs.test.tsx | 90 +- .../wallet/screens/WalletHomeScreen.tsx | 19 +- .../store/__tests__/placeholders.test.ts | 59 - ts/features/wallet/store/actions/cards.ts | 11 +- .../{ => reducers}/__tests__/cards.test.ts | 176 ++- .../reducers/__tests__/placeholders.test.ts | 120 ++ .../reducers/__tests__/preferences.test.ts | 33 + ts/features/wallet/store/reducers/cards.ts | 65 +- .../wallet/store/reducers/placeholders.ts | 26 +- .../store/selectors/__tests__/index.test.ts | 222 ++++ ts/features/wallet/store/selectors/index.ts | 56 +- ts/features/wallet/types/index.ts | 14 +- 26 files changed, 2304 insertions(+), 1399 deletions(-) create mode 100644 ts/features/itwallet/common/components/discoveryBanner/__tests__/ItwDiscoveryBannerStandalone.test.tsx create mode 100644 ts/features/itwallet/common/components/discoveryBanner/__tests__/__snapshots__/ItwDiscoveryBannerStandalone.test.tsx.snap create mode 100644 ts/features/wallet/components/__tests__/WalletCardsCategoryRetryErrorBanner.test.tsx delete mode 100644 ts/features/wallet/store/__tests__/placeholders.test.ts rename ts/features/wallet/store/{ => reducers}/__tests__/cards.test.ts (52%) create mode 100644 ts/features/wallet/store/reducers/__tests__/placeholders.test.ts create mode 100644 ts/features/wallet/store/reducers/__tests__/preferences.test.ts create mode 100644 ts/features/wallet/store/selectors/__tests__/index.test.ts diff --git a/ts/features/itwallet/common/components/ItwEidLifecycleAlert.tsx b/ts/features/itwallet/common/components/ItwEidLifecycleAlert.tsx index 2bd6f2d2f6b..e3e94f49711 100644 --- a/ts/features/itwallet/common/components/ItwEidLifecycleAlert.tsx +++ b/ts/features/itwallet/common/components/ItwEidLifecycleAlert.tsx @@ -54,6 +54,7 @@ export const ItwEidLifecycleAlert = ({ ComponentProps > = { valid: { + testID: "itwEidLifecycleAlertTestID_valid", variant: "success", content: I18n.t( "features.itWallet.presentation.bottomSheets.eidInfo.alert.valid", @@ -65,6 +66,7 @@ export const ItwEidLifecycleAlert = ({ ) }, jwtExpiring: { + testID: "itwEidLifecycleAlertTestID_jwtExpiring", variant: "warning", content: I18n.t( "features.itWallet.presentation.bottomSheets.eidInfo.alert.expiring", @@ -74,6 +76,7 @@ export const ItwEidLifecycleAlert = ({ ) }, jwtExpired: { + testID: "itwEidLifecycleAlertTestID_jwtExpired", variant: "error", content: I18n.t( "features.itWallet.presentation.bottomSheets.eidInfo.alert.expired" @@ -82,7 +85,7 @@ export const ItwEidLifecycleAlert = ({ }; return ( - + ); diff --git a/ts/features/itwallet/common/components/ItwWalletReadyBanner.tsx b/ts/features/itwallet/common/components/ItwWalletReadyBanner.tsx index a854a162c41..5db54a700f8 100644 --- a/ts/features/itwallet/common/components/ItwWalletReadyBanner.tsx +++ b/ts/features/itwallet/common/components/ItwWalletReadyBanner.tsx @@ -1,23 +1,16 @@ -import React from "react"; import { Banner } from "@pagopa/io-app-design-system"; +import React from "react"; import I18n from "../../../../i18n"; import { useIONavigation } from "../../../../navigation/params/AppParamsList"; -import { ITW_ROUTES } from "../../navigation/routes"; import { useIOSelector } from "../../../../store/hooks"; -import { - itwCredentialsEidStatusSelector, - itwIsWalletEmptySelector -} from "../../credentials/store/selectors"; -import { itwLifecycleIsValidSelector } from "../../lifecycle/store/selectors"; +import { ITW_ROUTES } from "../../navigation/routes"; +import { itwShouldRenderWalletReadyBannerSelector } from "../store/selectors"; export const ItwWalletReadyBanner = () => { - const isItwValid = useIOSelector(itwLifecycleIsValidSelector); - const eidStatus = useIOSelector(itwCredentialsEidStatusSelector); - const isWalletEmpty = useIOSelector(itwIsWalletEmptySelector); - const navigation = useIONavigation(); + const shouldRender = useIOSelector(itwShouldRenderWalletReadyBannerSelector); - if (!isItwValid || eidStatus === "jwtExpired" || !isWalletEmpty) { + if (!shouldRender) { return null; } diff --git a/ts/features/itwallet/common/components/discoveryBanner/ItwDiscoveryBannerStandalone.tsx b/ts/features/itwallet/common/components/discoveryBanner/ItwDiscoveryBannerStandalone.tsx index 93290902411..233bcef303d 100644 --- a/ts/features/itwallet/common/components/discoveryBanner/ItwDiscoveryBannerStandalone.tsx +++ b/ts/features/itwallet/common/components/discoveryBanner/ItwDiscoveryBannerStandalone.tsx @@ -18,7 +18,7 @@ export const ItwDiscoveryBannerStandalone = () => { } return ( - + ); diff --git a/ts/features/itwallet/common/components/discoveryBanner/__tests__/ItwDiscoveryBanner.test.tsx b/ts/features/itwallet/common/components/discoveryBanner/__tests__/ItwDiscoveryBanner.test.tsx index cc02665c16b..2ee77a50e64 100644 --- a/ts/features/itwallet/common/components/discoveryBanner/__tests__/ItwDiscoveryBanner.test.tsx +++ b/ts/features/itwallet/common/components/discoveryBanner/__tests__/ItwDiscoveryBanner.test.tsx @@ -1,110 +1,30 @@ -import * as O from "fp-ts/lib/Option"; import _ from "lodash"; -import * as React from "react"; -import { createStore } from "redux"; import configureMockStore from "redux-mock-store"; -import { ToolEnum } from "../../../../../../../definitions/content/AssistanceToolConfig"; -import { Config } from "../../../../../../../definitions/content/Config"; import ROUTES from "../../../../../../navigation/routes"; import { applicationChangeState } from "../../../../../../store/actions/application"; import { appReducer } from "../../../../../../store/reducers"; -import { RemoteConfigState } from "../../../../../../store/reducers/backendStatus/remoteConfig"; import { GlobalState } from "../../../../../../store/reducers/types"; import { renderScreenWithNavigationStoreContext } from "../../../../../../utils/testWrapper"; -import { ItwLifecycleState } from "../../../../lifecycle/store/reducers"; import { ItwDiscoveryBanner } from "../ItwDiscoveryBanner"; -import { ItwDiscoveryBannerStandalone } from "../ItwDiscoveryBannerStandalone"; - -type RenderOptions = { - isItwValid?: boolean; - isItwEnabled?: boolean; -}; describe("ItwDiscoveryBanner", () => { - const globalState = appReducer(undefined, applicationChangeState("active")); - const component = renderScreenWithNavigationStoreContext( - () => , - ROUTES.WALLET_HOME, - {}, - createStore(appReducer, globalState as any) - ); it("should match snapshot", () => { + const { component } = renderComponent(); expect(component.toJSON()).toMatchSnapshot(); }); }); -describe("ItwDiscoveryBannerStandalone", () => { - it("should render the banner", () => { - const { - component: { queryByTestId } - } = renderComponent({}); - expect(queryByTestId("itwDiscoveryBannerTestID")).not.toBeNull(); - }); - - it("should match snapshot", () => { - const { component } = renderComponent({}); - expect(component.toJSON()).toMatchSnapshot(); - }); - - test.each([ - { isItwEnabled: false }, - { isItwValid: true } - ] as ReadonlyArray)( - "should not render the banner if %p", - options => { - const { - component: { queryByTestId } - } = renderComponent(options); - expect(queryByTestId("itwDiscoveryBannerTestID")).toBeNull(); - } - ); -}); - -const renderComponent = ({ - isItwEnabled = true, - isItwValid = false -}: RenderOptions) => { +const renderComponent = () => { const globalState = appReducer(undefined, applicationChangeState("active")); const mockStore = configureMockStore(); const store: ReturnType = mockStore( - _.merge(undefined, globalState, { - features: { - itWallet: isItwValid - ? { - lifecycle: ItwLifecycleState.ITW_LIFECYCLE_VALID, - issuance: { integrityKeyTag: O.some("key-tag") }, - credentials: { eid: O.some({}) } - } - : { - lifecycle: ItwLifecycleState.ITW_LIFECYCLE_INSTALLED - } - }, - remoteConfig: O.some({ - itw: { - enabled: isItwEnabled, - min_app_version: { - android: "0.0.0.0", - ios: "0.0.0.0" - } - }, - assistanceTool: { tool: ToolEnum.none }, - cgn: { enabled: true }, - newPaymentSection: { - enabled: false, - min_app_version: { - android: "0.0.0.0", - ios: "0.0.0.0" - } - }, - fims: { enabled: true } - } as Config) as RemoteConfigState - } as GlobalState) + _.merge(undefined, globalState, globalState as GlobalState) ); return { component: renderScreenWithNavigationStoreContext( - ItwDiscoveryBannerStandalone, + ItwDiscoveryBanner, ROUTES.WALLET_HOME, {}, store diff --git a/ts/features/itwallet/common/components/discoveryBanner/__tests__/ItwDiscoveryBannerStandalone.test.tsx b/ts/features/itwallet/common/components/discoveryBanner/__tests__/ItwDiscoveryBannerStandalone.test.tsx new file mode 100644 index 00000000000..6955c2ff941 --- /dev/null +++ b/ts/features/itwallet/common/components/discoveryBanner/__tests__/ItwDiscoveryBannerStandalone.test.tsx @@ -0,0 +1,41 @@ +import configureMockStore from "redux-mock-store"; +import ROUTES from "../../../../../../navigation/routes"; +import { applicationChangeState } from "../../../../../../store/actions/application"; +import { appReducer } from "../../../../../../store/reducers"; +import { GlobalState } from "../../../../../../store/reducers/types"; +import { renderScreenWithNavigationStoreContext } from "../../../../../../utils/testWrapper"; +import * as selectors from "../../../store/selectors"; +import { ItwDiscoveryBannerStandalone } from "../ItwDiscoveryBannerStandalone"; + +describe("ItwDiscoveryBannerStandalone", () => { + test.each([true, false] as ReadonlyArray)( + "should match snapshot when isItwDiscoveryBannerRenderable is %p", + isItwDiscoveryBannerRenderable => { + jest + .spyOn(selectors, "isItwDiscoveryBannerRenderableSelector") + .mockImplementation(() => isItwDiscoveryBannerRenderable); + + const { component } = renderComponent(); + expect(component.toJSON()).toMatchSnapshot(); + } + ); +}); + +const renderComponent = () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + + const mockStore = configureMockStore(); + const store: ReturnType = mockStore( + globalState as GlobalState + ); + + return { + component: renderScreenWithNavigationStoreContext( + ItwDiscoveryBannerStandalone, + ROUTES.WALLET_HOME, + {}, + store + ), + store + }; +}; diff --git a/ts/features/itwallet/common/components/discoveryBanner/__tests__/__snapshots__/ItwDiscoveryBanner.test.tsx.snap b/ts/features/itwallet/common/components/discoveryBanner/__tests__/__snapshots__/ItwDiscoveryBanner.test.tsx.snap index 182b50e034b..0b30e7b2358 100644 --- a/ts/features/itwallet/common/components/discoveryBanner/__tests__/__snapshots__/ItwDiscoveryBanner.test.tsx.snap +++ b/ts/features/itwallet/common/components/discoveryBanner/__tests__/__snapshots__/ItwDiscoveryBanner.test.tsx.snap @@ -784,792 +784,3 @@ exports[`ItwDiscoveryBanner should match snapshot 1`] = ` `; - -exports[`ItwDiscoveryBannerStandalone should match snapshot 1`] = ` - - - - - - - - - - - - - - - WALLET_HOME - - - - - - - - - - - - - - - - - - - - - - - Novità: Documenti su IO - - - - Da oggi puoi aggiungere al Portafoglio di IO la versione digitale dei tuoi documenti! - - - - - - - - Inizia - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -`; diff --git a/ts/features/itwallet/common/components/discoveryBanner/__tests__/__snapshots__/ItwDiscoveryBannerStandalone.test.tsx.snap b/ts/features/itwallet/common/components/discoveryBanner/__tests__/__snapshots__/ItwDiscoveryBannerStandalone.test.tsx.snap new file mode 100644 index 00000000000..d88d6cb7505 --- /dev/null +++ b/ts/features/itwallet/common/components/discoveryBanner/__tests__/__snapshots__/ItwDiscoveryBannerStandalone.test.tsx.snap @@ -0,0 +1,1135 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ItwDiscoveryBannerStandalone should match snapshot when isItwDiscoveryBannerRenderable is false 1`] = ` + + + + + + + + + + + + + + + WALLET_HOME + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`ItwDiscoveryBannerStandalone should match snapshot when isItwDiscoveryBannerRenderable is true 1`] = ` + + + + + + + + + + + + + + + WALLET_HOME + + + + + + + + + + + + + + + + + + + + + + + Novità: Documenti su IO + + + + Da oggi puoi aggiungere al Portafoglio di IO la versione digitale dei tuoi documenti! + + + + + + + + Inizia + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/ts/features/itwallet/common/store/selectors/index.ts b/ts/features/itwallet/common/store/selectors/index.ts index a6e6cf65552..b4aa0bc14ff 100644 --- a/ts/features/itwallet/common/store/selectors/index.ts +++ b/ts/features/itwallet/common/store/selectors/index.ts @@ -3,7 +3,10 @@ import { isItwFeedbackBannerEnabledSelector } from "../../../../../store/reducers/backendStatus/remoteConfig"; import { GlobalState } from "../../../../../store/reducers/types"; -import { itwIsWalletEmptySelector } from "../../../credentials/store/selectors"; +import { + itwCredentialsEidStatusSelector, + itwIsWalletEmptySelector +} from "../../../credentials/store/selectors"; import { itwLifecycleIsValidSelector } from "../../../lifecycle/store/selectors"; import { itwIsFeedbackBannerHiddenSelector, @@ -43,3 +46,16 @@ export const itwShouldRenderFeedbackBannerSelector = (state: GlobalState) => itwLifecycleIsValidSelector(state) && !itwIsWalletEmptySelector(state) && !itwIsFeedbackBannerHiddenSelector(state); + +/** + * Returns if the wallet ready banner should be visible. The banner is visible if: + * - The Wallet has valid Wallet Instance and a valid eID + * - The eID is not expired + * - The Wallet is empty + * @param state the application global state + * @returns true if the banner should be visible, false otherwise + */ +export const itwShouldRenderWalletReadyBannerSelector = (state: GlobalState) => + itwLifecycleIsValidSelector(state) && + itwCredentialsEidStatusSelector(state) !== "jwtExpired" && + itwIsWalletEmptySelector(state); diff --git a/ts/features/payments/details/saga/handleDeleteWalletDetails.ts b/ts/features/payments/details/saga/handleDeleteWalletDetails.ts index 1160ced0e42..1accfc34cf9 100644 --- a/ts/features/payments/details/saga/handleDeleteWalletDetails.ts +++ b/ts/features/payments/details/saga/handleDeleteWalletDetails.ts @@ -9,11 +9,21 @@ import { paymentsDeleteMethodAction, paymentsGetMethodDetailsAction } from "../store/actions"; +import { + walletHideCards, + walletRemoveCards, + walletRestoreCards +} from "../../../wallet/store/actions/cards"; +import { mapWalletIdToCardKey } from "../../common/utils"; export function* handleDeleteWalletDetails( deleteWalletById: WalletClient["deleteIOPaymentWalletById"], action: ActionType<(typeof paymentsDeleteMethodAction)["request"]> ) { + const walletCardKey = mapWalletIdToCardKey(action.payload.walletId); + // Flag the card as hidden + yield* put(walletHideCards([walletCardKey])); + try { const deleteWalletResult = yield* withPaymentsSessionToken( deleteWalletById, @@ -27,41 +37,51 @@ export function* handleDeleteWalletDetails( if (E.isRight(deleteWalletResult)) { if (deleteWalletResult.right.status === 204) { // handled success - const successAction = paymentsDeleteMethodAction.success( - action.payload.walletId - ); - yield* put(successAction); + yield* put(paymentsDeleteMethodAction.success(action.payload.walletId)); + // Remove the card from the wallet + yield* put(walletRemoveCards([walletCardKey])); action.payload.onSuccess?.(); return; } if (deleteWalletResult.right.status !== 401) { // The 401 status is handled by the withPaymentsSessionToken - const failureAction = paymentsDeleteMethodAction.failure({ - ...getGenericError( - new Error(`response status code ${deleteWalletResult.right.status}`) - ) - }); - yield* put(failureAction); + yield* put( + paymentsDeleteMethodAction.failure({ + ...getGenericError( + new Error( + `response status code ${deleteWalletResult.right.status}` + ) + ) + }) + ); + // Restore the previously hidden card + yield* put(walletRestoreCards([walletCardKey])); action.payload.onFailure?.(); } } else { // cannot decode response - const failureAction = paymentsDeleteMethodAction.failure({ - ...getGenericError( - new Error(readablePrivacyReport(deleteWalletResult.left)) - ) - }); - yield* put(failureAction); + yield* put( + paymentsDeleteMethodAction.failure({ + ...getGenericError( + new Error(readablePrivacyReport(deleteWalletResult.left)) + ) + }) + ); + // Restore the previously hidden card + yield* put(walletRestoreCards([walletCardKey])); action.payload.onFailure?.(); } } catch (e) { - const failureAction = paymentsDeleteMethodAction.failure({ - ...getNetworkError(e) - }); - yield* put(failureAction); + yield* put( + paymentsDeleteMethodAction.failure({ + ...getNetworkError(e) + }) + ); yield* put( paymentsGetMethodDetailsAction.failure({ ...getNetworkError(e) }) ); + // Restore the previously hidden card + yield* put(walletRestoreCards([walletCardKey])); action.payload.onFailure?.(); } } diff --git a/ts/features/wallet/components/WalletCardsCategoryRetryErrorBanner.tsx b/ts/features/wallet/components/WalletCardsCategoryRetryErrorBanner.tsx index b87fbccbbf7..d4f7a9c0f2e 100644 --- a/ts/features/wallet/components/WalletCardsCategoryRetryErrorBanner.tsx +++ b/ts/features/wallet/components/WalletCardsCategoryRetryErrorBanner.tsx @@ -49,7 +49,10 @@ export const WalletCardsCategoryRetryErrorBanner = () => { return ( (isPaymentMethodsError || isCgnError || isIdPayError) && ( - + { const isLoading = useIOSelector(selectIsWalletCardsLoading); - const cards = useIOSelector(selectSortedWalletCards); + const isWalletEmpty = useIOSelector(isWalletEmptySelector); const selectedCategory = useIOSelector(selectWalletCategoryFilter); - const paymentMethodsStatus = useIOSelector(paymentsWalletUserMethodsSelector); - const cgnStatus = useIOSelector(cgnDetailSelector); + const shouldRenderEmptyState = useIOSelector( + shouldRenderWalletEmptyStateSelector + ); - if (isLoading && cards.length === 0) { - return ( - <> - - - - ); - } + // Loading state is only displayed if there is the initial loading and there are no cards or + // placeholders in the wallet + const shouldRenderLoadingState = isLoading && isWalletEmpty; + + // Returns true if no category filter is selected or if the filter matches the given category + const shouldRenderCategory = React.useCallback( + (filter: WalletCardCategoryFilter): boolean => + selectedCategory === undefined || selectedCategory === filter, + [selectedCategory] + ); - if ( - cards.length === 0 && - pot.isSome(paymentMethodsStatus) && - !pot.isError(cgnStatus) - ) { - // In this case we can display the empty state: we do not have cards to display and - // the wallet is not in a loading state anymore + // Content to render in the wallet screen, based on the current state + const walletContent = React.useMemo(() => { + if (shouldRenderLoadingState) { + return ; + } + if (shouldRenderEmptyState) { + return ; + } return ( - - - + + {shouldRenderCategory("itw") && } + {shouldRenderCategory("other") && } ); - } - - const shouldRender = (filter: WalletCardCategoryFilter) => - selectedCategory ? selectedCategory === filter : true; + }, [shouldRenderEmptyState, shouldRenderCategory, shouldRenderLoadingState]); return ( - - - {shouldRender("itw") && } - {shouldRender("other") && } - + + {walletContent} ); }; -const ItwCardsContainer = () => { +const WalletCardsContainerSkeleton = () => ( + <> + + + + +); + +const ItwWalletCardsContainer = () => { const navigation = useIONavigation(); const cards = useIOSelector(selectWalletItwCards); const isItwValid = useIOSelector(itwLifecycleIsValidSelector); @@ -114,15 +120,12 @@ const ItwCardsContainer = () => { ) ); - if (!isItwEnabled) { - return null; - } - - const getHeader = (): ListItemHeader | undefined => { + const sectionHeader = React.useMemo((): ListItemHeader | undefined => { if (!isItwValid) { return undefined; } return { + testID: "walletCardsCategoryItwHeaderTestID", iconName: "legalValue", iconColor: isEidExpired ? "grey-300" : "blueIO-500", label: I18n.t("features.wallet.cards.categories.itw"), @@ -137,15 +140,19 @@ const ItwCardsContainer = () => { } } }; - }; + }, [isItwValid, isEidExpired, eidInfoBottomSheet.present]); + + if (!isItwEnabled) { + return null; + } return ( <> @@ -161,28 +168,39 @@ const ItwCardsContainer = () => { ); }; -const OtherCardsContainer = () => { +const OtherWalletCardsContainer = () => { const cards = useIOSelector(selectWalletOtherCards); - const isItwEnabled = useIOSelector(isItwEnabledSelector); - const isItwValid = useIOSelector(itwLifecycleIsValidSelector); + const categories = useIOSelector(selectWalletCategories); - const displayHeader = isItwEnabled && isItwValid; + const sectionHeader = React.useMemo((): ListItemHeader | undefined => { + // The section header must be displayed only if there are more categories + if (categories.size <= 1) { + return undefined; + } + return { + testID: "walletCardsCategoryOtherHeaderTestID", + label: I18n.t("features.wallet.cards.categories.other") + }; + }, [categories.size]); + + // If there are no cards, don't render the container + if (cards.length === 0) { + return ; + } return ( } /> ); }; -export { WalletCardsContainer }; +export { + ItwWalletCardsContainer, + OtherWalletCardsContainer, + WalletCardsContainer +}; diff --git a/ts/features/wallet/components/WalletCategoryFilterTabs.tsx b/ts/features/wallet/components/WalletCategoryFilterTabs.tsx index 69e0c5d1a77..9cec1e0534e 100644 --- a/ts/features/wallet/components/WalletCategoryFilterTabs.tsx +++ b/ts/features/wallet/components/WalletCategoryFilterTabs.tsx @@ -7,28 +7,37 @@ import React from "react"; import { StyleSheet, View } from "react-native"; import I18n from "../../../i18n"; import { useIODispatch, useIOSelector } from "../../../store/hooks"; +import { trackWalletCategoryFilter } from "../../itwallet/analytics"; import { walletSetCategoryFilter } from "../store/actions/preferences"; -import { selectWalletCategoryFilter } from "../store/selectors"; +import { + selectWalletCategories, + selectWalletCategoryFilter +} from "../store/selectors"; import { walletCardCategoryFilters } from "../types"; -import { itwLifecycleIsValidSelector } from "../../itwallet/lifecycle/store/selectors"; -import { itwIsWalletEmptySelector } from "../../itwallet/credentials/store/selectors"; -import { trackWalletCategoryFilter } from "../../itwallet/analytics"; +/** + * Renders filter tabs to categorize cards on the wallet home screen. + * The tabs allow users to filter between different wallet categories like ITW, payments and bonus cards. + * Automatically hides when only one category is available to avoid unnecessary UI clutter. + */ const WalletCategoryFilterTabs = () => { const dispatch = useIODispatch(); const selectedCategory = useIOSelector(selectWalletCategoryFilter); - const isItwValid = useIOSelector(itwLifecycleIsValidSelector); - const isWalletEmpty = useIOSelector(itwIsWalletEmptySelector); + const categories = useIOSelector(selectWalletCategories); - if (!isItwValid || isWalletEmpty) { + const selectedIndex = React.useMemo( + () => + selectedCategory + ? walletCardCategoryFilters.indexOf(selectedCategory) + 1 + : 0, + [selectedCategory] + ); + + if (categories.size <= 1) { return null; } - const selectedIndex = selectedCategory - ? walletCardCategoryFilters.indexOf(selectedCategory) + 1 - : 0; - const handleFilterSelected = (index: number) => { const categoryByIndex = index === 0 ? undefined : walletCardCategoryFilters[index - 1]; diff --git a/ts/features/wallet/components/__tests__/WalletCardsCategoryRetryErrorBanner.test.tsx b/ts/features/wallet/components/__tests__/WalletCardsCategoryRetryErrorBanner.test.tsx new file mode 100644 index 00000000000..78b92b46beb --- /dev/null +++ b/ts/features/wallet/components/__tests__/WalletCardsCategoryRetryErrorBanner.test.tsx @@ -0,0 +1,63 @@ +import * as pot from "@pagopa/ts-commons/lib/pot"; +import configureMockStore from "redux-mock-store"; +import ROUTES from "../../../../navigation/routes"; +import { applicationChangeState } from "../../../../store/actions/application"; +import { appReducer } from "../../../../store/reducers"; +import { GlobalState } from "../../../../store/reducers/types"; +import { renderScreenWithNavigationStoreContext } from "../../../../utils/testWrapper"; +import * as cgnSelectors from "../../../bonus/cgn/store/reducers/details"; +import * as idPayWalletSelectors from "../../../idpay/wallet/store/reducers"; +import * as paymentsWalletSelectors from "../../../payments/wallet/store/selectors"; +import { WalletCardsCategoryRetryErrorBanner } from "../WalletCardsCategoryRetryErrorBanner"; + +describe("WalletCardsCategoryRetryErrorBanner", () => { + it.each` + isIdPayError | isPaymentMethodsError | isCgnError | shouldRender + ${true} | ${false} | ${false} | ${true} + ${false} | ${true} | ${false} | ${true} + ${false} | ${false} | ${true} | ${true} + ${false} | ${false} | ${false} | ${false} + `( + "should render if %p", + ({ isIdPayError, isPaymentMethodsError, isCgnError, shouldRender }) => { + jest + .spyOn(idPayWalletSelectors, "idPayWalletInitiativeListSelector") + .mockImplementation(() => + isIdPayError ? pot.someError({} as any, {} as any) : pot.none + ); + + jest + .spyOn(paymentsWalletSelectors, "paymentsWalletUserMethodsSelector") + .mockImplementation(() => + isPaymentMethodsError ? pot.someError({} as any, {} as any) : pot.none + ); + + jest + .spyOn(cgnSelectors, "cgnDetailSelector") + .mockImplementation(() => + isCgnError ? pot.someError({} as any, {} as any) : pot.none + ); + + const { queryAllByTestId } = renderComponent(); + + const elements = queryAllByTestId( + "walletCardsCategoryRetryErrorBannerTestID" + ); + expect(elements).toHaveLength(shouldRender ? 1 : 0); + } + ); +}); + +const renderComponent = () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + + const mockStore = configureMockStore(); + const store: ReturnType = mockStore(globalState); + + return renderScreenWithNavigationStoreContext( + WalletCardsCategoryRetryErrorBanner, + ROUTES.WALLET_HOME, + {}, + store + ); +}; diff --git a/ts/features/wallet/components/__tests__/WalletCardsContainer.test.tsx b/ts/features/wallet/components/__tests__/WalletCardsContainer.test.tsx index 9ecb08d55d9..a10f9fd4446 100644 --- a/ts/features/wallet/components/__tests__/WalletCardsContainer.test.tsx +++ b/ts/features/wallet/components/__tests__/WalletCardsContainer.test.tsx @@ -2,28 +2,28 @@ import * as O from "fp-ts/lib/Option"; import _ from "lodash"; import * as React from "react"; import configureMockStore from "redux-mock-store"; -import { ToolEnum } from "../../../../../definitions/content/AssistanceToolConfig"; -import { Config } from "../../../../../definitions/content/Config"; import ROUTES from "../../../../navigation/routes"; import { applicationChangeState } from "../../../../store/actions/application"; import { appReducer } from "../../../../store/reducers"; -import { RemoteConfigState } from "../../../../store/reducers/backendStatus/remoteConfig"; +import * as configSelectors from "../../../../store/reducers/backendStatus/remoteConfig"; import { GlobalState } from "../../../../store/reducers/types"; import { renderScreenWithNavigationStoreContext } from "../../../../utils/testWrapper"; -import { CredentialType } from "../../../itwallet/common/utils/itwMocksUtils"; -import { ItwLifecycleState } from "../../../itwallet/lifecycle/store/reducers"; +import * as itwSelectors from "../../../itwallet/common/store/selectors"; +import { + CredentialType, + ItwStoredCredentialsMocks +} from "../../../itwallet/common/utils/itwMocksUtils"; +import { ItwJwtCredentialStatus } from "../../../itwallet/common/utils/itwTypesUtils"; +import * as itwCredentialsSelectors from "../../../itwallet/credentials/store/selectors"; +import * as itwLifecycleSelectors from "../../../itwallet/lifecycle/store/selectors"; import { WalletCardsState } from "../../store/reducers/cards"; -import { WalletPlaceholdersState } from "../../store/reducers/placeholders"; +import * as walletSelectors from "../../store/selectors"; import { WalletCard } from "../../types"; -import { WalletCardsContainer } from "../WalletCardsContainer"; - -type RenderOptions = { - cards?: WalletCardsState; - isLoading?: WalletPlaceholdersState["isLoading"]; - isItwEnabled?: boolean; - isItwValid?: boolean; - isWalletEmpty?: boolean; -}; +import { + ItwWalletCardsContainer, + OtherWalletCardsContainer, + WalletCardsContainer +} from "../WalletCardsContainer"; jest.mock("react-native-reanimated", () => ({ ...require("react-native-reanimated/mock"), @@ -65,46 +65,267 @@ const T_CARDS: WalletCardsState = { category: "itw", type: "itw", credentialType: CredentialType.DRIVING_LICENSE + }, + "5": { + key: "5", + category: "itw", + type: "itw", + credentialType: CredentialType.EUROPEAN_HEALTH_INSURANCE_CARD } }; -const T_PLACEHOLDERS: WalletCardsState = _.omit( - _.mapValues( - T_CARDS, - card => - ({ - type: "placeholder", - category: card.category, - key: card.key - } as WalletCard) - ), - "deletedCard" +const T_PLACEHOLDERS: WalletCardsState = _.mapValues( + T_CARDS, + card => + ({ + type: "placeholder", + category: card.category, + key: card.key + } as WalletCard) ); describe("WalletCardsContainer", () => { jest.useFakeTimers(); jest.runAllTimers(); - it("should render the loading screen", async () => { - const { queryByTestId } = renderComponent({ - isLoading: true - }); + it("should render the loading screen", () => { + jest + .spyOn(walletSelectors, "selectIsWalletCardsLoading") + .mockImplementation(() => true); + jest + .spyOn(walletSelectors, "selectWalletCategoryFilter") + .mockImplementation(() => undefined); + jest + .spyOn(walletSelectors, "shouldRenderWalletEmptyStateSelector") + .mockImplementation(() => true); + + const { queryByTestId } = renderComponent(WalletCardsContainer); - expect(queryByTestId("walletCardSkeletonTestID")).not.toBeNull(); - expect(queryByTestId(`walletCardsCategoryTestID_itw`)).toBeNull(); - expect(queryByTestId(`walletCardsCategoryTestID_other`)).toBeNull(); + expect(queryByTestId("walletCardSkeletonTestID_1")).not.toBeNull(); + expect(queryByTestId("walletCardSkeletonTestID_2")).not.toBeNull(); + expect(queryByTestId("walletCardSkeletonTestID_3")).not.toBeNull(); + expect(queryByTestId(`itwWalletCardsContainerTestID`)).toBeNull(); + expect(queryByTestId(`otherWalletCardsContainerTestID`)).toBeNull(); + expect(queryByTestId(`walletEmptyScreenContentTestID`)).toBeNull(); + expect(queryByTestId(`walletCardsContainerTestID`)).toBeNull(); }); - it("should render the placeholders", () => { - const { queryByTestId } = renderComponent({ - cards: T_PLACEHOLDERS, - isLoading: true - }); + it("should render the empty screen", () => { + jest + .spyOn(walletSelectors, "selectIsWalletCardsLoading") + .mockImplementation(() => false); + jest + .spyOn(walletSelectors, "selectWalletCategoryFilter") + .mockImplementation(() => undefined); + jest + .spyOn(walletSelectors, "shouldRenderWalletEmptyStateSelector") + .mockImplementation(() => true); + + const { queryByTestId } = renderComponent(WalletCardsContainer); + + expect(queryByTestId("walletCardSkeletonTestID_1")).toBeNull(); + expect(queryByTestId("walletCardSkeletonTestID_2")).toBeNull(); + expect(queryByTestId("walletCardSkeletonTestID_3")).toBeNull(); + expect(queryByTestId(`itwWalletCardsContainerTestID`)).toBeNull(); + expect(queryByTestId(`otherWalletCardsContainerTestID`)).toBeNull(); + expect(queryByTestId(`walletEmptyScreenContentTestID`)).not.toBeNull(); + expect(queryByTestId(`walletCardsContainerTestID`)).toBeNull(); + expect(queryByTestId(`walletCardsContainerTestID`)).toBeNull(); + }); + + it.each([ + [undefined, ["itw", "other"]], + ["itw", ["itw"]], + ["other", ["other"]] + ] as const)( + "when the category filter is %p, the %p cards should be rendered", + (categoryFilter, expectedCategories) => { + jest + .spyOn(walletSelectors, "selectWalletOtherCards") + .mockImplementation(() => [T_CARDS["1"], T_CARDS["2"], T_CARDS["3"]]); + + jest + .spyOn(walletSelectors, "selectWalletItwCards") + .mockImplementation(() => [T_CARDS["4"], T_CARDS["5"]]); + + jest + .spyOn(configSelectors, "isItwEnabledSelector") + .mockImplementation(() => true); + jest + .spyOn(walletSelectors, "selectIsWalletCardsLoading") + .mockImplementation(() => false); + jest + .spyOn(walletSelectors, "selectWalletCategoryFilter") + .mockImplementation(() => categoryFilter); + jest + .spyOn(walletSelectors, "shouldRenderWalletEmptyStateSelector") + .mockImplementation(() => false); + + const { queryByTestId } = renderComponent(WalletCardsContainer); + + expect(queryByTestId("walletCardSkeletonTestID_1")).toBeNull(); + expect(queryByTestId("walletCardSkeletonTestID_2")).toBeNull(); + expect(queryByTestId("walletCardSkeletonTestID_3")).toBeNull(); + expect(queryByTestId(`walletEmptyScreenContentTestID`)).toBeNull(); + expect(queryByTestId(`walletCardsContainerTestID`)).not.toBeNull(); + + expectedCategories.forEach(category => { + expect( + queryByTestId(`${category}WalletCardsContainerTestID`) + ).not.toBeNull(); + }); + } + ); + + it.each([ + { isLoading: true, isEmpty: false }, + { isLoading: false, isEmpty: true } + ])( + "should render the ITW discovery banner if %p", + ({ isLoading, isEmpty }) => { + jest + .spyOn(itwSelectors, "isItwDiscoveryBannerRenderableSelector") + .mockImplementation(() => true); + + jest + .spyOn(walletSelectors, "selectIsWalletCardsLoading") + .mockImplementation(() => isLoading); + jest + .spyOn(walletSelectors, "shouldRenderWalletEmptyStateSelector") + .mockImplementation(() => isEmpty); + + const { queryByTestId } = renderComponent(WalletCardsContainer); + + expect( + queryByTestId("itwDiscoveryBannerStandaloneTestID") + ).not.toBeNull(); + } + ); +}); + +describe("ItwWalletCardsContainer", () => { + it("should not render if ITW is not enabled", () => { + jest + .spyOn(itwLifecycleSelectors, "itwLifecycleIsValidSelector") + .mockImplementation(() => true); + jest + .spyOn(configSelectors, "isItwEnabledSelector") + .mockImplementation(() => false); + + const { queryByTestId } = renderComponent(ItwWalletCardsContainer); + expect(queryByTestId("itwWalletReadyBannerTestID")).toBeNull(); + }); + + it("should render the wallet ready banner", () => { + jest + .spyOn(itwLifecycleSelectors, "itwLifecycleIsValidSelector") + .mockImplementation(() => true); + jest + .spyOn(configSelectors, "isItwEnabledSelector") + .mockImplementation(() => true); + jest + .spyOn(itwSelectors, "itwShouldRenderWalletReadyBannerSelector") + .mockImplementation(() => true); + + const { queryByTestId } = renderComponent(ItwWalletCardsContainer); + expect(queryByTestId("itwWalletReadyBannerTestID")).not.toBeNull(); + }); + + it("should render credential cards", () => { + jest + .spyOn(itwLifecycleSelectors, "itwLifecycleIsValidSelector") + .mockImplementation(() => true); + jest + .spyOn(configSelectors, "isItwEnabledSelector") + .mockImplementation(() => true); + jest + .spyOn(walletSelectors, "selectWalletItwCards") + .mockImplementation(() => [T_CARDS["4"], T_CARDS["5"]]); + + const { queryByTestId } = renderComponent(ItwWalletCardsContainer); + expect(queryByTestId(`walletCardsCategoryItwHeaderTestID`)).not.toBeNull(); + expect(queryByTestId(`walletCardTestID_itw_itw_4`)).not.toBeNull(); + expect(queryByTestId(`walletCardTestID_itw_itw_5`)).not.toBeNull(); + }); + + it("should render the feedback banner", () => { + jest + .spyOn(itwLifecycleSelectors, "itwLifecycleIsValidSelector") + .mockImplementation(() => true); + jest + .spyOn(configSelectors, "isItwEnabledSelector") + .mockImplementation(() => true); + jest + .spyOn(itwSelectors, "itwShouldRenderFeedbackBannerSelector") + .mockImplementation(() => true); + + const { queryByTestId } = renderComponent(ItwWalletCardsContainer); + expect(queryByTestId("itwFeedbackBannerTestID")).not.toBeNull(); + }); + + it.each([ + ["valid", 0], + ["jwtExpiring", 1], + ["jwtExpired", 1] + ])( + "if the eid status is %p, the eid lifecycle alert should be rendered %p times", + (eidStatus, renderCount) => { + jest + .spyOn(itwCredentialsSelectors, "itwCredentialsEidSelector") + .mockImplementation(() => O.some(ItwStoredCredentialsMocks.eid)); + jest + .spyOn(itwCredentialsSelectors, "itwCredentialsEidStatusSelector") + .mockImplementation(() => eidStatus as ItwJwtCredentialStatus); + + const { getAllByTestId } = renderComponent(ItwWalletCardsContainer); + const alerts = getAllByTestId(`itwEidLifecycleAlertTestID_${eidStatus}`); + expect(alerts).toHaveLength(renderCount + 1); + } + ); +}); + +describe("OtherWalletCardsContainer", () => { + it("should not render if there are no payments or bonuses cards", () => { + jest + .spyOn(walletSelectors, "selectWalletOtherCards") + .mockImplementation(() => []); - expect(queryByTestId("walletCardSkeletonTestID")).toBeNull(); + const { queryByTestId } = renderComponent(OtherWalletCardsContainer); + expect(queryByTestId("otherWalletCardsContainerTestID")).toBeNull(); + }); - expect(queryByTestId(`walletCardsCategoryTestID_itw`)).not.toBeNull(); - expect(queryByTestId(`walletCardsCategoryTestID_other`)).not.toBeNull(); + it("should render other cards", () => { + jest + .spyOn(walletSelectors, "selectWalletOtherCards") + .mockImplementation(() => [T_CARDS["1"], T_CARDS["2"], T_CARDS["3"]]); + + const { queryByTestId } = renderComponent(OtherWalletCardsContainer); + expect(queryByTestId(`walletCardsCategoryOtherHeaderTestID`)).toBeNull(); + expect(queryByTestId(`walletCardTestID_payment_payment_1`)).not.toBeNull(); + expect(queryByTestId(`walletCardTestID_bonus_idPay_2`)).not.toBeNull(); + expect(queryByTestId(`walletCardTestID_cgn_cgn_3`)).not.toBeNull(); + }); + + it("should render header if there are more than one category", () => { + jest + .spyOn(walletSelectors, "selectWalletOtherCards") + .mockImplementation(() => [T_CARDS["1"], T_CARDS["2"], T_CARDS["3"]]); + jest + .spyOn(walletSelectors, "selectWalletCategories") + .mockImplementation(() => new Set(["itw", "other"])); + + const { queryByTestId } = renderComponent(OtherWalletCardsContainer); + expect( + queryByTestId(`walletCardsCategoryOtherHeaderTestID`) + ).not.toBeNull(); + }); + + it("should render the placeholders", () => { + jest + .spyOn(walletSelectors, "selectWalletOtherCards") + .mockImplementation(() => Object.values(T_PLACEHOLDERS)); + + const { queryByTestId } = renderComponent(OtherWalletCardsContainer); expect( queryByTestId(`walletCardTestID_payment_placeholder_1`) @@ -117,19 +338,16 @@ describe("WalletCardsContainer", () => { }); it("should render placeholders along with available cards", () => { - const { queryByTestId } = renderComponent({ - cards: { - "1": T_CARDS["1"], - "2": T_PLACEHOLDERS["2"], - "3": T_CARDS["3"], - "4": T_PLACEHOLDERS["4"] - } - }); - - expect(queryByTestId("walletCardSkeletonTestID")).toBeNull(); + jest + .spyOn(walletSelectors, "selectWalletOtherCards") + .mockImplementation(() => [ + T_CARDS["1"], + T_PLACEHOLDERS["2"], + T_CARDS["3"], + T_PLACEHOLDERS["4"] + ]); - expect(queryByTestId(`walletCardsCategoryTestID_itw`)).not.toBeNull(); - expect(queryByTestId(`walletCardsCategoryTestID_other`)).not.toBeNull(); + const { queryByTestId } = renderComponent(OtherWalletCardsContainer); expect(queryByTestId(`walletCardTestID_payment_payment_1`)).not.toBeNull(); expect( @@ -138,102 +356,16 @@ describe("WalletCardsContainer", () => { expect(queryByTestId(`walletCardTestID_cgn_cgn_3`)).not.toBeNull(); expect(queryByTestId(`walletCardTestID_itw_placeholder_4`)).not.toBeNull(); }); - - it("should not render ITW section if ITW is disabled", () => { - const { queryByTestId } = renderComponent({ - isItwEnabled: false, - cards: T_CARDS - }); - - expect(queryByTestId("walletCardSkeletonTestID")).toBeNull(); - expect(queryByTestId(`walletCardsCategoryTestID_itw`)).toBeNull(); - expect(queryByTestId(`walletCardsCategoryTestID_other`)).not.toBeNull(); - - expect(queryByTestId(`walletCardTestID_payment_payment_1`)).not.toBeNull(); - expect(queryByTestId(`walletCardTestID_bonus_idPay_2`)).not.toBeNull(); - expect(queryByTestId(`walletCardTestID_cgn_cgn_3`)).not.toBeNull(); - expect(queryByTestId(`walletCardTestID_itw_itw_4`)).toBeNull(); - }); - - it("should render the wallet ready banner when the wallet instance is valid and the wallet is empty", () => { - const { queryByTestId } = renderComponent({ - isItwValid: true, - cards: T_CARDS - }); - expect(queryByTestId("itwWalletReadyBannerTestID")).not.toBeNull(); - }); - - test.each([ - { isItwValid: false }, - { isItwValid: true, isWalletEmpty: false } - ] as ReadonlyArray)( - "should not render the wallet ready banner when %p", - options => { - const { queryByTestId } = renderComponent({ - ...options, - cards: T_CARDS - }); - expect(queryByTestId("itwWalletReadyBannerTestID")).toBeNull(); - } - ); }); -const renderComponent = ({ - cards = {}, - isItwEnabled = true, - isItwValid = true, - isLoading = false, - isWalletEmpty = true -}: RenderOptions) => { +const renderComponent = (component: React.ComponentType) => { const globalState = appReducer(undefined, applicationChangeState("active")); const mockStore = configureMockStore(); - const store: ReturnType = mockStore( - _.merge(undefined, globalState, { - features: { - wallet: { - cards, - placeholders: { isLoading } - }, - itWallet: { - ...(isItwValid && { - issuance: { - integrityKeyTag: O.some("key-tag") - }, - credentials: { - eid: O.some({ parsedCredential: {}, jwt: {} }), - credentials: isWalletEmpty - ? [] - : [O.some({ parsedCredential: {} })] - }, - lifecycle: ItwLifecycleState.ITW_LIFECYCLE_VALID - }) - } - }, - remoteConfig: O.some({ - itw: { - enabled: isItwEnabled, - min_app_version: { - android: "0.0.0.0", - ios: "0.0.0.0" - } - }, - assistanceTool: { tool: ToolEnum.none }, - cgn: { enabled: true }, - newPaymentSection: { - enabled: false, - min_app_version: { - android: "0.0.0.0", - ios: "0.0.0.0" - } - }, - fims: { enabled: true } - } as Config) as RemoteConfigState - } as GlobalState) - ); + const store: ReturnType = mockStore(globalState); return renderScreenWithNavigationStoreContext( - () => , + component, ROUTES.WALLET_HOME, {}, store diff --git a/ts/features/wallet/components/__tests__/WalletCategoryFilterTabs.test.tsx b/ts/features/wallet/components/__tests__/WalletCategoryFilterTabs.test.tsx index 5fbcc0df229..06452f7ab0c 100644 --- a/ts/features/wallet/components/__tests__/WalletCategoryFilterTabs.test.tsx +++ b/ts/features/wallet/components/__tests__/WalletCategoryFilterTabs.test.tsx @@ -1,70 +1,66 @@ -import _ from "lodash"; +import { fireEvent } from "@testing-library/react-native"; +import { AnyAction, Dispatch } from "redux"; import configureMockStore from "redux-mock-store"; -import * as O from "fp-ts/lib/Option"; import ROUTES from "../../../../navigation/routes"; import { applicationChangeState } from "../../../../store/actions/application"; +import * as hooks from "../../../../store/hooks"; import { appReducer } from "../../../../store/reducers"; import { GlobalState } from "../../../../store/reducers/types"; import { renderScreenWithNavigationStoreContext } from "../../../../utils/testWrapper"; +import { walletSetCategoryFilter } from "../../store/actions/preferences"; +import * as selectors from "../../store/selectors"; import { WalletCategoryFilterTabs } from "../WalletCategoryFilterTabs"; -import { ItwLifecycleState } from "../../../itwallet/lifecycle/store/reducers"; describe("WalletCategoryFilterTabs", () => { - it("should not render the component when the wallet is not active", () => { - const { queryByTestId } = renderComponent({ - isItwValid: false, - isWalletEmpty: true - }); - expect(queryByTestId("CategoryTabsContainerTestID")).toBeNull(); - }); + it("should not render the component if there is only one cards category in the wallet", () => { + jest + .spyOn(selectors, "selectWalletCategoryFilter") + .mockImplementation(() => undefined); + jest + .spyOn(selectors, "selectWalletCategories") + .mockImplementation(() => new Set(["itw"])); - it("should not render the component when the wallet is empty", () => { - const { queryByTestId } = renderComponent({ - isItwValid: true, - isWalletEmpty: true - }); + const { queryByTestId } = renderComponent(); expect(queryByTestId("CategoryTabsContainerTestID")).toBeNull(); }); - it("should render the component when the wallet is active and not empty", () => { - const { queryByTestId } = renderComponent({ - isItwValid: true, - isWalletEmpty: false - }); + it("should render the component if there is more than one cards category in the wallet", () => { + jest + .spyOn(selectors, "selectWalletCategoryFilter") + .mockImplementation(() => undefined); + jest + .spyOn(selectors, "selectWalletCategories") + .mockImplementation(() => new Set(["itw", "other"])); + + const { queryByTestId } = renderComponent(); expect(queryByTestId("CategoryTabsContainerTestID")).not.toBeNull(); }); + + it("should change the selected category when the user clicks on a tab", () => { + const mockedDispatch = jest.fn(); + jest + .spyOn(hooks, "useIODispatch") + .mockImplementation(() => mockedDispatch as Dispatch); + jest + .spyOn(selectors, "selectWalletCategoryFilter") + .mockImplementation(() => undefined); + jest + .spyOn(selectors, "selectWalletCategories") + .mockImplementation(() => new Set(["itw", "other"])); + + const { getByTestId } = renderComponent(); + const itwTab = getByTestId("CategoryTabTestID-itw"); + fireEvent.press(itwTab); + + expect(mockedDispatch).toHaveBeenCalledWith(walletSetCategoryFilter("itw")); + }); }); -const renderComponent = ({ - isItwValid, - isWalletEmpty -}: { - isItwValid: boolean; - isWalletEmpty: boolean; -}) => { +const renderComponent = () => { const globalState = appReducer(undefined, applicationChangeState("active")); const mockStore = configureMockStore(); - const store: ReturnType = mockStore( - _.merge(undefined, globalState, { - features: { - itWallet: { - ...(isItwValid && { - issuance: { - integrityKeyTag: O.some("key-tag") - }, - credentials: { - eid: O.some({ parsedCredential: {} }), - credentials: isWalletEmpty - ? [] - : [O.some({ parsedCredential: {} })] - }, - lifecycle: ItwLifecycleState.ITW_LIFECYCLE_VALID - }) - } - } - }) - ); + const store: ReturnType = mockStore(globalState); return renderScreenWithNavigationStoreContext( WalletCategoryFilterTabs, diff --git a/ts/features/wallet/screens/WalletHomeScreen.tsx b/ts/features/wallet/screens/WalletHomeScreen.tsx index c19d144060b..34ecd81979e 100644 --- a/ts/features/wallet/screens/WalletHomeScreen.tsx +++ b/ts/features/wallet/screens/WalletHomeScreen.tsx @@ -23,7 +23,7 @@ import { getPaymentsWalletUserMethods } from "../../payments/wallet/store/action import { WalletCardsContainer } from "../components/WalletCardsContainer"; import { WalletCategoryFilterTabs } from "../components/WalletCategoryFilterTabs"; import { walletToggleLoadingState } from "../store/actions/placeholders"; -import { selectWalletCards } from "../store/selectors"; +import { isWalletEmptySelector } from "../store/selectors"; export type WalletHomeNavigationParams = Readonly<{ newMethodAdded: boolean; @@ -33,13 +33,13 @@ export type WalletHomeNavigationParams = Readonly<{ type Props = IOStackNavigationRouteProps; const WalletHomeScreen = ({ route }: Props) => { + const dispatch = useIODispatch(); + const isNewElementAdded = React.useRef(route.params?.newMethodAdded || false); + useFocusEffect(() => { trackOpenWalletScreen(); }); - const dispatch = useIODispatch(); - const isNewElementAdded = React.useRef(route.params?.newMethodAdded || false); - useOnFirstRender(() => { fetchWalletSectionData(); }); @@ -70,10 +70,15 @@ const WalletHomeScreen = ({ route }: Props) => { ); }; -const WalletScrollView = ({ children }: React.PropsWithChildren) => { +/** + * A wrapper which renders a scrollview with an optional CTA based on the wallet state: + * - If the wallet is empty, it renders the empty state + * - If the wallet is not empty, it renders the scrollview with the "add to wallet" button + */ +const WalletScrollView = ({ children }: React.PropsWithChildren) => { const animatedScrollViewRef = useAnimatedRef(); const navigation = useIONavigation(); - const cards = useIOSelector(selectWalletCards); + const isWalletEmpty = useIOSelector(isWalletEmptySelector); const handleAddToWalletButtonPress = () => { trackWalletAdd(); @@ -89,7 +94,7 @@ const WalletScrollView = ({ children }: React.PropsWithChildren) => { false ); - if (cards.length === 0) { + if (isWalletEmpty) { return ( { - it("should start with initial state", () => { - const globalState = appReducer(undefined, applicationChangeState("active")); - expect(globalState.features.wallet.placeholders).toStrictEqual({ - items: {}, - isLoading: false - }); - - const store = createStore(appReducer, globalState as any); - - expect(selectIsWalletCardsLoading(store.getState())).toEqual(false); - }); - - it("should disable loading state when at least a card is added", () => { - const globalState = appReducer(undefined, applicationChangeState("active")); - expect(globalState.features.wallet.placeholders).toStrictEqual({ - items: {}, - isLoading: false - }); - - const store = createStore(appReducer, globalState as any); - - store.dispatch(walletAddCards([T_CARD_1, T_CARD_2, T_CARD_3])); - - expect(selectIsWalletCardsLoading(store.getState())).toEqual(false); - }); -}); diff --git a/ts/features/wallet/store/actions/cards.ts b/ts/features/wallet/store/actions/cards.ts index 6862330390b..7c00fe40136 100644 --- a/ts/features/wallet/store/actions/cards.ts +++ b/ts/features/wallet/store/actions/cards.ts @@ -15,8 +15,17 @@ export const walletRemoveCardsByType = createStandardAction( "WALLET_REMOVE_CARDS_BY_TYPE" )(); +export const walletHideCards = + createStandardAction("WALLET_HIDE_CARDS")>(); + +export const walletRestoreCards = createStandardAction("WALLET_RESTORE_CARDS")< + ReadonlyArray +>(); + export type WalletCardsActions = | ActionType | ActionType | ActionType - | ActionType; + | ActionType + | ActionType + | ActionType; diff --git a/ts/features/wallet/store/__tests__/cards.test.ts b/ts/features/wallet/store/reducers/__tests__/cards.test.ts similarity index 52% rename from ts/features/wallet/store/__tests__/cards.test.ts rename to ts/features/wallet/store/reducers/__tests__/cards.test.ts index 653edfbdc82..87d6c8c2735 100644 --- a/ts/features/wallet/store/__tests__/cards.test.ts +++ b/ts/features/wallet/store/reducers/__tests__/cards.test.ts @@ -1,17 +1,18 @@ +import _ from "lodash"; import { createStore } from "redux"; -import { applicationChangeState } from "../../../../store/actions/application"; -import { appReducer } from "../../../../store/reducers"; -import { WalletCard } from "../../types"; +import { applicationChangeState } from "../../../../../store/actions/application"; +import { appReducer } from "../../../../../store/reducers"; +import { WalletCard } from "../../../types"; import { walletAddCards, + walletHideCards, walletRemoveCards, walletRemoveCardsByType, + walletRestoreCards, walletUpsertCard -} from "../actions/cards"; -import { selectWalletCards } from "../selectors"; -import { walletResetPlaceholders } from "../actions/placeholders"; -import { paymentsDeleteMethodAction } from "../../../payments/details/store/actions"; -import { getNetworkError } from "../../../../utils/errors"; +} from "../../actions/cards"; +import { walletResetPlaceholders } from "../../actions/placeholders"; +import { selectWalletCards } from "../../selectors"; const T_CARD_1: WalletCard = { category: "bonus", @@ -27,15 +28,15 @@ const T_CARD_1: WalletCard = { }; const T_CARD_2: WalletCard = { category: "payment", - key: "9999", + key: "method_1234", type: "payment", - walletId: "" + walletId: "1234" }; const T_CARD_3: WalletCard = { category: "payment", - key: "4444", + key: "method_5678", type: "payment", - walletId: "" + walletId: "5678" }; describe("Wallet cards reducer", () => { @@ -63,13 +64,12 @@ describe("Wallet cards reducer", () => { it("should update a specific card in the store", () => { const globalState = appReducer(undefined, applicationChangeState("active")); - const store = createStore(appReducer, globalState as any); - - store.dispatch(walletAddCards([T_CARD_1])); - - expect(store.getState().features.wallet.cards).toStrictEqual({ - [T_CARD_1.key]: T_CARD_1 - }); + const store = createStore( + appReducer, + _.set(globalState, "features.wallet.cards", { + [T_CARD_1.key]: T_CARD_1 + }) as any + ); store.dispatch(walletUpsertCard({ ...T_CARD_1, type: "cgn" })); @@ -80,13 +80,12 @@ describe("Wallet cards reducer", () => { it("should add a card in the store if not present another with the same key", () => { const globalState = appReducer(undefined, applicationChangeState("active")); - const store = createStore(appReducer, globalState as any); - - store.dispatch(walletAddCards([T_CARD_1])); - - expect(store.getState().features.wallet.cards).toStrictEqual({ - [T_CARD_1.key]: T_CARD_1 - }); + const store = createStore( + appReducer, + _.set(globalState, "features.wallet.cards", { + [T_CARD_1.key]: T_CARD_1 + }) as any + ); store.dispatch(walletUpsertCard(T_CARD_2)); @@ -98,15 +97,14 @@ describe("Wallet cards reducer", () => { it("should remove cards from the store", () => { const globalState = appReducer(undefined, applicationChangeState("active")); - const store = createStore(appReducer, globalState as any); - - store.dispatch(walletAddCards([T_CARD_1, T_CARD_2, T_CARD_3])); - - expect(store.getState().features.wallet.cards).toStrictEqual({ - [T_CARD_1.key]: T_CARD_1, - [T_CARD_2.key]: T_CARD_2, - [T_CARD_3.key]: T_CARD_3 - }); + const store = createStore( + appReducer, + _.set(globalState, "features.wallet.cards", { + [T_CARD_1.key]: T_CARD_1, + [T_CARD_2.key]: T_CARD_2, + [T_CARD_3.key]: T_CARD_3 + }) as any + ); store.dispatch(walletRemoveCards([T_CARD_1.key, T_CARD_3.key])); @@ -117,15 +115,14 @@ describe("Wallet cards reducer", () => { it("should remove cards of the same type from the store", () => { const globalState = appReducer(undefined, applicationChangeState("active")); - const store = createStore(appReducer, globalState as any); - - store.dispatch(walletAddCards([T_CARD_1, T_CARD_2, T_CARD_3])); - - expect(store.getState().features.wallet.cards).toStrictEqual({ - [T_CARD_1.key]: T_CARD_1, - [T_CARD_2.key]: T_CARD_2, - [T_CARD_3.key]: T_CARD_3 - }); + const store = createStore( + appReducer, + _.set(globalState, "features.wallet.cards", { + [T_CARD_1.key]: T_CARD_1, + [T_CARD_2.key]: T_CARD_2, + [T_CARD_3.key]: T_CARD_3 + }) as any + ); store.dispatch(walletRemoveCardsByType("payment")); @@ -135,18 +132,16 @@ describe("Wallet cards reducer", () => { }); it("should remove placeholder cards from the store", () => { - const globalState = appReducer(undefined, applicationChangeState("active")); - const store = createStore(appReducer, globalState as any); - const placeholderCard: WalletCard = { ...T_CARD_1, type: "placeholder" }; - - store.dispatch(walletAddCards([placeholderCard, T_CARD_2, T_CARD_3])); - - expect(store.getState().features.wallet.cards).toStrictEqual({ - [placeholderCard.key]: placeholderCard, - [T_CARD_2.key]: T_CARD_2, - [T_CARD_3.key]: T_CARD_3 - }); + const globalState = appReducer(undefined, applicationChangeState("active")); + const store = createStore( + appReducer, + _.set(globalState, "features.wallet.cards", { + [placeholderCard.key]: placeholderCard, + [T_CARD_2.key]: T_CARD_2, + [T_CARD_3.key]: T_CARD_3 + }) as any + ); store.dispatch(walletResetPlaceholders([placeholderCard])); @@ -156,66 +151,41 @@ describe("Wallet cards reducer", () => { }); }); - it("should handle paymentsDeleteMethodAction request", () => { + it("should hide cards", () => { const globalState = appReducer(undefined, applicationChangeState("active")); - const store = createStore(appReducer, globalState as any); - - const cardKey = { - ...T_CARD_1, - key: "method_1234" - }; - - store.dispatch(walletAddCards([cardKey])); - - expect(store.getState().features.wallet.cards).toStrictEqual({ - [cardKey.key]: cardKey - }); - - store.dispatch( - paymentsDeleteMethodAction.request({ - walletId: "1234" - }) + const store = createStore( + appReducer, + _.set(globalState, "features.wallet.cards", { + [T_CARD_1.key]: T_CARD_1, + [T_CARD_2.key]: T_CARD_2, + [T_CARD_3.key]: T_CARD_3 + }) as any ); - expect(store.getState().features.wallet.cards).toStrictEqual({ - deletedCard: { ...cardKey, index: 0 } - }); - }); - - it("should handle paymentsDeleteMethodAction cancel and failure", () => { - const globalState = appReducer(undefined, applicationChangeState("active")); - const store = createStore(appReducer, globalState as any); - const networkError = getNetworkError(new Error("test")); - - const cardKey = { - ...T_CARD_1, - key: "method_1234" - }; - - store.dispatch(walletAddCards([cardKey, T_CARD_2, T_CARD_3])); + store.dispatch(walletHideCards([T_CARD_2.key])); expect(store.getState().features.wallet.cards).toStrictEqual({ - [cardKey.key]: cardKey, - [T_CARD_2.key]: T_CARD_2, + [T_CARD_1.key]: T_CARD_1, + [T_CARD_2.key]: { ...T_CARD_2, hidden: true }, [T_CARD_3.key]: T_CARD_3 }); + }); - store.dispatch( - paymentsDeleteMethodAction.request({ - walletId: "1234" - }) + it("should restore hidden cards", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + const store = createStore( + appReducer, + _.set(globalState, "features.wallet.cards", { + [T_CARD_1.key]: T_CARD_1, + [T_CARD_2.key]: { ...T_CARD_2, hidden: true }, + [T_CARD_3.key]: T_CARD_3 + }) as any ); - expect(store.getState().features.wallet.cards).toStrictEqual({ - deletedCard: { ...cardKey, index: 2 }, - [T_CARD_2.key]: T_CARD_2, - [T_CARD_3.key]: T_CARD_3 - }); - - store.dispatch(paymentsDeleteMethodAction.failure(networkError)); + store.dispatch(walletRestoreCards([T_CARD_2.key])); expect(store.getState().features.wallet.cards).toStrictEqual({ - [cardKey.key]: { ...cardKey, index: 2 }, + [T_CARD_1.key]: T_CARD_1, [T_CARD_2.key]: T_CARD_2, [T_CARD_3.key]: T_CARD_3 }); diff --git a/ts/features/wallet/store/reducers/__tests__/placeholders.test.ts b/ts/features/wallet/store/reducers/__tests__/placeholders.test.ts new file mode 100644 index 00000000000..a3a7908827b --- /dev/null +++ b/ts/features/wallet/store/reducers/__tests__/placeholders.test.ts @@ -0,0 +1,120 @@ +import _ from "lodash"; +import { createStore } from "redux"; +import { applicationChangeState } from "../../../../../store/actions/application"; +import { appReducer } from "../../../../../store/reducers"; +import { WalletCard } from "../../../types"; +import { walletAddCards, walletRemoveCards } from "../../actions/cards"; +import { walletResetPlaceholders } from "../../actions/placeholders"; + +const T_CARD_1: WalletCard = { + category: "bonus", + key: "1234", + type: "idPay", + amount: 123, + avatarSource: { + uri: "" + }, + expireDate: new Date(), + initiativeId: "123", + name: "Test" +}; +const T_CARD_2: WalletCard = { + category: "payment", + key: "9999", + type: "payment", + walletId: "" +}; +const T_CARD_3: WalletCard = { + category: "payment", + key: "4444", + type: "payment", + walletId: "" +}; +const T_CARD_4: WalletCard = { + category: "payment", + key: "5555", + type: "payment", + walletId: "", + hidden: true +}; +const T_CARD_5: WalletCard = { + category: "payment", + key: "6666", + type: "placeholder" +}; + +describe("Wallet placeholders reducer", () => { + it("should start with initial state", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + const store = createStore(appReducer, globalState as any); + + expect(store.getState().features.wallet.placeholders).toStrictEqual({ + items: {}, + isLoading: false + }); + }); + + it("should add cards to the placeholders", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + expect(globalState.features.wallet.placeholders).toStrictEqual({ + items: {}, + isLoading: false + }); + + const store = createStore(appReducer, globalState as any); + + store.dispatch( + walletAddCards([T_CARD_1, T_CARD_2, T_CARD_3, T_CARD_4, T_CARD_5]) + ); + + expect(store.getState().features.wallet.placeholders).toStrictEqual({ + items: { + [T_CARD_1.key]: T_CARD_1.category, + [T_CARD_2.key]: T_CARD_2.category, + [T_CARD_3.key]: T_CARD_3.category + }, + isLoading: false + }); + }); + + it("should remove placeholders", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + const store = createStore( + appReducer, + _.set(globalState, "features.wallet.placeholders.items", { + [T_CARD_1.key]: T_CARD_1.category, + [T_CARD_2.key]: T_CARD_2.category, + [T_CARD_3.key]: T_CARD_3.category + }) as any + ); + + store.dispatch(walletRemoveCards([T_CARD_1.key, T_CARD_2.key])); + + expect(store.getState().features.wallet.placeholders).toStrictEqual({ + items: { + [T_CARD_3.key]: T_CARD_3.category + }, + isLoading: false + }); + }); + + it("should reset placeholders", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + const store = createStore( + appReducer, + _.set(globalState, "features.wallet.placeholders.items", { + [T_CARD_1.key]: T_CARD_1.category, + [T_CARD_2.key]: T_CARD_2.category + }) as any + ); + + store.dispatch(walletResetPlaceholders([T_CARD_3, T_CARD_4, T_CARD_5])); + + expect(store.getState().features.wallet.placeholders).toStrictEqual({ + items: { + [T_CARD_3.key]: T_CARD_3.category + }, + isLoading: false + }); + }); +}); diff --git a/ts/features/wallet/store/reducers/__tests__/preferences.test.ts b/ts/features/wallet/store/reducers/__tests__/preferences.test.ts new file mode 100644 index 00000000000..fb23705d3f2 --- /dev/null +++ b/ts/features/wallet/store/reducers/__tests__/preferences.test.ts @@ -0,0 +1,33 @@ +import { createStore } from "redux"; +import { applicationChangeState } from "../../../../../store/actions/application"; +import { appReducer } from "../../../../../store/reducers"; +import { walletSetCategoryFilter } from "../../actions/preferences"; + +describe("Wallet preferences reducer", () => { + it("should start with initial state", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + expect(globalState.features.wallet.preferences).toStrictEqual({}); + }); + + it("should set the category filter", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + const store = createStore(appReducer, globalState as any); + + store.dispatch(walletSetCategoryFilter("itw")); + + expect(store.getState().features.wallet.preferences).toStrictEqual({ + categoryFilter: "itw" + }); + }); + + it("should set the category filter to undefined", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + const store = createStore(appReducer, globalState as any); + + store.dispatch(walletSetCategoryFilter(undefined)); + + expect(store.getState().features.wallet.preferences).toStrictEqual({ + categoryFilter: undefined + }); + }); +}); diff --git a/ts/features/wallet/store/reducers/cards.ts b/ts/features/wallet/store/reducers/cards.ts index 55e6ec78f8b..33eda85163e 100644 --- a/ts/features/wallet/store/reducers/cards.ts +++ b/ts/features/wallet/store/reducers/cards.ts @@ -3,21 +3,15 @@ import { Action } from "../../../../store/actions/types"; import { WalletCard } from "../../types"; import { walletAddCards, + walletHideCards, walletRemoveCards, walletRemoveCardsByType, + walletRestoreCards, walletUpsertCard } from "../actions/cards"; import { walletResetPlaceholders } from "../actions/placeholders"; -import { paymentsDeleteMethodAction } from "../../../payments/details/store/actions"; -import { mapWalletIdToCardKey } from "../../../payments/common/utils"; -type DeletedCard = WalletCard & { index: number }; - -export type WalletCardsState = { - [key: string]: WalletCard; -} & { - deletedCard?: DeletedCard; -}; +export type WalletCardsState = { [key: string]: WalletCard }; const INITIAL_STATE: WalletCardsState = {}; @@ -48,7 +42,9 @@ const reducer = ( case getType(walletResetPlaceholders): return Object.fromEntries( - Object.entries(state).filter(([_, card]) => card.type !== "placeholder") + Object.entries(state).filter( + ([_key, card]) => card.type !== "placeholder" + ) ); case getType(walletRemoveCardsByType): @@ -56,39 +52,26 @@ const reducer = ( Object.entries(state).filter(([, { type }]) => type !== action.payload) ); - case getType(paymentsDeleteMethodAction.request): { - const cardKey = mapWalletIdToCardKey(action.payload.walletId); - const deletedCard = { - ...state[cardKey], - index: Object.keys(state).indexOf(cardKey) - }; - - const newState = Object.fromEntries( - Object.entries(state).filter(([key]) => key !== cardKey) + case getType(walletHideCards): + return Object.fromEntries( + Object.entries(state).map(([key, card]) => { + if (action.payload.includes(key)) { + return [key, { ...card, hidden: true }]; + } + return [key, card]; + }) ); - return { - ...newState, - deletedCard - }; - } - - case getType(paymentsDeleteMethodAction.cancel): - case getType(paymentsDeleteMethodAction.failure): { - if (!state.deletedCard) { - return state; // No deletedCard to restore - } - - const { deletedCard, ...rest } = state; - // Reconstruct state with deletedCard in its original position - const restoredEntries = [ - ...Object.entries(rest).slice(0, deletedCard.index), - [deletedCard.key, deletedCard], - ...Object.entries(rest).slice(deletedCard.index) - ]; - - return Object.fromEntries(restoredEntries); - } + case getType(walletRestoreCards): + return Object.fromEntries( + Object.entries(state).map(([key, card]) => { + if (action.payload.includes(key)) { + const { hidden, ...rest } = card; + return [key, rest]; + } + return [key, card]; + }) + ); } return state; }; diff --git a/ts/features/wallet/store/reducers/placeholders.ts b/ts/features/wallet/store/reducers/placeholders.ts index 794f7a6e41b..a4ffb4a5bdb 100644 --- a/ts/features/wallet/store/reducers/placeholders.ts +++ b/ts/features/wallet/store/reducers/placeholders.ts @@ -35,10 +35,9 @@ const reducer = ( case getType(walletAddCards): return { ...state, - items: action.payload - .filter(({ type }) => type !== "placeholder") - .reduce(cardPlaceholderReducerFn, state.items) + items: action.payload.reduce(cardPlaceholderReducerFn, state.items) }; + case getType(walletRemoveCards): return { ...state, @@ -52,9 +51,7 @@ const reducer = ( case getType(walletResetPlaceholders): return { ...state, - items: action.payload - .filter(({ type }) => type !== "placeholder") - .reduce(cardPlaceholderReducerFn, {}) + items: action.payload.reduce(cardPlaceholderReducerFn, {}) }; } return state; @@ -62,11 +59,18 @@ const reducer = ( const cardPlaceholderReducerFn = ( acc: WalletPlaceholders, - { category, key }: WalletCard -) => ({ - ...acc, - [key]: category -}); + { category, key, type, hidden }: WalletCard +) => { + // Hidden cards and placeholder cards are not added to the placeholders + if (hidden || type === "placeholder") { + return acc; + } + + return { + ...acc, + [key]: category + }; +}; const CURRENT_REDUX_WALLET_PLACEHOLDERS_STORE_VERSION = -1; diff --git a/ts/features/wallet/store/selectors/__tests__/index.test.ts b/ts/features/wallet/store/selectors/__tests__/index.test.ts new file mode 100644 index 00000000000..bf524fdbf2d --- /dev/null +++ b/ts/features/wallet/store/selectors/__tests__/index.test.ts @@ -0,0 +1,222 @@ +import * as O from "fp-ts/lib/Option"; +import * as pot from "@pagopa/ts-commons/lib/pot"; +import _ from "lodash"; +import { + isWalletEmptySelector, + selectWalletCards, + selectWalletCategories, + shouldRenderWalletEmptyStateSelector +} from ".."; +import { applicationChangeState } from "../../../../../store/actions/application"; +import { appReducer } from "../../../../../store/reducers"; +import { + CredentialType, + ItwStoredCredentialsMocks +} from "../../../../itwallet/common/utils/itwMocksUtils"; +import { ItwLifecycleState } from "../../../../itwallet/lifecycle/store/reducers"; +import * as itwLifecycleSelectors from "../../../../itwallet/lifecycle/store/selectors"; +import { WalletCardsState } from "../../reducers/cards"; + +const T_CARDS: WalletCardsState = { + "1": { + key: "1", + category: "payment", + type: "payment", + walletId: "" + }, + "2": { + key: "2", + category: "bonus", + type: "idPay", + amount: 1234, + avatarSource: { + uri: "" + }, + expireDate: new Date(), + initiativeId: "", + name: "ABC" + }, + "3": { + key: "3", + category: "cgn", + type: "cgn" + }, + "4": { + key: "4", + category: "itw", + type: "itw", + credentialType: CredentialType.DRIVING_LICENSE + }, + "5": { + key: "5", + category: "itw", + type: "itw", + credentialType: CredentialType.EUROPEAN_HEALTH_INSURANCE_CARD + } +}; + +describe("selectWalletCards", () => { + it("should return the correct cards", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + + const cards = selectWalletCards( + _.set(globalState, "features.wallet", { + cards: { + ...T_CARDS, + test123: { + key: "test123", + category: "itw", + type: "itw", + hidden: true + } + } + }) + ); + + expect(cards).toEqual(Object.values(T_CARDS)); + }); +}); + +describe("selectWalletCategories", () => { + it("should return 'itw' and 'other'", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + + jest + .spyOn(itwLifecycleSelectors, "itwLifecycleIsValidSelector") + .mockImplementation(() => true); + + const categories = selectWalletCategories( + _.set(globalState, "features.wallet", { + cards: T_CARDS + }) + ); + expect(categories).toEqual(new Set(["itw", "other"])); + }); + + it("should return 'itw' and 'other' categories when itw is valid but no ITW cards are present", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + + const categories = selectWalletCategories( + _.merge( + globalState, + _.set(globalState, "features.wallet", { + cards: [T_CARDS["1"], T_CARDS["2"], T_CARDS["3"]] + }), + _.set(globalState, "features.itWallet", { + lifecycle: ItwLifecycleState.ITW_LIFECYCLE_VALID + }), + _.set(globalState, "features.itWallet.issuance", { + integrityKeyTag: O.some("dummy") + }), + _.set(globalState, "features.itWallet.credentials", { + eid: O.some(ItwStoredCredentialsMocks.eid) + }) + ) + ); + expect(categories).toEqual(new Set(["itw", "other"])); + }); + + it("should return only `other` category", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + + const categories = selectWalletCategories( + _.set(globalState, "features.wallet", { + cards: [T_CARDS["1"], T_CARDS["2"], T_CARDS["3"]] + }) + ); + expect(categories).toEqual(new Set(["other"])); + }); +}); + +describe("isWalletEmptySelector", () => { + it("should return true if there are no categories to display", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + + const isWalletEmpty = isWalletEmptySelector( + _.merge( + globalState, + _.set(globalState, "features.wallet", { + cards: [] + }), + _.set(globalState, "features.itWallet", { + lifecycle: ItwLifecycleState.ITW_LIFECYCLE_INSTALLED + }), + _.set(globalState, "features.itWallet.issuance", { + integrityKeyTag: O.none + }), + _.set(globalState, "features.itWallet.credentials", { + eid: O.none + }) + ) + ); + expect(isWalletEmpty).toBe(true); + }); + + it("should return false if there are some cards", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + + const isWalletEmpty = isWalletEmptySelector( + _.merge( + globalState, + _.set(globalState, "features.wallet", { + cards: [T_CARDS["1"], T_CARDS["2"], T_CARDS["3"]] + }) + ) + ); + expect(isWalletEmpty).toBe(false); + }); + + it("should return false if ITW is valid", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + + const isWalletEmpty = isWalletEmptySelector( + _.merge( + globalState, + _.set(globalState, "features.wallet", { + cards: [] + }), + _.set(globalState, "features.itWallet", { + lifecycle: ItwLifecycleState.ITW_LIFECYCLE_VALID + }), + _.set(globalState, "features.itWallet.issuance", { + integrityKeyTag: O.some("dummy") + }), + _.set(globalState, "features.itWallet.credentials", { + eid: O.some(ItwStoredCredentialsMocks.eid) + }) + ) + ); + expect(isWalletEmpty).toBe(false); + }); +}); + +describe("shouldRenderWalletEmptyStateSelector", () => { + it.each` + walletCards | userMethods | cgnInformation | expected + ${[]} | ${pot.some([])} | ${pot.none} | ${true} + ${[T_CARDS["1"]]} | ${pot.some([])} | ${pot.none} | ${false} + ${[]} | ${pot.some([])} | ${pot.someError({}, {})} | ${false} + `( + "should return $expected if walletCards is $walletCards, userMethods is $userMethods and cgnInformation is $cgnInformation", + ({ walletCards, userMethods, cgnInformation, expected }) => { + const globalState = appReducer( + undefined, + applicationChangeState("active") + ); + + const shouldRenderWalletEmptyState = shouldRenderWalletEmptyStateSelector( + _.merge( + globalState, + _.set(globalState, "features.wallet", { + cards: walletCards + }), + _.set(globalState, "features.payments.wallet", { + userMethods + }), + _.set(globalState, "bonus.cgn.detail.information", cgnInformation) + ) + ); + expect(shouldRenderWalletEmptyState).toBe(expected); + } + ); +}); diff --git a/ts/features/wallet/store/selectors/index.ts b/ts/features/wallet/store/selectors/index.ts index 5a922b24b25..fc3731dd8da 100644 --- a/ts/features/wallet/store/selectors/index.ts +++ b/ts/features/wallet/store/selectors/index.ts @@ -1,5 +1,9 @@ +import * as pot from "@pagopa/ts-commons/lib/pot"; import { createSelector } from "reselect"; import { GlobalState } from "../../../../store/reducers/types"; +import { cgnDetailSelector } from "../../../bonus/cgn/store/reducers/details"; +import { itwLifecycleIsValidSelector } from "../../../itwallet/lifecycle/store/selectors"; +import { paymentsWalletUserMethodsSelector } from "../../../payments/wallet/store/selectors"; import { WalletCard, walletCardCategories } from "../../types"; const selectWalletFeature = (state: GlobalState) => state.features.wallet; @@ -13,10 +17,38 @@ export const selectWalletPlaceholders = createSelector( ) ); -export const selectWalletCards = createSelector(selectWalletFeature, wallet => { - const { deletedCard, ...cards } = wallet.cards; - return Object.values(cards); -}); +/** + * Returns the list of cards excluding hidden cards + */ +export const selectWalletCards = createSelector( + selectWalletFeature, + ({ cards }) => Object.values(cards).filter(({ hidden }) => !hidden) +); + +/** + * Returns the list of card categories available in the wallet + * If there are categories other that ITW, they will become "other" + */ +export const selectWalletCategories = createSelector( + selectWalletCards, + itwLifecycleIsValidSelector, + (cards, isItwValid) => { + // Get unique categories from cards + const cardCategories = new Set( + cards.map(card => + // Convert all non-ITW categories to "other" + card.category === "itw" ? "itw" : "other" + ) + ); + + // Add ITW category if valid, even if no ITW cards exist + if (isItwValid) { + cardCategories.add("itw"); + } + + return cardCategories; + } +); /** * Gets the cards sorted by their category order, specified in the {@see walletCardCategories} array @@ -68,3 +100,19 @@ export const selectWalletCgnCard = createSelector( export const selectBonusCards = createSelector(selectSortedWalletCards, cards => cards.filter(({ category }) => category === "bonus") ); + +/** + * Gets if the wallet can be considered empty. + * The wallet is empty if there are no categories to display + * @see selectWalletCategories + */ +export const isWalletEmptySelector = (state: GlobalState) => + selectWalletCategories(state).size === 0; + +/** + * The wallet can be considered empty only if we do not have cards stores and there are no errors + */ +export const shouldRenderWalletEmptyStateSelector = (state: GlobalState) => + isWalletEmptySelector(state) && // No cards to display + pot.isSome(paymentsWalletUserMethodsSelector(state)) && // Payment methods are loaded without errors + !pot.isError(cgnDetailSelector(state)); // CGN is not in error state diff --git a/ts/features/wallet/types/index.ts b/ts/features/wallet/types/index.ts index 1acb643fb79..549319c65b3 100644 --- a/ts/features/wallet/types/index.ts +++ b/ts/features/wallet/types/index.ts @@ -13,11 +13,21 @@ export const walletCardCategoryFilters = ["itw", "other"] as const; export type WalletCardCategoryFilter = (typeof walletCardCategoryFilters)[number]; -// Basic type definition for a wallet card, describes the properties that -// each card MUST have in order to be placed inside the wallet. +/** + * Base type definition for all wallet cards. + * Every card in the wallet must implement these essential properties + * to ensure proper identification, categorization, and lifecycle management. + */ type WalletCardBase = { + /** Unique identifier used to track and reference individual cards */ key: string; + /** Classification of the card (e.g., itw, cgn, bonus, payment) */ category: WalletCardCategory; + /** + * Marks a card as hidden. Hidden cards are not displayed in the wallet UI + * Usefull when we need to remove card without deleting its data from the wallet + */ + hidden?: true; }; // Specific type for ID Pay bonus cards