From eea9ad4c1b706889c1cb74ceab8819b6b0e1322f Mon Sep 17 00:00:00 2001 From: Francesco Persico Date: Fri, 4 Nov 2022 11:42:48 +0100 Subject: [PATCH] chore(IDPay): [IDPAY-30] Create XState machine for IDPay Onboarding flow (#4151) * Create XState machine for IDPay Onboarding flow * Add xstate types generation * State machine refactoring * Add provider * Refactor to test the provider * Add IDPay envs * Remove API test token --- .env.local | 3 + .env.production | 3 + .eslintignore | 1 + .gitignore | 3 + package.json | 8 +- ts/config.ts | 9 + .../idpay/onboarding/navigation/navigator.tsx | 32 +++ .../screens/InitiativeDetailScreen.tsx | 64 +++++ .../idpay/onboarding/xstate/machine.ts | 85 ++++++ .../idpay/onboarding/xstate/provider.tsx | 62 +++++ .../idpay/onboarding/xstate/services.ts | 38 +++ ts/navigation/AppStackNavigator.tsx | 9 + ts/navigation/ProfileNavigator.tsx | 5 + ts/navigation/params/AppParamsList.ts | 6 + ts/navigation/params/ProfileParamsList.ts | 1 + ts/navigation/routes.ts | 1 + ts/screens/profile/ProfileMainScreen.tsx | 8 + .../playgrounds/IDPayOnboardingPlayground.tsx | 53 ++++ ts/store/reducers/authentication.ts | 11 +- ts/utils/xstate/index.ts | 5 + yarn.lock | 260 +++++++++++++++++- 21 files changed, 656 insertions(+), 11 deletions(-) create mode 100644 ts/features/idpay/onboarding/navigation/navigator.tsx create mode 100644 ts/features/idpay/onboarding/screens/InitiativeDetailScreen.tsx create mode 100644 ts/features/idpay/onboarding/xstate/machine.ts create mode 100644 ts/features/idpay/onboarding/xstate/provider.tsx create mode 100644 ts/features/idpay/onboarding/xstate/services.ts create mode 100644 ts/screens/profile/playgrounds/IDPayOnboardingPlayground.tsx create mode 100644 ts/utils/xstate/index.ts diff --git a/.env.local b/.env.local index 99593d5232b..db82cf5ba3d 100644 --- a/.env.local +++ b/.env.local @@ -75,3 +75,6 @@ PN_ENABLED=YES REMINDERS_OPT_IN_ENABLED=YES # FCI (Firma con IO) feature FCI_ENABLED=YES + +# IDPay +IDPAY_API_UAT_BASEURL='https://api-io.uat.cstar.pagopa.it' diff --git a/.env.production b/.env.production index bd9728399fd..51bfe465729 100644 --- a/.env.production +++ b/.env.production @@ -75,3 +75,6 @@ PN_ENABLED=YES REMINDERS_OPT_IN_ENABLED=NO # FCI (Firma con IO) feature FCI_ENABLED=NO + +# IDPay +IDPAY_API_UAT_BASEURL='https://api-io.uat.cstar.pagopa.it' diff --git a/.eslintignore b/.eslintignore index 92effe8471b..247631d1a94 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,4 @@ locales/locales.ts ts/utils/__tests__/xss.test.ts definitions/* +**/*.typegen.ts \ No newline at end of file diff --git a/.gitignore b/.gitignore index db1fe7aa10c..c880c3a4cab 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,6 @@ shim.js android/app/google-services.json # React native config generated file GeneratedDotEnv.m + +# XState Typegen +**/*.typegen.* \ No newline at end of file diff --git a/package.json b/package.json index 2eb45edcb80..845ccdd61ac 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,8 @@ "generate:idpay-onboarding": "rimraf definitions/idpay/onboarding && mkdir -p definitions/idpay/onboarding && gen-api-models --api-spec $npm_package_idpay_onboarding --out-dir ./definitions/idpay/onboarding --no-strict --response-decoders --request-types --client", "generate": "npm-run-all generate:*", "locales_unused": "ts-node --skip-project -O '{\"lib\":[\"es2015\"]}' scripts/unused-locales.ts", - "generate-myportal-readme": "jsdoc2md ts/utils/webviewScripts/*.js > MYPORTAL_README.md" + "generate-myportal-readme": "jsdoc2md ts/utils/webviewScripts/*.js > MYPORTAL_README.md", + "generate:xstate-types": "xstate typegen \"ts/**/*.ts?(x)\"" }, "dependencies": { "@babel/plugin-transform-regenerator": "^7.18.6", @@ -101,6 +102,7 @@ "@react-navigation/native": "^5.9.8", "@react-navigation/stack": "^5.14.9", "@redux-saga/testing-utils": "^1.1.3", + "@xstate/react": "^3.0.1", "abort-controller": "^1.0.2", "async-mutex": "^0.1.3", "buffer": "^4.9.1", @@ -190,7 +192,8 @@ "uuid": "^8.3.2", "validator": "^13.7.0", "vision-camera-code-scanner": "^0.2.0", - "xss": "1.0.10" + "xss": "1.0.10", + "xstate": "^4.33.6" }, "devDependencies": { "@babel/core": "^7.15.0", @@ -229,6 +232,7 @@ "@types/validator": "^9.4.2", "@typescript-eslint/eslint-plugin": "^5.9.1", "@typescript-eslint/parser": "^5.9.1", + "@xstate/cli": "^0.3.3", "abortcontroller-polyfill": "1.7.3", "babel-jest": "^26.6.3", "babel-plugin-macros": "^3.1.0", diff --git a/ts/config.ts b/ts/config.ts index 085ec1cc264..4496672979f 100644 --- a/ts/config.ts +++ b/ts/config.ts @@ -204,3 +204,12 @@ export const POSTE_DATAMATRIX_SCAN_PREFERRED_PSPS: ), O.toUndefined ); + +/** + * IDPay + */ + +export const IDPAY_API_TEST_TOKEN = + Config.IDPAY_API_TEST_TOKEN !== "" ? Config.IDPAY_API_TEST_TOKEN : undefined; + +export const IDPAY_API_UAT_BASEURL = Config.IDPAY_API_UAT_BASEURL; diff --git a/ts/features/idpay/onboarding/navigation/navigator.tsx b/ts/features/idpay/onboarding/navigation/navigator.tsx new file mode 100644 index 00000000000..1478d3f943f --- /dev/null +++ b/ts/features/idpay/onboarding/navigation/navigator.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { createStackNavigator } from "@react-navigation/stack"; +import InitiativeDetailsScreen, { + InitiativeDetailsScreenRouteParams +} from "../screens/InitiativeDetailScreen"; +import { IDPayOnboardingMachineProvider } from "../xstate/provider"; + +export const IDPayOnboardingRoutes = { + IDPAY_ONBOARDING_MAIN: "IDPAY_ONBOARDING_MAIN", + IDPAY_ONBOARDING_INITIATIVE_DETAILS: "IDPAY_ONBOARDING_INITIATIVE_DETAILS" +} as const; + +export type IDPayOnboardingParamsList = { + [IDPayOnboardingRoutes.IDPAY_ONBOARDING_INITIATIVE_DETAILS]: InitiativeDetailsScreenRouteParams; +}; + +const Stack = createStackNavigator(); + +export const IDPayOnboardingNavigator = () => ( + + + + + +); diff --git a/ts/features/idpay/onboarding/screens/InitiativeDetailScreen.tsx b/ts/features/idpay/onboarding/screens/InitiativeDetailScreen.tsx new file mode 100644 index 00000000000..f60af4751d9 --- /dev/null +++ b/ts/features/idpay/onboarding/screens/InitiativeDetailScreen.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import { RouteProp, useRoute } from "@react-navigation/native"; +import { Text } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useActor } from "@xstate/react"; +import { IDPayOnboardingParamsList } from "../navigation/navigator"; +import { useOnboardingMachineService } from "../xstate/provider"; + +type InitiativeDetailsScreenRouteParams = { + serviceId: string; +}; + +const InitiativeDetailsScreen = () => { + const onboardingMachineService = useOnboardingMachineService(); + + const [state, send] = useActor(onboardingMachineService); + + const route = + useRoute< + RouteProp< + IDPayOnboardingParamsList, + "IDPAY_ONBOARDING_INITIATIVE_DETAILS" + > + >(); + + const { serviceId } = route.params; + + React.useEffect(() => { + send({ + type: "SELECT_INITIATIVE", + serviceId + }); + }, [send, serviceId]); + + const content = React.useMemo(() => { + if ( + state.matches("WAITING_INITIATIVE_SELECTION") || + state.matches("LOADING_INITIATIVE") + ) { + return Loading...; + } + + if ( + state.matches("DISPLAYING_INITIATIVE") && + state.context.initative !== undefined + ) { + return Initiative ID: {state.context.initative.initiativeId}; + } + + return null; + }, [state]); + + return ( + + ServiceID: {serviceId} + State: {state.value} + {content} + + ); +}; + +export type { InitiativeDetailsScreenRouteParams }; + +export default InitiativeDetailsScreen; diff --git a/ts/features/idpay/onboarding/xstate/machine.ts b/ts/features/idpay/onboarding/xstate/machine.ts new file mode 100644 index 00000000000..864220a0852 --- /dev/null +++ b/ts/features/idpay/onboarding/xstate/machine.ts @@ -0,0 +1,85 @@ +import { assign, createMachine } from "xstate"; +import { InitiativeDto } from "../../../../../definitions/idpay/onboarding/InitiativeDto"; +import { LOADING_TAG } from "../../../../utils/xstate"; + +// Context types +export type Context = { + serviceId?: string; + initative?: InitiativeDto; +}; + +// Events types +type E_SELECT_INITIATIVE = { + type: "SELECT_INITIATIVE"; + serviceId: string; +}; + +type Events = E_SELECT_INITIATIVE; + +// Services types +type Services = { + loadInitiative: { + data: InitiativeDto; + }; +}; + +const createIDPayOnboardingMachine = () => + /** @xstate-layout N4IgpgJg5mDOIC5QEkAiAFAggTQPoHkA5AIX0wCVVlCBxAOgHVNkAVam3a15TNgNQCiuAMoCAMgIDCbIgGJRE6Z0LdeyQQG0ADAF1EoAA4B7WAEsALqaMA7fSACeiAEwAWLQBoQAD2cB2AMx0vgCs-v6+TgCMAJxO-gAcTvEAvsmeaFh4RKQUVLR0YmR5HFxsaoKyEDZgdKbWAG5GANY1ADZGAIYQyNYWph2W9WDaekggxmaWNnaOCP7B8UGR8aFaAGzBLtFaEZ4+CE6+i+Errkdah-4uLqlpINZGEHB2GTgEJGSU7IzMbLTKqn4QgUUhkhDsEz60zG+w8DmcblS6Qwb2yn2KBSK7ABZSBEJMUNsMMQcNm80WvmWqw2Wx2TiRIFeWQ+uW+VGE6DEOGxpR4eLGkKmRNAsM8s2CWhcdHivjW0Uia3iK3CawZTPeOS+tHxkyswu8iAAtC54mLEJFgpE6Fp-GsXKEzjsbbdkkA */ + createMachine( + { + context: {}, + tsTypes: {} as import("./machine.typegen").Typegen0, + schema: { + context: {} as Context, + events: {} as Events, + services: {} as Services + }, + predictableActionArguments: true, + id: "IDPAY_ONBOARDING", + initial: "WAITING_INITIATIVE_SELECTION", + states: { + WAITING_INITIATIVE_SELECTION: { + tags: [LOADING_TAG], + on: { + SELECT_INITIATIVE: { + target: "LOADING_INITIATIVE", + actions: "selectInitiative" + } + } + }, + LOADING_INITIATIVE: { + tags: [LOADING_TAG], + invoke: { + src: "loadInitiative", + id: "loadInitiative", + onDone: [ + { + target: "DISPLAYING_INITIATIVE", + actions: "loadInitiativeSuccess" + } + ] + } + }, + DISPLAYING_INITIATIVE: { + type: "final" + } + } + }, + { + actions: { + selectInitiative: assign((_, event) => ({ + serviceId: event.serviceId + })), + loadInitiativeSuccess: assign((_, event) => ({ + initative: event.data + })) + } + } + ); + +type IDPayOnboardingMachineType = ReturnType< + typeof createIDPayOnboardingMachine +>; + +export type { IDPayOnboardingMachineType }; +export { createIDPayOnboardingMachine }; diff --git a/ts/features/idpay/onboarding/xstate/provider.tsx b/ts/features/idpay/onboarding/xstate/provider.tsx new file mode 100644 index 00000000000..f1f3970c689 --- /dev/null +++ b/ts/features/idpay/onboarding/xstate/provider.tsx @@ -0,0 +1,62 @@ +import * as O from "fp-ts/lib/Option"; +import React from "react"; +import { InterpreterFrom } from "xstate"; +import { useInterpret } from "@xstate/react"; +import { useIOSelector } from "../../../../store/hooks"; +import { sessionInfoSelector } from "../../../../store/reducers/authentication"; +import { createOnboardingClient } from "../api/client"; +import { + IDPAY_API_TEST_TOKEN, + IDPAY_API_UAT_BASEURL +} from "../../../../config"; +import { createServicesImplementation } from "./services"; +import { + createIDPayOnboardingMachine, + IDPayOnboardingMachineType +} from "./machine"; + +type OnboardingMachineContext = InterpreterFrom; + +const OnboardingMachineContext = React.createContext( + {} as OnboardingMachineContext +); + +type Props = { + children: React.ReactNode; +}; + +const IDPayOnboardingMachineProvider = (props: Props) => { + const { children } = props; + + const sessionInfo = useIOSelector(sessionInfoSelector); + + if (O.isNone(sessionInfo)) { + throw new Error("Session info is undefined"); + } + + const token = + IDPAY_API_TEST_TOKEN !== undefined + ? IDPAY_API_TEST_TOKEN + : sessionInfo.value.bpdToken; + + const onboardingClient = createOnboardingClient(IDPAY_API_UAT_BASEURL, token); + + const machine = createIDPayOnboardingMachine(); + + const services = createServicesImplementation(onboardingClient); + + const machineService = useInterpret(machine, { + services + }); + + return ( + + {children} + + ); +}; + +const useOnboardingMachineService = () => + React.useContext(OnboardingMachineContext); + +export { IDPayOnboardingMachineProvider, useOnboardingMachineService }; diff --git a/ts/features/idpay/onboarding/xstate/services.ts b/ts/features/idpay/onboarding/xstate/services.ts new file mode 100644 index 00000000000..1e906e82339 --- /dev/null +++ b/ts/features/idpay/onboarding/xstate/services.ts @@ -0,0 +1,38 @@ +import { pipe } from "fp-ts/lib/function"; +import * as E from "fp-ts/lib/Either"; +import { InitiativeDto } from "../../../../../definitions/idpay/onboarding/InitiativeDto"; +import { OnboardingClient } from "../api/client"; +import { Context } from "./machine"; + +const createServicesImplementation = (onboardingClient: OnboardingClient) => { + const loadInitiative = async (context: Context) => { + if (context.serviceId === undefined) { + return Promise.reject("serviceId is undefined"); + } + + const response = await onboardingClient.getInitiativeId({ + serviceId: context.serviceId + }); + + const data: Promise = pipe( + response, + E.fold( + _ => Promise.reject("error loading initiative"), + _ => { + if (_.status !== 200) { + return Promise.reject("error loading initiative"); + } + return Promise.resolve(_.value); + } + ) + ); + + return data; + }; + + return { + loadInitiative + }; +}; + +export { createServicesImplementation }; diff --git a/ts/navigation/AppStackNavigator.tsx b/ts/navigation/AppStackNavigator.tsx index d74e3cd024e..14be2c5782e 100644 --- a/ts/navigation/AppStackNavigator.tsx +++ b/ts/navigation/AppStackNavigator.tsx @@ -31,6 +31,10 @@ import { FimsNavigator } from "../features/fims/navigation/navigator"; import FIMS_ROUTES from "../features/fims/navigation/routes"; +import { + IDPayOnboardingNavigator, + IDPayOnboardingRoutes +} from "../features/idpay/onboarding/navigation/navigator"; import UADONATION_ROUTES from "../features/uaDonations/navigation/routes"; import { UAWebViewScreen } from "../features/uaDonations/screens/UAWebViewScreen"; import { ZendeskStackNavigator } from "../features/zendesk/navigation/navigator"; @@ -141,6 +145,11 @@ export const AppStackNavigator = () => { component={CdcStackNavigator} /> )} + + ); }; diff --git a/ts/navigation/ProfileNavigator.tsx b/ts/navigation/ProfileNavigator.tsx index 1e969708286..4a296e8949f 100644 --- a/ts/navigation/ProfileNavigator.tsx +++ b/ts/navigation/ProfileNavigator.tsx @@ -12,6 +12,7 @@ import EmailForwardingScreen from "../screens/profile/EmailForwardingScreen"; import FiscalCodeScreen from "../screens/profile/FiscalCodeScreen"; import LanguagesPreferencesScreen from "../screens/profile/LanguagesPreferencesScreen"; import { NotificationsPreferencesScreen } from "../screens/profile/NotificationsPreferencesScreen"; +import IDPayOnboardingPlayground from "../screens/profile/playgrounds/IDPayOnboardingPlayground"; import MarkdownPlayground from "../screens/profile/playgrounds/MarkdownPlayground"; import PreferencesScreen from "../screens/profile/PreferencesScreen"; import PrivacyMainScreen from "../screens/profile/PrivacyMainScreen"; @@ -94,6 +95,10 @@ const ProfileStackNavigator = () => ( name={ROUTES.CGN_LANDING_PLAYGROUND} component={CgnLandingPlayground} /> + ; [CDC_ROUTES.BONUS_REQUEST_MAIN]: NavigatorScreenParams; [FIMS_ROUTES.MAIN]: NavigatorScreenParams; + + [IDPayOnboardingRoutes.IDPAY_ONBOARDING_MAIN]: NavigatorScreenParams; }; /** diff --git a/ts/navigation/params/ProfileParamsList.ts b/ts/navigation/params/ProfileParamsList.ts index 330aea61ebe..15c920e3d75 100644 --- a/ts/navigation/params/ProfileParamsList.ts +++ b/ts/navigation/params/ProfileParamsList.ts @@ -24,4 +24,5 @@ export type ProfileParamsList = { [ROUTES.PROFILE_REMOVE_ACCOUNT_SUCCESS]: undefined; [ROUTES.CGN_LANDING_PLAYGROUND]: undefined; [ROUTES.PROFILE_PREFERENCES_NOTIFICATIONS]: undefined; + [ROUTES.IDPAY_ONBOARDING_PLAYGROUND]: undefined; }; diff --git a/ts/navigation/routes.ts b/ts/navigation/routes.ts index fb15e369637..553f9827517 100644 --- a/ts/navigation/routes.ts +++ b/ts/navigation/routes.ts @@ -109,6 +109,7 @@ const ROUTES = { SHOWROOM: "SHOWROOM", WEB_PLAYGROUND: "WEB_PLAYGROUND", CGN_LANDING_PLAYGROUND: "CGN_LANDING_PLAYGROUND", + IDPAY_ONBOARDING_PLAYGROUND: "IDPAY_ONBOARDING_PLAYGROUND", // Preferences READ_EMAIL_SCREEN: "READ_EMAIL_SCREEN", diff --git a/ts/screens/profile/ProfileMainScreen.tsx b/ts/screens/profile/ProfileMainScreen.tsx index ae762183d0f..1942f7ac5ae 100644 --- a/ts/screens/profile/ProfileMainScreen.tsx +++ b/ts/screens/profile/ProfileMainScreen.tsx @@ -324,6 +324,14 @@ class ProfileMainScreen extends React.PureComponent { }) } /> + + navigation.navigate(ROUTES.PROFILE_NAVIGATOR, { + screen: ROUTES.IDPAY_ONBOARDING_PLAYGROUND + }) + } + /> )} diff --git a/ts/screens/profile/playgrounds/IDPayOnboardingPlayground.tsx b/ts/screens/profile/playgrounds/IDPayOnboardingPlayground.tsx new file mode 100644 index 00000000000..a5e34b25dbf --- /dev/null +++ b/ts/screens/profile/playgrounds/IDPayOnboardingPlayground.tsx @@ -0,0 +1,53 @@ +import { useNavigation } from "@react-navigation/native"; +import { View } from "native-base"; +import React from "react"; +import { Button, SafeAreaView, ScrollView } from "react-native"; +import { IOStyles } from "../../../components/core/variables/IOStyles"; +import { LabelledItem } from "../../../components/LabelledItem"; +import BaseScreenComponent from "../../../components/screens/BaseScreenComponent"; +import { IDPayOnboardingRoutes } from "../../../features/idpay/onboarding/navigation/navigator"; +import { + AppParamsList, + IOStackNavigationProp +} from "../../../navigation/params/AppParamsList"; + +const IDPayOnboardingPlayground = () => { + const navigation = useNavigation>(); + const [serviceId, setServiceId] = React.useState(); + + const navigateToIDPayOnboarding = () => { + if (serviceId !== undefined && serviceId !== "") { + navigation.navigate(IDPayOnboardingRoutes.IDPAY_ONBOARDING_MAIN, { + screen: IDPayOnboardingRoutes.IDPAY_ONBOARDING_INITIATIVE_DETAILS, + params: { + serviceId + } + }); + } + }; + + return ( + + + + setServiceId(text) + }} + /> + +