diff --git a/ts/boot/__tests__/__snapshots__/persistedStore.test.ts.snap b/ts/boot/__tests__/__snapshots__/persistedStore.test.ts.snap index 57278305868..dc483927d4a 100644 --- a/ts/boot/__tests__/__snapshots__/persistedStore.test.ts.snap +++ b/ts/boot/__tests__/__snapshots__/persistedStore.test.ts.snap @@ -43,48 +43,6 @@ Object { "calendarEvents": Object { "byMessageId": Object {}, }, - "messages": Object { - "allPaginated": Object { - "archive": Object { - "data": Object { - "kind": "PotNone", - }, - "lastRequest": Object { - "_tag": "None", - }, - }, - "inbox": Object { - "data": Object { - "kind": "PotNone", - }, - "lastRequest": Object { - "_tag": "None", - }, - }, - "migration": Object { - "_tag": "None", - }, - "shownCategory": "INBOX", - }, - "detailsById": Object {}, - "downloads": Object {}, - "messageGetStatus": Object { - "status": "idle", - }, - "messagePrecondition": Object { - "content": Object { - "kind": "undefined", - }, - "messageId": Object { - "_tag": "None", - }, - }, - "paginatedById": Object {}, - "payments": Object { - "userSelectedPayments": Set {}, - }, - "thirdPartyById": Object {}, - }, "messagesStatus": Object {}, "organizations": Object { "all": Array [], diff --git a/ts/boot/__tests__/persistedStore.test.ts b/ts/boot/__tests__/persistedStore.test.ts index ad2db917a6e..4e89877d969 100644 --- a/ts/boot/__tests__/persistedStore.test.ts +++ b/ts/boot/__tests__/persistedStore.test.ts @@ -1,3 +1,4 @@ +import _ from "lodash"; import { applicationChangeState } from "../../store/actions/application"; import { appReducer } from "../../store/reducers"; import { GlobalState } from "../../store/reducers/types"; @@ -43,7 +44,8 @@ describe("Check the addition for new fields to the persisted store. If one of th expect(globalState.crossSessions).toMatchSnapshot(); }); it("Freeze 'entities' state", () => { - expect(globalState.entities).toMatchSnapshot(); + const entitiesWithoutMessages = _.omit(globalState.entities, "messages"); + expect(entitiesWithoutMessages).toMatchSnapshot(); }); it("Freeze 'authentication' state", () => { expect(globalState.authentication).toMatchSnapshot(); diff --git a/ts/components/services/SpecialServices/LegacySpecialServicesCTA.tsx b/ts/components/services/SpecialServices/LegacySpecialServicesCTA.tsx index 9cda98014f0..107118a98e5 100644 --- a/ts/components/services/SpecialServices/LegacySpecialServicesCTA.tsx +++ b/ts/components/services/SpecialServices/LegacySpecialServicesCTA.tsx @@ -14,7 +14,7 @@ import { isCdcEnabledSelector, isCGNEnabledSelector, isPnEnabledSelector, - isPnSupportedSelector + isPnAppVersionSupportedSelector } from "../../../store/reducers/backendStatus"; import { openAppStoreUrl } from "../../../utils/url"; @@ -69,7 +69,7 @@ const LegacySpecialServicesCTA = (props: Props) => { const isCdcEnabled = cdcEnabledSelector && cdcEnabled; const isPnEnabled = useIOSelector(isPnEnabledSelector); - const isPnSupported = useIOSelector(isPnSupportedSelector); + const isPnSupported = useIOSelector(isPnAppVersionSupportedSelector); const mapSpecialServiceConfig = new Map([ ["cgn", { isEnabled: isCGNEnabled, isSupported: true }], diff --git a/ts/features/messages/components/Home/Preconditions.tsx b/ts/features/messages/components/Home/Preconditions.tsx new file mode 100644 index 00000000000..72881d90005 --- /dev/null +++ b/ts/features/messages/components/Home/Preconditions.tsx @@ -0,0 +1,88 @@ +import React, { useCallback, useEffect } from "react"; +import { useIOBottomSheetModal } from "../../../../utils/hooks/bottomSheet"; +import { + useIODispatch, + useIOSelector, + useIOStore +} from "../../../../store/hooks"; +import { + preconditionsCategoryTagSelector, + preconditionsRequireAppUpdateSelector, + shouldPresentPreconditionsBottomSheetSelector +} from "../../store/reducers/messagePrecondition"; +import { + clearLegacyMessagePrecondition, + idlePreconditionStatusAction, + retrievingDataPreconditionStatusAction, + toIdlePayload, + toRetrievingDataPayload, + toUpdateRequiredPayload, + updateRequiredPreconditionStatusAction +} from "../../store/actions/preconditions"; +import { MESSAGES_ROUTES } from "../../navigation/routes"; +import { trackDisclaimerOpened } from "../../analytics"; +import { UIMessageId } from "../../types"; +import { useIONavigation } from "../../../../navigation/params/AppParamsList"; +import { PreconditionsTitle } from "./PreconditionsTitle"; +import { PreconditionsContent } from "./PreconditionsContent"; +import { PreconditionsFooter } from "./PreconditionsFooter"; + +export const Preconditions = () => { + const navigation = useIONavigation(); + const dispatch = useIODispatch(); + const store = useIOStore(); + const onDismissCallback = useCallback(() => { + dispatch(clearLegacyMessagePrecondition()); + dispatch(idlePreconditionStatusAction(toIdlePayload())); + }, [dispatch]); + const onNavigationCallback = useCallback( + (messageId: UIMessageId) => { + navigation.navigate(MESSAGES_ROUTES.MESSAGES_NAVIGATOR, { + screen: MESSAGES_ROUTES.MESSAGE_ROUTER, + params: { + messageId, + fromNotification: false + } + }); + }, + [navigation] + ); + const modal = useIOBottomSheetModal({ + snapPoint: [500], + title: , + component: , + footer: ( + modal.dismiss()} + onNavigation={onNavigationCallback} + /> + ), + onDismiss: onDismissCallback + }); + const shouldPresentBottomSheet = useIOSelector( + shouldPresentPreconditionsBottomSheetSelector + ); + + useEffect(() => { + if (shouldPresentBottomSheet) { + const state = store.getState(); + const categoryTag = preconditionsCategoryTagSelector(state); + if (categoryTag) { + trackDisclaimerOpened(categoryTag); + } + modal.present(); + + const requiresAppUpdate = preconditionsRequireAppUpdateSelector(state); + if (requiresAppUpdate) { + dispatch( + updateRequiredPreconditionStatusAction(toUpdateRequiredPayload()) + ); + } else { + dispatch( + retrievingDataPreconditionStatusAction(toRetrievingDataPayload()) + ); + } + } + }, [dispatch, modal, shouldPresentBottomSheet, store]); + return modal.bottomSheet; +}; diff --git a/ts/features/messages/components/Home/PreconditionsContent.tsx b/ts/features/messages/components/Home/PreconditionsContent.tsx new file mode 100644 index 00000000000..a68e6a01482 --- /dev/null +++ b/ts/features/messages/components/Home/PreconditionsContent.tsx @@ -0,0 +1,131 @@ +import React, { useCallback } from "react"; +import { View } from "react-native"; +import Placeholder from "rn-placeholder"; +import { VSpacer } from "@pagopa/io-app-design-system"; +import { + useIODispatch, + useIOSelector, + useIOStore +} from "../../../../store/hooks"; +import { + preconditionsCategoryTagSelector, + preconditionsContentMarkdownSelector, + preconditionsContentSelector +} from "../../store/reducers/messagePrecondition"; +import I18n from "../../../../i18n"; +import { pnMinAppVersionSelector } from "../../../../store/reducers/backendStatus"; +import { MessageMarkdown } from "../MessageDetail/MessageMarkdown"; +import { + errorPreconditionStatusAction, + shownPreconditionStatusAction, + toErrorPayload, + toShownPayload +} from "../../store/actions/preconditions"; +import { trackDisclaimerLoadError } from "../../analytics"; +import { PreconditionsFeedback } from "./PreconditionsFeedback"; + +export const PreconditionsContent = () => { + const content = useIOSelector(preconditionsContentSelector); + switch (content) { + case "content": + return ; + case "error": + return ; + case "loading": + return ; + case "update": + return ; + } + return null; +}; + +const PreconditionsContentMarkdown = () => { + const dispatch = useIODispatch(); + const store = useIOStore(); + + const markdown = useIOSelector(preconditionsContentMarkdownSelector); + + const onLoadEndCallback = useCallback(() => { + dispatch(shownPreconditionStatusAction(toShownPayload())); + }, [dispatch]); + const onErrorCallback = useCallback( + (anyError: any) => { + const state = store.getState(); + const category = preconditionsCategoryTagSelector(state); + if (category) { + trackDisclaimerLoadError(category); + } + dispatch( + errorPreconditionStatusAction( + toErrorPayload(`Markdown loading failure (${anyError})`) + ) + ); + }, + [dispatch, store] + ); + + if (!markdown) { + return null; + } + + return ( + + {markdown} + + ); +}; + +const PreconditionsContentError = () => ( + +); + +const PreconditionsContentSkeleton = () => ( + + {Array.from({ length: 3 }).map((_, i) => ( + + + + + + + + + ))} + +); + +const PreconditionsContentUpdate = () => { + const pnMinAppVersion = useIOSelector(pnMinAppVersionSelector); + return ( + + ); +}; diff --git a/ts/features/messages/components/Home/legacy/MessageFeedback.tsx b/ts/features/messages/components/Home/PreconditionsFeedback.tsx similarity index 90% rename from ts/features/messages/components/Home/legacy/MessageFeedback.tsx rename to ts/features/messages/components/Home/PreconditionsFeedback.tsx index 2ea7668b2ac..784c5a93e8a 100644 --- a/ts/features/messages/components/Home/legacy/MessageFeedback.tsx +++ b/ts/features/messages/components/Home/PreconditionsFeedback.tsx @@ -27,7 +27,11 @@ type Props = { subtitle?: string; }; -export const MessageFeedback = ({ pictogram, title, subtitle }: Props) => ( +export const PreconditionsFeedback = ({ + pictogram, + title, + subtitle +}: Props) => ( diff --git a/ts/features/messages/components/Home/PreconditionsFooter.tsx b/ts/features/messages/components/Home/PreconditionsFooter.tsx new file mode 100644 index 00000000000..5c67693aa5c --- /dev/null +++ b/ts/features/messages/components/Home/PreconditionsFooter.tsx @@ -0,0 +1,109 @@ +import React, { useCallback } from "react"; +import { View } from "react-native"; +import { useIOSelector, useIOStore } from "../../../../store/hooks"; +import { + preconditionsCategoryTagSelector, + preconditionsFooterSelector, + preconditionsMessageIdSelector +} from "../../store/reducers/messagePrecondition"; +import { FooterActions } from "../../../../components/ui/FooterActions"; +import I18n from "../../../../i18n"; +import { openAppStoreUrl } from "../../../../utils/url"; +import { trackNotificationRejected, trackUxConversion } from "../../analytics"; +import { UIMessageId } from "../../types"; + +export type PreconditionsFooterProps = { + onNavigation: (messageId: UIMessageId) => void; + onDismiss: () => void; +}; + +export const PreconditionsFooter = ({ + onNavigation, + onDismiss +}: PreconditionsFooterProps) => { + const footerContent = useIOSelector(preconditionsFooterSelector); + switch (footerContent) { + case "content": + return ( + + ); + case "update": + return ; + case "view": + return ; + } + return null; +}; + +const PreconditionsFooterContent = ({ + onNavigation, + onDismiss +}: PreconditionsFooterProps) => { + const store = useIOStore(); + + const onCancelCallback = useCallback(() => { + const state = store.getState(); + const categoryTag = preconditionsCategoryTagSelector(state); + if (categoryTag) { + trackNotificationRejected(categoryTag); + } + onDismiss(); + }, [onDismiss, store]); + const onContinueCallback = useCallback(() => { + const state = store.getState(); + const categoryTag = preconditionsCategoryTagSelector(state); + if (categoryTag) { + trackUxConversion(categoryTag); + } + const messageId = preconditionsMessageIdSelector(state); + if (messageId) { + onNavigation(messageId); + } + onDismiss(); + }, [onDismiss, onNavigation, store]); + + return ( + + ); +}; + +type PreconditionsFooterUpdateProps = { + onDismiss: () => void; +}; + +const PreconditionsFooterUpdate = ({ + onDismiss +}: PreconditionsFooterUpdateProps) => ( + openAppStoreUrl(), + testID: "message_preconditions_footer_update" + }, + secondary: { + label: I18n.t("global.buttons.cancel"), + onPress: onDismiss, + testID: "message_preconditions_footer_update_cancel" + } + }} + /> +); diff --git a/ts/features/messages/components/Home/PreconditionsTitle.tsx b/ts/features/messages/components/Home/PreconditionsTitle.tsx new file mode 100644 index 00000000000..44955d7fb45 --- /dev/null +++ b/ts/features/messages/components/Home/PreconditionsTitle.tsx @@ -0,0 +1,47 @@ +import * as React from "react"; +import { StyleSheet, View } from "react-native"; +import Placeholder from "rn-placeholder"; +import { H3, IOStyles } from "@pagopa/io-app-design-system"; +import { useIOSelector } from "../../../../store/hooks"; +import { + preconditionsTitleContentSelector, + preconditionsTitleSelector +} from "../../store/reducers/messagePrecondition"; + +const styles = StyleSheet.create({ + preconditionHeader: { + flex: 1, + flexWrap: "wrap" + } +}); + +export const PreconditionsTitle = () => { + const titleContent = useIOSelector(preconditionsTitleContentSelector); + switch (titleContent) { + case "empty": + return ; + case "loading": + return ; + case "header": + return ; + } + return null; +}; + +const PreconditionsSkeleton = () => ( + + + +); + +const PreconditionsHeader = () => { + const title = useIOSelector(preconditionsTitleSelector); + if (!title) { + return null; + } + return ( + +

{title}

+
+ ); +}; diff --git a/ts/features/messages/components/Home/WrappedMessageListItem.tsx b/ts/features/messages/components/Home/WrappedMessageListItem.tsx index 9572dfaab88..836b6ae6aa6 100644 --- a/ts/features/messages/components/Home/WrappedMessageListItem.tsx +++ b/ts/features/messages/components/Home/WrappedMessageListItem.tsx @@ -1,4 +1,7 @@ import React, { useCallback, useMemo } from "react"; +import { pipe } from "fp-ts/lib/function"; +import * as B from "fp-ts/lib/boolean"; +import { useIODispatch, useIOSelector } from "../../../../store/hooks"; import { UIMessage } from "../../types"; import I18n from "../../../../i18n"; import { TagEnum as PaymentTagEnum } from "../../../../../definitions/backend/MessageCategoryPayment"; @@ -7,7 +10,10 @@ import { convertDateToWordDistance } from "../../utils/convertDateToWordDistance import { useIONavigation } from "../../../../navigation/params/AppParamsList"; import { MESSAGES_ROUTES } from "../../navigation/routes"; import { logoForService } from "../../../services/home/utils"; -import { useIOSelector } from "../../../../store/hooks"; +import { + scheduledPreconditionStatusAction, + toScheduledPayload +} from "../../store/actions/preconditions"; import { isPaymentMessageWithPaidNoticeSelector } from "../../store/reducers/allPaginated"; import { accessibilityLabelForMessageItem } from "./homeUtils"; import { MessageListItem } from "./DS/MessageListItem"; @@ -21,6 +27,7 @@ export const WrappedMessageListItem = ({ index, message }: WrappedMessageListItemProps) => { + const dispatch = useIODispatch(); const navigation = useIONavigation(); const serviceId = message.serviceId; const organizationFiscalCode = message.organizationFiscalCode; @@ -62,19 +69,29 @@ export const WrappedMessageListItem = ({ [message] ); - const onPressCallback = useCallback(() => { - if (message.category.tag === SENDTagEnum.PN || message.hasPrecondition) { - // TODO preconditions IOCOM-840 - return; - } - navigation.navigate(MESSAGES_ROUTES.MESSAGES_NAVIGATOR, { - screen: MESSAGES_ROUTES.MESSAGE_ROUTER, - params: { - messageId: message.id, - fromNotification: false - } - }); - }, [message, navigation]); + const onPressCallback = useCallback( + () => + pipe( + message.hasPrecondition, + B.fold( + () => + navigation.navigate(MESSAGES_ROUTES.MESSAGES_NAVIGATOR, { + screen: MESSAGES_ROUTES.MESSAGE_ROUTER, + params: { + messageId: message.id, + fromNotification: false + } + }), + () => + pipe( + toScheduledPayload(message.id, message.category.tag), + scheduledPreconditionStatusAction, + dispatch + ) + ) + ), + [dispatch, message, navigation] + ); return ( ((_, ref) => ( + + Mock Inbox + Mock Archive + +)); diff --git a/ts/features/messages/components/Home/__mocks__/Preconditions.tsx b/ts/features/messages/components/Home/__mocks__/Preconditions.tsx new file mode 100644 index 00000000000..96c4c5fe81c --- /dev/null +++ b/ts/features/messages/components/Home/__mocks__/Preconditions.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { View } from "react-native"; + +export const Preconditions = () => ( + This is a mock for Preconditions +); diff --git a/ts/features/messages/components/Home/__mocks__/PreconditionsContent.tsx b/ts/features/messages/components/Home/__mocks__/PreconditionsContent.tsx new file mode 100644 index 00000000000..0d2542b2cbe --- /dev/null +++ b/ts/features/messages/components/Home/__mocks__/PreconditionsContent.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { View } from "react-native"; + +export const PreconditionsContent = () => ( + Mock Preconditions Content +); diff --git a/ts/features/messages/components/Home/__mocks__/SecuritySuggestions.tsx b/ts/features/messages/components/Home/__mocks__/SecuritySuggestions.tsx new file mode 100644 index 00000000000..30440dcff05 --- /dev/null +++ b/ts/features/messages/components/Home/__mocks__/SecuritySuggestions.tsx @@ -0,0 +1,8 @@ +import React from "react"; +import { View } from "react-native"; + +export const Preconditions = () => ( + + This is a mock for Security Suggestions + +); diff --git a/ts/features/messages/components/Home/__mocks__/TabNavigationContainer.tsx b/ts/features/messages/components/Home/__mocks__/TabNavigationContainer.tsx new file mode 100644 index 00000000000..473d406d6ae --- /dev/null +++ b/ts/features/messages/components/Home/__mocks__/TabNavigationContainer.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import { View } from "react-native"; +import PagerView from "react-native-pager-view"; + +export const TabNavigationContainer = React.forwardRef((_, _ref) => ( + + This is a mock for TabNavigationContainer + +)); diff --git a/ts/features/messages/components/Home/__mocks__/Toasts.tsx b/ts/features/messages/components/Home/__mocks__/Toasts.tsx new file mode 100644 index 00000000000..93dea9693b0 --- /dev/null +++ b/ts/features/messages/components/Home/__mocks__/Toasts.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { View } from "react-native"; + +export const Toasts = () => ( + This is a mock for Toasts +); diff --git a/ts/features/messages/components/Home/__tests__/Preconditions.test.tsx b/ts/features/messages/components/Home/__tests__/Preconditions.test.tsx new file mode 100644 index 00000000000..833b612b654 --- /dev/null +++ b/ts/features/messages/components/Home/__tests__/Preconditions.test.tsx @@ -0,0 +1,241 @@ +import React from "react"; +import { constUndefined } from "fp-ts/lib/function"; +import { createStore } from "redux"; +import { applicationChangeState } from "../../../../../store/actions/application"; +import { preferencesDesignSystemSetEnabled } from "../../../../../store/actions/persistedPreferences"; +import { appReducer } from "../../../../../store/reducers"; +import { renderScreenWithNavigationStoreContext } from "../../../../../utils/testWrapper"; +import { Preconditions } from "../Preconditions"; +import { MESSAGES_ROUTES } from "../../../navigation/routes"; +import * as messagePrecondition from "../../../store/reducers/messagePrecondition"; +import { TagEnum } from "../../../../../../definitions/backend/MessageCategoryBase"; +import * as analytics from "../../../analytics"; +import { + clearLegacyMessagePrecondition, + idlePreconditionStatusAction, + retrievingDataPreconditionStatusAction, + toIdlePayload, + toRetrievingDataPayload, + toUpdateRequiredPayload, + updateRequiredPreconditionStatusAction +} from "../../../store/actions/preconditions"; +import * as bottomSheet from "../../../../../utils/hooks/bottomSheet"; +import { PreconditionsFooterProps } from "../PreconditionsFooter"; +import { UIMessageId } from "../../../types"; + +jest.mock("../PreconditionsContent"); + +const mockDispatch = jest.fn(); +jest.mock("react-redux", () => ({ + ...jest.requireActual("react-redux"), + useDispatch: () => mockDispatch +})); + +const mockNavigate = jest.fn(); +jest.mock("@react-navigation/native", () => ({ + ...jest.requireActual( + "@react-navigation/native" + ), + useNavigation: () => ({ + navigate: mockNavigate + }) +})); + +describe("Preconditions", () => { + afterEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + it("should match snapshot with mocked components", () => { + const component = renderComponent(); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("should call 'trackDisclaimerOpened'+'present'+'dispatch(updateRequiredPreconditionStatusAction)' upon mounting, when 'preconditionsRequireAppUpdateSelector' returns true", () => { + jest + .spyOn( + messagePrecondition, + "shouldPresentPreconditionsBottomSheetSelector" + ) + .mockImplementation(_ => true); + const categoryTag = TagEnum.GENERIC; + jest + .spyOn(messagePrecondition, "preconditionsCategoryTagSelector") + .mockImplementation(_ => categoryTag); + const mockTrackDislaimerOpened = jest.fn(); + jest + .spyOn(analytics, "trackDisclaimerOpened") + .mockImplementation(mockTrackDislaimerOpened); + jest + .spyOn(messagePrecondition, "preconditionsRequireAppUpdateSelector") + .mockImplementation(_ => true); + const mockModalPresent = jest.fn(); + jest.spyOn(bottomSheet, "useIOBottomSheetModal").mockImplementation(_ => ({ + present: mockModalPresent, + dismiss: () => undefined, + dismissAll: () => undefined, + bottomSheet: <> + })); + + renderComponent(); + + expect(mockTrackDislaimerOpened.mock.calls.length).toBe(1); + expect(mockTrackDislaimerOpened.mock.calls[0][0]).toStrictEqual( + categoryTag + ); + + expect(mockModalPresent.mock.calls.length).toBe(1); + + expect(mockDispatch.mock.calls.length).toBe(1); + expect(mockDispatch.mock.calls[0][0]).toStrictEqual( + updateRequiredPreconditionStatusAction(toUpdateRequiredPayload()) + ); + }); + it("should call 'trackDisclaimerOpened'+'present'+'dispatch(retrievingDataPreconditionStatusAction)' upon mounting, when 'preconditionsRequireAppUpdateSelector' returns false", () => { + jest + .spyOn( + messagePrecondition, + "shouldPresentPreconditionsBottomSheetSelector" + ) + .mockImplementation(_ => true); + const categoryTag = TagEnum.GENERIC; + jest + .spyOn(messagePrecondition, "preconditionsCategoryTagSelector") + .mockImplementation(_ => categoryTag); + const mockTrackDislaimerOpened = jest.fn(); + jest + .spyOn(analytics, "trackDisclaimerOpened") + .mockImplementation(mockTrackDislaimerOpened); + jest + .spyOn(messagePrecondition, "preconditionsRequireAppUpdateSelector") + .mockImplementation(_ => false); + const mockModalPresent = jest.fn(); + // eslint-disable-next-line sonarjs/no-identical-functions + jest.spyOn(bottomSheet, "useIOBottomSheetModal").mockImplementation(_ => ({ + present: mockModalPresent, + dismiss: () => undefined, + dismissAll: () => undefined, + bottomSheet: <> + })); + + renderComponent(); + + expect(mockTrackDislaimerOpened.mock.calls.length).toBe(1); + expect(mockTrackDislaimerOpened.mock.calls[0][0]).toStrictEqual( + categoryTag + ); + + expect(mockModalPresent.mock.calls.length).toBe(1); + + expect(mockDispatch.mock.calls.length).toBe(1); + expect(mockDispatch.mock.calls[0][0]).toStrictEqual( + retrievingDataPreconditionStatusAction(toRetrievingDataPayload()) + ); + }); + it("should provide 'PreconditionsFooter' with a navigation callback that navigates to the message router", () => { + // eslint-disable-next-line functional/no-let, no-undef-init + let onNavigationCallback: (_messageId: UIMessageId) => void = jest.fn(); + jest + .spyOn(bottomSheet, "useIOBottomSheetModal") + .mockImplementation(props => { + onNavigationCallback = (props.footer?.props as PreconditionsFooterProps) + ?.onNavigation; + return { + present: constUndefined, + dismiss: constUndefined, + dismissAll: constUndefined, + bottomSheet: <> + }; + }); + + renderComponent(); + + expect(onNavigationCallback).toBeTruthy(); + + const messageId = "01J1PVGMXAZ2SGCQ4H3341MARC" as UIMessageId; + onNavigationCallback?.(messageId); + + expect(mockNavigate.mock.calls.length).toBe(1); + expect(mockNavigate.mock.calls[0][0]).toStrictEqual( + MESSAGES_ROUTES.MESSAGES_NAVIGATOR + ); + expect(mockNavigate.mock.calls[0][1]).toStrictEqual({ + screen: MESSAGES_ROUTES.MESSAGE_ROUTER, + params: { + messageId, + fromNotification: false + } + }); + }); + + it("should provide 'PreconditionsFooter' with a dismiss callback that dispatches 'clearLegacyMessagePrecondition' and 'idlePreconditionStatusAction'", () => { + // eslint-disable-next-line functional/no-let, no-undef-init + let onDismissCallback: (() => void) | undefined = jest.fn(); + jest + .spyOn(bottomSheet, "useIOBottomSheetModal") + .mockImplementation(props => { + onDismissCallback = props.onDismiss; + return { + present: constUndefined, + dismiss: constUndefined, + dismissAll: constUndefined, + bottomSheet: <> + }; + }); + + renderComponent(); + + expect(onDismissCallback).toBeTruthy(); + + onDismissCallback?.(); + + expect(mockDispatch.mock.calls.length).toBe(2); + expect(mockDispatch.mock.calls[0][0]).toStrictEqual( + clearLegacyMessagePrecondition() + ); + expect(mockDispatch.mock.calls[1][0]).toStrictEqual( + idlePreconditionStatusAction(toIdlePayload()) + ); + }); + it("should provider the 'PreconditionsFooter' with the bottom sheet's 'dismiss' callback", () => { + // eslint-disable-next-line functional/no-let, no-undef-init + let footerOnDismissCallback: (() => void) | undefined = jest.fn(); + const mockBottomSheetDismiss = jest.fn(); + jest + .spyOn(bottomSheet, "useIOBottomSheetModal") + .mockImplementation(props => { + footerOnDismissCallback = ( + props.footer?.props as PreconditionsFooterProps + )?.onDismiss; + return { + present: constUndefined, + dismiss: mockBottomSheetDismiss, + dismissAll: constUndefined, + bottomSheet: <> + }; + }); + + renderComponent(); + + expect(footerOnDismissCallback).toBeTruthy(); + + footerOnDismissCallback?.(); + + expect(mockBottomSheetDismiss.mock.calls.length).toBe(1); + }); +}); + +const renderComponent = () => { + const initialState = appReducer(undefined, applicationChangeState("active")); + const designSystemState = appReducer( + initialState, + preferencesDesignSystemSetEnabled({ isDesignSystemEnabled: true }) + ); + const store = createStore(appReducer, designSystemState as any); + + return renderScreenWithNavigationStoreContext( + () => , + MESSAGES_ROUTES.MESSAGES_HOME, + {}, + store + ); +}; diff --git a/ts/features/messages/components/Home/__tests__/PreconditionsContent.test.tsx b/ts/features/messages/components/Home/__tests__/PreconditionsContent.test.tsx new file mode 100644 index 00000000000..0492725098e --- /dev/null +++ b/ts/features/messages/components/Home/__tests__/PreconditionsContent.test.tsx @@ -0,0 +1,162 @@ +import React from "react"; +import { constUndefined } from "fp-ts/lib/function"; +import { createStore } from "redux"; +import { appReducer } from "../../../../../store/reducers"; +import { applicationChangeState } from "../../../../../store/actions/application"; +import { preferencesDesignSystemSetEnabled } from "../../../../../store/actions/persistedPreferences"; +import { renderScreenWithNavigationStoreContext } from "../../../../../utils/testWrapper"; +import { PreconditionsContent } from "../PreconditionsContent"; +import { MESSAGES_ROUTES } from "../../../navigation/routes"; +import * as messagePreconditions from "../../../store/reducers/messagePrecondition"; +import * as backendStatus from "../../../../../store/reducers/backendStatus"; +import { MarkdownProps } from "../../../../../components/ui/Markdown/Markdown"; +import { + errorPreconditionStatusAction, + shownPreconditionStatusAction, + toErrorPayload, + toShownPayload +} from "../../../store/actions/preconditions"; +import { TagEnum } from "../../../../../../definitions/backend/MessageCategoryBase"; +import * as analytics from "../../../analytics"; + +jest.mock("../../MessageDetail/MessageMarkdown"); + +const mockDispatch = jest.fn(); +jest.mock("react-redux", () => ({ + ...jest.requireActual("react-redux"), + useDispatch: () => mockDispatch +})); + +describe("PreconditionsContent", () => { + afterEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + it("should match snapshot when content category is 'content' but markdown is undefined", () => { + jest + .spyOn(messagePreconditions, "preconditionsContentSelector") + .mockImplementation(_ => "content"); + jest + .spyOn(messagePreconditions, "preconditionsContentMarkdownSelector") + .mockImplementation(constUndefined); + const component = renderComponent(); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("should match snapshot when content category is 'content' and markdown is defined", () => { + jest + .spyOn(messagePreconditions, "preconditionsContentSelector") + .mockImplementation(_ => "content"); + jest + .spyOn(messagePreconditions, "preconditionsContentMarkdownSelector") + .mockImplementation(_ => "A markdown content"); + const component = renderComponent(); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("should match snapshot when content category is 'error'", () => { + jest + .spyOn(messagePreconditions, "preconditionsContentSelector") + .mockImplementation(_ => "error"); + const component = renderComponent(); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("should match snapshot when content category is 'loading'", () => { + jest + .spyOn(messagePreconditions, "preconditionsContentSelector") + .mockImplementation(_ => "loading"); + const component = renderComponent(); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("should match snapshot when content category is 'update' and SEND min app version is '1.0.0.0'", () => { + jest + .spyOn(messagePreconditions, "preconditionsContentSelector") + .mockImplementation(_ => "update"); + jest + .spyOn(backendStatus, "pnMinAppVersionSelector") + .mockImplementation(_ => "1.0.0.0"); + const component = renderComponent(); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("should match snapshot when content category is undefined", () => { + jest + .spyOn(messagePreconditions, "preconditionsContentSelector") + .mockImplementation(constUndefined); + const component = renderComponent(); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("should dispatch `shownPreconditionStatusAction` when markdown loading completes", () => { + jest + .spyOn(messagePreconditions, "preconditionsContentSelector") + .mockImplementation(_ => "content"); + jest + .spyOn(messagePreconditions, "preconditionsContentMarkdownSelector") + .mockImplementation(_ => "A markdown content"); + const component = renderComponent(); + const mockMessageMarkdown = component.getByTestId( + "preconditions_content_message_markdown" + ); + const props = mockMessageMarkdown.props as Omit; + const onLoadEndCallback = props.onLoadEnd; + expect(onLoadEndCallback).toBeTruthy(); + + onLoadEndCallback?.(); + + expect(mockDispatch.mock.calls.length).toBe(1); + expect(mockDispatch.mock.calls[0][0]).toStrictEqual( + shownPreconditionStatusAction(toShownPayload()) + ); + }); + it("should track an error and dispatch 'errorPreconditionStatusAction' when an error occours during markdown loading", () => { + jest + .spyOn(messagePreconditions, "preconditionsContentSelector") + .mockImplementation(_ => "content"); + jest + .spyOn(messagePreconditions, "preconditionsContentMarkdownSelector") + .mockImplementation(_ => "A markdown content"); + const categoryTag = TagEnum.GENERIC; + jest + .spyOn(messagePreconditions, "preconditionsCategoryTagSelector") + .mockImplementation(_ => categoryTag); + const mockTrackDislaimerLoadError = jest.fn(); + jest + .spyOn(analytics, "trackDisclaimerLoadError") + .mockImplementation(mockTrackDislaimerLoadError); + const component = renderComponent(); + const mockMessageMarkdown = component.getByTestId( + "preconditions_content_message_markdown" + ); + const props = mockMessageMarkdown.props as Omit; + const onErrorCallback = props.onError; + expect(onErrorCallback).toBeTruthy(); + + const expectedError = new Error("An error"); + onErrorCallback?.(expectedError); + + expect(mockTrackDislaimerLoadError.mock.calls.length).toBe(1); + expect(mockTrackDislaimerLoadError.mock.calls[0][0]).toStrictEqual( + categoryTag + ); + + expect(mockDispatch.mock.calls.length).toBe(1); + expect(mockDispatch.mock.calls[0][0]).toStrictEqual( + errorPreconditionStatusAction( + toErrorPayload(`Markdown loading failure (${expectedError})`) + ) + ); + }); +}); + +const renderComponent = () => { + const initialState = appReducer(undefined, applicationChangeState("active")); + const designSystemState = appReducer( + initialState, + preferencesDesignSystemSetEnabled({ isDesignSystemEnabled: true }) + ); + const store = createStore(appReducer, designSystemState as any); + + return renderScreenWithNavigationStoreContext( + () => , + MESSAGES_ROUTES.MESSAGES_HOME, + {}, + store + ); +}; diff --git a/ts/features/messages/components/Home/__tests__/PreconditionsFeedback.test.tsx b/ts/features/messages/components/Home/__tests__/PreconditionsFeedback.test.tsx new file mode 100644 index 00000000000..ad2e306e0f8 --- /dev/null +++ b/ts/features/messages/components/Home/__tests__/PreconditionsFeedback.test.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { createStore } from "redux"; +import { applicationChangeState } from "../../../../../store/actions/application"; +import { preferencesDesignSystemSetEnabled } from "../../../../../store/actions/persistedPreferences"; +import { appReducer } from "../../../../../store/reducers"; +import { renderScreenWithNavigationStoreContext } from "../../../../../utils/testWrapper"; +import { MESSAGES_ROUTES } from "../../../navigation/routes"; +import { PreconditionsFeedback } from "../PreconditionsFeedback"; + +describe("PreconditionsFeedback", () => { + it("should match snapshot with title and no subtitle", () => { + const component = renderComponent(); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("should match snapshot with title and subtitle", () => { + const component = renderComponent("The subtitle"); + expect(component.toJSON()).toMatchSnapshot(); + }); +}); + +const renderComponent = (subtitle: string | undefined = undefined) => { + const initialState = appReducer(undefined, applicationChangeState("active")); + const designSystemState = appReducer( + initialState, + preferencesDesignSystemSetEnabled({ isDesignSystemEnabled: true }) + ); + const store = createStore(appReducer, designSystemState as any); + + return renderScreenWithNavigationStoreContext( + () => ( + + ), + MESSAGES_ROUTES.MESSAGES_HOME, + {}, + store + ); +}; diff --git a/ts/features/messages/components/Home/__tests__/PreconditionsFooter.test.tsx b/ts/features/messages/components/Home/__tests__/PreconditionsFooter.test.tsx new file mode 100644 index 00000000000..91f2aeddb58 --- /dev/null +++ b/ts/features/messages/components/Home/__tests__/PreconditionsFooter.test.tsx @@ -0,0 +1,139 @@ +import React from "react"; +import { constUndefined } from "fp-ts/lib/function"; +import { fireEvent } from "@testing-library/react-native"; +import { createStore } from "redux"; +import { appReducer } from "../../../../../store/reducers"; +import { applicationChangeState } from "../../../../../store/actions/application"; +import { preferencesDesignSystemSetEnabled } from "../../../../../store/actions/persistedPreferences"; +import { renderScreenWithNavigationStoreContext } from "../../../../../utils/testWrapper"; +import { PreconditionsFooter } from "../PreconditionsFooter"; +import { MESSAGES_ROUTES } from "../../../navigation/routes"; +import { TagEnum } from "../../../../../../definitions/backend/MessageCategoryBase"; +import * as messagePrecondition from "../../../store/reducers/messagePrecondition"; +import * as urlUtils from "../../../../../utils/url"; +import * as analytics from "../../../analytics"; +import { UIMessageId } from "../../../types"; + +describe("PreconditionsFooter", () => { + afterEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + it("should match snapshot for 'content' footer category", () => { + jest + .spyOn(messagePrecondition, "preconditionsFooterSelector") + .mockImplementation(_ => "content"); + const component = renderComponent(); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("should match snapshot for 'update' footer category", () => { + jest + .spyOn(messagePrecondition, "preconditionsFooterSelector") + .mockImplementation(_ => "update"); + const component = renderComponent(); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("should match snapshot for 'view' footer category", () => { + jest + .spyOn(messagePrecondition, "preconditionsFooterSelector") + .mockImplementation(_ => "view"); + const component = renderComponent(); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("should match snapshot for 'undefined' footer category", () => { + jest + .spyOn(messagePrecondition, "preconditionsFooterSelector") + .mockImplementation(constUndefined); + const component = renderComponent(); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("should call 'onDismiss' and 'openAppStoreUrl' when footer category is 'update' and the related buttons have been pressed", () => { + jest + .spyOn(messagePrecondition, "preconditionsFooterSelector") + .mockImplementation(_ => "update"); + const mockOpenAppStoreUrl = jest.fn(); + jest + .spyOn(urlUtils, "openAppStoreUrl") + .mockImplementation(_ => mockOpenAppStoreUrl()); + const mockOnDismiss = jest.fn(); + const component = renderComponent(mockOnDismiss); + const cancelButton = component.getByTestId( + "message_preconditions_footer_update_cancel" + ); + fireEvent(cancelButton, "onPress"); + expect(mockOnDismiss.mock.calls.length).toBe(1); + + const updateButton = component.getByTestId( + "message_preconditions_footer_update" + ); + fireEvent(updateButton, "onPress"); + expect(mockOpenAppStoreUrl.mock.calls.length).toBe(1); + }); + it("should call 'trackNotificationRejected'+'onDismiss' and 'trackUxConversion'+'onNavigation'+'onDismiss' when footer category is 'content' and the related buttons have been pressed", () => { + jest + .spyOn(messagePrecondition, "preconditionsFooterSelector") + .mockImplementation(_ => "content"); + const categoryTag = TagEnum.GENERIC; + jest + .spyOn(messagePrecondition, "preconditionsCategoryTagSelector") + .mockImplementation(_ => categoryTag); + const messageId = "01J1NE8BY7YV0WJ2240HNQ2KJN" as UIMessageId; + jest + .spyOn(messagePrecondition, "preconditionsMessageIdSelector") + .mockImplementation(_ => messageId); + const mockTrackNotificationRejected = jest.fn(); + jest + .spyOn(analytics, "trackNotificationRejected") + .mockImplementation(_ => mockTrackNotificationRejected(_)); + const mockUXConversion = jest.fn(); + jest + .spyOn(analytics, "trackUxConversion") + .mockImplementation(_ => mockUXConversion(_)); + + const mockOnDismiss = jest.fn(); + const mockNavigation = jest.fn(); + const component = renderComponent(mockOnDismiss, mockNavigation); + const cancelButton = component.getByTestId( + "message_preconditions_footer_cancel" + ); + fireEvent(cancelButton, "onPress"); + expect(mockTrackNotificationRejected.mock.calls.length).toBe(1); + expect(mockTrackNotificationRejected.mock.calls[0][1]).toStrictEqual( + analytics.trackNotificationRejected(categoryTag) + ); + expect(mockOnDismiss.mock.calls.length).toBe(1); + + const continueButton = component.getByTestId( + "message_preconditions_footer_continue" + ); + fireEvent(continueButton, "onPress"); + expect(mockUXConversion.mock.calls.length).toBe(1); + expect(mockUXConversion.mock.calls[0][1]).toStrictEqual( + analytics.trackUxConversion(categoryTag) + ); + expect(mockNavigation.mock.calls.length).toBe(1); + expect(mockNavigation.mock.calls[0][0]).toBe(messageId); + expect(mockOnDismiss.mock.calls.length).toBe(2); + }); +}); + +const renderComponent = ( + onDismiss: () => void = jest.fn(), + onNavigation: () => void = jest.fn() +) => { + const initialState = appReducer(undefined, applicationChangeState("active")); + const designSystemState = appReducer( + initialState, + preferencesDesignSystemSetEnabled({ isDesignSystemEnabled: true }) + ); + const store = createStore(appReducer, designSystemState as any); + + return renderScreenWithNavigationStoreContext( + () => ( + + ), + MESSAGES_ROUTES.MESSAGES_HOME, + {}, + store + ); +}; diff --git a/ts/features/messages/components/Home/__tests__/PreconditionsTitle.test.tsx b/ts/features/messages/components/Home/__tests__/PreconditionsTitle.test.tsx new file mode 100644 index 00000000000..2fced769e8c --- /dev/null +++ b/ts/features/messages/components/Home/__tests__/PreconditionsTitle.test.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { constUndefined } from "fp-ts/lib/function"; +import { createStore } from "redux"; +import { applicationChangeState } from "../../../../../store/actions/application"; +import { preferencesDesignSystemSetEnabled } from "../../../../../store/actions/persistedPreferences"; +import { appReducer } from "../../../../../store/reducers"; +import { renderScreenWithNavigationStoreContext } from "../../../../../utils/testWrapper"; +import { PreconditionsTitle } from "../PreconditionsTitle"; +import { MESSAGES_ROUTES } from "../../../navigation/routes"; +import * as messagePreconditions from "../../../store/reducers/messagePrecondition"; + +describe("PreconditionsTitle", () => { + afterEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + it("should match snapshot when title content is 'empty'", () => { + jest + .spyOn(messagePreconditions, "preconditionsTitleContentSelector") + .mockImplementation(_ => "empty"); + const component = renderComponent(); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("should match snapshot when title content is 'loading'", () => { + jest + .spyOn(messagePreconditions, "preconditionsTitleContentSelector") + .mockImplementation(_ => "loading"); + const component = renderComponent(); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("should match snapshot when title content is 'header' but title is undefined", () => { + jest + .spyOn(messagePreconditions, "preconditionsTitleContentSelector") + .mockImplementation(_ => "header"); + jest + .spyOn(messagePreconditions, "preconditionsTitleSelector") + .mockImplementation(constUndefined); + const component = renderComponent(); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("should match snapshot when title content is 'header' and title is defined", () => { + jest + .spyOn(messagePreconditions, "preconditionsTitleContentSelector") + .mockImplementation(_ => "header"); + jest + .spyOn(messagePreconditions, "preconditionsTitleSelector") + .mockImplementation(_ => "This is a title"); + const component = renderComponent(); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("should match snapshot when title content is undefined", () => { + jest + .spyOn(messagePreconditions, "preconditionsTitleContentSelector") + .mockImplementation(constUndefined); + const component = renderComponent(); + expect(component.toJSON()).toMatchSnapshot(); + }); +}); + +const renderComponent = () => { + const initialState = appReducer(undefined, applicationChangeState("active")); + const designSystemState = appReducer( + initialState, + preferencesDesignSystemSetEnabled({ isDesignSystemEnabled: true }) + ); + const store = createStore(appReducer, designSystemState as any); + + return renderScreenWithNavigationStoreContext( + () => , + MESSAGES_ROUTES.MESSAGES_HOME, + {}, + store + ); +}; diff --git a/ts/features/messages/components/Home/__tests__/WrappedMessageListItem.test.tsx b/ts/features/messages/components/Home/__tests__/WrappedMessageListItem.test.tsx index 0c137d666fa..f2e96b0058a 100644 --- a/ts/features/messages/components/Home/__tests__/WrappedMessageListItem.test.tsx +++ b/ts/features/messages/components/Home/__tests__/WrappedMessageListItem.test.tsx @@ -13,6 +13,10 @@ import { TagEnum as PaymentTagEnum } from "../../../../../../definitions/backend import { WrappedMessageListItem } from "../WrappedMessageListItem"; import { TagEnum } from "../../../../../../definitions/backend/MessageCategoryBase"; import { GlobalState } from "../../../../../store/reducers/types"; +import { + scheduledPreconditionStatusAction, + toScheduledPayload +} from "../../../store/actions/preconditions"; const mockNavigate = jest.fn(); jest.mock("@react-navigation/native", () => ({ @@ -23,6 +27,11 @@ jest.mock("@react-navigation/native", () => ({ navigate: mockNavigate }) })); +const mockDispatch = jest.fn(); +jest.mock("react-redux", () => ({ + ...jest.requireActual("react-redux"), + useDispatch: () => mockDispatch +})); describe("WrappedMessageListItem", () => { beforeEach(() => { @@ -81,7 +90,7 @@ describe("WrappedMessageListItem", () => { const component = renderComponent(message); expect(component.toJSON()).toMatchSnapshot(); }); - it("should trigger navigation to Message Routing when the component is pressed", () => { + it("should trigger navigation to Message Routing when the component is pressed and the message has no preconditions", () => { const message = messageGenerator(false, false, true); const component = renderComponent(message); const pressable = component.getByTestId("wrapped_message_list_item_0"); @@ -95,6 +104,21 @@ describe("WrappedMessageListItem", () => { fromNotification: false } }); + expect(mockDispatch.mock.calls.length).toBe(0); + }); + it("should dispatch 'scheduledPreconditionStatusAction' when the message has preconditions", () => { + const message = messageGenerator(false, true, true); + const component = renderComponent(message); + const pressable = component.getByTestId("wrapped_message_list_item_0"); + expect(pressable).toBeDefined(); + fireEvent.press(pressable); + expect(mockNavigate.mock.calls.length).toBe(0); + expect(mockDispatch.mock.calls.length).toBe(1); + expect(mockDispatch.mock.calls[0][0]).toStrictEqual( + scheduledPreconditionStatusAction( + toScheduledPayload(message.id, message.category.tag) + ) + ); }); }); @@ -119,7 +143,8 @@ const messageGenerator = ( ? PaymentTagEnum.PAYMENT : TagEnum.GENERIC, rptId - } + }, + hasPrecondition: isFromSend } as UIMessage); const renderComponent = ( diff --git a/ts/features/messages/components/Home/__tests__/__snapshots__/Preconditions.test.tsx.snap b/ts/features/messages/components/Home/__tests__/__snapshots__/Preconditions.test.tsx.snap new file mode 100644 index 00000000000..0fc4ae3903f --- /dev/null +++ b/ts/features/messages/components/Home/__tests__/__snapshots__/Preconditions.test.tsx.snap @@ -0,0 +1,378 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Preconditions should match snapshot with mocked components 1`] = ` + + + + + + + + + + + + + + + MESSAGES_HOME + + + + + + + + + + + + + + + + + + + + + Mock Preconditions Content + + + + + + + + + + + + + + + +`; diff --git a/ts/features/messages/components/Home/__tests__/__snapshots__/PreconditionsContent.test.tsx.snap b/ts/features/messages/components/Home/__tests__/__snapshots__/PreconditionsContent.test.tsx.snap new file mode 100644 index 00000000000..c6474caebde --- /dev/null +++ b/ts/features/messages/components/Home/__tests__/__snapshots__/PreconditionsContent.test.tsx.snap @@ -0,0 +1,2753 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PreconditionsContent should match snapshot when content category is 'content' and markdown is defined 1`] = ` + + + + + + + + + + + + + + + MESSAGES_HOME + + + + + + + + + + + + + + + + + + Mock Message Markdown + + + + + + + + + + + + +`; + +exports[`PreconditionsContent should match snapshot when content category is 'content' but markdown is undefined 1`] = ` + + + + + + + + + + + + + + + MESSAGES_HOME + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`PreconditionsContent should match snapshot when content category is 'error' 1`] = ` + + + + + + + + + + + + + + + MESSAGES_HOME + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + There is a temporary problem, please try again. + + + + + + + + + + + + + +`; + +exports[`PreconditionsContent should match snapshot when content category is 'loading' 1`] = ` + + + + + + + + + + + + + + + MESSAGES_HOME + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`PreconditionsContent should match snapshot when content category is 'update' and SEND min app version is '1.0.0.0' 1`] = ` + + + + + + + + + + + + + + + MESSAGES_HOME + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Aggiorna l'app IO + + + + Per usare questa funzionalità è richiesta la versione 1.0.0.0 di app IO. Aggiorna l’app per continuare. + + + + + + + + + + + + + +`; + +exports[`PreconditionsContent should match snapshot when content category is undefined 1`] = ` + + + + + + + + + + + + + + + MESSAGES_HOME + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/ts/features/messages/components/Home/__tests__/__snapshots__/PreconditionsFeedback.test.tsx.snap b/ts/features/messages/components/Home/__tests__/__snapshots__/PreconditionsFeedback.test.tsx.snap new file mode 100644 index 00000000000..9777ed082c3 --- /dev/null +++ b/ts/features/messages/components/Home/__tests__/__snapshots__/PreconditionsFeedback.test.tsx.snap @@ -0,0 +1,1149 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PreconditionsFeedback should match snapshot with title and no subtitle 1`] = ` + + + + + + + + + + + + + + + MESSAGES_HOME + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + The title + + + + + + + + + + + + + +`; + +exports[`PreconditionsFeedback should match snapshot with title and subtitle 1`] = ` + + + + + + + + + + + + + + + MESSAGES_HOME + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + The title + + + + The subtitle + + + + + + + + + + + + + +`; diff --git a/ts/features/messages/components/Home/__tests__/__snapshots__/PreconditionsFooter.test.tsx.snap b/ts/features/messages/components/Home/__tests__/__snapshots__/PreconditionsFooter.test.tsx.snap new file mode 100644 index 00000000000..edcade8f7b3 --- /dev/null +++ b/ts/features/messages/components/Home/__tests__/__snapshots__/PreconditionsFooter.test.tsx.snap @@ -0,0 +1,1855 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PreconditionsFooter should match snapshot for 'content' footer category 1`] = ` + + + + + + + + + + + + + + + MESSAGES_HOME + + + + + + + + + + + + + + + + + + + + + + + + Continue + + + + + + + + + + Cancel + + + + + + + + + + + + + + + + + +`; + +exports[`PreconditionsFooter should match snapshot for 'undefined' footer category 1`] = ` + + + + + + + + + + + + + + + MESSAGES_HOME + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`PreconditionsFooter should match snapshot for 'update' footer category 1`] = ` + + + + + + + + + + + + + + + MESSAGES_HOME + + + + + + + + + + + + + + + + + + + + + + + + Update IO + + + + + + + + + + Cancel + + + + + + + + + + + + + + + + + +`; + +exports[`PreconditionsFooter should match snapshot for 'view' footer category 1`] = ` + + + + + + + + + + + + + + + MESSAGES_HOME + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/ts/features/messages/components/Home/__tests__/__snapshots__/PreconditionsTitle.test.tsx.snap b/ts/features/messages/components/Home/__tests__/__snapshots__/PreconditionsTitle.test.tsx.snap new file mode 100644 index 00000000000..97909355e69 --- /dev/null +++ b/ts/features/messages/components/Home/__tests__/__snapshots__/PreconditionsTitle.test.tsx.snap @@ -0,0 +1,1733 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PreconditionsTitle should match snapshot when title content is 'empty' 1`] = ` + + + + + + + + + + + + + + + MESSAGES_HOME + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`PreconditionsTitle should match snapshot when title content is 'header' and title is defined 1`] = ` + + + + + + + + + + + + + + + MESSAGES_HOME + + + + + + + + + + + + + + + + + + + This is a title + + + + + + + + + + + + + +`; + +exports[`PreconditionsTitle should match snapshot when title content is 'header' but title is undefined 1`] = ` + + + + + + + + + + + + + + + MESSAGES_HOME + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`PreconditionsTitle should match snapshot when title content is 'loading' 1`] = ` + + + + + + + + + + + + + + + MESSAGES_HOME + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`PreconditionsTitle should match snapshot when title content is undefined 1`] = ` + + + + + + + + + + + + + + + MESSAGES_HOME + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/ts/features/messages/components/MessageDetail/__mocks__/MessageMarkdown.tsx b/ts/features/messages/components/MessageDetail/__mocks__/MessageMarkdown.tsx new file mode 100644 index 00000000000..cbf76fcd848 --- /dev/null +++ b/ts/features/messages/components/MessageDetail/__mocks__/MessageMarkdown.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import WebView from "react-native-webview"; +import { MarkdownProps } from "../../../../../components/ui/Markdown/Markdown"; + +type Props = Omit; + +export const MessageMarkdown = ({ onError, onLoadEnd, testID }: Props) => ( + + Mock Message Markdown + +); diff --git a/ts/features/messages/components/PreconditionBottomSheet/PreconditionContent.tsx b/ts/features/messages/components/PreconditionBottomSheet/LegacyPreconditionContent.tsx similarity index 86% rename from ts/features/messages/components/PreconditionBottomSheet/PreconditionContent.tsx rename to ts/features/messages/components/PreconditionBottomSheet/LegacyPreconditionContent.tsx index e3e2651f1c2..ce86a8b3e46 100644 --- a/ts/features/messages/components/PreconditionBottomSheet/PreconditionContent.tsx +++ b/ts/features/messages/components/PreconditionBottomSheet/LegacyPreconditionContent.tsx @@ -5,12 +5,15 @@ import { VSpacer } from "@pagopa/io-app-design-system"; import customVariables from "../../../../theme/variables"; import { MessageMarkdown } from "../MessageDetail/MessageMarkdown"; -type Props = { +type LegacyProps = { markdown: string; onLoadEnd: () => void; }; -export const PreconditionContent = ({ markdown, onLoadEnd }: Props) => { +export const LegacyPreconditionContent = ({ + markdown, + onLoadEnd +}: LegacyProps) => { const [loaded, setLoaded] = React.useState(false); const handleOnLoadEnd = () => { @@ -20,7 +23,7 @@ export const PreconditionContent = ({ markdown, onLoadEnd }: Props) => { return ( <> - {!loaded && } + {!loaded && } { ); }; -export const PreconditionContentSkeleton = () => ( +export const LegacyPreconditionContentSkeleton = () => ( {Array.from({ length: 4 }).map((_, i) => ( diff --git a/ts/features/messages/components/PreconditionBottomSheet/PreconditionFooter.tsx b/ts/features/messages/components/PreconditionBottomSheet/LegacyPreconditionFooter.tsx similarity index 95% rename from ts/features/messages/components/PreconditionBottomSheet/PreconditionFooter.tsx rename to ts/features/messages/components/PreconditionBottomSheet/LegacyPreconditionFooter.tsx index 68a4c18cace..dc9d374ac6f 100644 --- a/ts/features/messages/components/PreconditionBottomSheet/PreconditionFooter.tsx +++ b/ts/features/messages/components/PreconditionBottomSheet/LegacyPreconditionFooter.tsx @@ -9,7 +9,7 @@ import { getPaginatedMessageById } from "../../store/reducers/paginatedById"; import I18n from "../../../../i18n"; import { trackNotificationRejected, trackUxConversion } from "../../analytics"; -type Props = { +type LegacyProps = { messageId: string; onDismiss: () => void; navigationAction: (message: UIMessage) => void; @@ -20,11 +20,11 @@ const foldMessage = ( callback: (message: UIMessage) => void ) => pipe(message, pot.toOption, O.map(callback)); -export const PreconditionFooter = ({ +export const LegacyPreconditionFooter = ({ messageId, navigationAction, onDismiss -}: Props) => { +}: LegacyProps) => { const message = useIOSelector(state => getPaginatedMessageById(state, messageId) ); diff --git a/ts/features/messages/components/PreconditionBottomSheet/PreconditionHeader.tsx b/ts/features/messages/components/PreconditionBottomSheet/LegacyPreconditionHeader.tsx similarity index 91% rename from ts/features/messages/components/PreconditionBottomSheet/PreconditionHeader.tsx rename to ts/features/messages/components/PreconditionBottomSheet/LegacyPreconditionHeader.tsx index 9ee8eebbe76..9eec5b06089 100644 --- a/ts/features/messages/components/PreconditionBottomSheet/PreconditionHeader.tsx +++ b/ts/features/messages/components/PreconditionBottomSheet/LegacyPreconditionHeader.tsx @@ -18,7 +18,7 @@ type Props = { title: string; }; -export const PreconditionHeader = ({ title }: Props) => ( +export const LegacyPreconditionHeader = ({ title }: Props) => ( ( ); -export const PreconditionHeaderSkeleton = () => ( +export const LegacyPreconditionHeaderSkeleton = () => ( @@ -39,8 +39,8 @@ const renderPreconditionHeader = ( fold( content, constNull, - () => , - ({ title }) => , + () => , + ({ title }) => , () => ); @@ -51,15 +51,15 @@ const renderPreconditionContent = ( fold( content, constNull, - () => , + () => , ({ markdown }) => ( - onLoadEnd(true)} /> ), () => ( - @@ -78,7 +78,7 @@ const renderPreconditionFooter = ( } return ( - onDismiss()} navigationAction={navigationAction} @@ -86,15 +86,15 @@ const renderPreconditionFooter = ( ); }; -export const useMessageOpening = () => { +export const useLegacyMessageOpening = () => { const navigation = useIONavigation(); const dispatch = useIODispatch(); - const pnSupported = useIOSelector(isPnSupportedSelector); + const pnSupported = useIOSelector(isPnAppVersionSupportedSelector); const pnMinAppVersion = useIOSelector(pnMinAppVersionSelector); const { messageId: maybeMessageId, content } = useIOSelector( - messagePreconditionSelector + legacyMessagePreconditionSelector ); const [isContentLoadCompleted, setIsContentLoadCompleted] = React.useState(false); @@ -125,7 +125,7 @@ export const useMessageOpening = () => { const getContentModal = () => { if (!pnSupported) { return ( - { return () => { setIsContentLoadCompleted(false); - dispatch(clearMessagePrecondition()); + dispatch(clearLegacyMessagePrecondition()); }; }; @@ -203,7 +203,7 @@ export const useMessageOpening = () => { trackDisclaimerOpened(message.category.tag); dispatch( - getMessagePrecondition.request({ + getLegacyMessagePrecondition.request({ id: message.id, categoryTag: message.category.tag }) diff --git a/ts/features/messages/saga/__test__/handleMessagePrecondition.test.ts b/ts/features/messages/saga/__test__/handleMessagePrecondition.test.ts index 47e4cb8e464..73228b7ad3c 100644 --- a/ts/features/messages/saga/__test__/handleMessagePrecondition.test.ts +++ b/ts/features/messages/saga/__test__/handleMessagePrecondition.test.ts @@ -1,13 +1,22 @@ import * as E from "fp-ts/lib/Either"; import { testSaga } from "redux-saga-test-plan"; import { getType } from "typesafe-actions"; -import { getMessagePrecondition } from "../../store/actions"; import { UIMessageId } from "../../types"; -import { testMessagePreconditionWorker } from "../handleMessagePrecondition"; +import { + getMessageIdAndCategoryTag, + testMessagePreconditionWorker +} from "../handleMessagePrecondition"; import { ThirdPartyMessagePrecondition } from "../../../../../definitions/backend/ThirdPartyMessagePrecondition"; import { TagEnum as TagEnumPN } from "../../../../../definitions/backend/MessageCategoryPN"; import { withRefreshApiCall } from "../../../fastLogin/saga/utils"; import { BackendClient } from "../../../../api/__mocks__/backend"; +import { + errorPreconditionStatusAction, + getLegacyMessagePrecondition, + loadingContentPreconditionStatusAction, + toErrorPayload, + toLoadingContentPayload +} from "../../store/actions/preconditions"; const messagePreconditionWorker = testMessagePreconditionWorker!; @@ -22,56 +31,89 @@ const mockResponseSuccess: ThirdPartyMessagePrecondition = { describe("messagePreconditionWorker", () => { it(`should put ${getType( - getMessagePrecondition.success + getLegacyMessagePrecondition.success )} when the response is successful`, () => { testSaga( messagePreconditionWorker, BackendClient.getThirdPartyMessagePrecondition, - getMessagePrecondition.request(action) + getLegacyMessagePrecondition.request(action) ) .next() + .call( + getMessageIdAndCategoryTag, + getLegacyMessagePrecondition.request(action) + ) + .next({ messageId: action.id, categoryTag: action.categoryTag }) .call( withRefreshApiCall, BackendClient.getThirdPartyMessagePrecondition(action), - getMessagePrecondition.request(action) + getLegacyMessagePrecondition.request(action) ) .next(E.right({ status: 200, value: mockResponseSuccess })) - .put(getMessagePrecondition.success(mockResponseSuccess)) + .put(getLegacyMessagePrecondition.success(mockResponseSuccess)) + .next() + .put( + loadingContentPreconditionStatusAction( + toLoadingContentPayload(mockResponseSuccess) + ) + ) .next() .isDone(); }); it(`should put ${getType( - getMessagePrecondition.failure + getLegacyMessagePrecondition.failure )} when the response is an error`, () => { testSaga( messagePreconditionWorker, BackendClient.getThirdPartyMessagePrecondition, - getMessagePrecondition.request(action) + getLegacyMessagePrecondition.request(action) ) .next() + .call( + getMessageIdAndCategoryTag, + getLegacyMessagePrecondition.request(action) + ) + .next({ messageId: action.id, categoryTag: action.categoryTag }) .call( withRefreshApiCall, BackendClient.getThirdPartyMessagePrecondition(action), - getMessagePrecondition.request(action) + getLegacyMessagePrecondition.request(action) ) .next(E.right({ status: 500, value: `response status ${500}` })) - .put(getMessagePrecondition.failure(new Error(`response status ${500}`))) + .put( + getLegacyMessagePrecondition.failure( + new Error(`response status ${500}`) + ) + ) + .next() + .put( + errorPreconditionStatusAction( + toErrorPayload(`Error: response status ${500}`) + ) + ) .next() .isDone(); }); it(`should put ${getType( - getMessagePrecondition.failure + getLegacyMessagePrecondition.failure )} when the handler throws an exception`, () => { testSaga( messagePreconditionWorker, BackendClient.getThirdPartyMessagePrecondition, - getMessagePrecondition.request(action) + getLegacyMessagePrecondition.request(action) ) .next() + .call( + getMessageIdAndCategoryTag, + getLegacyMessagePrecondition.request(action) + ) + .next({ messageId: action.id, categoryTag: action.categoryTag }) .next(E.left([])) - .put(getMessagePrecondition.failure(new Error())) + .put(getLegacyMessagePrecondition.failure(new Error())) + .next() + .put(errorPreconditionStatusAction(toErrorPayload(`Error`))) .next() .isDone(); }); diff --git a/ts/features/messages/saga/handleMessagePrecondition.ts b/ts/features/messages/saga/handleMessagePrecondition.ts index 3110b5c65f2..2d1c00e5b19 100644 --- a/ts/features/messages/saga/handleMessagePrecondition.ts +++ b/ts/features/messages/saga/handleMessagePrecondition.ts @@ -1,34 +1,67 @@ import { readableReport } from "@pagopa/ts-commons/lib/reporters"; import * as E from "fp-ts/lib/Either"; -import { call, put, race, take } from "typed-redux-saga/macro"; -import { ActionType } from "typesafe-actions"; +import { call, put, race, select, take } from "typed-redux-saga/macro"; +import { ActionType, getType } from "typesafe-actions"; import { BackendClient } from "../../../api/backend"; import { convertUnknownToError } from "../../../utils/errors"; -import { - getMessagePrecondition, - clearMessagePrecondition -} from "../store/actions"; import { isTestEnv } from "../../../utils/environment"; import { withRefreshApiCall } from "../../fastLogin/saga/utils"; -import { SagaCallReturnType } from "../../../types/utils"; +import { ReduxSagaEffect, SagaCallReturnType } from "../../../types/utils"; import { trackDisclaimerLoadError } from "../analytics"; +import { + clearLegacyMessagePrecondition, + errorPreconditionStatusAction, + getLegacyMessagePrecondition, + loadingContentPreconditionStatusAction, + retrievingDataPreconditionStatusAction, + toErrorPayload, + toLoadingContentPayload +} from "../store/actions/preconditions"; +import { UIMessageId } from "../types"; +import { MessageCategory } from "../../../../definitions/backend/MessageCategory"; +import { + preconditionsCategoryTagSelector, + preconditionsMessageIdSelector +} from "../store/reducers/messagePrecondition"; -export const testMessagePreconditionWorker = isTestEnv - ? messagePreconditionWorker - : undefined; +export function* handleMessagePrecondition( + getThirdPartyMessagePrecondition: BackendClient["getThirdPartyMessagePrecondition"], + action: + | ActionType + | ActionType +) { + yield* race({ + response: call( + messagePreconditionWorker, + getThirdPartyMessagePrecondition(), + action + ), + cancel: take(clearLegacyMessagePrecondition) + }); +} function* messagePreconditionWorker( getThirdPartyMessagePrecondition: ReturnType< BackendClient["getThirdPartyMessagePrecondition"] >, - action: ActionType + action: + | ActionType + | ActionType ) { - const { id, categoryTag } = action.payload; - + const messageIdAndCategoryTag = yield* call( + getMessageIdAndCategoryTag, + action + ); try { + if (!messageIdAndCategoryTag) { + throw Error("Unable to get `messageId` or `categoryTag`"); + } + const result = (yield* call( withRefreshApiCall, - getThirdPartyMessagePrecondition({ id }), + getThirdPartyMessagePrecondition({ + id: messageIdAndCategoryTag.messageId + }), action )) as unknown as SagaCallReturnType< typeof getThirdPartyMessagePrecondition @@ -36,7 +69,13 @@ function* messagePreconditionWorker( if (E.isRight(result)) { if (result.right.status === 200) { - yield* put(getMessagePrecondition.success(result.right.value)); + const content = result.right.value; + yield* put(getLegacyMessagePrecondition.success(content)); + yield* put( + loadingContentPreconditionStatusAction( + toLoadingContentPayload(content) + ) + ); return; } throw Error(`response status ${result.right.status}`); @@ -44,21 +83,45 @@ function* messagePreconditionWorker( throw Error(readableReport(result.left)); } } catch (e) { - trackDisclaimerLoadError(categoryTag); - yield* put(getMessagePrecondition.failure(convertUnknownToError(e))); + const categoryTag = messageIdAndCategoryTag?.categoryTag; + if (categoryTag) { + trackDisclaimerLoadError(categoryTag); + } + yield* put(getLegacyMessagePrecondition.failure(convertUnknownToError(e))); + yield* put( + errorPreconditionStatusAction( + toErrorPayload(`${convertUnknownToError(e)}`) + ) + ); } } -export function* handleMessagePrecondition( - getThirdPartyMessagePrecondition: BackendClient["getThirdPartyMessagePrecondition"], - action: ActionType -) { - yield* race({ - response: call( - messagePreconditionWorker, - getThirdPartyMessagePrecondition(), - action - ), - cancel: take(clearMessagePrecondition) - }); +export function* getMessageIdAndCategoryTag( + action: + | ActionType + | ActionType +): Generator< + ReduxSagaEffect, + { messageId: UIMessageId; categoryTag: MessageCategory["tag"] } | undefined, + any +> { + if (action.type === getType(getLegacyMessagePrecondition.request)) { + return { + messageId: action.payload.id, + categoryTag: action.payload.categoryTag + }; + } + + const messageId = yield* select(preconditionsMessageIdSelector); + const categoryTag = yield* select(preconditionsCategoryTagSelector); + return messageId && categoryTag + ? { + messageId, + categoryTag + } + : undefined; } + +export const testMessagePreconditionWorker = isTestEnv + ? messagePreconditionWorker + : undefined; diff --git a/ts/features/messages/saga/index.ts b/ts/features/messages/saga/index.ts index a4cacd70da3..ef7e2b5b041 100644 --- a/ts/features/messages/saga/index.ts +++ b/ts/features/messages/saga/index.ts @@ -12,7 +12,6 @@ import { logoutSuccess } from "../../../store/actions/authentication"; import { downloadAttachment, getMessageDataAction, - getMessagePrecondition, loadMessageById, loadMessageDetails, loadNextPageMessages, @@ -25,6 +24,10 @@ import { } from "../store/actions"; import { retryDataAfterFastLoginSessionExpirationSelector } from "../store/reducers/messageGetStatus"; import { BackendClient } from "../../../api/backend"; +import { + getLegacyMessagePrecondition, + retrievingDataPreconditionStatusAction +} from "../store/actions/preconditions"; import { handleDownloadAttachment } from "./handleDownloadAttachment"; import { handleClearAllAttachments, @@ -82,7 +85,10 @@ export function* watchMessagesSaga( ); yield* takeLatest( - getMessagePrecondition.request, + [ + getLegacyMessagePrecondition.request, + retrievingDataPreconditionStatusAction + ], handleMessagePrecondition, backendClient.getThirdPartyMessagePrecondition ); diff --git a/ts/features/messages/screens/MessagesHomeScreen.tsx b/ts/features/messages/screens/MessagesHomeScreen.tsx index 9f8157aafac..bb97d3bfd79 100644 --- a/ts/features/messages/screens/MessagesHomeScreen.tsx +++ b/ts/features/messages/screens/MessagesHomeScreen.tsx @@ -6,6 +6,7 @@ import { PagerViewContainer } from "../components/Home/PagerViewContainer"; import { TabNavigationContainer } from "../components/Home/TabNavigationContainer"; import { SecuritySuggestions } from "../components/Home/SecuritySuggestions"; import { Toasts } from "../components/Home/Toasts"; +import { Preconditions } from "../components/Home/Preconditions"; export const MessagesHomeScreen = () => { const pagerViewRef = useRef(null); @@ -14,6 +15,7 @@ export const MessagesHomeScreen = () => { + ); diff --git a/ts/features/messages/screens/__tests__/MessagesHomeScreen.test.tsx b/ts/features/messages/screens/__tests__/MessagesHomeScreen.test.tsx new file mode 100644 index 00000000000..18adb30c7e2 --- /dev/null +++ b/ts/features/messages/screens/__tests__/MessagesHomeScreen.test.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { createStore } from "redux"; +import { appReducer } from "../../../../store/reducers"; +import { applicationChangeState } from "../../../../store/actions/application"; +import { preferencesDesignSystemSetEnabled } from "../../../../store/actions/persistedPreferences"; +import { renderScreenWithNavigationStoreContext } from "../../../../utils/testWrapper"; +import { MESSAGES_ROUTES } from "../../navigation/routes"; +import { MessagesHomeScreen } from "../MessagesHomeScreen"; + +jest.mock("../../components/Home/PagerViewContainer"); +jest.mock("../../components/Home/Preconditions"); +jest.mock("../../components/Home/TabNavigationContainer"); +jest.mock("../../components/Home/Toasts"); + +describe("MessagesHomeScreen", () => { + afterEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + it("should match snapshot (with mocked components", () => { + const screen = renderScreen(); + expect(screen.toJSON()).toMatchSnapshot(); + }); +}); + +const renderScreen = () => { + const initialState = appReducer(undefined, applicationChangeState("active")); + const designSystemState = appReducer( + initialState, + preferencesDesignSystemSetEnabled({ isDesignSystemEnabled: true }) + ); + const store = createStore(appReducer, designSystemState as any); + + return renderScreenWithNavigationStoreContext( + () => , + MESSAGES_ROUTES.MESSAGES_HOME, + {}, + store + ); +}; diff --git a/ts/features/messages/screens/__tests__/__snapshots__/MessagesHomeScreen.test.tsx.snap b/ts/features/messages/screens/__tests__/__snapshots__/MessagesHomeScreen.test.tsx.snap new file mode 100644 index 00000000000..d2e8005bc2f --- /dev/null +++ b/ts/features/messages/screens/__tests__/__snapshots__/MessagesHomeScreen.test.tsx.snap @@ -0,0 +1,1064 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MessagesHomeScreen should match snapshot (with mocked components 1`] = ` + + + + + + + + + + + + + + + MESSAGES_HOME + + + + + + + + + + + + + + + + + + + This is a mock for Toasts + + + This is a mock for TabNavigationContainer + + + + + Mock Inbox + + + + + Mock Archive + + + + + This is a mock for Preconditions + + + + + + + + + + + + + + + + Do not share your unlock code or biometric recognition with anyone. + + + + + + + + + + + + + + If you lose your device, log out from the web. + + + + Go to website + + + + + + + + + + + + + + If you fear that someone might use your SPID or CIE, lock access to the app after authenticating from the web. + + + + Go to website + + + + + + + + + + + + + + If you access IO with someone else's device, remember to log out when you are finished. + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/ts/features/messages/screens/legacy/LegacyMessagesHomeScreen.tsx b/ts/features/messages/screens/legacy/LegacyMessagesHomeScreen.tsx index a13f15e7640..3f40d53ef7f 100644 --- a/ts/features/messages/screens/legacy/LegacyMessagesHomeScreen.tsx +++ b/ts/features/messages/screens/legacy/LegacyMessagesHomeScreen.tsx @@ -27,7 +27,7 @@ import { import { useOnFirstRender } from "../../../../utils/hooks/useOnFirstRender"; import MessageList from "../../components/Home/legacy"; import MessagesSearch from "../../components/Home/legacy/MessagesSearch"; -import { useMessageOpening } from "../../hooks/useMessageOpening"; +import { useLegacyMessageOpening } from "../../hooks/useLegacyMessageOpening"; import MessagesHomeTabNavigator from "../../navigation/MessagesHomeTabNavigator"; import { migrateToPaginatedMessages, @@ -102,7 +102,7 @@ const LegacyMessagesHomeScreen = ({ ); }, [latestMessageOperation]); - const { present, bottomSheet } = useMessageOpening(); + const { present, bottomSheet } = useLegacyMessageOpening(); const isScreenReaderEnabled = useScreenReaderEnabled(); diff --git a/ts/features/messages/screens/legacy/MessageListScreen.tsx b/ts/features/messages/screens/legacy/MessageListScreen.tsx index 022ddb20e28..d927c04530d 100644 --- a/ts/features/messages/screens/legacy/MessageListScreen.tsx +++ b/ts/features/messages/screens/legacy/MessageListScreen.tsx @@ -3,7 +3,7 @@ import { RouteProp, useRoute } from "@react-navigation/native"; import * as pot from "@pagopa/ts-commons/lib/pot"; import { pipe } from "fp-ts/lib/function"; import * as O from "fp-ts/lib/Option"; -import { useMessageOpening } from "../../hooks/useMessageOpening"; +import { useLegacyMessageOpening } from "../../hooks/useLegacyMessageOpening"; import MessagesInbox from "../../components/Home/legacy/MessagesInbox"; import { upsertMessageStatusAttributes } from "../../store/actions"; import { useIODispatch, useIOSelector } from "../../../../store/hooks"; @@ -41,7 +41,7 @@ const MessageListScreen = () => { messagesByCategorySelector(state, route.params.category) ); const messages = getMessages(messagePagePot); - const { present, bottomSheet } = useMessageOpening(); + const { present, bottomSheet } = useLegacyMessageOpening(); const setArchived = ( isArchived: boolean, diff --git a/ts/features/messages/store/actions/__tests__/preconditions.test.ts b/ts/features/messages/store/actions/__tests__/preconditions.test.ts new file mode 100644 index 00000000000..38603164830 --- /dev/null +++ b/ts/features/messages/store/actions/__tests__/preconditions.test.ts @@ -0,0 +1,123 @@ +import { MessageCategory } from "../../../../../../definitions/backend/MessageCategory"; +import { ThirdPartyMessagePrecondition } from "../../../../../../definitions/backend/ThirdPartyMessagePrecondition"; +import { UIMessageId } from "../../../types"; +import { + errorPreconditionStatusAction, + idlePreconditionStatusAction, + loadingContentPreconditionStatusAction, + retrievingDataPreconditionStatusAction, + scheduledPreconditionStatusAction, + shownPreconditionStatusAction, + toErrorPayload, + toIdlePayload, + toLoadingContentPayload, + toRetrievingDataPayload, + toScheduledPayload, + toShownPayload, + toUpdateRequiredPayload, + updateRequiredPreconditionStatusAction +} from "../preconditions"; + +describe("Action payload generators", () => { + it("should generate proper payload with 'toErrorPayload'", () => { + const reason = "The reason"; + const errorPayload = toErrorPayload(reason); + expect(errorPayload.nextStatus).toStrictEqual("error"); + expect(errorPayload.reason).toStrictEqual(reason); + }); + it("should generate proper payload with 'toIdlePayload'", () => { + const idlePayload = toIdlePayload(); + expect(idlePayload.nextStatus).toStrictEqual("idle"); + }); + it("should generate proper payload with 'toLoadingContentPayload'", () => { + const content: ThirdPartyMessagePrecondition = { + title: "The title", + markdown: "The content" + }; + const loadingContentPayload = toLoadingContentPayload(content); + expect(loadingContentPayload.nextStatus).toStrictEqual("loadingContent"); + expect(loadingContentPayload.content).toStrictEqual(content); + }); + it("should generate proper payload with 'toRetrievingDataPayload'", () => { + const retrievingDataPayload = toRetrievingDataPayload(); + expect(retrievingDataPayload.nextStatus).toStrictEqual("retrievingData"); + }); + it("should generate proper payload with 'toScheduledPayload'", () => { + const messageId = "01J1F5K8D1S71NZJNCDBWN4RYP" as UIMessageId; + const categoryTag = "GENERIC" as MessageCategory["tag"]; + const scheduledPayload = toScheduledPayload(messageId, categoryTag); + expect(scheduledPayload.nextStatus).toStrictEqual("scheduled"); + expect(scheduledPayload.messageId).toStrictEqual(messageId); + expect(scheduledPayload.categoryTag).toStrictEqual(categoryTag); + }); + it("should generate proper payload with 'toShownPayload'", () => { + const shownPayload = toShownPayload(); + expect(shownPayload.nextStatus).toStrictEqual("shown"); + }); + it("should generate proper payload with 'toUpdateRequiredPayload'", () => { + const updateRequiredPayload = toUpdateRequiredPayload(); + expect(updateRequiredPayload.nextStatus).toStrictEqual("updateRequired"); + }); +}); + +describe("Action generators", () => { + it("should return the proper action data for 'errorPreconditionStatusAction'", () => { + const errorPayload = toErrorPayload("reason"); + const errorPSA = errorPreconditionStatusAction(errorPayload); + expect(errorPSA.type).toStrictEqual("TO_ERROR_PRECONDITION_STATUS"); + expect(errorPSA.payload).toStrictEqual(errorPayload); + }); + it("should return the proper action data for 'idlePreconditionStatusAction'", () => { + const idlePayload = toIdlePayload(); + const idlePSA = idlePreconditionStatusAction(idlePayload); + expect(idlePSA.type).toStrictEqual("TO_IDLE_PRECONDITION_STATUS"); + expect(idlePSA.payload).toStrictEqual(idlePayload); + }); + it("should return the proper action data for 'loadingContentPreconditionStatusAction'", () => { + const loadingContentPayload = toLoadingContentPayload({ + title: "", + markdown: "" + }); + const loadingContentPSA = loadingContentPreconditionStatusAction( + loadingContentPayload + ); + expect(loadingContentPSA.type).toStrictEqual( + "TO_LOADING_CONTENT_PRECONDITION_STATUS" + ); + expect(loadingContentPSA.payload).toStrictEqual(loadingContentPayload); + }); + it("should return the proper action data for 'retrievingDataPreconditionStatusAction'", () => { + const retrievingDatPayload = toRetrievingDataPayload(); + const rerievingDataPSA = + retrievingDataPreconditionStatusAction(retrievingDatPayload); + expect(rerievingDataPSA.type).toStrictEqual( + "TO_RETRIEVING_DATA_PRECONDITION_STATUS" + ); + expect(rerievingDataPSA.payload).toStrictEqual(retrievingDatPayload); + }); + it("should return the proper action data for 'scheduledPreconditionStatusAction'", () => { + const scheduledPayload = toScheduledPayload( + "01J1F6DVSZF2SMV2T8635D25BQ" as UIMessageId, + "GENERIC" as MessageCategory["tag"] + ); + const scheduledPSA = scheduledPreconditionStatusAction(scheduledPayload); + expect(scheduledPSA.type).toStrictEqual("TO_SCHEDULED_PRECONDITION_STATUS"); + expect(scheduledPSA.payload).toStrictEqual(scheduledPayload); + }); + it("should return the proper action data for 'shownPreconditionStatusAction'", () => { + const shownPayload = toShownPayload(); + const shownPSA = shownPreconditionStatusAction(shownPayload); + expect(shownPSA.type).toStrictEqual("TO_SHOWN_PRECONDITION_STATUS"); + expect(shownPSA.payload).toStrictEqual(shownPayload); + }); + it("should return the proper action data for 'updateRequiredPreconditionStatusAction'", () => { + const updateRequiredPayload = toUpdateRequiredPayload(); + const updateRequiredPSA = updateRequiredPreconditionStatusAction( + updateRequiredPayload + ); + expect(updateRequiredPSA.type).toStrictEqual( + "TO_UPDATE_REQUIRED_PRECONDITION_STATUS" + ); + expect(updateRequiredPSA.payload).toStrictEqual(updateRequiredPayload); + }); +}); diff --git a/ts/features/messages/store/actions/index.ts b/ts/features/messages/store/actions/index.ts index 3d98adeb7c3..fe7feafb4ee 100644 --- a/ts/features/messages/store/actions/index.ts +++ b/ts/features/messages/store/actions/index.ts @@ -6,20 +6,24 @@ import { } from "typesafe-actions"; import { ThirdPartyMessageWithContent } from "../../../../../definitions/backend/ThirdPartyMessageWithContent"; import { ServiceId } from "../../../../../definitions/backend/ServiceId"; -import { - UIMessage, - UIMessageDetails, - UIMessageId, - WithUIMessageId -} from "../../types"; +import { UIMessage, UIMessageDetails, UIMessageId } from "../../types"; import { MessageGetStatusFailurePhaseType } from "../reducers/messageGetStatus"; -import { MessageCategory } from "../../../../../definitions/backend/MessageCategory"; -import { ThirdPartyMessagePrecondition } from "../../../../../definitions/backend/ThirdPartyMessagePrecondition"; import { MessagesStatus } from "../reducers/messagesStatus"; import { ThirdPartyAttachment } from "../../../../../definitions/backend/ThirdPartyAttachment"; import { PaymentRequestsGetResponse } from "../../../../../definitions/backend/PaymentRequestsGetResponse"; import { Detail_v2Enum } from "../../../../../definitions/backend/PaymentProblemJson"; import { MessageListCategory } from "../../types/messageListCategory"; +import { + clearLegacyMessagePrecondition, + errorPreconditionStatusAction, + getLegacyMessagePrecondition, + idlePreconditionStatusAction, + loadingContentPreconditionStatusAction, + retrievingDataPreconditionStatusAction, + scheduledPreconditionStatusAction, + shownPreconditionStatusAction, + updateRequiredPreconditionStatusAction +} from "./preconditions"; export type ThirdPartyMessageActions = ActionType; @@ -199,20 +203,6 @@ export const migrateToPaginatedMessages = createAsyncAction( "MESSAGES_MIGRATE_TO_PAGINATED_FAILURE" )(); -export const getMessagePrecondition = createAsyncAction( - "GET_MESSAGE_PRECONDITION_REQUEST", - "GET_MESSAGE_PRECONDITION_SUCCESS", - "GET_MESSAGE_PRECONDITION_FAILURE" -)< - WithUIMessageId<{ categoryTag: MessageCategory["tag"] }>, - ThirdPartyMessagePrecondition, - Error ->(); - -export const clearMessagePrecondition = createAction( - "CLEAR_MESSAGE_PRECONDITION" -); - /** * Used to mark the end of a migration and reset it to a pristine state. */ @@ -333,8 +323,15 @@ export type MessagesActions = ActionType< | typeof cancelPreviousAttachmentDownload | typeof clearRequestedAttachmentDownload | typeof removeCachedAttachment - | typeof getMessagePrecondition - | typeof clearMessagePrecondition + | typeof errorPreconditionStatusAction + | typeof idlePreconditionStatusAction + | typeof loadingContentPreconditionStatusAction + | typeof retrievingDataPreconditionStatusAction + | typeof scheduledPreconditionStatusAction + | typeof shownPreconditionStatusAction + | typeof updateRequiredPreconditionStatusAction + | typeof getLegacyMessagePrecondition + | typeof clearLegacyMessagePrecondition | typeof getMessageDataAction | typeof cancelGetMessageDataAction | typeof resetGetMessageDataAction diff --git a/ts/features/messages/store/actions/preconditions.ts b/ts/features/messages/store/actions/preconditions.ts new file mode 100644 index 00000000000..caaa7a90957 --- /dev/null +++ b/ts/features/messages/store/actions/preconditions.ts @@ -0,0 +1,98 @@ +import { createAsyncAction, createStandardAction } from "typesafe-actions"; +import { ThirdPartyMessagePrecondition } from "../../../../../definitions/backend/ThirdPartyMessagePrecondition"; +import { UIMessageId, WithUIMessageId } from "../../types"; +import { MessageCategory } from "../../../../../definitions/backend/MessageCategory"; + +// NPS stands for Next Precondition Status +export type NPSError = { + nextStatus: "error"; + reason: string; +}; +export type NPSIdle = { + nextStatus: "idle"; +}; +export type NPSLoadingContent = { + nextStatus: "loadingContent"; + content: ThirdPartyMessagePrecondition; +}; +export type NPSRetrievingData = { + nextStatus: "retrievingData"; +}; +export type NPSScheduled = { + nextStatus: "scheduled"; + messageId: UIMessageId; + categoryTag: MessageCategory["tag"]; +}; +export type NPSShown = { + nextStatus: "shown"; +}; +export type NPSUpdateRequired = { + nextStatus: "updateRequired"; +}; + +export const toErrorPayload = (reason: string): NPSError => ({ + nextStatus: "error", + reason +}); +export const toIdlePayload = (): NPSIdle => ({ + nextStatus: "idle" +}); +export const toLoadingContentPayload = ( + content: ThirdPartyMessagePrecondition +): NPSLoadingContent => ({ + nextStatus: "loadingContent", + content +}); +export const toRetrievingDataPayload = (): NPSRetrievingData => ({ + nextStatus: "retrievingData" +}); +export const toScheduledPayload = ( + messageId: UIMessageId, + categoryTag: MessageCategory["tag"] +): NPSScheduled => ({ + nextStatus: "scheduled", + messageId, + categoryTag +}); +export const toShownPayload = (): NPSShown => ({ + nextStatus: "shown" +}); +export const toUpdateRequiredPayload = (): NPSUpdateRequired => ({ + nextStatus: "updateRequired" +}); + +export const errorPreconditionStatusAction = createStandardAction( + "TO_ERROR_PRECONDITION_STATUS" +)(); +export const idlePreconditionStatusAction = createStandardAction( + "TO_IDLE_PRECONDITION_STATUS" +)(); +export const loadingContentPreconditionStatusAction = createStandardAction( + "TO_LOADING_CONTENT_PRECONDITION_STATUS" +)(); +export const retrievingDataPreconditionStatusAction = createStandardAction( + "TO_RETRIEVING_DATA_PRECONDITION_STATUS" +)(); +export const scheduledPreconditionStatusAction = createStandardAction( + "TO_SCHEDULED_PRECONDITION_STATUS" +)(); +export const shownPreconditionStatusAction = createStandardAction( + "TO_SHOWN_PRECONDITION_STATUS" +)(); +export const updateRequiredPreconditionStatusAction = createStandardAction( + "TO_UPDATE_REQUIRED_PRECONDITION_STATUS" +)(); + +export const getLegacyMessagePrecondition = createAsyncAction( + "GET_MESSAGE_PRECONDITION_REQUEST", + "GET_MESSAGE_PRECONDITION_SUCCESS", + "GET_MESSAGE_PRECONDITION_FAILURE" +)< + WithUIMessageId<{ categoryTag: MessageCategory["tag"] }>, + ThirdPartyMessagePrecondition, + Error +>(); + +export const clearLegacyMessagePrecondition = createStandardAction( + "CLEAR_MESSAGE_PRECONDITION" +)(); diff --git a/ts/features/messages/store/reducers/__tests__/legacyMessagePrecondition.test.ts b/ts/features/messages/store/reducers/__tests__/legacyMessagePrecondition.test.ts new file mode 100644 index 00000000000..c49a8c53f96 --- /dev/null +++ b/ts/features/messages/store/reducers/__tests__/legacyMessagePrecondition.test.ts @@ -0,0 +1,139 @@ +import * as O from "fp-ts/lib/Option"; +import { createStore } from "redux"; +import { appReducer } from "../../../../../store/reducers"; +import { ThirdPartyMessagePrecondition } from "../../../../../../definitions/backend/ThirdPartyMessagePrecondition"; +import { TagEnum as TagEnumPN } from "../../../../../../definitions/backend/MessageCategoryPN"; +import { applicationChangeState } from "../../../../../store/actions/application"; +import { + remoteError, + remoteLoading, + remoteReady, + remoteUndefined +} from "../../../../../common/model/RemoteValue"; +import { message_1 } from "../../../__mocks__/message"; +import { toUIMessage } from "../transformers"; +import { GlobalState } from "../../../../../store/reducers/types"; +import { + clearLegacyMessagePrecondition, + getLegacyMessagePrecondition +} from "../../actions/preconditions"; + +const mockThirdPartyMessagePrecondition: ThirdPartyMessagePrecondition = { + title: "placeholder_title", + markdown: "placeholder_markdown" +}; + +const message = toUIMessage(message_1); + +describe("legacyMessagePrecondition", () => { + it("The initial state should have the messageId undefined and the content as remoteUndefined", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + expect( + globalState.entities.messages.legacyMessagePrecondition + ).toStrictEqual({ + messageId: O.none, + content: remoteUndefined + }); + }); + + it("The messageId should be defined and the content should be remoteLoading if the getMessagePrecondition.request is dispatched", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + const store = createStore(appReducer, globalState as any); + const action = store.dispatch( + getLegacyMessagePrecondition.request({ + id: message.id, + categoryTag: TagEnumPN.PN + }) + ); + expect( + store.getState().entities.messages.legacyMessagePrecondition + ).toStrictEqual({ + messageId: O.some(action.payload.id), + content: remoteLoading + }); + }); + + it("The messageId should be defined and the content should be remoteReady with action payload as value if the getMessagePrecondition.success is dispatched", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + const finalState: GlobalState = { + ...globalState, + entities: { + ...globalState.entities, + messages: { + ...globalState.entities.messages, + legacyMessagePrecondition: { + messageId: O.some(message.id), + content: remoteLoading + } + } + } + }; + + const store = createStore(appReducer, finalState as any); + const action = store.dispatch( + getLegacyMessagePrecondition.success(mockThirdPartyMessagePrecondition) + ); + expect( + store.getState().entities.messages.legacyMessagePrecondition + ).toStrictEqual({ + messageId: O.some(message.id), + content: remoteReady(action.payload) + }); + }); + + it("The messageId should be defined and the content should be remoteError with action payload as value if the getMessagePrecondition.failure is dispatched", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + const finalState: GlobalState = { + ...globalState, + entities: { + ...globalState.entities, + messages: { + ...globalState.entities.messages, + legacyMessagePrecondition: { + messageId: O.some(message.id), + content: remoteLoading + } + } + } + }; + + const store = createStore(appReducer, finalState as any); + const action = store.dispatch( + getLegacyMessagePrecondition.failure( + new Error("Error load remote content") + ) + ); + expect( + store.getState().entities.messages.legacyMessagePrecondition + ).toStrictEqual({ + messageId: O.some(message.id), + content: remoteError(action.payload) + }); + }); + + it("The messageId should be undefined and the content should be remoteUndefined if the clearMessagePrecondition is dispatched", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + const finalState: GlobalState = { + ...globalState, + entities: { + ...globalState.entities, + messages: { + ...globalState.entities.messages, + legacyMessagePrecondition: { + messageId: O.some(message.id), + content: remoteReady(mockThirdPartyMessagePrecondition) + } + } + } + }; + + const store = createStore(appReducer, finalState as any); + store.dispatch(clearLegacyMessagePrecondition()); + expect( + store.getState().entities.messages.legacyMessagePrecondition + ).toStrictEqual({ + messageId: O.none, + content: remoteUndefined + }); + }); +}); diff --git a/ts/features/messages/store/reducers/__tests__/messagePrecondition.test.ts b/ts/features/messages/store/reducers/__tests__/messagePrecondition.test.ts index 2ac830f730c..311e9bd8431 100644 --- a/ts/features/messages/store/reducers/__tests__/messagePrecondition.test.ts +++ b/ts/features/messages/store/reducers/__tests__/messagePrecondition.test.ts @@ -1,135 +1,547 @@ -import * as O from "fp-ts/lib/Option"; -import { createStore } from "redux"; +/* eslint-disable sonarjs/no-nested-switch */ +import { ActionType } from "typesafe-actions"; +import { TagEnum } from "../../../../../../definitions/backend/MessageCategoryBase"; +import { TagEnum as PaymentTagEnum } from "../../../../../../definitions/backend/MessageCategoryPayment"; +import { TagEnum as SENDTagEnum } from "../../../../../../definitions/backend/MessageCategoryPN"; +import { UIMessageId } from "../../../types"; import { - getMessagePrecondition, - clearMessagePrecondition -} from "../../actions"; -import { appReducer } from "../../../../../store/reducers"; -import { ThirdPartyMessagePrecondition } from "../../../../../../definitions/backend/ThirdPartyMessagePrecondition"; -import { TagEnum as TagEnumPN } from "../../../../../../definitions/backend/MessageCategoryPN"; -import { applicationChangeState } from "../../../../../store/actions/application"; + errorPreconditionStatusAction, + idlePreconditionStatusAction, + loadingContentPreconditionStatusAction, + retrievingDataPreconditionStatusAction, + scheduledPreconditionStatusAction, + shownPreconditionStatusAction, + toErrorPayload, + toIdlePayload, + toLoadingContentPayload, + toRetrievingDataPayload, + toScheduledPayload, + toShownPayload, + toUpdateRequiredPayload, + updateRequiredPreconditionStatusAction +} from "../../actions/preconditions"; import { - remoteError, - remoteLoading, - remoteReady, - remoteUndefined -} from "../../../../../common/model/RemoteValue"; -import { message_1 } from "../../../__mocks__/message"; -import { toUIMessage } from "../transformers"; + MessagePreconditionStatus, + foldPreconditionStatus, + isShownPreconditionStatusSelector, + preconditionReducer, + preconditionsCategoryTagSelector, + preconditionsContentMarkdownSelector, + preconditionsContentSelector, + preconditionsFooterSelector, + preconditionsMessageIdSelector, + preconditionsRequireAppUpdateSelector, + preconditionsTitleContentSelector, + preconditionsTitleSelector, + shouldPresentPreconditionsBottomSheetSelector, + toErrorMPS, + toIdleMPS, + toLoadingContentMPS, + toRetrievingDataMPS, + toScheduledMPS, + toShownMPS, + toUpdateRequiredMPS +} from "../messagePrecondition"; import { GlobalState } from "../../../../../store/reducers/types"; +import * as backendStatus from "../../../../../store/reducers/backendStatus"; +import { MessageCategory } from "../../../../../../definitions/backend/MessageCategory"; -const mockThirdPartyMessagePrecondition: ThirdPartyMessagePrecondition = { - title: "placeholder_title", - markdown: "placeholder_markdown" +const messageId = "01J1FJADCJ53SN4A11J3TBSKQE" as UIMessageId; +const categoryTag = TagEnum.GENERIC; +const errorReason = "An error reason"; +const content = { + title: "A title", + markdown: "A markdown" }; +const messagePreconditionStatusesGenerator = ( + categoryTag: MessageCategory["tag"] +) => [ + toErrorMPS(messageId, categoryTag, errorReason), + toIdleMPS(), + toLoadingContentMPS(messageId, categoryTag, content), + toRetrievingDataMPS(messageId, categoryTag), + toScheduledMPS(messageId, categoryTag), + toShownMPS(messageId, categoryTag, content), + toUpdateRequiredMPS() +]; -const message = toUIMessage(message_1); +const computeExpectedOutput = ( + fromStatus: MessagePreconditionStatus, + withAction: ActionType< + | typeof errorPreconditionStatusAction + | typeof idlePreconditionStatusAction + | typeof loadingContentPreconditionStatusAction + | typeof retrievingDataPreconditionStatusAction + | typeof scheduledPreconditionStatusAction + | typeof shownPreconditionStatusAction + | typeof updateRequiredPreconditionStatusAction + > +) => { + switch (fromStatus.state) { + case "error": + switch (withAction.type) { + case "TO_IDLE_PRECONDITION_STATUS": + return toIdleMPS(); + case "TO_RETRIEVING_DATA_PRECONDITION_STATUS": + return toRetrievingDataMPS( + fromStatus.messageId, + fromStatus.categoryTag + ); + } + break; + case "idle": + switch (withAction.type) { + case "TO_SCHEDULED_PRECONDITION_STATUS": + return toScheduledMPS( + withAction.payload.messageId, + withAction.payload.categoryTag + ); + } + break; + case "loadingContent": + switch (withAction.type) { + case "TO_IDLE_PRECONDITION_STATUS": + return toIdleMPS(); + case "TO_ERROR_PRECONDITION_STATUS": + return toErrorMPS( + fromStatus.messageId, + fromStatus.categoryTag, + withAction.payload.reason + ); + case "TO_SHOWN_PRECONDITION_STATUS": + return toShownMPS( + fromStatus.messageId, + fromStatus.categoryTag, + fromStatus.content + ); + } + break; + case "retrievingData": + switch (withAction.type) { + case "TO_IDLE_PRECONDITION_STATUS": + return toIdleMPS(); + case "TO_ERROR_PRECONDITION_STATUS": + return toErrorMPS( + fromStatus.messageId, + fromStatus.categoryTag, + withAction.payload.reason + ); + case "TO_LOADING_CONTENT_PRECONDITION_STATUS": + return toLoadingContentMPS( + fromStatus.messageId, + fromStatus.categoryTag, + withAction.payload.content + ); + } + break; + case "scheduled": + switch (withAction.type) { + case "TO_RETRIEVING_DATA_PRECONDITION_STATUS": + return toRetrievingDataMPS( + fromStatus.messageId, + fromStatus.categoryTag + ); + case "TO_UPDATE_REQUIRED_PRECONDITION_STATUS": + return toUpdateRequiredMPS(); + } + break; + case "shown": + case "updateRequired": + switch (withAction.type) { + case "TO_IDLE_PRECONDITION_STATUS": + return toIdleMPS(); + } + break; + } + return fromStatus; +}; + +describe("messagePrecondition reducer", () => { + const changeStatusActions = [ + errorPreconditionStatusAction(toErrorPayload(errorReason)), + idlePreconditionStatusAction(toIdlePayload()), + loadingContentPreconditionStatusAction(toLoadingContentPayload(content)), + retrievingDataPreconditionStatusAction(toRetrievingDataPayload()), + scheduledPreconditionStatusAction( + toScheduledPayload(messageId, categoryTag) + ), + shownPreconditionStatusAction(toShownPayload()), + updateRequiredPreconditionStatusAction(toUpdateRequiredPayload()) + ]; + messagePreconditionStatusesGenerator(TagEnum.GENERIC).forEach(initialStatus => + changeStatusActions.forEach(changeStatusAction => { + const expectedStatus = computeExpectedOutput( + initialStatus, + changeStatusAction + ); + it(`should output '${expectedStatus.state}', starting from '${initialStatus.state}', after receiving action '${changeStatusAction.type}'`, () => { + const preconditionStatus = preconditionReducer( + initialStatus, + changeStatusAction + ); + expect(preconditionStatus).toStrictEqual(expectedStatus); + }); + }) + ); +}); + +describe("Message precondition status generators", () => { + it("should return proper istance for 'toErrorMPS'", () => { + const expectedMPS = { + state: "error", + messageId, + categoryTag, + reason: "An error reason" + }; + const mps = toErrorMPS( + expectedMPS.messageId, + expectedMPS.categoryTag, + expectedMPS.reason + ); + expect(mps).toStrictEqual(expectedMPS); + }); + it("should return proper istance for 'toIdleMPS'", () => { + const expectedMPS = { + state: "idle" + }; + const mps = toIdleMPS(); + expect(mps).toStrictEqual(expectedMPS); + }); + it("should return proper istance for 'toLoadingContentMPS'", () => { + const expectedMPS = { + state: "loadingContent", + messageId, + categoryTag, + content: { + title: "A title", + markdown: "A markdown content" + } + }; + const mps = toLoadingContentMPS( + expectedMPS.messageId, + expectedMPS.categoryTag, + expectedMPS.content + ); + expect(mps).toStrictEqual(expectedMPS); + }); + it("should return proper istance for 'toRetrievingDataMPS'", () => { + const expectedMPS = { + state: "retrievingData", + messageId, + categoryTag + }; + const mps = toRetrievingDataMPS( + expectedMPS.messageId, + expectedMPS.categoryTag + ); + expect(mps).toStrictEqual(expectedMPS); + }); + it("should return proper istance for 'toScheduledMPS'", () => { + const expectedMPS = { + state: "scheduled", + messageId, + categoryTag + }; + const mps = toScheduledMPS(expectedMPS.messageId, expectedMPS.categoryTag); + expect(mps).toStrictEqual(expectedMPS); + }); + it("should return proper istance for 'toShownMPS'", () => { + const expectedMPS = { + state: "shown", + messageId, + categoryTag, + content + }; + const mps = toShownMPS( + expectedMPS.messageId, + expectedMPS.categoryTag, + expectedMPS.content + ); + expect(mps).toStrictEqual(expectedMPS); + }); + it("should return proper istance for 'toUpdateRequiredMPS'", () => { + const expectedMPS = { + state: "updateRequired" + }; + const mps = toUpdateRequiredMPS(); + expect(mps).toStrictEqual(expectedMPS); + }); +}); + +describe("foldPreconditionStatus", () => { + const mocks = [ + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn() + ]; + + messagePreconditionStatusesGenerator(TagEnum.GENERIC).forEach( + (status, statusIndex) => { + afterEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + it(`should call function argument at index '${statusIndex}' for status '${status.state}'`, () => { + foldPreconditionStatus( + mocks[0], + mocks[1], + mocks[2], + mocks[3], + mocks[4], + mocks[5], + mocks[6] + )(status); + mocks.forEach((mock, mockIndex) => { + if (statusIndex === mockIndex) { + expect(mock.mock.calls.length).toBe(1); + expect(mock.mock.calls[0][0]).toStrictEqual(status); + } else { + expect(mock.mock.calls.length).toBe(0); + } + }); + }); + } + ); +}); + +describe("shouldPresentPreconditionsBottomSheetSelector", () => { + messagePreconditionStatusesGenerator(TagEnum.GENERIC).forEach(status => { + const expectedOutput = status.state === "scheduled"; + it(`should return '${expectedOutput}' for status '${status.state}'`, () => { + const globalState = { + entities: { + messages: { + precondition: status + } + } + } as GlobalState; -describe("messagePrecondition", () => { - it("The initial state should have the messageId undefined and the content as remoteUndefined", () => { - const globalState = appReducer(undefined, applicationChangeState("active")); - expect(globalState.entities.messages.messagePrecondition).toStrictEqual({ - messageId: O.none, - content: remoteUndefined + const shouldPresent = + shouldPresentPreconditionsBottomSheetSelector(globalState); + expect(shouldPresent).toBe(expectedOutput); }); }); +}); - it("The messageId should be defined and the content should be remoteLoading if the getMessagePrecondition.request is dispatched", () => { - const globalState = appReducer(undefined, applicationChangeState("active")); - const store = createStore(appReducer, globalState as any); - const action = store.dispatch( - getMessagePrecondition.request({ - id: message.id, - categoryTag: TagEnumPN.PN +describe("preconditionsRequireAppUpdateSelector", () => { + [ + TagEnum.GENERIC, + TagEnum.EU_COVID_CERT, + TagEnum.LEGAL_MESSAGE, + PaymentTagEnum.PAYMENT, + SENDTagEnum.PN + ].forEach(tagEnum => + messagePreconditionStatusesGenerator(tagEnum).forEach(status => + [false, true].forEach(pnAppVersionSupported => { + const expectedOutput = + status.state === "updateRequired" || + (status.state === "scheduled" && + tagEnum === SENDTagEnum.PN && + !pnAppVersionSupported); + afterEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + it(`should return '${expectedOutput}' for status '${ + status.state + }', category '${tagEnum}', SEND app version is ${ + pnAppVersionSupported ? "" : "not" + } supported`, () => { + const globalState = { + entities: { + messages: { + precondition: status + } + } + } as GlobalState; + jest + .spyOn(backendStatus, "isPnAppVersionSupportedSelector") + .mockImplementation(_ => pnAppVersionSupported); + const shouldPresent = + preconditionsRequireAppUpdateSelector(globalState); + expect(shouldPresent).toBe(expectedOutput); + }); }) - ); - expect( - store.getState().entities.messages.messagePrecondition - ).toStrictEqual({ - messageId: O.some(action.payload.id), - content: remoteLoading + ) + ); +}); + +describe("preconditionsTitleContentSelector", () => { + const expectedOutput = [ + "empty", + undefined, + "header", + "loading", + undefined, + "header", + "empty" + ]; + messagePreconditionStatusesGenerator(TagEnum.GENERIC).forEach( + (status, statusIndex) => + it(`should return '${expectedOutput[statusIndex]}' for status '${status.state}'`, () => { + const globalStatus = { + entities: { + messages: { + precondition: status + } + } + } as GlobalState; + const titleContent = preconditionsTitleContentSelector(globalStatus); + expect(titleContent).toStrictEqual(expectedOutput[statusIndex]); + }) + ); +}); + +describe("preconditionsTitleSelector", () => { + messagePreconditionStatusesGenerator(TagEnum.GENERIC).forEach(status => { + const expectedOutput = + status.state === "loadingContent" || status.state === "shown" + ? status.content.title + : undefined; + it(`should return '${expectedOutput}' for status '${status.state}'`, () => { + const globalStatus = { + entities: { + messages: { + precondition: status + } + } + } as GlobalState; + const title = preconditionsTitleSelector(globalStatus); + expect(title).toStrictEqual(expectedOutput); }); }); +}); - it("The messageId should be defined and the content should be remoteReady with action payload as value if the getMessagePrecondition.success is dispatched", () => { - const globalState = appReducer(undefined, applicationChangeState("active")); - const finalState: GlobalState = { - ...globalState, - entities: { - ...globalState.entities, - messages: { - ...globalState.entities.messages, - messagePrecondition: { - messageId: O.some(message.id), - content: remoteLoading +describe("preconditionsContentSelector", () => { + const expectedOutput = [ + "error", + undefined, + "content", + "loading", + undefined, + "content", + "update" + ]; + messagePreconditionStatusesGenerator(TagEnum.GENERIC).forEach( + (status, statusIndex) => + it(`should return '${expectedOutput[statusIndex]}' for status '${status.state}'`, () => { + const globalStatus = { + entities: { + messages: { + precondition: status + } } - } - } - }; + } as GlobalState; + const contentStatus = preconditionsContentSelector(globalStatus); + expect(contentStatus).toStrictEqual(expectedOutput[statusIndex]); + }) + ); +}); - const store = createStore(appReducer, finalState as any); - const action = store.dispatch( - getMessagePrecondition.success(mockThirdPartyMessagePrecondition) - ); - expect( - store.getState().entities.messages.messagePrecondition - ).toStrictEqual({ - messageId: O.some(message.id), - content: remoteReady(action.payload) +describe("preconditionsContentMarkdownSelector", () => { + messagePreconditionStatusesGenerator(TagEnum.GENERIC).forEach(status => { + const expectedOutput = + status.state === "loadingContent" || status.state === "shown" + ? status.content.markdown + : undefined; + it(`should return '${expectedOutput}' for status '${status.state}'`, () => { + const globalStatus = { + entities: { + messages: { + precondition: status + } + } + } as GlobalState; + const content = preconditionsContentMarkdownSelector(globalStatus); + expect(content).toStrictEqual(expectedOutput); }); }); +}); - it("The messageId should be defined and the content should be remoteError with action payload as value if the getMessagePrecondition.failure is dispatched", () => { - const globalState = appReducer(undefined, applicationChangeState("active")); - const finalState: GlobalState = { - ...globalState, - entities: { - ...globalState.entities, - messages: { - ...globalState.entities.messages, - messagePrecondition: { - messageId: O.some(message.id), - content: remoteLoading +describe("isShownPreconditionStatusSelector", () => { + messagePreconditionStatusesGenerator(TagEnum.GENERIC).forEach(status => { + const expectedOutput = status.state === "shown"; + it(`should return '${expectedOutput}' for status '${status.state}'`, () => { + const globalStatus = { + entities: { + messages: { + precondition: status } } - } - }; - - const store = createStore(appReducer, finalState as any); - const action = store.dispatch( - getMessagePrecondition.failure(new Error("Error load remote content")) - ); - expect( - store.getState().entities.messages.messagePrecondition - ).toStrictEqual({ - messageId: O.some(message.id), - content: remoteError(action.payload) + } as GlobalState; + const isShown = isShownPreconditionStatusSelector(globalStatus); + expect(isShown).toStrictEqual(expectedOutput); }); }); +}); + +describe("preconditionsFooterSelector", () => { + const expectedOutput = [ + "view", + undefined, + "view", + "view", + undefined, + "content", + "update" + ]; + messagePreconditionStatusesGenerator(TagEnum.GENERIC).forEach( + (status, statusIndex) => { + it(`should return '${expectedOutput[statusIndex]}' for status '${status.state}'`, () => { + const globalStatus = { + entities: { + messages: { + precondition: status + } + } + } as GlobalState; + const footerContent = preconditionsFooterSelector(globalStatus); + expect(footerContent).toStrictEqual(expectedOutput[statusIndex]); + }); + } + ); +}); - it("The messageId should be undefined and the content should be remoteUndefined if the clearMessagePrecondition is dispatched", () => { - const globalState = appReducer(undefined, applicationChangeState("active")); - const finalState: GlobalState = { - ...globalState, - entities: { - ...globalState.entities, - messages: { - ...globalState.entities.messages, - messagePrecondition: { - messageId: O.some(message.id), - content: remoteReady(mockThirdPartyMessagePrecondition) +describe("preconditionsCategoryTagSelector", () => { + messagePreconditionStatusesGenerator(TagEnum.GENERIC).forEach(status => { + const expectedOutput = + status.state === "idle" || status.state === "updateRequired" + ? undefined + : status.categoryTag; + it(`should return '${expectedOutput}' for status '${status.state}'`, () => { + const globalStatus = { + entities: { + messages: { + precondition: status } } - } - }; + } as GlobalState; + const categoryTag = preconditionsCategoryTagSelector(globalStatus); + expect(categoryTag).toStrictEqual(expectedOutput); + }); + }); +}); - const store = createStore(appReducer, finalState as any); - store.dispatch(clearMessagePrecondition()); - expect( - store.getState().entities.messages.messagePrecondition - ).toStrictEqual({ - messageId: O.none, - content: remoteUndefined +describe("preconditionsMessageIdSelector", () => { + messagePreconditionStatusesGenerator(TagEnum.GENERIC).forEach(status => { + const expectedOutput = + status.state === "idle" || status.state === "updateRequired" + ? undefined + : status.messageId; + it(`should return '${expectedOutput}' for status '${status.state}'`, () => { + const globalStatus = { + entities: { + messages: { + precondition: status + } + } + } as GlobalState; + const messageId = preconditionsMessageIdSelector(globalStatus); + expect(messageId).toStrictEqual(expectedOutput); }); }); }); diff --git a/ts/features/messages/store/reducers/index.ts b/ts/features/messages/store/reducers/index.ts index bd535eb4d2d..550309123ab 100644 --- a/ts/features/messages/store/reducers/index.ts +++ b/ts/features/messages/store/reducers/index.ts @@ -12,11 +12,15 @@ import paginatedByIdReducer, { PaginatedById } from "./paginatedById"; import { thirdPartyByIdReducer, ThirdPartyById } from "./thirdPartyById"; import { Downloads, downloadsReducer } from "./downloads"; import { - MessagePrecondition, - messagePreconditionReducer -} from "./messagePrecondition"; + LegacyMessagePrecondition, + legacyMessagePreconditionReducer +} from "./legacyMessagePrecondition"; import { MessageGetStatus, messageGetStatusReducer } from "./messageGetStatus"; import { MultiplePaymentState, paymentsReducer } from "./payments"; +import { + MessagePreconditionStatus, + preconditionReducer +} from "./messagePrecondition"; export type MessagesState = Readonly<{ allPaginated: AllPaginated; @@ -24,9 +28,10 @@ export type MessagesState = Readonly<{ detailsById: DetailsById; thirdPartyById: ThirdPartyById; downloads: Downloads; - messagePrecondition: MessagePrecondition; + legacyMessagePrecondition: LegacyMessagePrecondition; messageGetStatus: MessageGetStatus; payments: MultiplePaymentState; + precondition: MessagePreconditionStatus; }>; const reducer = combineReducers({ @@ -35,9 +40,10 @@ const reducer = combineReducers({ detailsById: detailsByIdReducer, thirdPartyById: thirdPartyByIdReducer, downloads: downloadsReducer, - messagePrecondition: messagePreconditionReducer, + legacyMessagePrecondition: legacyMessagePreconditionReducer, messageGetStatus: messageGetStatusReducer, - payments: paymentsReducer + payments: paymentsReducer, + precondition: preconditionReducer }); export default reducer; diff --git a/ts/features/messages/store/reducers/legacyMessagePrecondition.ts b/ts/features/messages/store/reducers/legacyMessagePrecondition.ts new file mode 100644 index 00000000000..e632469ab9c --- /dev/null +++ b/ts/features/messages/store/reducers/legacyMessagePrecondition.ts @@ -0,0 +1,56 @@ +import * as O from "fp-ts/lib/Option"; +import { getType } from "typesafe-actions"; +import { Action } from "../../../../store/actions/types"; +import { GlobalState } from "../../../../store/reducers/types"; +import { ThirdPartyMessagePrecondition } from "../../../../../definitions/backend/ThirdPartyMessagePrecondition"; +import { + clearLegacyMessagePrecondition, + getLegacyMessagePrecondition +} from "../actions/preconditions"; +import { + RemoteValue, + remoteError, + remoteLoading, + remoteReady, + remoteUndefined +} from "../../../../common/model/RemoteValue"; +import { UIMessageId } from "../../types"; + +export type LegacyMessagePrecondition = { + messageId: O.Option; + content: RemoteValue; +}; + +const INITIAL_STATE: LegacyMessagePrecondition = { + messageId: O.none, + content: remoteUndefined +}; + +export const legacyMessagePreconditionReducer = ( + state: LegacyMessagePrecondition = INITIAL_STATE, + action: Action +): LegacyMessagePrecondition => { + switch (action.type) { + case getType(getLegacyMessagePrecondition.request): + return { + messageId: O.some(action.payload.id), + content: remoteLoading + }; + case getType(getLegacyMessagePrecondition.success): + return { + ...state, + content: remoteReady(action.payload) + }; + case getType(getLegacyMessagePrecondition.failure): + return { + ...state, + content: remoteError(action.payload) + }; + case getType(clearLegacyMessagePrecondition): + return INITIAL_STATE; + } + return state; +}; + +export const legacyMessagePreconditionSelector = (state: GlobalState) => + state.entities.messages.legacyMessagePrecondition; diff --git a/ts/features/messages/store/reducers/messagePrecondition.ts b/ts/features/messages/store/reducers/messagePrecondition.ts index 13c629382c6..9a780aa01cf 100644 --- a/ts/features/messages/store/reducers/messagePrecondition.ts +++ b/ts/features/messages/store/reducers/messagePrecondition.ts @@ -1,53 +1,399 @@ -import * as O from "fp-ts/lib/Option"; +import { + constFalse, + constTrue, + constUndefined, + pipe +} from "fp-ts/lib/function"; +import * as B from "fp-ts/lib/boolean"; import { getType } from "typesafe-actions"; -import { Action } from "../../../../store/actions/types"; -import { GlobalState } from "../../../../store/reducers/types"; import { ThirdPartyMessagePrecondition } from "../../../../../definitions/backend/ThirdPartyMessagePrecondition"; -import { getMessagePrecondition, clearMessagePrecondition } from "../actions"; -import { - RemoteValue, - remoteError, - remoteLoading, - remoteReady, - remoteUndefined -} from "../../../../common/model/RemoteValue"; +import { Action } from "../../../../store/actions/types"; import { UIMessageId } from "../../types"; +import { + errorPreconditionStatusAction, + idlePreconditionStatusAction, + loadingContentPreconditionStatusAction, + retrievingDataPreconditionStatusAction, + scheduledPreconditionStatusAction, + shownPreconditionStatusAction, + updateRequiredPreconditionStatusAction +} from "../actions/preconditions"; +import { GlobalState } from "../../../../store/reducers/types"; +import { isPnAppVersionSupportedSelector } from "../../../../store/reducers/backendStatus"; +import { TagEnum as SENDTagEnum } from "../../../../../definitions/backend/MessageCategoryPN"; +import { MessageCategory } from "../../../../../definitions/backend/MessageCategory"; -export type MessagePrecondition = { - messageId: O.Option; - content: RemoteValue; +// MPS stands for Message Precondition Status +type MPSError = { + state: "error"; + messageId: UIMessageId; + categoryTag: MessageCategory["tag"]; + reason: string; +}; +type MPSIdle = { + state: "idle"; +}; +type MPSLoadingContent = { + state: "loadingContent"; + messageId: UIMessageId; + categoryTag: MessageCategory["tag"]; + content: ThirdPartyMessagePrecondition; +}; +type MPSRetrievingData = { + state: "retrievingData"; + messageId: UIMessageId; + categoryTag: MessageCategory["tag"]; +}; +type MPSScheduled = { + state: "scheduled"; + messageId: UIMessageId; + categoryTag: MessageCategory["tag"]; +}; +type MPSShown = { + state: "shown"; + messageId: UIMessageId; + categoryTag: MessageCategory["tag"]; + content: ThirdPartyMessagePrecondition; +}; +type MPSUpdateRequired = { + state: "updateRequired"; }; -const INITIAL_STATE: MessagePrecondition = { - messageId: O.none, - content: remoteUndefined +export type MessagePreconditionStatus = + | MPSError + | MPSIdle + | MPSLoadingContent + | MPSRetrievingData + | MPSScheduled + | MPSShown + | MPSUpdateRequired; + +const INITIAL_STATE: MPSIdle = { + state: "idle" }; -export const messagePreconditionReducer = ( - state: MessagePrecondition = INITIAL_STATE, +export const preconditionReducer = ( + state: MessagePreconditionStatus = INITIAL_STATE, action: Action -): MessagePrecondition => { +): MessagePreconditionStatus => { switch (action.type) { - case getType(getMessagePrecondition.request): - return { - messageId: O.some(action.payload.id), - content: remoteLoading - }; - case getType(getMessagePrecondition.success): - return { - ...state, - content: remoteReady(action.payload) - }; - case getType(getMessagePrecondition.failure): - return { - ...state, - content: remoteError(action.payload) - }; - case getType(clearMessagePrecondition): - return INITIAL_STATE; + case getType(errorPreconditionStatusAction): + return foldPreconditionStatus( + () => state, + () => state, + loadingContentStatus => + toErrorMPS( + loadingContentStatus.messageId, + loadingContentStatus.categoryTag, + action.payload.reason + ), // From Loading Content to Error + retrievingDataStatus => + toErrorMPS( + retrievingDataStatus.messageId, + retrievingDataStatus.categoryTag, + action.payload.reason + ), // From Retrieving Data to Error + () => state, + () => state, + () => state + )(state); + case getType(idlePreconditionStatusAction): + return foldPreconditionStatus( + () => toIdleMPS(), // From Error to Idle + () => state, + () => toIdleMPS(), // From Loading Content to Idle, + () => toIdleMPS(), // From Retrieving Data to Idle, + () => state, + () => toIdleMPS(), // From Shown to Idle + () => toIdleMPS() // From Update Required to Idle + )(state); + case getType(loadingContentPreconditionStatusAction): + return foldPreconditionStatus( + () => state, + () => state, + () => state, + retrievingDataStatus => + toLoadingContentMPS( + retrievingDataStatus.messageId, + retrievingDataStatus.categoryTag, + action.payload.content + ), // From Retrieving Data to Loading Content + () => state, + () => state, + () => state + )(state); + case getType(retrievingDataPreconditionStatusAction): + return foldPreconditionStatus( + errorStatus => + toRetrievingDataMPS(errorStatus.messageId, errorStatus.categoryTag), // From Error to Retrieving Data + () => state, + () => state, + () => state, + scheduledStatus => + toRetrievingDataMPS( + scheduledStatus.messageId, + scheduledStatus.categoryTag + ), // From Scheduled to Retrieving Data + () => state, + () => state + )(state); + case getType(scheduledPreconditionStatusAction): + return foldPreconditionStatus( + () => state, + () => + toScheduledMPS(action.payload.messageId, action.payload.categoryTag), // From Idle to Scheduled + () => state, + () => state, + () => state, + () => state, + () => state + )(state); + case getType(shownPreconditionStatusAction): + return foldPreconditionStatus( + () => state, + () => state, + loadingContentStatus => + toShownMPS( + loadingContentStatus.messageId, + loadingContentStatus.categoryTag, + loadingContentStatus.content + ), // From Loading Content to Shown + () => state, + () => state, + () => state, + () => state + )(state); + case getType(updateRequiredPreconditionStatusAction): + return foldPreconditionStatus( + () => state, + () => state, + () => state, + () => state, + () => toUpdateRequiredMPS(), // From Scheduled to Update Required, + () => state, + () => state + )(state); } return state; }; -export const messagePreconditionSelector = (state: GlobalState) => - state.entities.messages.messagePrecondition; +export const toErrorMPS = ( + messageId: UIMessageId, + categoryTag: MessageCategory["tag"], + reason: string +): MPSError => ({ + state: "error", + messageId, + categoryTag, + reason +}); +export const toIdleMPS = (): MPSIdle => ({ + state: "idle" +}); +export const toLoadingContentMPS = ( + messageId: UIMessageId, + categoryTag: MessageCategory["tag"], + content: ThirdPartyMessagePrecondition +): MPSLoadingContent => ({ + state: "loadingContent", + messageId, + categoryTag, + content +}); +export const toRetrievingDataMPS = ( + messageId: UIMessageId, + categoryTag: MessageCategory["tag"] +): MPSRetrievingData => ({ + state: "retrievingData", + messageId, + categoryTag +}); +export const toScheduledMPS = ( + messageId: UIMessageId, + categoryTag: MessageCategory["tag"] +): MPSScheduled => ({ + state: "scheduled", + messageId, + categoryTag +}); +export const toShownMPS = ( + messageId: UIMessageId, + categoryTag: MessageCategory["tag"], + content: ThirdPartyMessagePrecondition +): MPSShown => ({ + state: "shown", + messageId, + categoryTag, + content +}); +export const toUpdateRequiredMPS = (): MPSUpdateRequired => ({ + state: "updateRequired" +}); + +export const foldPreconditionStatus = + ( + onError: (status: MPSError) => A, + onIdle: (status: MPSIdle) => A, + onLoadingContent: (status: MPSLoadingContent) => A, + onRetrievingData: (status: MPSRetrievingData) => A, + onScheduled: (status: MPSScheduled) => A, + onShown: (status: MPSShown) => A, + onUpdateRequired: (status: MPSUpdateRequired) => A + ) => + (status: MessagePreconditionStatus) => { + switch (status.state) { + case "error": + return onError(status); + case "loadingContent": + return onLoadingContent(status); + case "retrievingData": + return onRetrievingData(status); + case "scheduled": + return onScheduled(status); + case "shown": + return onShown(status); + case "updateRequired": + return onUpdateRequired(status); + } + return onIdle(status); + }; + +export const shouldPresentPreconditionsBottomSheetSelector = ( + state: GlobalState +) => state.entities.messages.precondition.state === "scheduled"; + +export const preconditionsRequireAppUpdateSelector = (state: GlobalState) => + pipe( + state.entities.messages.precondition, + foldPreconditionStatus( + constFalse, + constFalse, + constFalse, + constFalse, + scheduled => + pipe( + scheduled.categoryTag === SENDTagEnum.PN, + B.fold(constFalse, () => + pipe( + state, + isPnAppVersionSupportedSelector, + appVersionSupported => !appVersionSupported + ) + ) + ), + constFalse, + constTrue + ) + ); + +export const preconditionsTitleContentSelector = (state: GlobalState) => + pipe( + state.entities.messages.precondition, + foldPreconditionStatus( + () => "empty" as const, + constUndefined, + () => "header" as const, + () => "loading" as const, + constUndefined, + () => "header" as const, + () => "empty" as const + ) + ); + +export const preconditionsTitleSelector = (state: GlobalState) => + pipe( + state.entities.messages.precondition, + foldPreconditionStatus( + constUndefined, + constUndefined, + loadingContentStatus => loadingContentStatus.content.title, + constUndefined, + constUndefined, + shownStatus => shownStatus.content.title, + constUndefined + ) + ); + +export const preconditionsContentSelector = (state: GlobalState) => + pipe( + state.entities.messages.precondition, + foldPreconditionStatus( + _ => "error" as const, + constUndefined, + _ => "content" as const, + _ => "loading" as const, + constUndefined, + _ => "content" as const, + _ => "update" as const + ) + ); + +export const preconditionsContentMarkdownSelector = (state: GlobalState) => + pipe( + state.entities.messages.precondition, + foldPreconditionStatus( + constUndefined, + constUndefined, + loadingContentStatus => loadingContentStatus.content.markdown, + constUndefined, + constUndefined, + shownStatus => shownStatus.content.markdown, + constUndefined + ) + ); + +export const isShownPreconditionStatusSelector = (state: GlobalState) => + pipe( + state.entities.messages.precondition, + foldPreconditionStatus( + constFalse, + constFalse, + constFalse, + constFalse, + constFalse, + constTrue, + constFalse + ) + ); + +export const preconditionsFooterSelector = (state: GlobalState) => + pipe( + state.entities.messages.precondition, + foldPreconditionStatus( + _ => "view" as const, + constUndefined, + _ => "view" as const, + _ => "view" as const, + constUndefined, + _ => "content" as const, + _ => "update" as const + ) + ); + +export const preconditionsCategoryTagSelector = (state: GlobalState) => + pipe( + state.entities.messages.precondition, + foldPreconditionStatus( + errorStatus => errorStatus.categoryTag, + constUndefined, + loadingContentStatus => loadingContentStatus.categoryTag, + retrievingDataStatus => retrievingDataStatus.categoryTag, + scheduledStatus => scheduledStatus.categoryTag, + shownStatus => shownStatus.categoryTag, + constUndefined + ) + ); + +export const preconditionsMessageIdSelector = (state: GlobalState) => + pipe( + state.entities.messages.precondition, + foldPreconditionStatus( + errorStatus => errorStatus.messageId, + constUndefined, + loadingContentStatus => loadingContentStatus.messageId, + retrievingDataStatus => retrievingDataStatus.messageId, + scheduledStatus => scheduledStatus.messageId, + shownStatus => shownStatus.messageId, + constUndefined + ) + ); diff --git a/ts/features/services/details/components/ServiceSpecialAction.tsx b/ts/features/services/details/components/ServiceSpecialAction.tsx index 108584e10c9..8de8d7ef77f 100644 --- a/ts/features/services/details/components/ServiceSpecialAction.tsx +++ b/ts/features/services/details/components/ServiceSpecialAction.tsx @@ -9,7 +9,7 @@ import { useIOSelector } from "../../../../store/hooks"; import { isCGNEnabledSelector, isPnEnabledSelector, - isPnSupportedSelector + isPnAppVersionSupportedSelector } from "../../../../store/reducers/backendStatus"; import { openAppStoreUrl } from "../../../../utils/url"; import { CgnServiceCta } from "../../../bonus/cgn/components/CgnServiceCTA"; @@ -64,7 +64,7 @@ export const ServiceSpecialAction = ({ }: ServiceSpecialActionProps) => { const isCGNEnabled = useIOSelector(isCGNEnabledSelector); const isPnEnabled = useIOSelector(isPnEnabledSelector); - const isPnSupported = useIOSelector(isPnSupportedSelector); + const isPnSupported = useIOSelector(isPnAppVersionSupportedSelector); const mapSpecialServiceConfig = new Map([ ["cgn", { isEnabled: isCGNEnabled, isSupported: true }], diff --git a/ts/store/reducers/__tests__/backendStatus.test.ts b/ts/store/reducers/__tests__/backendStatus.test.ts index 11f8fe9dbad..04654dc5e0c 100644 --- a/ts/store/reducers/__tests__/backendStatus.test.ts +++ b/ts/store/reducers/__tests__/backendStatus.test.ts @@ -6,11 +6,13 @@ import { areSystemsDeadReducer, BackendStatusState, barcodesScannerConfigSelector, + isPnAppVersionSupportedSelector, isPremiumMessagesOptInOutEnabledSelector, isUaDonationsEnabledSelector, uaDonationsBannerConfigSelector } from "../backendStatus"; import { GlobalState } from "../types"; +import * as appVersion from "../../../utils/appVersion"; describe("backend service status reducer", () => { // smoke tests: valid / invalid @@ -334,3 +336,72 @@ describe("test selectors", () => { }); }); }); + +describe("isPnAppVersionSupportedSelector", () => { + it("should return false, when 'backendStatus' is O.none", () => { + const state = { + backendStatus: { + status: O.none + } + } as GlobalState; + const isSupported = isPnAppVersionSupportedSelector(state); + expect(isSupported).toBe(false); + }); + it("should return false, when min_app_version is greater than `getAppVersion`", () => { + const state = { + backendStatus: { + status: O.some({ + config: { + pn: { + min_app_version: { + android: "2.0.0.0", + ios: "2.0.0.0" + } + } + } + }) + } + } as GlobalState; + jest.spyOn(appVersion, "getAppVersion").mockImplementation(() => "1.0.0.0"); + const isSupported = isPnAppVersionSupportedSelector(state); + expect(isSupported).toBe(false); + }); + it("should return true, when min_app_version is equal to `getAppVersion`", () => { + const state = { + backendStatus: { + status: O.some({ + config: { + pn: { + min_app_version: { + android: "2.0.0.0", + ios: "2.0.0.0" + } + } + } + }) + } + } as GlobalState; + jest.spyOn(appVersion, "getAppVersion").mockImplementation(() => "2.0.0.0"); + const isSupported = isPnAppVersionSupportedSelector(state); + expect(isSupported).toBe(true); + }); + it("should return true, when min_app_version is less than `getAppVersion`", () => { + const state = { + backendStatus: { + status: O.some({ + config: { + pn: { + min_app_version: { + android: "2.0.0.0", + ios: "2.0.0.0" + } + } + } + }) + } + } as GlobalState; + jest.spyOn(appVersion, "getAppVersion").mockImplementation(() => "3.0.0.0"); + const isSupported = isPnAppVersionSupportedSelector(state); + expect(isSupported).toBe(true); + }); +}); diff --git a/ts/store/reducers/backendStatus.ts b/ts/store/reducers/backendStatus.ts index 3ef53b1b170..81322ec4f28 100644 --- a/ts/store/reducers/backendStatus.ts +++ b/ts/store/reducers/backendStatus.ts @@ -301,22 +301,20 @@ export const isPnEnabledSelector = (state: GlobalState) => /** * Return false if the app needs to be updated in order to use PN. */ -export const isPnSupportedSelector = createSelector( - backendStatusSelector, - (backendStatus): boolean => - pipe( - backendStatus, - O.map(bs => - isVersionSupported( - Platform.OS === "ios" - ? bs.config.pn.min_app_version.ios - : bs.config.pn.min_app_version.android, - getAppVersion() - ) - ), - O.getOrElse(() => false) - ) -); +export const isPnAppVersionSupportedSelector = (state: GlobalState) => + pipe( + state, + backendStatusSelector, + O.map(bs => + isVersionSupported( + Platform.OS === "ios" + ? bs.config.pn.min_app_version.ios + : bs.config.pn.min_app_version.android, + getAppVersion() + ) + ), + O.getOrElse(() => false) + ); /** * Return the minimum app version required to use PN.