Skip to content

Commit

Permalink
feat: [IOCOM-840] Preconditions bottom sheet, new DS (#5912)
Browse files Browse the repository at this point in the history
## Short description
This PR implements the preconditions bottom sheet, with the new DS.

|iOS|Android|
|-|-|
|<video
src="https://github.com/pagopa/io-app/assets/5150343/c0f2d650-c75b-4fb3-9d9a-bdbc939d04cf"
/>|<video
src="https://github.com/pagopa/io-app/assets/5150343/e542305e-0979-4114-add3-fda8ca20f0bf"
/>|

## List of changes proposed in this pull request
The preconditions bottom sheet is handled as a state-machine:

![state_preconditions](https://github.com/pagopa/io-app/assets/5150343/94104365-9a1e-4dbc-abb9-3055df747a97)
The `messagePrecondition` reducer implements the state machine, using
the `preconditions` actions to switch from one state to another (if
allowed).

- States:
  - `Idle`: in this status, the bottom sheet is not shown nor loaded;
- `Scheduled`: in this status, some actor has requested to show the
bottom sheet (but it is not visible yet). `WrappedMessageListItem` is
the component that dispatches the `scheduledPreconditionStatusAction`
while `Preconditions` component uses an `useEffect` hook to detect the
status and request the switch to either `Update Required` or `Retrieving
data`;
- `Update Required`: this status is used to inform the user about an
unsupported app version that needs to be updated. At the moment, it is
triggered when the selected message is a SEND one. The bottom sheet is
shown and the user can either dismiss it or navigate to the store to
update the application;
- `Retrieving Data`: in this status, the application is retrieving the
precondition data from server. The bottom sheet is shown and it displays
a progress skeleton;
- `Loading Content`: in this status, precondition data has been
retrieved and the markdown component is loading the precondition
content. The bottom sheet is shown and it displays a progress skeleton;
- `Shown`: in this status, the bottom sheet is shown, it displays the
precondition title and markdown and the footer displays both `cancel`
and `continue` button
- `Error`: this status is triggered if data retrieval from server fails
or if markdown loading fails. The bottom sheet is shown, it displays a
generic error message. No footer is shown so the user can just dismiss
the bottom sheet using its cancelling mechanism (closing button on top,
swipe gesture, backdrop tap or the physical back button on Android)

- Actions:
- `errorPreconditionStatusAction`: switches to the `Error` status. Valid
source states are `Retrieving Data` and `Loading Content`. If received
in any other state, it is ignored;
- `idlePreconditionStatusAction`: switches to the `Idle` status. Valid
source states are all but `Scheduled` (this transition maps either an
end-of-flow or a cancelling from the user);
- `loadingContentPreconditionStatusAction`: switches to the `Loading
Content` status. Valid source state is `Retrieving Data`. If received in
any other state, it is ignored;
- `retrievingDataPreconditionStatusAction`: switches to the `Retrieving
Data` status. Valid source states is `Scheduled` and `Error`. If
received in any other state, it is ignored. The switch from `Error` to
`Retrieving Data` is supported but it is not available from the UI so at
the moment it never happens;
- `scheduledPreconditionStatusAction`: switches to the `Scheduled`
status. Valid source state is `Idle`. If received in any other state, it
is ignored;
- `shownPreconditionStatusAction`: switches to the `Shown` status. Valid
source state is `Loading Content`. If received in any other state, it is
ignored;
- `updateRequiredPreconditionStatusAction`: switches to the `Update
Required` status. Valid source state is `Scheduled`. If received in any
other state, it is ignored;

- UI Components:
- `Preconditions`: the bottom sheet wrapper entry point. It creates the
bottom sheet, links other preconditions component and detects the
`Scheduled`state, in order to trigger the bottom-sheet opening.
- `PreconditionsTitle`: computes and displays the bottom sheet title,
based on the state machine status (sometimes it displays an empty view
in order for margin to be computed properly);
- `PreconditionsContent`: computes and displays the bottom sheet
markdown, based on the state machine status
- `PreconditionsFooter`: computes and displays the bottom sheet CTAs.
Apart from an empt view (used in order for margins to be computed
properly), it either displays the `Cancel` + `Update` buttons (`Update
Required` state) or the `Cancel` + `Continue` buttons (`Shown` state);
- `PreconditionsFeedback`: common component linked inside
`PreconditionsContent` to display either an error or the update required
message.

The main idea behind the switch from the `Idle` to the `Scheduled` state
is to decouple the actor that requires the displaying from the actual
displaying of the bottom sheet. Up to this PR, only a tap on a single
message on the message list can open the bottom sheet but in the future,
it is going to be opened from other entry points (like a push
notification).

## How to test
- Using the io-dev-api-server, generate a SEND message. Select it and
check that the bottom sheet appears.
- Using the io-dev-api-server, set the SEND minimum version to something
greater that 2.63. Generate a SEND message, select it and check that the
bottom sheet appears with the required update CTAs.
- Using the io-dev-api-server, change the message generation code that
computes the hasPreconditions flag so that it always returns true. Also
change the preconditions endpoint as to return preconditions for any
message. Select a non SEND message and check that the bototm sheet
appears (minimum app version is checked only for SEND messages)
  • Loading branch information
Vangaorth authored Jul 8, 2024
1 parent bb96fd2 commit 153b621
Show file tree
Hide file tree
Showing 50 changed files with 11,765 additions and 337 deletions.
42 changes: 0 additions & 42 deletions ts/boot/__tests__/__snapshots__/persistedStore.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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 [],
Expand Down
4 changes: 3 additions & 1 deletion ts/boot/__tests__/persistedStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import _ from "lodash";
import { applicationChangeState } from "../../store/actions/application";
import { appReducer } from "../../store/reducers";
import { GlobalState } from "../../store/reducers/types";
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
isCdcEnabledSelector,
isCGNEnabledSelector,
isPnEnabledSelector,
isPnSupportedSelector
isPnAppVersionSupportedSelector
} from "../../../store/reducers/backendStatus";
import { openAppStoreUrl } from "../../../utils/url";

Expand Down Expand Up @@ -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<string, SpecialServiceConfig>([
["cgn", { isEnabled: isCGNEnabled, isSupported: true }],
Expand Down
88 changes: 88 additions & 0 deletions ts/features/messages/components/Home/Preconditions.tsx
Original file line number Diff line number Diff line change
@@ -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: <PreconditionsTitle />,
component: <PreconditionsContent />,
footer: (
<PreconditionsFooter
onDismiss={() => 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;
};
131 changes: 131 additions & 0 deletions ts/features/messages/components/Home/PreconditionsContent.tsx
Original file line number Diff line number Diff line change
@@ -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 <PreconditionsContentMarkdown />;
case "error":
return <PreconditionsContentError />;
case "loading":
return <PreconditionsContentSkeleton />;
case "update":
return <PreconditionsContentUpdate />;
}
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 (
<MessageMarkdown
loadingLines={7}
onLoadEnd={onLoadEndCallback}
onError={onErrorCallback}
testID="preconditions_content_message_markdown"
>
{markdown}
</MessageMarkdown>
);
};

const PreconditionsContentError = () => (
<PreconditionsFeedback
pictogram="umbrellaNew"
title={I18n.t("global.genericError")}
/>
);

const PreconditionsContentSkeleton = () => (
<View accessible={false}>
{Array.from({ length: 3 }).map((_, i) => (
<View key={`pre_content_ske_${i}`}>
<Placeholder.Box
width={"100%"}
animate={"fade"}
height={21}
radius={4}
/>
<VSpacer size={8} />
<Placeholder.Box
width={"100%"}
animate={"fade"}
height={21}
radius={4}
/>
<VSpacer size={8} />
<Placeholder.Box
width={"90%"}
animate={"fade"}
height={21}
radius={4}
/>
<VSpacer size={8} />
</View>
))}
</View>
);

const PreconditionsContentUpdate = () => {
const pnMinAppVersion = useIOSelector(pnMinAppVersionSelector);
return (
<PreconditionsFeedback
pictogram="umbrellaNew"
title={I18n.t("features.messages.updateBottomSheet.title")}
subtitle={I18n.t("features.messages.updateBottomSheet.subtitle", {
value: pnMinAppVersion
})}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ type Props = {
subtitle?: string;
};

export const MessageFeedback = ({ pictogram, title, subtitle }: Props) => (
export const PreconditionsFeedback = ({
pictogram,
title,
subtitle
}: Props) => (
<View style={styles.container}>
<Pictogram name={pictogram} size={120} />
<VSpacer size={24} />
Expand Down
Loading

0 comments on commit 153b621

Please sign in to comment.