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/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" } ] 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 {