From 18f8cb3dc890f621256904eb07c1f4d30947bea1 Mon Sep 17 00:00:00 2001 From: Andrea Date: Fri, 26 Jan 2024 16:24:26 +0100 Subject: [PATCH] feat: [IOCOM-864,IOCOM-865] Update message attachment's preview with the new DS (#5428) ## Short description This PR updates the message attachment's preview screen to the new DS system. | Preview | Data Error | Preview Error | | ------------- | ------------- | ------------- | | ![Simulator Screenshot - iPhone 14 - 2024-01-23 at 18 54 24](https://github.com/pagopa/io-app/assets/5150343/198f1aaf-7b34-4567-a4ca-378eabd1bc1f) | ![Simulator Screenshot - iPhone 14 - 2024-01-23 at 18 53 46](https://github.com/pagopa/io-app/assets/5150343/0b228419-4389-4110-a57b-60bc20a762cc) | ![Simulator Screenshot - iPhone 14 - 2024-01-23 at 18 57 06](https://github.com/pagopa/io-app/assets/5150343/105ccf15-6738-4e45-ac7a-25c0a10056d6) | ## List of changes proposed in this pull request - New screen and components, referencing the new design system's components only - Removed in-preview attachment download ## How to test Using the io-dev-api-server, download some attachment. After that, enable the new DS and navigate to previously downloaded attachments to see the new DS. Check both Android and iOS. Check both success and failure cases. --------- Co-authored-by: Alessandro Dell'Oste --- ts/components/LoadingSpinnerOverlay.tsx | 100 +- .../__tests__/LoadingSpinnerOverlay.test.tsx | 77 + .../__tests__/WebviewComponent.test.tsx | 19 +- .../LoadingSpinnerOverlay.test.tsx.snap | 654 +++++++ ts/components/ui/BoxedRefreshIndicator.tsx | 27 +- ts/components/ui/Overlay.tsx | 58 +- .../__test__/BoxedRefreshIndicator.test.tsx | 43 + ts/components/ui/__test__/Overlay.test.tsx | 27 + .../BoxedRefreshIndicator.test.tsx.snap | 339 ++++ .../__snapshots__/Overlay.test.tsx.snap | 78 + ts/features/messages/analytics/index.ts | 2 +- .../LegacyMessageAttachmentPreview.tsx} | 42 +- .../MessageAttachment/LegacyPdfViewer.tsx | 53 + .../PdfViewer.tsx | 8 +- .../__test__/PdfViewer.test.tsx | 29 + .../__snapshots__/PdfViewer.test.tsx.snap | 250 +++ .../components/MessageDetail/index.tsx | 7 +- .../MessageAttachmentPreview.test.tsx | 8 +- .../messages/navigation/MessagesNavigator.tsx | 33 +- ts/features/messages/navigation/params.ts | 4 +- .../screens/LegacyMessageAttachment.tsx | 76 + .../messages/screens/MessageAttachment.tsx | 270 ++- .../__tests__/MessageAttachment.test.tsx | 58 + .../__tests__/MessageRouterScreen.test.tsx | 4 +- .../MessageAttachment.test.tsx.snap | 1638 +++++++++++++++++ .../reducers/__tests__/downloads.test.ts | 227 ++- .../messages/store/reducers/downloads.ts | 16 + .../pn/screens/AttachmentPreviewScreen.tsx | 4 +- ts/features/pn/utils/__tests__/index.test.ts | 3 +- ts/store/reducers/entities/services/types.ts | 3 +- 30 files changed, 3964 insertions(+), 193 deletions(-) create mode 100644 ts/components/__tests__/LoadingSpinnerOverlay.test.tsx create mode 100644 ts/components/__tests__/__snapshots__/LoadingSpinnerOverlay.test.tsx.snap create mode 100644 ts/components/ui/__test__/BoxedRefreshIndicator.test.tsx create mode 100644 ts/components/ui/__test__/Overlay.test.tsx create mode 100644 ts/components/ui/__test__/__snapshots__/BoxedRefreshIndicator.test.tsx.snap create mode 100644 ts/components/ui/__test__/__snapshots__/Overlay.test.tsx.snap rename ts/features/messages/components/{MessageAttachmentPreview.tsx => MessageAttachment/LegacyMessageAttachmentPreview.tsx} (85%) create mode 100644 ts/features/messages/components/MessageAttachment/LegacyPdfViewer.tsx rename ts/features/messages/components/{MessageDetail => MessageAttachment}/PdfViewer.tsx (94%) create mode 100644 ts/features/messages/components/MessageAttachment/__test__/PdfViewer.test.tsx create mode 100644 ts/features/messages/components/MessageAttachment/__test__/__snapshots__/PdfViewer.test.tsx.snap create mode 100644 ts/features/messages/screens/LegacyMessageAttachment.tsx create mode 100644 ts/features/messages/screens/__tests__/MessageAttachment.test.tsx create mode 100644 ts/features/messages/screens/__tests__/__snapshots__/MessageAttachment.test.tsx.snap diff --git a/ts/components/LoadingSpinnerOverlay.tsx b/ts/components/LoadingSpinnerOverlay.tsx index 57e9bbd8217..22271727f72 100644 --- a/ts/components/LoadingSpinnerOverlay.tsx +++ b/ts/components/LoadingSpinnerOverlay.tsx @@ -1,22 +1,27 @@ -import { Text as NBButtonText } from "native-base"; import * as React from "react"; import { StyleSheet, View } from "react-native"; -import { IOColors, hexToRgba } from "@pagopa/io-app-design-system"; +import { + ButtonOutline, + IOColors, + hexToRgba +} from "@pagopa/io-app-design-system"; import I18n from "../i18n"; -import variables from "../theme/variables"; +import { useIOSelector } from "../store/hooks"; +import { isDesignSystemEnabledSelector } from "../store/reducers/persistedPreferences"; import ButtonDefaultOpacity from "./ButtonDefaultOpacity"; -import BoxedRefreshIndicator from "./ui/BoxedRefreshIndicator"; import { Overlay } from "./ui/Overlay"; import { IOStyles } from "./core/variables/IOStyles"; import { Body } from "./core/typography/Body"; +import BoxedRefreshIndicator from "./ui/BoxedRefreshIndicator"; const styles = StyleSheet.create({ textCaption: { - padding: variables.contentPadding + padding: 24 } }); type Props = Readonly<{ + children?: React.ReactNode; isLoading: boolean; loadingCaption?: string; loadingOpacity?: number; @@ -26,51 +31,56 @@ type Props = Readonly<{ /** * A Component to display and overlay spinner conditionally */ -class LoadingSpinnerOverlay extends React.Component { - public render() { - const { - children, - isLoading, - loadingCaption, - loadingOpacity = 0.7, - onCancel - } = this.props; - return ( - - - {loadingCaption || I18n.t("global.remoteStates.wait")} - - - } - action={ - onCancel && ( - +const LoadingSpinnerOverlay = ({ + children, + isLoading, + loadingCaption, + loadingOpacity = 0.7, + onCancel +}: Props) => { + const isDesignSystemEnabled = useIOSelector(isDesignSystemEnabledSelector); + return ( + + + {loadingCaption || I18n.t("global.remoteStates.wait")} + + + } + action={ + onCancel && ( + + {isDesignSystemEnabled ? ( + + ) : ( - - {I18n.t("global.buttons.cancel")} - + {I18n.t("global.buttons.cancel")} - - ) - } - /> - ) - } - > - {children} - - ); - } -} + )} + + ) + } + /> + ) + } + > + {children} + + ); +}; export default LoadingSpinnerOverlay; diff --git a/ts/components/__tests__/LoadingSpinnerOverlay.test.tsx b/ts/components/__tests__/LoadingSpinnerOverlay.test.tsx new file mode 100644 index 00000000000..98acfdc04b3 --- /dev/null +++ b/ts/components/__tests__/LoadingSpinnerOverlay.test.tsx @@ -0,0 +1,77 @@ +import * as React from "react"; +import { Text } from "react-native"; +import { render } from "@testing-library/react-native"; +import configureMockStore from "redux-mock-store"; +import { Provider } from "react-redux"; +import { appReducer } from "../../store/reducers"; +import { applicationChangeState } from "../../store/actions/application"; +import { preferencesDesignSystemSetEnabled } from "../../store/actions/persistedPreferences"; +import { GlobalState } from "../../store/reducers/types"; +import LoadingSpinnerOverlay from "../LoadingSpinnerOverlay"; + +describe("LoadingSpinnerOverlay", () => { + it("Should match base no-loading snapshot", () => { + const component = renderComponent(false); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("Should match base loading snapshot", () => { + const component = renderComponent(true); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("Should match all-properties and not-loading snapshot", () => { + const child = This is a child; + const loadingCaption = "This is the loading caption"; + const loadingOpacity = 0.65; + const onCancelCallback = () => undefined; + const component = renderComponent( + false, + child, + loadingCaption, + loadingOpacity, + onCancelCallback + ); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("Should match all-properties and loading snapshot", () => { + const child = This is a child; + const loadingCaption = "This is the loading caption"; + const loadingOpacity = 0.65; + const onCancelCallback = () => undefined; + const component = renderComponent( + true, + child, + loadingCaption, + loadingOpacity, + onCancelCallback + ); + expect(component.toJSON()).toMatchSnapshot(); + }); +}); + +const renderComponent = ( + isLoading: boolean, + children?: React.ReactNode, + loadingCaption?: string, + loadingOpacity?: number, + onCancel?: () => void +) => { + const initialState = appReducer(undefined, applicationChangeState("active")); + const dsState = appReducer( + initialState, + preferencesDesignSystemSetEnabled({ isDesignSystemEnabled: true }) + ); + const mockStore = configureMockStore(); + const store: ReturnType = mockStore(dsState); + return render( + + + {children} + + + ); +}; diff --git a/ts/components/__tests__/WebviewComponent.test.tsx b/ts/components/__tests__/WebviewComponent.test.tsx index 3b8b632bd24..9b0fc0c36b2 100644 --- a/ts/components/__tests__/WebviewComponent.test.tsx +++ b/ts/components/__tests__/WebviewComponent.test.tsx @@ -1,11 +1,28 @@ import { render } from "@testing-library/react-native"; +import { Provider } from "react-redux"; +import configureMockStore from "redux-mock-store"; import React from "react"; import WebviewComponent from "../WebviewComponent"; +import { appReducer } from "../../store/reducers"; +import { applicationChangeState } from "../../store/actions/application"; +import { GlobalState } from "../../store/reducers/types"; describe("WebviewComponent tests", () => { it("snapshot for component", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + const enrichedState = { + ...globalState, + persistedPreferences: { + ...globalState.persistedPreferences, + isDesignSystemEnabled: false + } + }; + const mockStore = configureMockStore(); + const store: ReturnType = mockStore(enrichedState); const component = render( - + + + ); expect(component).toMatchSnapshot(); }); diff --git a/ts/components/__tests__/__snapshots__/LoadingSpinnerOverlay.test.tsx.snap b/ts/components/__tests__/__snapshots__/LoadingSpinnerOverlay.test.tsx.snap new file mode 100644 index 00000000000..5f42ae771f3 --- /dev/null +++ b/ts/components/__tests__/__snapshots__/LoadingSpinnerOverlay.test.tsx.snap @@ -0,0 +1,654 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LoadingSpinnerOverlay Should match all-properties and loading snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + This is the loading caption + + + + + + + Cancel + + + + + + + + + This is a child + + + +`; + +exports[`LoadingSpinnerOverlay Should match all-properties and not-loading snapshot 1`] = ` + + + + This is a child + + + +`; + +exports[`LoadingSpinnerOverlay Should match base loading snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + Wait a few seconds.. + + + + + + +`; + +exports[`LoadingSpinnerOverlay Should match base no-loading snapshot 1`] = ` + + + +`; diff --git a/ts/components/ui/BoxedRefreshIndicator.tsx b/ts/components/ui/BoxedRefreshIndicator.tsx index c0fb1ddf565..eb102cb687b 100644 --- a/ts/components/ui/BoxedRefreshIndicator.tsx +++ b/ts/components/ui/BoxedRefreshIndicator.tsx @@ -1,6 +1,8 @@ import * as React from "react"; import { StyleSheet, View } from "react-native"; -import { IOColors } from "@pagopa/io-app-design-system"; +import { IOColors, LoadingSpinner } from "@pagopa/io-app-design-system"; +import { useIOSelector } from "../../store/hooks"; +import { isDesignSystemEnabledSelector } from "../../store/reducers/persistedPreferences"; import { RefreshIndicator } from "./RefreshIndicator"; const styles = StyleSheet.create({ @@ -16,18 +18,21 @@ const styles = StyleSheet.create({ } }); -interface Props { +type Props = { + action?: React.ReactNode; caption?: React.ReactNode; white?: boolean; - action?: React.ReactNode; -} +}; -const BoxedRefreshIndicator: React.SFC = props => ( - - - {props.caption ? props.caption : null} - {props.action ? props.action : null} - -); +const BoxedRefreshIndicator = ({ action, caption, white }: Props) => { + const isDesignSystemEnabled = useIOSelector(isDesignSystemEnabledSelector); + return ( + + {isDesignSystemEnabled ? : } + {caption ? caption : null} + {action ? action : null} + + ); +}; export default BoxedRefreshIndicator; diff --git a/ts/components/ui/Overlay.tsx b/ts/components/ui/Overlay.tsx index abe4e00cad0..6aa2ece0a73 100644 --- a/ts/components/ui/Overlay.tsx +++ b/ts/components/ui/Overlay.tsx @@ -1,9 +1,6 @@ -import { IOColors } from "@pagopa/io-app-design-system"; import * as React from "react"; import { StyleSheet, View } from "react-native"; - -const DEFAULT_OVERLAY_OPACITY = 1; -const DEFAULT_BACKGROUND_COLOR = IOColors.white; +import { IOColors } from "@pagopa/io-app-design-system"; const styles = StyleSheet.create({ container: { @@ -16,7 +13,7 @@ const styles = StyleSheet.create({ bottom: 0, left: 0, right: 0, - backgroundColor: DEFAULT_BACKGROUND_COLOR, + backgroundColor: IOColors.white, zIndex: 1, justifyContent: "center" }, @@ -26,9 +23,10 @@ const styles = StyleSheet.create({ }); type Props = Readonly<{ + backgroundColor?: string; + children?: React.ReactNode; foreground?: React.ReactNode; opacity?: number; - backgroundColor?: string; }>; /** @@ -36,28 +34,26 @@ type Props = Readonly<{ * * Used for loading spinners and error screens. */ -export const Overlay: React.SFC = props => { - const { - opacity = DEFAULT_OVERLAY_OPACITY, - backgroundColor = DEFAULT_BACKGROUND_COLOR - } = props; - return ( - - {props.foreground && ( - - {props.foreground} - - )} - - {props.children} - - ); -}; +export const Overlay = ({ + backgroundColor = IOColors.white, + children, + foreground, + opacity = 1 +}: Props) => ( + + {foreground && ( + + {foreground} + + )} + {children} + +); diff --git a/ts/components/ui/__test__/BoxedRefreshIndicator.test.tsx b/ts/components/ui/__test__/BoxedRefreshIndicator.test.tsx new file mode 100644 index 00000000000..3245dc4e74d --- /dev/null +++ b/ts/components/ui/__test__/BoxedRefreshIndicator.test.tsx @@ -0,0 +1,43 @@ +import * as React from "react"; +import { Text } from "react-native"; +import { render } from "@testing-library/react-native"; +import configureMockStore from "redux-mock-store"; +import { Provider } from "react-redux"; +import { appReducer } from "../../../store/reducers"; +import { applicationChangeState } from "../../../store/actions/application"; +import { preferencesDesignSystemSetEnabled } from "../../../store/actions/persistedPreferences"; +import { GlobalState } from "../../../store/reducers/types"; +import BoxedRefreshIndicator from "../BoxedRefreshIndicator"; + +describe("BoxedRefreshIndicator", () => { + it("Should match base snapshot", () => { + const component = renderComponent(); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("Should match all-properties snapshot", () => { + const action = This is the action; + const caption = This is the caption; + const white = true; + const component = renderComponent(action, caption, white); + expect(component.toJSON()).toMatchSnapshot(); + }); +}); + +const renderComponent = ( + action?: React.ReactNode, + caption?: React.ReactNode, + white?: boolean +) => { + const initialState = appReducer(undefined, applicationChangeState("active")); + const dsState = appReducer( + initialState, + preferencesDesignSystemSetEnabled({ isDesignSystemEnabled: true }) + ); + const mockStore = configureMockStore(); + const store: ReturnType = mockStore(dsState); + return render( + + + + ); +}; diff --git a/ts/components/ui/__test__/Overlay.test.tsx b/ts/components/ui/__test__/Overlay.test.tsx new file mode 100644 index 00000000000..930cbc12f71 --- /dev/null +++ b/ts/components/ui/__test__/Overlay.test.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import { render } from "@testing-library/react-native"; +import { Text } from "react-native"; +import { Overlay } from "../Overlay"; + +describe("Overlay", () => { + it("Should match base snapshot", () => { + const component = render(); + expect(component.toJSON()).toMatchSnapshot(); + }); + it("Should match all-properties snapshot", () => { + const backgroundColor = "#FF0000"; + const opacity = 0.65; + const foreground = This is a foreground; + const children = This is a child; + const component = render( + + {children} + + ); + expect(component.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/ts/components/ui/__test__/__snapshots__/BoxedRefreshIndicator.test.tsx.snap b/ts/components/ui/__test__/__snapshots__/BoxedRefreshIndicator.test.tsx.snap new file mode 100644 index 00000000000..a0c0c9aebab --- /dev/null +++ b/ts/components/ui/__test__/__snapshots__/BoxedRefreshIndicator.test.tsx.snap @@ -0,0 +1,339 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BoxedRefreshIndicator Should match all-properties snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + This is the caption + + + This is the action + + +`; + +exports[`BoxedRefreshIndicator Should match base snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + +`; diff --git a/ts/components/ui/__test__/__snapshots__/Overlay.test.tsx.snap b/ts/components/ui/__test__/__snapshots__/Overlay.test.tsx.snap new file mode 100644 index 00000000000..8b954f15681 --- /dev/null +++ b/ts/components/ui/__test__/__snapshots__/Overlay.test.tsx.snap @@ -0,0 +1,78 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Overlay Should match all-properties snapshot 1`] = ` + + + + This is a foreground + + + + + This is a child + + + +`; + +exports[`Overlay Should match base snapshot 1`] = ` + + + +`; diff --git a/ts/features/messages/analytics/index.ts b/ts/features/messages/analytics/index.ts index 96fde7ede6e..c8939737924 100644 --- a/ts/features/messages/analytics/index.ts +++ b/ts/features/messages/analytics/index.ts @@ -109,7 +109,7 @@ export function trackThirdPartyMessageAttachmentBadFormat( export function trackThirdPartyMessageAttachmentCorruptedFile( messageId: UIMessageId, - serviceId: ServiceId | undefined + serviceId?: ServiceId ) { void mixpanelTrack( "THIRD_PARTY_MESSAGE_ATTACHMENT_CORRUPTED_FILE", diff --git a/ts/features/messages/components/MessageAttachmentPreview.tsx b/ts/features/messages/components/MessageAttachment/LegacyMessageAttachmentPreview.tsx similarity index 85% rename from ts/features/messages/components/MessageAttachmentPreview.tsx rename to ts/features/messages/components/MessageAttachment/LegacyMessageAttachmentPreview.tsx index bd635ea16ef..c20551a8d11 100644 --- a/ts/features/messages/components/MessageAttachmentPreview.tsx +++ b/ts/features/messages/components/MessageAttachment/LegacyMessageAttachmentPreview.tsx @@ -3,28 +3,28 @@ import { useNavigation } from "@react-navigation/native"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { ActivityIndicator, SafeAreaView, StyleSheet } from "react-native"; import ReactNativeBlobUtil from "react-native-blob-util"; -import image from "../../../../img/servicesStatus/error-detail-icon.png"; -import { H2 } from "../../../components/core/typography/H2"; -import { renderInfoRasterImage } from "../../../components/infoScreen/imageRendering"; -import { InfoScreenComponent } from "../../../components/infoScreen/InfoScreenComponent"; -import BaseScreenComponent from "../../../components/screens/BaseScreenComponent"; -import FooterWithButtons from "../../../components/ui/FooterWithButtons"; -import I18n from "../../../i18n"; +import image from "../../../../../img/servicesStatus/error-detail-icon.png"; +import { H2 } from "../../../../components/core/typography/H2"; +import { renderInfoRasterImage } from "../../../../components/infoScreen/imageRendering"; +import { InfoScreenComponent } from "../../../../components/infoScreen/InfoScreenComponent"; +import BaseScreenComponent from "../../../../components/screens/BaseScreenComponent"; +import FooterWithButtons from "../../../../components/ui/FooterWithButtons"; +import I18n from "../../../../i18n"; import { cancelPreviousAttachmentDownload, downloadAttachment -} from "../store/actions"; -import { useIODispatch, useIOSelector } from "../../../store/hooks"; -import { downloadPotForMessageAttachmentSelector } from "../store/reducers/downloads"; -import { UIAttachment, UIMessageId } from "../types"; -import variables from "../../../theme/variables"; -import { emptyContextualHelp } from "../../../utils/emptyContextualHelp"; -import { isIos } from "../../../utils/platform"; -import { isStrictNone } from "../../../utils/pot"; -import { share } from "../../../utils/share"; -import { showToast } from "../../../utils/showToast"; -import { confirmButtonProps } from "../../../components/buttons/ButtonConfigurations"; -import PdfViewer from "./MessageDetail/PdfViewer"; +} from "../../store/actions"; +import { useIODispatch, useIOSelector } from "../../../../store/hooks"; +import { downloadPotForMessageAttachmentSelector } from "../../store/reducers/downloads"; +import { UIAttachment, UIMessageId } from "../../types"; +import variables from "../../../../theme/variables"; +import { emptyContextualHelp } from "../../../../utils/emptyContextualHelp"; +import { isIos } from "../../../../utils/platform"; +import { isStrictNone } from "../../../../utils/pot"; +import { share } from "../../../../utils/share"; +import { showToast } from "../../../../utils/showToast"; +import { confirmButtonProps } from "../../../../components/buttons/ButtonConfigurations"; +import LegacyPdfViewer from "./LegacyPdfViewer"; type Props = { messageId: UIMessageId; @@ -86,7 +86,7 @@ const renderPDF = ( I18n.t("messagePDFPreview.errors.previewing.body") ) ) : ( - ); -export const MessageAttachmentPreview = ({ +export const LegacyMessageAttachmentPreview = ({ enableDownloadAttachment = true, ...props }: Props): React.ReactElement => { diff --git a/ts/features/messages/components/MessageAttachment/LegacyPdfViewer.tsx b/ts/features/messages/components/MessageAttachment/LegacyPdfViewer.tsx new file mode 100644 index 00000000000..d10f3fa1d80 --- /dev/null +++ b/ts/features/messages/components/MessageAttachment/LegacyPdfViewer.tsx @@ -0,0 +1,53 @@ +import React, { useState } from "react"; +import { StyleSheet } from "react-native"; +import Pdf from "react-native-pdf"; +import { IOColors } from "@pagopa/io-app-design-system"; +import LoadingSpinnerOverlay from "../../../../components/LoadingSpinnerOverlay"; +import I18n from "../../../../i18n"; + +const styles = StyleSheet.create({ + pdf: { + flex: 1, + backgroundColor: IOColors.bluegrey + } +}); + +type OwnProps = { + downloadPath: string; +}; + +type Props = OwnProps & Omit, "source">; + +const LegacyPdfViewer = ({ + style, + downloadPath, + onError, + onLoadComplete, + ...rest +}: Props) => { + const [isLoading, setIsLoading] = useState(true); + + return ( + + { + setIsLoading(false); + onLoadComplete?.(...args); + }} + onError={(...args) => { + setIsLoading(false); + onError?.(...args); + }} + /> + + ); +}; + +export default LegacyPdfViewer; diff --git a/ts/features/messages/components/MessageDetail/PdfViewer.tsx b/ts/features/messages/components/MessageAttachment/PdfViewer.tsx similarity index 94% rename from ts/features/messages/components/MessageDetail/PdfViewer.tsx rename to ts/features/messages/components/MessageAttachment/PdfViewer.tsx index 791b519fcda..b26f69052c2 100644 --- a/ts/features/messages/components/MessageDetail/PdfViewer.tsx +++ b/ts/features/messages/components/MessageAttachment/PdfViewer.tsx @@ -2,8 +2,8 @@ import React, { useState } from "react"; import { StyleSheet } from "react-native"; import Pdf from "react-native-pdf"; import { IOColors } from "@pagopa/io-app-design-system"; -import LoadingSpinnerOverlay from "../../../../components/LoadingSpinnerOverlay"; import I18n from "../../../../i18n"; +import LoadingSpinnerOverlay from "../../../../components/LoadingSpinnerOverlay"; const styles = StyleSheet.create({ pdf: { @@ -18,7 +18,7 @@ type OwnProps = { type Props = OwnProps & Omit, "source">; -const PdfViewer = ({ +export const PdfViewer = ({ style, downloadPath, onError, @@ -26,11 +26,9 @@ const PdfViewer = ({ ...rest }: Props) => { const [isLoading, setIsLoading] = useState(true); - return ( ); }; - -export default PdfViewer; diff --git a/ts/features/messages/components/MessageAttachment/__test__/PdfViewer.test.tsx b/ts/features/messages/components/MessageAttachment/__test__/PdfViewer.test.tsx new file mode 100644 index 00000000000..72145e61fab --- /dev/null +++ b/ts/features/messages/components/MessageAttachment/__test__/PdfViewer.test.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import { render } from "@testing-library/react-native"; +import configureMockStore from "redux-mock-store"; +import { Provider } from "react-redux"; +import { PdfViewer } from "../PdfViewer"; +import { appReducer } from "../../../../../store/reducers"; +import { applicationChangeState } from "../../../../../store/actions/application"; +import { GlobalState } from "../../../../../store/reducers/types"; + +describe("PdfViewer", () => { + it("should match the snapshot", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + const enrichedState = { + ...globalState, + persistedPreferences: { + ...globalState.persistedPreferences, + isDesignSystemEnabled: true + } + }; + const mockStore = configureMockStore(); + const store: ReturnType = mockStore(enrichedState); + const component = render( + + + + ); + expect(component.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/ts/features/messages/components/MessageAttachment/__test__/__snapshots__/PdfViewer.test.tsx.snap b/ts/features/messages/components/MessageAttachment/__test__/__snapshots__/PdfViewer.test.tsx.snap new file mode 100644 index 00000000000..07e6e5f8ead --- /dev/null +++ b/ts/features/messages/components/MessageAttachment/__test__/__snapshots__/PdfViewer.test.tsx.snap @@ -0,0 +1,250 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PdfViewer should match the snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + Loading document... + + + + + + +`; diff --git a/ts/features/messages/components/MessageDetail/index.tsx b/ts/features/messages/components/MessageDetail/index.tsx index 31a2bf26c1d..245dc7c0bad 100644 --- a/ts/features/messages/components/MessageDetail/index.tsx +++ b/ts/features/messages/components/MessageDetail/index.tsx @@ -155,17 +155,20 @@ const MessageDetailsComponent = ({ const messageTitle = useIOSelector(state => messageTitleSelector(state, messageId)) ?? title; + const serviceIdOpt = service?.id; const openAttachment = useCallback( (attachment: UIAttachment) => { navigation.navigate(MESSAGES_ROUTES.MESSAGES_NAVIGATOR, { screen: MESSAGES_ROUTES.MESSAGE_DETAIL_ATTACHMENT, params: { messageId, - attachmentId: attachment.id + serviceId: serviceIdOpt, + attachmentId: attachment.id, + isPN: false } }); }, - [messageId, navigation] + [messageId, navigation, serviceIdOpt] ); const renderThirdPartyAttachments = useCallback( diff --git a/ts/features/messages/components/__test__/MessageAttachmentPreview.test.tsx b/ts/features/messages/components/__test__/MessageAttachmentPreview.test.tsx index 3820baec458..d91181746df 100644 --- a/ts/features/messages/components/__test__/MessageAttachmentPreview.test.tsx +++ b/ts/features/messages/components/__test__/MessageAttachmentPreview.test.tsx @@ -9,12 +9,12 @@ import { renderScreenWithNavigationStoreContext } from "../../../../utils/testWr import { Downloads } from "../../store/reducers/downloads"; import { mockPdfAttachment } from "../../__mocks__/attachment"; import I18n from "../../../../i18n"; -import { MessageAttachmentPreview } from "../MessageAttachmentPreview"; +import { LegacyMessageAttachmentPreview } from "../MessageAttachment/LegacyMessageAttachmentPreview"; const mockOpen = jest.fn(); const mockPdfViewer = ; -jest.mock("../MessageDetail/PdfViewer", () => () => mockPdfViewer); +jest.mock("../MessageAttachment/LegacyPdfViewer", () => () => mockPdfViewer); describe("MessageAttachmentPreview", () => { describe("when enableDownloadAttachment is false", () => { @@ -67,7 +67,7 @@ describe("MessageAttachmentPreview", () => { }); const renderComponent = ( - props: React.ComponentProps, + props: React.ComponentProps, downloads: Downloads = {} ) => { const globalState = appReducer(undefined, applicationChangeState("active")); @@ -84,7 +84,7 @@ const renderComponent = ( return { component: renderScreenWithNavigationStoreContext( - () => , + () => , "DUMMY", {}, store diff --git a/ts/features/messages/navigation/MessagesNavigator.tsx b/ts/features/messages/navigation/MessagesNavigator.tsx index 75527b7e274..56680906c65 100644 --- a/ts/features/messages/navigation/MessagesNavigator.tsx +++ b/ts/features/messages/navigation/MessagesNavigator.tsx @@ -9,43 +9,68 @@ import PN_ROUTES from "../../pn/navigation/routes"; import { useIOSelector } from "../../../store/hooks"; import { isGestureEnabled } from "../../../utils/navigation"; import { isPnEnabledSelector } from "../../../store/reducers/backendStatus"; -import { MessageDetailAttachment } from "../screens/MessageAttachment"; +import { LegacyMessageDetailAttachment } from "../screens/LegacyMessageAttachment"; +import { isDesignSystemEnabledSelector } from "../../../store/reducers/persistedPreferences"; +import { MessageAttachment } from "../screens/MessageAttachment"; import { MessagesParamsList } from "./params"; import { MESSAGES_ROUTES } from "./routes"; const Stack = createStackNavigator(); export const MessagesStackNavigator = () => { + const isDesignSystemEnabled = useIOSelector(isDesignSystemEnabledSelector); const isPnEnabled = useIOSelector(isPnEnabledSelector); return ( {isPnEnabled && ( - + )} ); diff --git a/ts/features/messages/navigation/params.ts b/ts/features/messages/navigation/params.ts index 162ed2e37a7..ae6f1f95751 100644 --- a/ts/features/messages/navigation/params.ts +++ b/ts/features/messages/navigation/params.ts @@ -3,15 +3,15 @@ import EUCOVIDCERT_ROUTES from "../../euCovidCert/navigation/routes"; import PN_ROUTES from "../../pn/navigation/routes"; import { MessageRouterScreenNavigationParams } from "../screens/MessageRouterScreen"; import { MessageDetailScreenNavigationParams } from "../screens/MessageDetailScreen"; -import { MessageDetailAttachmentNavigationParams } from "../screens/MessageAttachment"; import { EUCovidCertParamsList } from "../../euCovidCert/navigation/params"; import { PnParamsList } from "../../pn/navigation/params"; +import { MessageAttachmentNavigationParams } from "../screens/MessageAttachment"; import { MESSAGES_ROUTES } from "./routes"; export type MessagesParamsList = { [MESSAGES_ROUTES.MESSAGE_ROUTER]: MessageRouterScreenNavigationParams; [MESSAGES_ROUTES.MESSAGE_DETAIL]: MessageDetailScreenNavigationParams; - [MESSAGES_ROUTES.MESSAGE_DETAIL_ATTACHMENT]: MessageDetailAttachmentNavigationParams; + [MESSAGES_ROUTES.MESSAGE_DETAIL_ATTACHMENT]: MessageAttachmentNavigationParams; [EUCOVIDCERT_ROUTES.MAIN]: NavigatorScreenParams; [PN_ROUTES.MAIN]: NavigatorScreenParams; }; diff --git a/ts/features/messages/screens/LegacyMessageAttachment.tsx b/ts/features/messages/screens/LegacyMessageAttachment.tsx new file mode 100644 index 00000000000..d60ee13fd11 --- /dev/null +++ b/ts/features/messages/screens/LegacyMessageAttachment.tsx @@ -0,0 +1,76 @@ +import React, { useEffect, useRef } from "react"; +import { useNavigation } from "@react-navigation/native"; +import * as O from "fp-ts/lib/Option"; +import I18n from "../../../i18n"; +import { IOStackNavigationRouteProps } from "../../../navigation/params/AppParamsList"; +import { LegacyMessageAttachmentPreview } from "../components/MessageAttachment/LegacyMessageAttachmentPreview"; +import { MessagesParamsList } from "../navigation/params"; +import { showToast } from "../../../utils/showToast"; +import { getServiceByMessageId } from "../store/reducers/paginatedById"; +import { useIOSelector } from "../../../store/hooks"; +import { thirdPartyMessageUIAttachment } from "../store/reducers/thirdPartyById"; +import { + trackThirdPartyMessageAttachmentCorruptedFile, + trackThirdPartyMessageAttachmentPreviewSuccess, + trackThirdPartyMessageAttachmentUserAction +} from "../analytics"; + +export const LegacyMessageDetailAttachment = ( + props: IOStackNavigationRouteProps< + MessagesParamsList, + "MESSAGE_DETAIL_ATTACHMENT" + > +): React.ReactElement => { + const navigation = useNavigation(); + const messageId = props.route.params.messageId; + const attachmentId = props.route.params.attachmentId; + // This ref is needed otherwise the auto back on the useEffect will fire multiple + // times, since its dependencies change during the back navigation + const autoBackOnErrorHandled = useRef(false); + const serviceId = useIOSelector(state => + getServiceByMessageId(state, messageId) + ); + + const maybeThirdPartyMessageUIAttachment = useIOSelector(state => + thirdPartyMessageUIAttachment(state)(messageId)(attachmentId) + ); + + useEffect(() => { + // This condition happens only if this screen is shown without having + // first retrieved the third party message (so it should never happen) + if ( + !autoBackOnErrorHandled.current && + O.isNone(maybeThirdPartyMessageUIAttachment) + ) { + // eslint-disable-next-line functional/immutable-data + autoBackOnErrorHandled.current = true; + showToast(I18n.t("messageDetails.attachments.downloadFailed")); + navigation.goBack(); + } + }, [navigation, maybeThirdPartyMessageUIAttachment]); + + return O.isSome(maybeThirdPartyMessageUIAttachment) ? ( + { + trackThirdPartyMessageAttachmentCorruptedFile(messageId, serviceId); + showToast(I18n.t("messageDetails.attachments.corruptedFile")); + }} + onLoadComplete={() => { + trackThirdPartyMessageAttachmentPreviewSuccess(); + }} + onDownload={() => { + trackThirdPartyMessageAttachmentUserAction("download"); + }} + onOpen={() => { + trackThirdPartyMessageAttachmentUserAction("open"); + }} + onShare={() => { + trackThirdPartyMessageAttachmentUserAction("share"); + }} + /> + ) : ( + <> + ); +}; diff --git a/ts/features/messages/screens/MessageAttachment.tsx b/ts/features/messages/screens/MessageAttachment.tsx index 03d545ed4c6..5945c03aed9 100644 --- a/ts/features/messages/screens/MessageAttachment.tsx +++ b/ts/features/messages/screens/MessageAttachment.tsx @@ -1,82 +1,236 @@ -import React, { useEffect, useRef } from "react"; -import { useNavigation } from "@react-navigation/native"; -import * as O from "fp-ts/lib/Option"; +import React, { useCallback, useState } from "react"; +import { pipe } from "fp-ts/lib/function"; +import * as B from "fp-ts/lib/boolean"; +import ReactNativeBlobUtil from "react-native-blob-util"; +import { FooterWithButtons } from "@pagopa/io-app-design-system"; import I18n from "../../../i18n"; -import { UIAttachmentId, UIMessageId } from "../types"; +import { useIOSelector } from "../../../store/hooks"; +import { downloadedMessageAttachmentSelector } from "../store/reducers/downloads"; +import { UIAttachment, UIAttachmentId, UIMessageId } from "../types"; +import { isIos } from "../../../utils/platform"; +import { share } from "../../../utils/share"; +import { IOToast } from "../../../components/Toast"; +import { useHeaderSecondLevel } from "../../../hooks/useHeaderSecondLevel"; +import { PdfViewer } from "../components/MessageAttachment/PdfViewer"; import { IOStackNavigationRouteProps } from "../../../navigation/params/AppParamsList"; -import { MessageAttachmentPreview } from "../components/MessageAttachmentPreview"; import { MessagesParamsList } from "../navigation/params"; -import { showToast } from "../../../utils/showToast"; -import { getServiceByMessageId } from "../store/reducers/paginatedById"; -import { useIOSelector } from "../../../store/hooks"; -import { thirdPartyMessageUIAttachment } from "../store/reducers/thirdPartyById"; import { trackThirdPartyMessageAttachmentCorruptedFile, trackThirdPartyMessageAttachmentPreviewSuccess, trackThirdPartyMessageAttachmentUserAction } from "../analytics"; +import { ServiceId } from "../../../../definitions/backend/ServiceId"; +import { + trackPNAttachmentOpen, + trackPNAttachmentOpeningSuccess, + trackPNAttachmentSave, + trackPNAttachmentSaveShare, + trackPNAttachmentShare +} from "../../pn/analytics"; +import { OperationResultScreenContent } from "../../../components/screens/OperationResultScreenContent"; -export type MessageDetailAttachmentNavigationParams = Readonly<{ +export type MessageAttachmentNavigationParams = Readonly<{ messageId: UIMessageId; attachmentId: UIAttachmentId; + isPN: boolean; + serviceId?: ServiceId; }>; -export const MessageDetailAttachment = ( +const renderFooter = ( + attachment: UIAttachment, + downloadPath: string, + isPN: boolean, + attachmentCategory?: string +) => + isIos ? ( + { + onShare(isPN, attachmentCategory); + ReactNativeBlobUtil.ios.presentOptionsMenu(downloadPath); + }, + label: I18n.t("messagePDFPreview.singleBtn") + } + }} + /> + ) : ( + { + onShare(isPN, attachmentCategory); + share(`file://${downloadPath}`, undefined, false)().catch(_ => { + IOToast.show(I18n.t("messagePDFPreview.errors.sharing")); + }); + }, + label: I18n.t("global.buttons.share") + } + }} + third={{ + type: "Outline", + buttonProps: { + accessibilityLabel: I18n.t("messagePDFPreview.save"), + onPress: () => { + onDownload(isPN, attachmentCategory); + ReactNativeBlobUtil.MediaCollection.copyToMediaStore( + { + name: attachment.displayName, + parentFolder: "", + mimeType: attachment.contentType + }, + "Download", + downloadPath + ) + .then(_ => { + IOToast.show( + I18n.t("messagePDFPreview.savedAtLocation", { + name: attachment.displayName + }) + ); + }) + .catch(_ => { + IOToast.error(I18n.t("messagePDFPreview.errors.saving")); + }); + }, + label: I18n.t("messagePDFPreview.save") + } + }} + secondary={{ + type: "Solid", + buttonProps: { + accessibilityLabel: I18n.t("messagePDFPreview.open"), + onPress: () => { + onOpen(isPN, attachmentCategory); + ReactNativeBlobUtil.android + .actionViewIntent(downloadPath, attachment.contentType) + .catch(_ => { + IOToast.error(I18n.t("messagePDFPreview.errors.opening")); + }); + }, + label: I18n.t("messagePDFPreview.open") + } + }} + /> + ); + +const onPDFError = ( + messageId: UIMessageId, + isPN: boolean, + serviceId?: ServiceId, + attachmentCategory?: string +) => + pipe( + isPN, + B.fold( + () => { + trackThirdPartyMessageAttachmentCorruptedFile(messageId, serviceId); + IOToast.error(I18n.t("messageDetails.attachments.corruptedFile")); + }, + () => trackPNAttachmentOpeningSuccess("error", attachmentCategory) + ) + ); + +const onLoadComplete = (isPN: boolean, attachmentCategory?: string) => + pipe( + isPN, + B.fold( + () => trackThirdPartyMessageAttachmentPreviewSuccess(), + () => trackPNAttachmentOpeningSuccess("displayer", attachmentCategory) + ) + ); + +const onShare = (isPN: boolean, attachmentCategory?: string) => + pipe( + isPN, + B.fold( + () => trackThirdPartyMessageAttachmentUserAction("share"), + () => + pipe( + isIos, + B.fold( + () => trackPNAttachmentShare(attachmentCategory), + () => trackPNAttachmentSaveShare(attachmentCategory) + ) + ) + ) + ); + +const onOpen = (isPN: boolean, attachmentCategory?: string) => + pipe( + isPN, + B.fold( + () => trackThirdPartyMessageAttachmentUserAction("open"), + () => trackPNAttachmentOpen(attachmentCategory) + ) + ); + +const onDownload = (isPN: boolean, attachmentCategory?: string) => + pipe( + isPN, + B.fold( + () => trackThirdPartyMessageAttachmentUserAction("download"), + () => trackPNAttachmentSave(attachmentCategory) + ) + ); + +export const MessageAttachment = ( props: IOStackNavigationRouteProps< MessagesParamsList, "MESSAGE_DETAIL_ATTACHMENT" > ): React.ReactElement => { - const navigation = useNavigation(); - const messageId = props.route.params.messageId; - const attachmentId = props.route.params.attachmentId; - // This ref is needed otherwise the auto back on the useEffect will fire multiple - // times, since its dependencies change during the back navigation - const autoBackOnErrorHandled = useRef(false); - const serviceId = useIOSelector(state => - getServiceByMessageId(state, messageId) - ); + const { messageId, attachmentId, isPN, serviceId } = props.route.params; + const [isPDFRenderingError, setIsPDFRenderingError] = useState(false); - const maybeThirdPartyMessageUIAttachment = useIOSelector(state => - thirdPartyMessageUIAttachment(state)(messageId)(attachmentId) + const downloadedAttachment = useIOSelector(state => + downloadedMessageAttachmentSelector(state, messageId, attachmentId) ); + const attachmentOpt = downloadedAttachment?.attachment; + const attachmentCategory = attachmentOpt?.category; + const downloadPathOpt = downloadedAttachment?.path; - useEffect(() => { - // This condition happens only if this screen is shown without having - // first retrieved the third party message (so it should never happen) - if ( - !autoBackOnErrorHandled.current && - O.isNone(maybeThirdPartyMessageUIAttachment) - ) { - // eslint-disable-next-line functional/immutable-data - autoBackOnErrorHandled.current = true; - showToast(I18n.t("messageDetails.attachments.downloadFailed")); - navigation.goBack(); - } - }, [navigation, maybeThirdPartyMessageUIAttachment]); + const onPDFRenderingError = useCallback(() => { + setIsPDFRenderingError(true); + onPDFError(messageId, isPN, serviceId, attachmentCategory); + }, [attachmentCategory, messageId, isPN, serviceId]); - return O.isSome(maybeThirdPartyMessageUIAttachment) ? ( - { - trackThirdPartyMessageAttachmentCorruptedFile(messageId, serviceId); - showToast(I18n.t("messageDetails.attachments.corruptedFile")); - }} - onLoadComplete={() => { - trackThirdPartyMessageAttachmentPreviewSuccess(); - }} - onDownload={() => { - trackThirdPartyMessageAttachmentUserAction("download"); - }} - onOpen={() => { - trackThirdPartyMessageAttachmentUserAction("open"); - }} - onShare={() => { - trackThirdPartyMessageAttachmentUserAction("share"); - }} - /> - ) : ( - <> + useHeaderSecondLevel({ + title: I18n.t("messagePDFPreview.title"), + supportRequest: true + }); + + if (!attachmentOpt || !downloadPathOpt) { + return ( + + ); + } + return ( + <> + {isPDFRenderingError ? ( + + ) : ( + onLoadComplete(isPN, attachmentCategory)} + /> + )} + {renderFooter(attachmentOpt, downloadPathOpt, isPN, attachmentCategory)} + ); }; diff --git a/ts/features/messages/screens/__tests__/MessageAttachment.test.tsx b/ts/features/messages/screens/__tests__/MessageAttachment.test.tsx new file mode 100644 index 00000000000..5531553a12f --- /dev/null +++ b/ts/features/messages/screens/__tests__/MessageAttachment.test.tsx @@ -0,0 +1,58 @@ +import { createStore } from "redux"; +import { UIAttachment, UIAttachmentId, UIMessageId } from "../../types"; +import { renderScreenWithNavigationStoreContext } from "../../../../utils/testWrapper"; +import { MESSAGES_ROUTES } from "../../navigation/routes"; +import { appReducer } from "../../../../store/reducers"; +import { applicationChangeState } from "../../../../store/actions/application"; +import { MessageAttachment } from "../MessageAttachment"; +import { ServiceId } from "../../../../../definitions/backend/ServiceId"; +import { downloadAttachment } from "../../store/actions"; +import { preferencesDesignSystemSetEnabled } from "../../../../store/actions/persistedPreferences"; + +describe("MessageAttachment", () => { + it("Should match the snapshot when there is an error", () => { + const messageId = "01HMZWRG7549N76017YR8YBSG2" as UIMessageId; + const attachmentId = "1" as UIAttachmentId; + const serviceId = "01HMZXFS84T1Q1BN6GXRYT63VJ" as ServiceId; + const screen = renderScreen(messageId, attachmentId, serviceId, "failure"); + expect(screen.toJSON()).toMatchSnapshot(); + }); + it("Should match the snapshot when everything went fine", () => { + const messageId = "01HMZWRG7549N76017YR8YBSG2" as UIMessageId; + const attachmentId = "1" as UIAttachmentId; + const serviceId = "01HMZXFS84T1Q1BN6GXRYT63VJ" as ServiceId; + const screen = renderScreen(messageId, attachmentId, serviceId, "success"); + expect(screen.toJSON()).toMatchSnapshot(); + }); +}); + +const renderScreen = ( + messageId: UIMessageId, + attachmentId: UIAttachmentId, + serviceId: ServiceId, + configuration: "failure" | "success" +) => { + const initialState = appReducer(undefined, applicationChangeState("active")); + const designSystemState = appReducer( + initialState, + preferencesDesignSystemSetEnabled({ isDesignSystemEnabled: true }) + ); + const withDownloadState = appReducer( + designSystemState, + downloadAttachment.success({ + attachment: { id: attachmentId, messageId } as UIAttachment, + path: "file:///fileName.pdf" + }) + ); + const store = createStore( + appReducer, + (configuration === "success" ? withDownloadState : designSystemState) as any + ); + + return renderScreenWithNavigationStoreContext( + MessageAttachment, + MESSAGES_ROUTES.MESSAGE_DETAIL_ATTACHMENT, + { messageId, attachmentId, isPN: false, serviceId }, + store + ); +}; diff --git a/ts/features/messages/screens/__tests__/MessageRouterScreen.test.tsx b/ts/features/messages/screens/__tests__/MessageRouterScreen.test.tsx index 678d072ba1d..dfe8164405d 100644 --- a/ts/features/messages/screens/__tests__/MessageRouterScreen.test.tsx +++ b/ts/features/messages/screens/__tests__/MessageRouterScreen.test.tsx @@ -6,7 +6,7 @@ import { renderScreenWithNavigationStoreContext } from "../../../../utils/testWr import { MessageRouterScreen } from "../MessageRouterScreen"; import { getMessageDataAction } from "../../store/actions"; import { ServiceId } from "../../../../../definitions/backend/ServiceId"; -import * as ASD from "../../../../store/hooks"; +import * as IOHooks from "../../../../store/hooks"; import { MessageGetStatus } from "../../store/reducers/messageGetStatus"; import { MESSAGES_ROUTES } from "../../navigation/routes"; @@ -107,7 +107,7 @@ const renderScreen = ( const mockedDispatch = jest.fn(); jest - .spyOn(ASD, "useIODispatch") + .spyOn(IOHooks, "useIODispatch") .mockImplementation(() => mockedDispatch as Dispatch); const renderedMessageRouterScreen = renderScreenWithNavigationStoreContext( diff --git a/ts/features/messages/screens/__tests__/__snapshots__/MessageAttachment.test.tsx.snap b/ts/features/messages/screens/__tests__/__snapshots__/MessageAttachment.test.tsx.snap new file mode 100644 index 00000000000..18daafbbdfa --- /dev/null +++ b/ts/features/messages/screens/__tests__/__snapshots__/MessageAttachment.test.tsx.snap @@ -0,0 +1,1638 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MessageAttachment Should match the snapshot when there is an error 1`] = ` + + + + + + + + + + + + + + + + + PDF Preview + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + There is a temporary problem, please try again. + + + + If the problem is not resolved, report it with the '?' icon at the top right, thank you! + + + + + + + + + + + + + + +`; + +exports[`MessageAttachment Should match the snapshot when everything went fine 1`] = ` + + + + + + + + + + + + + + + + + PDF Preview + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Loading document... + + + + + + + + + + + + + + + Save or share + + + + + + + + + + + + + + + + + + +`; diff --git a/ts/features/messages/store/reducers/__tests__/downloads.test.ts b/ts/features/messages/store/reducers/__tests__/downloads.test.ts index 3191b856d39..49bee044cdd 100644 --- a/ts/features/messages/store/reducers/__tests__/downloads.test.ts +++ b/ts/features/messages/store/reducers/__tests__/downloads.test.ts @@ -1,10 +1,235 @@ import * as pot from "@pagopa/ts-commons/lib/pot"; import { mockPdfAttachment } from "../../../__mocks__/attachment"; import { downloadAttachment, removeCachedAttachment } from "../../actions"; -import { Downloads, downloadsReducer } from "../downloads"; +import { + Download, + DownloadError, + Downloads, + INITIAL_STATE, + downloadedMessageAttachmentSelector, + downloadsReducer +} from "../downloads"; +import { + UIAttachment, + UIAttachmentId, + UIMessageId, + WithSkipMixpanelTrackingOnFailure +} from "../../../types"; +import { GlobalState } from "../../../../../store/reducers/types"; const path = "/path/attachment.pdf"; +describe("downloadedMessageAttachmentSelector", () => { + it("Should return undefined for an unmatching messageId", () => { + const attachmentId = "1" as UIAttachmentId; + const successDownload = { + attachment: { + messageId: "01HMXFQ803Q8JGQECKQF0EX6KX" as UIMessageId, + id: attachmentId + } as UIAttachment, + path: "randomPath" + } as Download; + const downloadSuccessAction = downloadAttachment.success(successDownload); + const downloadsState = downloadsReducer( + INITIAL_STATE, + downloadSuccessAction + ); + const globalState = { + entities: { + messages: { + downloads: downloadsState + } + } + } as GlobalState; + const messageId = "01HMXFE7192J01KNK02BJAPMBR" as UIMessageId; + const downloadedAttachment = downloadedMessageAttachmentSelector( + globalState, + messageId, + attachmentId + ); + expect(downloadedAttachment).toBeUndefined(); + }); + it("Should return undefined for a matching messageId with an unmatching attachmentId", () => { + const messageId = "01HMXFE7192J01KNK02BJAPMBR" as UIMessageId; + const unrelatedAttachmentId = "2"; + const successDownload = { + attachment: { + messageId, + id: unrelatedAttachmentId + } as UIAttachment, + path: "randomPath" + } as Download; + const downloadSuccessAction = downloadAttachment.success(successDownload); + const downloadsState = downloadsReducer( + INITIAL_STATE, + downloadSuccessAction + ); + const globalState = { + entities: { + messages: { + downloads: downloadsState + } + } + } as GlobalState; + const attachmentId = "1" as UIAttachmentId; + const downloadedAttachment = downloadedMessageAttachmentSelector( + globalState, + messageId, + attachmentId + ); + expect(downloadedAttachment).toBeUndefined(); + }); + it("Should return undefined for an attachment that is loading", () => { + const messageId = "01HMXFE7192J01KNK02BJAPMBR" as UIMessageId; + const attachmentId = "1" as UIAttachmentId; + const uiAttachmentRequest = { + messageId, + id: attachmentId, + skipMixpanelTrackingOnFailure: true + } as WithSkipMixpanelTrackingOnFailure; + const downloadRequestAction = + downloadAttachment.request(uiAttachmentRequest); + const downloadsState = downloadsReducer( + INITIAL_STATE, + downloadRequestAction + ); + const globalState = { + entities: { + messages: { + downloads: downloadsState + } + } + } as GlobalState; + const downloadedAttachment = downloadedMessageAttachmentSelector( + globalState, + messageId, + attachmentId + ); + expect(downloadedAttachment).toBeUndefined(); + }); + it("Should return undefined for an attachment that got an error", () => { + const messageId = "01HMXFE7192J01KNK02BJAPMBR" as UIMessageId; + const attachmentId = "1" as UIAttachmentId; + const failedDownload = { + attachment: { + messageId, + id: attachmentId + } as UIAttachment, + error: new Error("An error") + } as DownloadError; + const downloadFailureAction = downloadAttachment.failure(failedDownload); + const downloadsState = downloadsReducer( + INITIAL_STATE, + downloadFailureAction + ); + const globalState = { + entities: { + messages: { + downloads: downloadsState + } + } + } as GlobalState; + const downloadedAttachment = downloadedMessageAttachmentSelector( + globalState, + messageId, + attachmentId + ); + expect(downloadedAttachment).toBeUndefined(); + }); + it("Should return undefined for an attachment that was cancelled before finishing the download", () => { + const messageId = "01HMXFE7192J01KNK02BJAPMBR" as UIMessageId; + const attachmentId = "1" as UIAttachmentId; + const uiAttachmentCancelled = { + messageId, + id: attachmentId + } as UIAttachment; + const downloadCancelAction = downloadAttachment.cancel( + uiAttachmentCancelled + ); + const downloadsState = downloadsReducer( + INITIAL_STATE, + downloadCancelAction + ); + const globalState = { + entities: { + messages: { + downloads: downloadsState + } + } + } as GlobalState; + const downloadedAttachment = downloadedMessageAttachmentSelector( + globalState, + messageId, + attachmentId + ); + expect(downloadedAttachment).toBeUndefined(); + }); + it("Should return undefined for an attachment that was removed by a removeCachedAttachment action", () => { + const messageId = "01HMXFE7192J01KNK02BJAPMBR" as UIMessageId; + const attachmentId = "1" as UIAttachmentId; + const successDownload = { + attachment: { + messageId, + id: attachmentId + } as UIAttachment, + path: "randomPath" + } as Download; + const removedCachedAttachmentAction = + removeCachedAttachment(successDownload); + const downloadsState = downloadsReducer( + INITIAL_STATE, + removedCachedAttachmentAction + ); + const globalState = { + entities: { + messages: { + downloads: downloadsState + } + } + } as GlobalState; + const downloadedAttachment = downloadedMessageAttachmentSelector( + globalState, + messageId, + attachmentId + ); + expect(downloadedAttachment).toBeUndefined(); + }); + it("Should return data for a matching downloaded attachment", () => { + const messageId = "01HMXFE7192J01KNK02BJAPMBR" as UIMessageId; + const attachmentId = "1" as UIAttachmentId; + const downloadPath = "randomPath"; + const successDownload = { + attachment: { + messageId, + id: attachmentId + } as UIAttachment, + path: downloadPath + } as Download; + const downloadSuccessAction = downloadAttachment.success(successDownload); + const downloadsState = downloadsReducer( + INITIAL_STATE, + downloadSuccessAction + ); + const globalState = { + entities: { + messages: { + downloads: downloadsState + } + } + } as GlobalState; + const downloadedAttachment = downloadedMessageAttachmentSelector( + globalState, + messageId, + attachmentId + ); + expect(downloadedAttachment).toBeDefined(); + expect(downloadedAttachment?.attachment).toBeDefined(); + expect(downloadedAttachment?.attachment.messageId).toBe(messageId); + expect(downloadedAttachment?.attachment.id).toBe(attachmentId); + expect(downloadedAttachment?.path).toBe(downloadPath); + }); +}); + describe("downloadsReducer", () => { describe("given no download", () => { const initialState = {}; diff --git a/ts/features/messages/store/reducers/downloads.ts b/ts/features/messages/store/reducers/downloads.ts index 5fd32447df3..3cf6630f74e 100644 --- a/ts/features/messages/store/reducers/downloads.ts +++ b/ts/features/messages/store/reducers/downloads.ts @@ -1,3 +1,5 @@ +import { pipe } from "fp-ts/lib/function"; +import * as O from "fp-ts/lib/Option"; import * as pot from "@pagopa/ts-commons/lib/pot"; import { createSelector } from "reselect"; import { getType } from "typesafe-actions"; @@ -107,3 +109,17 @@ export const downloadPotForMessageAttachmentSelector = createSelector( return pot.none; } ); + +export const downloadedMessageAttachmentSelector = ( + state: GlobalState, + messageId: UIMessageId, + attachmentId: UIAttachmentId +) => + pipe( + state.entities.messages.downloads[messageId], + O.fromNullable, + O.chainNullableK(messageDownloads => messageDownloads[attachmentId]), + O.map(pot.toOption), + O.flatten, + O.toUndefined + ); diff --git a/ts/features/pn/screens/AttachmentPreviewScreen.tsx b/ts/features/pn/screens/AttachmentPreviewScreen.tsx index 8f4acc12ef3..e516603718a 100644 --- a/ts/features/pn/screens/AttachmentPreviewScreen.tsx +++ b/ts/features/pn/screens/AttachmentPreviewScreen.tsx @@ -5,7 +5,6 @@ import * as O from "fp-ts/lib/Option"; import { PnParamsList } from "../navigation/params"; import { UIMessageId, UIAttachmentId } from "../../messages/types"; import { IOStackNavigationRouteProps } from "../../../navigation/params/AppParamsList"; -import { MessageAttachmentPreview } from "../../messages/components/MessageAttachmentPreview"; import { useIOSelector } from "../../../store/hooks"; import { pnMessageAttachmentSelector } from "../store/reducers"; import { @@ -16,6 +15,7 @@ import { trackPNAttachmentShare } from "../analytics"; import { isIos } from "../../../utils/platform"; +import { LegacyMessageAttachmentPreview } from "../../messages/components/MessageAttachment/LegacyMessageAttachmentPreview"; export type AttachmentPreviewScreenNavigationParams = Readonly<{ messageId: UIMessageId; @@ -51,7 +51,7 @@ export const AttachmentPreviewScreen = ({ }, [maybePnMessageAttachment, navigation]); return O.isSome(maybePnMessageAttachment) ? ( - "optInServiceId"; const navigateToServiceLink = () => @@ -188,7 +189,7 @@ const isPNOptInMessageTestInput: Array = [ CTAs: getMaybeCTAs(), service: { ...getMockService(), - id: "NotTheOptInOne" + id: "NotTheOptInOne" as ServiceId }, state: getMockState() }, diff --git a/ts/store/reducers/entities/services/types.ts b/ts/store/reducers/entities/services/types.ts index 2e1d6ee3cdd..e1bdac128a4 100644 --- a/ts/store/reducers/entities/services/types.ts +++ b/ts/store/reducers/entities/services/types.ts @@ -1,11 +1,12 @@ import { ImageURISource } from "react-native"; import { ServicePublic } from "../../../../../definitions/backend/ServicePublic"; +import { ServiceId } from "../../../../../definitions/backend/ServiceId"; /** * Domain-specific representation of a Service with aggregated data. */ export type UIService = { - id: string; + id: ServiceId; name: string; organizationName: string; organizationFiscalCode: string;