From dcde5de8423d95ad7fcc24c059032596b3e85cb2 Mon Sep 17 00:00:00 2001 From: Alessandro Mazzon Date: Fri, 20 Dec 2024 11:20:34 +0100 Subject: [PATCH 1/2] fix(IT Wallet): [SIW-1934] Cleanup the integrityKeyTag if getWalletAttestation throws an error (#6569) ## Short description This PR updates the `WalletInstanceAttestationObtainment` machine step adding a cleanup function if there is an error. ## List of changes proposed in this pull request - Added cleanupIntegrityKeyTag if `getWalletAttestation` throws an error ## How to test Ensure that, when creating a WI, after an error on the `WalletInstanceAttestationObtainment` step, the `IntegrityKeyTag` has been correctly deleted, and a new key is generated when retrying to create a WI. --- .../common/utils/itwAttestationUtils.ts | 3 ++ .../machine/eid/__tests__/machine.test.ts | 47 +++++++++++++++++++ ts/features/itwallet/machine/eid/actions.ts | 10 +++- ts/features/itwallet/machine/eid/machine.ts | 3 +- 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/ts/features/itwallet/common/utils/itwAttestationUtils.ts b/ts/features/itwallet/common/utils/itwAttestationUtils.ts index a28f06739b5..985163f6dfb 100644 --- a/ts/features/itwallet/common/utils/itwAttestationUtils.ts +++ b/ts/features/itwallet/common/utils/itwAttestationUtils.ts @@ -23,6 +23,7 @@ export const getIntegrityHardwareKeyTag = async (): Promise => /** * Register a new wallet instance with hardwareKeyTag. * @param hardwareKeyTag - the hardware key tag of the integrity Context + * @param sessionToken - the session token to use for the API calls */ export const registerWalletInstance = async ( hardwareKeyTag: string, @@ -42,6 +43,7 @@ export const registerWalletInstance = async ( /** * Getter for the wallet attestation binded to the wallet instance created with the given hardwareKeyTag. * @param hardwareKeyTag - the hardware key tag of the wallet instance + * @param sessionToken - the session token to use for the API calls * @return the wallet attestation and the related key tag */ export const getAttestation = async ( @@ -81,6 +83,7 @@ export const isWalletInstanceAttestationValid = ( * Get the wallet instance status from the Wallet Provider. * This operation is more lightweight than getting a new attestation to check the status. * @param hardwareKeyTag The hardware key tag used to create the wallet instance + * @param sessionToken The session token to use for the API calls */ export const getWalletInstanceStatus = ( hardwareKeyTag: string, diff --git a/ts/features/itwallet/machine/eid/__tests__/machine.test.ts b/ts/features/itwallet/machine/eid/__tests__/machine.test.ts index c7d51a9c7ab..751df57822b 100644 --- a/ts/features/itwallet/machine/eid/__tests__/machine.test.ts +++ b/ts/features/itwallet/machine/eid/__tests__/machine.test.ts @@ -40,6 +40,7 @@ describe("itwEidIssuanceMachine", () => { const navigateToNfcInstructionsScreen = jest.fn(); const navigateToCieIdLoginScreen = jest.fn(); const storeIntegrityKeyTag = jest.fn(); + const cleanupIntegrityKeyTag = jest.fn(); const storeWalletInstanceAttestation = jest.fn(); const storeEidCredential = jest.fn(); const closeIssuance = jest.fn(); @@ -82,6 +83,7 @@ describe("itwEidIssuanceMachine", () => { navigateToNfcInstructionsScreen, navigateToCieIdLoginScreen, storeIntegrityKeyTag, + cleanupIntegrityKeyTag, storeWalletInstanceAttestation, storeEidCredential, closeIssuance, @@ -982,4 +984,49 @@ describe("itwEidIssuanceMachine", () => { expect(actor.getSnapshot().value).toStrictEqual("Idle"); }); + + it("should cleanup integrity key tag and fail when obtaining Wallet Instance Attestation fails", async () => { + const actor = createActor(mockedMachine); + actor.start(); + + await waitFor(() => expect(onInit).toHaveBeenCalledTimes(1)); + + expect(actor.getSnapshot().value).toStrictEqual("Idle"); + expect(actor.getSnapshot().context).toStrictEqual(InitialContext); + expect(actor.getSnapshot().tags).toStrictEqual(new Set()); + + /** + * Start eID issuance + */ + actor.send({ type: "start" }); + + expect(actor.getSnapshot().value).toStrictEqual("TosAcceptance"); + expect(actor.getSnapshot().tags).toStrictEqual(new Set()); + expect(navigateToTosScreen).toHaveBeenCalledTimes(1); + + /** + * Accept TOS and request WIA + */ + + createWalletInstance.mockImplementation(() => + Promise.resolve(T_INTEGRITY_KEY) + ); + getWalletAttestation.mockImplementation(() => Promise.reject({})); // Simulate failure + isSessionExpired.mockImplementation(() => false); // Session not expired + + actor.send({ type: "accept-tos" }); + + expect(actor.getSnapshot().value).toStrictEqual("WalletInstanceCreation"); + expect(actor.getSnapshot().tags).toStrictEqual(new Set([ItwTags.Loading])); + + await waitFor(() => expect(createWalletInstance).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(getWalletAttestation).toHaveBeenCalledTimes(1)); + + // Wallet Instance Attestation failure triggers cleanupIntegrityKeyTag + expect(cleanupIntegrityKeyTag).toHaveBeenCalledTimes(1); + + // Check that the machine transitions to Failure state + expect(actor.getSnapshot().value).toStrictEqual("Failure"); + expect(actor.getSnapshot().tags).toStrictEqual(new Set()); + }); }); diff --git a/ts/features/itwallet/machine/eid/actions.ts b/ts/features/itwallet/machine/eid/actions.ts index 9b84d7a1e23..2572a537d9f 100644 --- a/ts/features/itwallet/machine/eid/actions.ts +++ b/ts/features/itwallet/machine/eid/actions.ts @@ -9,7 +9,10 @@ import { checkCurrentSession } from "../../../../store/actions/authentication"; import { useIOStore } from "../../../../store/hooks"; import { assert } from "../../../../utils/assert"; import { itwCredentialsStore } from "../../credentials/store/actions"; -import { itwStoreIntegrityKeyTag } from "../../issuance/store/actions"; +import { + itwRemoveIntegrityKeyTag, + itwStoreIntegrityKeyTag +} from "../../issuance/store/actions"; import { itwLifecycleStateUpdated, itwLifecycleWalletReset @@ -168,6 +171,11 @@ export const createEidIssuanceActionsImplementation = ( store.dispatch(itwStoreIntegrityKeyTag(context.integrityKeyTag)); }, + cleanupIntegrityKeyTag: () => { + // Remove the integrity key tag from the store + store.dispatch(itwRemoveIntegrityKeyTag()); + }, + storeWalletInstanceAttestation: ({ context }: ActionArgs) => { diff --git a/ts/features/itwallet/machine/eid/machine.ts b/ts/features/itwallet/machine/eid/machine.ts index 3da95b4605c..1d381db258f 100644 --- a/ts/features/itwallet/machine/eid/machine.ts +++ b/ts/features/itwallet/machine/eid/machine.ts @@ -43,6 +43,7 @@ export const itwEidIssuanceMachine = setup({ navigateToNfcInstructionsScreen: notImplemented, navigateToWalletRevocationScreen: notImplemented, storeIntegrityKeyTag: notImplemented, + cleanupIntegrityKeyTag: notImplemented, storeWalletInstanceAttestation: notImplemented, storeEidCredential: notImplemented, closeIssuance: notImplemented, @@ -225,7 +226,7 @@ export const itwEidIssuanceMachine = setup({ target: "#itwEidIssuanceMachine.TosAcceptance" }, { - actions: "setFailure", + actions: ["setFailure", "cleanupIntegrityKeyTag"], target: "#itwEidIssuanceMachine.Failure" } ] From 9321272753c124be4627f8cd00830e2d591f520d Mon Sep 17 00:00:00 2001 From: Federico Mastrini Date: Fri, 20 Dec 2024 15:06:49 +0100 Subject: [PATCH 2/2] chore(IT Wallet): [SIW-1937] Improve wallet category filters (#6570) ## Short description This PR fixes a bug in the wallet screen where users were unable to see any screen content if the category filters were in the wrong state. This PR also improves the debug data overlay and the wallet redux selectors. ### Steps to reproduce the addressed bug - Navigate to the Wallet screen, ensure you have *Documenti su IO* enabled and at least one payment method saved - Select the "Other" category filter - Remove everything but ITW from the wallet, keeping the "Other" filter selected - Once you removed everything you should see a blank screen - Restarting the app should not fix the issue ## List of changes proposed in this pull request - Added `isWalletCategoryFilteringEnabledSelector` selector to check if the category filtering is available: filtering is available only if there is more than one category in the wallet. - Added `shouldRenderWalletCategorySelector` selector to check if a wallet category section should be rendered, based on the currently selected filter and the number of categories available in the wallet - Added `withWalletCategoryFilter` HOC, which display a component based on the given category filter - Added `selectWalletCardsByCategory` and `selectWalletCardsByType` to select cards based on category or type, removing the need to have dedicated selectors - Removed `selectWalletCgnCard` and `selectBonusCards` selectors - Improved debug data in `DebugInfoOverlay` - Added the ability to display `Set` objects - Added the ability to add data from different components at the same time by merging data using the same key - Improved wallet category filtering - Refactored `WalletCardsContainer` with new selectors - Added useful/missing debug data - Added tests - Removed `categoryFilter` preference persistence from the `wallet.preferences` feature reducer ## How to test - Static checks should pass - Navigate to the wallet screen and check that everything works fine, especially the category filters - Try to reproduce the bug, you should now see the wallet content. --- ts/components/debug/__tests__/utils.test.ts | 17 ++ ts/components/debug/utils.ts | 15 +- ts/components/debug/withDebugEnabled.tsx | 6 +- .../screens/WalletCardOnboardingScreen.tsx | 8 +- .../components/WalletCardsContainer.tsx | 66 ++++--- .../components/WalletCategoryFilterTabs.tsx | 24 ++- .../__tests__/WalletCardsContainer.test.tsx | 12 +- .../WalletCategoryFilterTabs.test.tsx | 16 +- ts/features/wallet/saga/index.ts | 4 +- .../__tests__/WalletHomeScreen.test.tsx | 99 +++------- .../wallet/store/reducers/preferences.ts | 24 ++- .../store/selectors/__tests__/index.test.ts | 174 ++++++++++++++++-- ts/features/wallet/store/selectors/index.ts | 114 ++++++++---- .../wallet/utils/__tests__/index.test.tsx | 40 ++++ ts/features/wallet/utils/index.tsx | 32 +++- ts/mixpanelConfig/mixpanelPropertyUtils.ts | 14 +- ts/store/reducers/debug.ts | 3 +- 17 files changed, 469 insertions(+), 199 deletions(-) create mode 100644 ts/components/debug/__tests__/utils.test.ts create mode 100644 ts/features/wallet/utils/__tests__/index.test.tsx diff --git a/ts/components/debug/__tests__/utils.test.ts b/ts/components/debug/__tests__/utils.test.ts new file mode 100644 index 00000000000..a7c472c8449 --- /dev/null +++ b/ts/components/debug/__tests__/utils.test.ts @@ -0,0 +1,17 @@ +import { truncateObjectStrings } from "../utils"; + +describe("truncateObjectStrings", () => { + it.each` + input | maxLength | expected + ${"Long string"} | ${4} | ${"Long..."} + ${{ outer: { inner: "Long string" }, bool: true }} | ${4} | ${{ outer: { inner: "Long..." }, bool: true }} + ${["Long string", "Very long string"]} | ${4} | ${["Long...", "Very..."]} + ${new Set(["Long string", "Very long string"])} | ${4} | ${["Long...", "Very..."]} + `( + "$input should be truncated to $expected", + ({ input, maxLength, expected }) => { + const result = truncateObjectStrings(input, maxLength); + expect(result).toEqual(expected); + } + ); +}); diff --git a/ts/components/debug/utils.ts b/ts/components/debug/utils.ts index 0057ede6745..698c8cd51d0 100644 --- a/ts/components/debug/utils.ts +++ b/ts/components/debug/utils.ts @@ -1,12 +1,17 @@ type Primitive = string | number | boolean | null | undefined; -type TruncatableValue = Primitive | TruncatableObject | TruncatableArray; +type TruncatableValue = + | Primitive + | TruncatableObject + | TruncatableArray + | TruncatableSet; interface TruncatableObject { [key: string]: TruncatableValue; } type TruncatableArray = Array; +type TruncatableSet = Set; /** * Truncates all string values in an object or array structure to a specified maximum length. @@ -37,6 +42,14 @@ export const truncateObjectStrings = ( } if (typeof value === "object" && value !== null) { + if (value instanceof Set) { + // Set could not be serialized to JSON because values are not stored as properties + // For display purposes, we convert it to an array + return Array.from(value).map(item => + truncateObjectStrings(item, maxLength) + ) as T; + } + return Object.entries(value).reduce( (acc, [key, val]) => ({ ...acc, diff --git a/ts/components/debug/withDebugEnabled.tsx b/ts/components/debug/withDebugEnabled.tsx index 72eb5eccea5..303a6aa45dd 100644 --- a/ts/components/debug/withDebugEnabled.tsx +++ b/ts/components/debug/withDebugEnabled.tsx @@ -6,11 +6,13 @@ import { isDebugModeEnabledSelector } from "../../store/reducers/debug"; * This HOC allows to render the wrapped component only if the debug mode is enabled, otherwise returns null (nothing) */ export const withDebugEnabled = - (WrappedComponent: React.ComponentType

) => +

>( + WrappedComponent: React.ComponentType

+ ) => (props: P) => { const isDebug = useIOSelector(isDebugModeEnabledSelector); if (!isDebug) { return null; } - return ; + return ; }; diff --git a/ts/features/itwallet/onboarding/screens/WalletCardOnboardingScreen.tsx b/ts/features/itwallet/onboarding/screens/WalletCardOnboardingScreen.tsx index 0efb8a6768f..cdaf466893f 100644 --- a/ts/features/itwallet/onboarding/screens/WalletCardOnboardingScreen.tsx +++ b/ts/features/itwallet/onboarding/screens/WalletCardOnboardingScreen.tsx @@ -112,7 +112,7 @@ const ItwCredentialOnboardingSection = () => { ); return ( - <> + @@ -134,7 +134,7 @@ const ItwCredentialOnboardingSection = () => { /> ))} - + ); }; @@ -175,7 +175,7 @@ const OtherCardsOnboardingSection = (props: { showTitle?: boolean }) => { ); return ( - <> + {props.showTitle && ( { onPress={navigateToPaymentMethodOnboarding} /> - + ); }; diff --git a/ts/features/wallet/components/WalletCardsContainer.tsx b/ts/features/wallet/components/WalletCardsContainer.tsx index 9d08e1c2a8b..562c74fec75 100644 --- a/ts/features/wallet/components/WalletCardsContainer.tsx +++ b/ts/features/wallet/components/WalletCardsContainer.tsx @@ -3,12 +3,12 @@ import { useFocusEffect } from "@react-navigation/native"; import * as React from "react"; import { View } from "react-native"; import Animated, { LinearTransition } from "react-native-reanimated"; +import { useDebugInfo } from "../../../hooks/useDebugInfo"; import I18n from "../../../i18n"; import { useIONavigation } from "../../../navigation/params/AppParamsList"; import { useIOSelector } from "../../../store/hooks"; import { isItwEnabledSelector } from "../../../store/reducers/backendStatus/remoteConfig"; import { useIOBottomSheetAutoresizableModal } from "../../../utils/hooks/bottomSheet"; -import { ItwDiscoveryBannerStandalone } from "../../itwallet/common/components/discoveryBanner/ItwDiscoveryBannerStandalone"; import { ItwEidInfoBottomSheetContent, ItwEidInfoBottomSheetTitle @@ -16,22 +16,22 @@ import { import { ItwEidLifecycleAlert } from "../../itwallet/common/components/ItwEidLifecycleAlert"; import { ItwFeedbackBanner } from "../../itwallet/common/components/ItwFeedbackBanner"; import { ItwWalletReadyBanner } from "../../itwallet/common/components/ItwWalletReadyBanner"; +import { ItwDiscoveryBannerStandalone } from "../../itwallet/common/components/discoveryBanner/ItwDiscoveryBannerStandalone"; import { itwCredentialsEidStatusSelector } from "../../itwallet/credentials/store/selectors"; import { itwLifecycleIsValidSelector } from "../../itwallet/lifecycle/store/selectors"; +import { useItwWalletInstanceRevocationAlert } from "../../itwallet/walletInstance/hook/useItwWalletInstanceRevocationAlert"; import { isWalletEmptySelector, - selectIsWalletCardsLoading, + selectIsWalletLoading, + selectWalletCardsByCategory, selectWalletCategories, - selectWalletCategoryFilter, - selectWalletItwCards, selectWalletOtherCards, shouldRenderWalletEmptyStateSelector } from "../store/selectors"; -import { WalletCardCategoryFilter } from "../types"; -import { useItwWalletInstanceRevocationAlert } from "../../itwallet/walletInstance/hook/useItwWalletInstanceRevocationAlert"; +import { withWalletCategoryFilter } from "../utils"; +import { WalletCardSkeleton } from "./WalletCardSkeleton"; import { WalletCardsCategoryContainer } from "./WalletCardsCategoryContainer"; import { WalletCardsCategoryRetryErrorBanner } from "./WalletCardsCategoryRetryErrorBanner"; -import { WalletCardSkeleton } from "./WalletCardSkeleton"; import { WalletEmptyScreenContent } from "./WalletEmptyScreenContent"; const EID_INFO_BOTTOM_PADDING = 128; @@ -42,9 +42,8 @@ const EID_INFO_BOTTOM_PADDING = 128; * and the empty state */ const WalletCardsContainer = () => { - const isLoading = useIOSelector(selectIsWalletCardsLoading); + const isLoading = useIOSelector(selectIsWalletLoading); const isWalletEmpty = useIOSelector(isWalletEmptySelector); - const selectedCategory = useIOSelector(selectWalletCategoryFilter); const shouldRenderEmptyState = useIOSelector( shouldRenderWalletEmptyStateSelector ); @@ -55,13 +54,6 @@ const WalletCardsContainer = () => { // 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] - ); - // Content to render in the wallet screen, based on the current state const walletContent = React.useMemo(() => { if (shouldRenderLoadingState) { @@ -72,11 +64,11 @@ const WalletCardsContainer = () => { } return ( - {shouldRenderCategory("itw") && } - {shouldRenderCategory("other") && } + + ); - }, [shouldRenderEmptyState, shouldRenderCategory, shouldRenderLoadingState]); + }, [shouldRenderEmptyState, shouldRenderLoadingState]); return ( { ); }; +/** + * Skeleton for the wallet cards container + */ const WalletCardsContainerSkeleton = () => ( <> @@ -97,15 +92,29 @@ const WalletCardsContainerSkeleton = () => ( ); -const ItwWalletCardsContainer = () => { +/** + * Card container for the ITW credentials + */ +const ItwWalletCardsContainer = withWalletCategoryFilter("itw", () => { const navigation = useIONavigation(); - const cards = useIOSelector(selectWalletItwCards); + const cards = useIOSelector(state => + selectWalletCardsByCategory(state, "itw") + ); const isItwValid = useIOSelector(itwLifecycleIsValidSelector); const isItwEnabled = useIOSelector(isItwEnabledSelector); const eidStatus = useIOSelector(itwCredentialsEidStatusSelector); const isEidExpired = eidStatus === "jwtExpired"; + useDebugInfo({ + itw: { + isItwValid, + isItwEnabled, + eidStatus, + cards + } + }); + const eidInfoBottomSheet = useIOBottomSheetAutoresizableModal( { title: , @@ -172,12 +181,21 @@ const ItwWalletCardsContainer = () => { {isItwValid && eidInfoBottomSheet.bottomSheet} ); -}; +}); -const OtherWalletCardsContainer = () => { +/** + * Card container for the other cards (payments, bonus, etc.) + */ +const OtherWalletCardsContainer = withWalletCategoryFilter("other", () => { const cards = useIOSelector(selectWalletOtherCards); const categories = useIOSelector(selectWalletCategories); + useDebugInfo({ + other: { + cards + } + }); + const sectionHeader = React.useMemo((): ListItemHeader | undefined => { // The section header must be displayed only if there are more categories if (categories.size <= 1) { @@ -203,7 +221,7 @@ const OtherWalletCardsContainer = () => { bottomElement={} /> ); -}; +}); export { ItwWalletCardsContainer, diff --git a/ts/features/wallet/components/WalletCategoryFilterTabs.tsx b/ts/features/wallet/components/WalletCategoryFilterTabs.tsx index 9cec1e0534e..10b0921f266 100644 --- a/ts/features/wallet/components/WalletCategoryFilterTabs.tsx +++ b/ts/features/wallet/components/WalletCategoryFilterTabs.tsx @@ -10,10 +10,11 @@ import { useIODispatch, useIOSelector } from "../../../store/hooks"; import { trackWalletCategoryFilter } from "../../itwallet/analytics"; import { walletSetCategoryFilter } from "../store/actions/preferences"; import { - selectWalletCategories, + isWalletCategoryFilteringEnabledSelector, selectWalletCategoryFilter } from "../store/selectors"; import { walletCardCategoryFilters } from "../types"; +import { useDebugInfo } from "../../../hooks/useDebugInfo"; /** * Renders filter tabs to categorize cards on the wallet home screen. @@ -23,18 +24,27 @@ import { walletCardCategoryFilters } from "../types"; const WalletCategoryFilterTabs = () => { const dispatch = useIODispatch(); - const selectedCategory = useIOSelector(selectWalletCategoryFilter); - const categories = useIOSelector(selectWalletCategories); + const categoryFilter = useIOSelector(selectWalletCategoryFilter); + const isFilteringEnabled = useIOSelector( + isWalletCategoryFilteringEnabledSelector + ); + + useDebugInfo({ + wallet: { + isFilteringEnabled, + categoryFilter + } + }); const selectedIndex = React.useMemo( () => - selectedCategory - ? walletCardCategoryFilters.indexOf(selectedCategory) + 1 + categoryFilter + ? walletCardCategoryFilters.indexOf(categoryFilter) + 1 : 0, - [selectedCategory] + [categoryFilter] ); - if (categories.size <= 1) { + if (!isFilteringEnabled) { return null; } diff --git a/ts/features/wallet/components/__tests__/WalletCardsContainer.test.tsx b/ts/features/wallet/components/__tests__/WalletCardsContainer.test.tsx index c364c9f9a4f..13b5dec74bd 100644 --- a/ts/features/wallet/components/__tests__/WalletCardsContainer.test.tsx +++ b/ts/features/wallet/components/__tests__/WalletCardsContainer.test.tsx @@ -95,7 +95,7 @@ describe("WalletCardsContainer", () => { it("should render the loading screen", () => { jest - .spyOn(walletSelectors, "selectIsWalletCardsLoading") + .spyOn(walletSelectors, "selectIsWalletLoading") .mockImplementation(() => true); jest .spyOn(walletSelectors, "selectWalletCategoryFilter") @@ -117,7 +117,7 @@ describe("WalletCardsContainer", () => { it("should render the empty screen", () => { jest - .spyOn(walletSelectors, "selectIsWalletCardsLoading") + .spyOn(walletSelectors, "selectIsWalletLoading") .mockImplementation(() => false); jest .spyOn(walletSelectors, "selectWalletCategoryFilter") @@ -150,14 +150,14 @@ describe("WalletCardsContainer", () => { .mockImplementation(() => [T_CARDS["1"], T_CARDS["2"], T_CARDS["3"]]); jest - .spyOn(walletSelectors, "selectWalletItwCards") + .spyOn(walletSelectors, "selectWalletCardsByCategory") .mockImplementation(() => [T_CARDS["4"], T_CARDS["5"]]); jest .spyOn(configSelectors, "isItwEnabledSelector") .mockImplementation(() => true); jest - .spyOn(walletSelectors, "selectIsWalletCardsLoading") + .spyOn(walletSelectors, "selectIsWalletLoading") .mockImplementation(() => false); jest .spyOn(walletSelectors, "selectWalletCategoryFilter") @@ -193,7 +193,7 @@ describe("WalletCardsContainer", () => { .mockImplementation(() => true); jest - .spyOn(walletSelectors, "selectIsWalletCardsLoading") + .spyOn(walletSelectors, "selectIsWalletLoading") .mockImplementation(() => isLoading); jest .spyOn(walletSelectors, "shouldRenderWalletEmptyStateSelector") @@ -244,7 +244,7 @@ describe("ItwWalletCardsContainer", () => { .spyOn(configSelectors, "isItwEnabledSelector") .mockImplementation(() => true); jest - .spyOn(walletSelectors, "selectWalletItwCards") + .spyOn(walletSelectors, "selectWalletCardsByCategory") .mockImplementation(() => [T_CARDS["4"], T_CARDS["5"]]); const { queryByTestId } = renderComponent(ItwWalletCardsContainer); diff --git a/ts/features/wallet/components/__tests__/WalletCategoryFilterTabs.test.tsx b/ts/features/wallet/components/__tests__/WalletCategoryFilterTabs.test.tsx index 06452f7ab0c..9740c683227 100644 --- a/ts/features/wallet/components/__tests__/WalletCategoryFilterTabs.test.tsx +++ b/ts/features/wallet/components/__tests__/WalletCategoryFilterTabs.test.tsx @@ -12,25 +12,25 @@ import * as selectors from "../../store/selectors"; import { WalletCategoryFilterTabs } from "../WalletCategoryFilterTabs"; describe("WalletCategoryFilterTabs", () => { - it("should not render the component if there is only one cards category in the wallet", () => { + it("should not render the component if category filtering is not enabled", () => { jest .spyOn(selectors, "selectWalletCategoryFilter") .mockImplementation(() => undefined); jest - .spyOn(selectors, "selectWalletCategories") - .mockImplementation(() => new Set(["itw"])); + .spyOn(selectors, "isWalletCategoryFilteringEnabledSelector") + .mockImplementation(() => false); const { queryByTestId } = renderComponent(); expect(queryByTestId("CategoryTabsContainerTestID")).toBeNull(); }); - it("should render the component if there is more than one cards category in the wallet", () => { + it("should render the component if category filtering is enabled", () => { jest .spyOn(selectors, "selectWalletCategoryFilter") .mockImplementation(() => undefined); jest - .spyOn(selectors, "selectWalletCategories") - .mockImplementation(() => new Set(["itw", "other"])); + .spyOn(selectors, "isWalletCategoryFilteringEnabledSelector") + .mockImplementation(() => true); const { queryByTestId } = renderComponent(); expect(queryByTestId("CategoryTabsContainerTestID")).not.toBeNull(); @@ -45,8 +45,8 @@ describe("WalletCategoryFilterTabs", () => { .spyOn(selectors, "selectWalletCategoryFilter") .mockImplementation(() => undefined); jest - .spyOn(selectors, "selectWalletCategories") - .mockImplementation(() => new Set(["itw", "other"])); + .spyOn(selectors, "isWalletCategoryFilteringEnabledSelector") + .mockImplementation(() => true); const { getByTestId } = renderComponent(); const itwTab = getByTestId("CategoryTabTestID-itw"); diff --git a/ts/features/wallet/saga/index.ts b/ts/features/wallet/saga/index.ts index 5511df75f9b..36eb1639359 100644 --- a/ts/features/wallet/saga/index.ts +++ b/ts/features/wallet/saga/index.ts @@ -10,7 +10,7 @@ import { import { walletUpdate } from "../store/actions"; import { walletAddCards } from "../store/actions/cards"; import { walletToggleLoadingState } from "../store/actions/placeholders"; -import { selectWalletPlaceholders } from "../store/selectors"; +import { selectWalletPlaceholderCards } from "../store/selectors"; import { handleWalletAnalyticsSaga } from "./handleWalletAnalyticsSaga"; import { handleWalletPlaceholdersTimeout } from "./handleWalletLoadingPlaceholdersTimeout"; import { handleWalletLoadingStateSaga } from "./handleWalletLoadingStateSaga"; @@ -21,7 +21,7 @@ const LOADING_STATE_TIMEOUT = 2000 as Millisecond; export function* watchWalletSaga(): SagaIterator { // Adds persisted placeholders as cards in the wallet // to be displayed while waiting for the actual cards - const placeholders = yield* select(selectWalletPlaceholders); + const placeholders = yield* select(selectWalletPlaceholderCards); yield* put(walletAddCards(placeholders)); yield* takeLatest( diff --git a/ts/features/wallet/screens/__tests__/WalletHomeScreen.test.tsx b/ts/features/wallet/screens/__tests__/WalletHomeScreen.test.tsx index 6bae047df8c..048de392d91 100644 --- a/ts/features/wallet/screens/__tests__/WalletHomeScreen.test.tsx +++ b/ts/features/wallet/screens/__tests__/WalletHomeScreen.test.tsx @@ -1,12 +1,10 @@ -import * as pot from "@pagopa/ts-commons/lib/pot"; -import _ from "lodash"; 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 { WalletCardsState } from "../../store/reducers/cards"; +import * as walletSelectors from "../../store/selectors"; import { WalletHomeScreen } from "../WalletHomeScreen"; jest.mock("react-native-reanimated", () => ({ @@ -18,98 +16,45 @@ jest.mock("react-native-reanimated", () => ({ } })); -const T_CARDS: WalletCardsState = { - "1": { - key: "1", - type: "payment", - category: "payment", - walletId: "" - }, - "2": { - key: "2", - type: "payment", - category: "payment", - walletId: "" - }, - "3": { - key: "3", - type: "idPay", - category: "bonus", - amount: 1234, - avatarSource: { - uri: "" - }, - expireDate: new Date(), - initiativeId: "", - name: "ABC" - } -}; - describe("WalletHomeScreen", () => { jest.useFakeTimers(); jest.runAllTimers(); - it("should correctly render empty screen", () => { - const { - component: { queryByTestId } - } = renderComponent({}); + it("should not render screen actions if the wallet is empty", () => { + jest + .spyOn(walletSelectors, "isWalletEmptySelector") + .mockImplementation(() => true); + + const { queryByTestId } = renderComponent(); jest.runOnlyPendingTimers(); - expect(queryByTestId("walletPaymentsRedirectBannerTestID")).toBeNull(); - expect(queryByTestId("walletEmptyScreenContentTestID")).not.toBeNull(); - expect(queryByTestId("walletCardsContainerTestID")).toBeNull(); expect(queryByTestId("walletAddCardButtonTestID")).toBeNull(); }); - it("should correctly render card list screen", () => { - const { - component: { queryByTestId } - } = renderComponent(T_CARDS); + it("should render screen actions if the wallet is not empty", () => { + jest + .spyOn(walletSelectors, "isWalletEmptySelector") + .mockImplementation(() => false); + + const { queryByTestId } = renderComponent(); + + jest.runOnlyPendingTimers(); - expect(queryByTestId("walletPaymentsRedirectBannerTestID")).toBeNull(); - expect(queryByTestId("walletEmptyScreenContentTestID")).toBeNull(); - expect(queryByTestId("walletCardsContainerTestID")).not.toBeNull(); expect(queryByTestId("walletAddCardButtonTestID")).not.toBeNull(); }); }); -const renderComponent = ( - cards: WalletCardsState, - options: { - isLoading?: boolean; - } = {} -) => { +const renderComponent = () => { const globalState = appReducer(undefined, applicationChangeState("active")); - const { isLoading = false } = options; const mockStore = configureMockStore(); - const store: ReturnType = mockStore( - _.merge(globalState, { - features: { - wallet: { - cards, - preferences: {}, - placeholders: { - isLoading - } - }, - payments: { - wallet: { - userMethods: pot.some([]) - } - } - } - }) - ); + const store: ReturnType = mockStore(globalState); - return { - component: renderScreenWithNavigationStoreContext( - WalletHomeScreen, - ROUTES.WALLET_HOME, - {}, - store - ), + return renderScreenWithNavigationStoreContext( + WalletHomeScreen, + ROUTES.WALLET_HOME, + {}, store - }; + ); }; diff --git a/ts/features/wallet/store/reducers/preferences.ts b/ts/features/wallet/store/reducers/preferences.ts index 49cf5f2c708..01b39b1dc87 100644 --- a/ts/features/wallet/store/reducers/preferences.ts +++ b/ts/features/wallet/store/reducers/preferences.ts @@ -1,9 +1,17 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; -import { PersistConfig, persistReducer } from "redux-persist"; +import _ from "lodash"; +import { + MigrationManifest, + PersistConfig, + PersistedState, + createMigrate, + persistReducer +} from "redux-persist"; import { getType } from "typesafe-actions"; import { Action } from "../../../../store/actions/types"; -import { walletSetCategoryFilter } from "../actions/preferences"; +import { isDevEnv } from "../../../../utils/environment"; import { WalletCardCategoryFilter } from "../../types"; +import { walletSetCategoryFilter } from "../actions/preferences"; export type WalletPreferencesState = { categoryFilter?: WalletCardCategoryFilter; @@ -25,12 +33,20 @@ const reducer = ( return state; }; -const CURRENT_REDUX_WALLET_PREFERENCES_STORE_VERSION = -1; +const CURRENT_REDUX_WALLET_PREFERENCES_STORE_VERSION = 0; + +const migrations: MigrationManifest = { + // Removed categoryFilter persistance requirement + "0": (state: PersistedState): PersistedState => + _.set(state, "preferences", {}) +}; const persistConfig: PersistConfig = { key: "walletPreferences", storage: AsyncStorage, - version: CURRENT_REDUX_WALLET_PREFERENCES_STORE_VERSION + version: CURRENT_REDUX_WALLET_PREFERENCES_STORE_VERSION, + blacklist: ["categoryFilter"], + migrate: createMigrate(migrations, { debug: isDevEnv }) }; export const walletReducerPersistor = persistReducer< diff --git a/ts/features/wallet/store/selectors/__tests__/index.test.ts b/ts/features/wallet/store/selectors/__tests__/index.test.ts index bf524fdbf2d..c1030f3dc6d 100644 --- a/ts/features/wallet/store/selectors/__tests__/index.test.ts +++ b/ts/features/wallet/store/selectors/__tests__/index.test.ts @@ -1,10 +1,14 @@ -import * as O from "fp-ts/lib/Option"; import * as pot from "@pagopa/ts-commons/lib/pot"; +import * as O from "fp-ts/lib/Option"; import _ from "lodash"; import { + isWalletCategoryFilteringEnabledSelector, isWalletEmptySelector, selectWalletCards, + selectWalletCardsByCategory, + selectWalletCardsByType, selectWalletCategories, + shouldRenderWalletCategorySelector, shouldRenderWalletEmptyStateSelector } from ".."; import { applicationChangeState } from "../../../../../store/actions/application"; @@ -15,9 +19,25 @@ import { } from "../../../../itwallet/common/utils/itwMocksUtils"; import { ItwLifecycleState } from "../../../../itwallet/lifecycle/store/reducers"; import * as itwLifecycleSelectors from "../../../../itwallet/lifecycle/store/selectors"; +import { walletCardCategoryFilters } from "../../../types"; import { WalletCardsState } from "../../reducers/cards"; -const T_CARDS: WalletCardsState = { +const T_ITW_CARDS: WalletCardsState = { + "4": { + key: "4", + category: "itw", + type: "itw", + credentialType: CredentialType.DRIVING_LICENSE + }, + "5": { + key: "5", + category: "itw", + type: "itw", + credentialType: CredentialType.EUROPEAN_HEALTH_INSURANCE_CARD + } +}; + +const T_OTHER_CARDS: WalletCardsState = { "1": { key: "1", category: "payment", @@ -40,21 +60,14 @@ const T_CARDS: WalletCardsState = { 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 } }; +const T_CARDS: WalletCardsState = { + ...T_ITW_CARDS, + ...T_OTHER_CARDS +}; + describe("selectWalletCards", () => { it("should return the correct cards", () => { const globalState = appReducer(undefined, applicationChangeState("active")); @@ -128,6 +141,34 @@ describe("selectWalletCategories", () => { }); }); +describe("selectWalletCardsByType", () => { + it("should return the correct cards", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + + const cards = selectWalletCardsByType( + _.set(globalState, "features.wallet", { + cards: T_CARDS + }), + "idPay" + ); + expect(cards).toEqual([T_CARDS["2"]]); + }); +}); + +describe("selectWalletCardsByCategory", () => { + it("should return the correct cards", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + + const cards = selectWalletCardsByCategory( + _.set(globalState, "features.wallet", { + cards: T_CARDS + }), + "itw" + ); + expect(cards).toEqual([T_CARDS["4"], T_CARDS["5"]]); + }); +}); + describe("isWalletEmptySelector", () => { it("should return true if there are no categories to display", () => { const globalState = appReducer(undefined, applicationChangeState("active")); @@ -220,3 +261,108 @@ describe("shouldRenderWalletEmptyStateSelector", () => { } ); }); + +describe("isWalletCategoryFilteringEnabledSelector", () => { + it("should return true if the categories are ['itw', 'other']", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + + const isWalletCategoryFilteringEnabled = + isWalletCategoryFilteringEnabledSelector( + _.merge( + globalState, + _.set(globalState, "features.wallet", { + cards: T_CARDS + }) + ) + ); + + expect(isWalletCategoryFilteringEnabled).toBe(true); + }); + + it("should return false if the categories are ['itw']", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + + const isWalletCategoryFilteringEnabled = + isWalletCategoryFilteringEnabledSelector( + _.merge( + globalState, + _.set(globalState, "features.wallet", { + cards: T_ITW_CARDS + }) + ) + ); + + expect(isWalletCategoryFilteringEnabled).toBe(false); + }); +}); + +describe("shouldRenderWalletCategorySelector", () => { + it("should return true if the category filter is undefined", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + + const shouldRenderWalletCategory = shouldRenderWalletCategorySelector( + _.merge( + globalState, + _.set(globalState, "features.wallet", { + cards: T_CARDS, + preferences: { + categoryFilter: undefined + } + }) + ), + "itw" + ); + + expect(shouldRenderWalletCategory).toBe(true); + }); + + it.each(walletCardCategoryFilters)( + "should return true if the category filter matches the given category when the category is %s", + categoryFilter => { + const globalState = appReducer( + undefined, + applicationChangeState("active") + ); + + const shouldRenderWalletCategory = shouldRenderWalletCategorySelector( + _.merge( + globalState, + _.set(globalState, "features.wallet", { + cards: T_CARDS, + preferences: { + categoryFilter + } + }) + ), + categoryFilter + ); + + expect(shouldRenderWalletCategory).toBe(true); + } + ); + + it.each(walletCardCategoryFilters)( + "should return true if the category filtering is not enabled and the category filter is %s", + categoryFilter => { + const globalState = appReducer( + undefined, + applicationChangeState("active") + ); + + const shouldRenderWalletCategory = shouldRenderWalletCategorySelector( + _.merge( + globalState, + _.set(globalState, "features.wallet", { + cards: T_ITW_CARDS, + preferences: { + categoryFilter + } + }) + ), + categoryFilter + ); + + expect(shouldRenderWalletCategory).toBe(true); + } + ); +}); diff --git a/ts/features/wallet/store/selectors/index.ts b/ts/features/wallet/store/selectors/index.ts index 0fc70d8a3fd..5ee212b0cce 100644 --- a/ts/features/wallet/store/selectors/index.ts +++ b/ts/features/wallet/store/selectors/index.ts @@ -1,35 +1,31 @@ import * as pot from "@pagopa/ts-commons/lib/pot"; import { createSelector } from "reselect"; import { GlobalState } from "../../../../store/reducers/types"; +import { isSomeLoadingOrSomeUpdating } from "../../../../utils/pot"; import { cgnDetailSelector } from "../../../bonus/cgn/store/reducers/details"; import { idPayWalletInitiativeListSelector } from "../../../idpay/wallet/store/reducers"; import { itwLifecycleIsValidSelector } from "../../../itwallet/lifecycle/store/selectors"; import { paymentsWalletUserMethodsSelector } from "../../../payments/wallet/store/selectors"; -import { WalletCard, walletCardCategories } from "../../types"; -import { isSomeLoadingOrSomeUpdating } from "../../../../utils/pot"; - -const selectWalletFeature = (state: GlobalState) => state.features.wallet; - -export const selectWalletPlaceholders = createSelector( - selectWalletFeature, - wallet => - Object.entries(wallet.placeholders.items).map( - ([key, category]) => - ({ key, category, type: "placeholder" } as WalletCard) - ) -); +import { + WalletCard, + WalletCardCategory, + WalletCardType, + walletCardCategories +} from "../../types"; +import { WalletCardCategoryFilter } from "../../types/index"; /** * Returns the list of cards excluding hidden cards */ export const selectWalletCards = createSelector( - selectWalletFeature, - ({ cards }) => Object.values(cards).filter(({ hidden }) => !hidden) + (state: GlobalState) => state.features.wallet.cards, + 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" + * If the ITW is valid, it will be counted as "itw" category, since we do not have eID card anymore */ export const selectWalletCategories = createSelector( selectWalletCards, @@ -66,47 +62,58 @@ export const selectSortedWalletCards = createSelector( ); /** - * Only gets cards which are part of the IT Wallet + * Selects the cards by their category + * @param category - The category of the cards to select */ -export const selectWalletItwCards = createSelector( +export const selectWalletCardsByCategory = createSelector( selectSortedWalletCards, - cards => cards.filter(({ category }) => category === "itw") + (_: GlobalState, category: WalletCardCategory) => category, + (cards, category) => + cards.filter(({ category: cardCategory }) => cardCategory === category) ); /** - * Only gets cards which are not part of the IT Wallet + * Selects the cards by their type + * @param type - The type of the cards to select */ -export const selectWalletOtherCards = createSelector( +export const selectWalletCardsByType = createSelector( selectSortedWalletCards, - cards => cards.filter(({ category }) => category !== "itw") + (_: GlobalState, type: WalletCardType) => type, + (cards, type) => cards.filter(({ type: cardType }) => cardType === type) ); -export const selectIsWalletCardsLoading = (state: GlobalState) => - state.features.wallet.placeholders.isLoading; - -export const selectWalletCategoryFilter = createSelector( - selectWalletFeature, - wallet => wallet.preferences.categoryFilter -); - -export const selectWalletPaymentMethods = createSelector( +/** + * Currently, if a card is not part of the IT Wallet, it is considered as "other" + * This selector returns the cards which are not part of the IT Wallet which must be displayed in the "other" section + */ +export const selectWalletOtherCards = createSelector( selectSortedWalletCards, - cards => cards.filter(({ category }) => category === "payment") + cards => cards.filter(({ category }) => category !== "itw") ); -export const selectWalletCgnCard = createSelector( - selectSortedWalletCards, - cards => cards.filter(({ category }) => category === "cgn") -); +/** + * Selects the loading state of the wallet cards + */ +export const selectIsWalletLoading = (state: GlobalState) => + state.features.wallet.placeholders.isLoading; -export const selectBonusCards = createSelector(selectSortedWalletCards, cards => - cards.filter(({ category }) => category === "bonus") +/** + * Selects the placeholders from the wallet + */ +export const selectWalletPlaceholderCards = createSelector( + (state: GlobalState) => state.features.wallet.placeholders.items, + placeholders => + Object.entries(placeholders).map( + ([key, category]) => + ({ key, category, type: "placeholder" } as WalletCard) + ) ); /** * Gets if the wallet can be considered empty. - * The wallet is empty if there are no categories to display - * @see selectWalletCategories + * The wallet is empty if there are no categories to display (@see selectWalletCategories) + * + * Note: we check categories because if ITW is valid, it is considered as one category even if there are no cards */ export const isWalletEmptySelector = (state: GlobalState) => selectWalletCategories(state).size === 0; @@ -127,3 +134,32 @@ export const isWalletScreenRefreshingSelector = (state: GlobalState) => isSomeLoadingOrSomeUpdating(paymentsWalletUserMethodsSelector(state)) || isSomeLoadingOrSomeUpdating(idPayWalletInitiativeListSelector(state)) || isSomeLoadingOrSomeUpdating(cgnDetailSelector(state)); + +/** + * Selects if the wallet categories can be filtered. + * The filter is only enabled if there are more than one category available + */ +export const isWalletCategoryFilteringEnabledSelector = createSelector( + selectWalletCategories, + categories => categories.size > 1 +); + +/** + * Selects the category filter from the wallet preferences + */ +export const selectWalletCategoryFilter = (state: GlobalState) => + state.features.wallet.preferences.categoryFilter; + +/** + * Checks if a wallet category section should be rendered. A category section is rendered if: + * - the category filtering is not enabled, or + * - no category filter is selected, or + * - the filter matches the given category + */ +export const shouldRenderWalletCategorySelector = createSelector( + isWalletCategoryFilteringEnabledSelector, + selectWalletCategoryFilter, + (_: GlobalState, category: WalletCardCategoryFilter) => category, + (isFilteringEnabled, filter, category) => + !isFilteringEnabled || filter === undefined || filter === category +); diff --git a/ts/features/wallet/utils/__tests__/index.test.tsx b/ts/features/wallet/utils/__tests__/index.test.tsx new file mode 100644 index 00000000000..534d6e66d93 --- /dev/null +++ b/ts/features/wallet/utils/__tests__/index.test.tsx @@ -0,0 +1,40 @@ +import { Body } from "@pagopa/io-app-design-system"; +import * as React from "react"; +import configureMockStore from "redux-mock-store"; +import { withWalletCategoryFilter } from ".."; +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"; + +describe("withWalletCategoryFilter", () => { + it("should return null if the category filter does not match", () => { + const WrappedComponent = () => ( + Hello + ); + const ComponentWithFilter = withWalletCategoryFilter( + "itw", + WrappedComponent + ); + + const globalState = appReducer(undefined, applicationChangeState("active")); + + const mockStore = configureMockStore(); + const store: ReturnType = mockStore(globalState); + + jest + .spyOn(selectors, "shouldRenderWalletCategorySelector") + .mockImplementation(() => false); + + const { queryByTestId } = + renderScreenWithNavigationStoreContext( + () => , + ROUTES.WALLET_HOME, + {}, + store + ); + expect(queryByTestId("WrappedComponentTestID")).toBeNull(); + }); +}); diff --git a/ts/features/wallet/utils/index.tsx b/ts/features/wallet/utils/index.tsx index e2dad27dfdd..00a11ed90fe 100644 --- a/ts/features/wallet/utils/index.tsx +++ b/ts/features/wallet/utils/index.tsx @@ -1,11 +1,14 @@ import * as React from "react"; -import { WalletCard, WalletCardType } from "../types"; +import { WalletCard, WalletCardCategoryFilter, WalletCardType } from "../types"; import { WalletCardBaseComponent } from "../components/WalletCardBaseComponent"; import { CgnWalletCard } from "../../bonus/cgn/components/CgnWalletCard"; import { IdPayWalletCard } from "../../idpay/wallet/components/IdPayWalletCard"; import { PaymentWalletCard } from "../../payments/wallet/components/PaymentWalletCard"; import { WalletCardSkeleton } from "../components/WalletCardSkeleton"; import { ItwCredentialWalletCard } from "../../itwallet/wallet/components/ItwCredentialWalletCard"; +import { shouldRenderWalletCategorySelector } from "../store/selectors"; +import { useIOSelector } from "../../../store/hooks"; +import { GlobalState } from "../../../store/reducers/types"; /** * Wallet card component mapper which translates a WalletCardType to a @@ -24,6 +27,12 @@ export const walletCardComponentMapper: Record< placeholder: WalletCardSkeleton }; +/** + * Function that renders a wallet card using the mapped component inside {@see walletCardComponentMapper} + * @param card - The wallet card object to render + * @param stacked - Whether the card is stacked or not + * @returns The rendered card or null if the card is not found + */ export const renderWalletCardFn = ( card: WalletCard, stacked: boolean = false @@ -39,3 +48,24 @@ export const renderWalletCardFn = ( /> ) : null; }; + +/** + * A higher-order component which renders a component only if the category filter matches the given category + * @param category - The category to filter by + * @param WrappedComponent - The component to render + * @returns The component or null if the category filter does not match + */ +export const withWalletCategoryFilter = +

>( + category: WalletCardCategoryFilter, + WrappedComponent: React.ComponentType

+ ) => + (props: P) => { + const shouldRenderCategory = useIOSelector((state: GlobalState) => + shouldRenderWalletCategorySelector(state, category) + ); + if (!shouldRenderCategory) { + return null; + } + return ; + }; diff --git a/ts/mixpanelConfig/mixpanelPropertyUtils.ts b/ts/mixpanelConfig/mixpanelPropertyUtils.ts index 1d0617f2898..19b3fbe8ed8 100644 --- a/ts/mixpanelConfig/mixpanelPropertyUtils.ts +++ b/ts/mixpanelConfig/mixpanelPropertyUtils.ts @@ -4,10 +4,7 @@ import { ServicesPreferencesModeEnum } from "../../definitions/backend/ServicesP import { TrackCgnStatus } from "../features/bonus/cgn/analytics"; import { LoginSessionDuration } from "../features/fastLogin/analytics/optinAnalytics"; import { fastLoginOptInSelector } from "../features/fastLogin/store/selectors"; -import { - selectBonusCards, - selectWalletCgnCard -} from "../features/wallet/store/selectors"; +import { selectWalletCardsByType } from "../features/wallet/store/selectors"; import { WalletCardBonus } from "../features/wallet/types"; import { paymentsWalletUserMethodsSelector } from "../features/payments/wallet/store/selectors"; import { @@ -91,17 +88,16 @@ export const paymentMethodsHandler = (state: GlobalState): number | undefined => paymentsWalletUserMethodsNumberFromPotSelector(state)?.length; export const cgnStatusHandler = (state: GlobalState): TrackCgnStatus => { - const cgnCard = selectWalletCgnCard(state); + const cgnCard = selectWalletCardsByType(state, "cgn"); return cgnCard.length > 0 ? "active" : "not_active"; }; export const welfareStatusHandler = ( state: GlobalState ): ReadonlyArray => { - const bonusCards = selectBonusCards(state); - const idPayCards = bonusCards.filter( - card => card.type === "idPay" + const idPayCards = selectWalletCardsByType( + state, + "idPay" ) as Array; - return idPayCards.map(card => card.name); }; diff --git a/ts/store/reducers/debug.ts b/ts/store/reducers/debug.ts index 79b127ba1ac..99c957ee9a4 100644 --- a/ts/store/reducers/debug.ts +++ b/ts/store/reducers/debug.ts @@ -1,4 +1,5 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; +import _ from "lodash"; import { PersistConfig, PersistPartial, persistReducer } from "redux-persist"; import { getType } from "typesafe-actions"; import { @@ -37,7 +38,7 @@ function debugReducer( case getType(setDebugData): return { ...state, - debugData: action.payload + debugData: _.merge(state.debugData, action.payload) }; case getType(resetDebugData): return {