diff --git a/locales/en/index.yml b/locales/en/index.yml index 18c70b86d43..b500e69f96b 100644 --- a/locales/en/index.yml +++ b/locales/en/index.yml @@ -2104,6 +2104,11 @@ services: emptyListMessage: There are no services available at this time, pull down to refresh new: New home: + featured: + services: + title: Featured + institutions: + title: Featured Institutions institutions: title: National institution: diff --git a/locales/it/index.yml b/locales/it/index.yml index af7f473f317..3afe881a8c0 100644 --- a/locales/it/index.yml +++ b/locales/it/index.yml @@ -2104,6 +2104,11 @@ services: emptyListMessage: Non ci sono servizi disponibili al momento, trascina in basso per aggiornare new: Nuovo home: + featured: + services: + title: In primo piano + institutions: + title: Enti in evidenza institutions: title: Nazionali institution: diff --git a/ts/features/services/common/components/CardPressableBase.tsx b/ts/features/services/common/components/CardPressableBase.tsx new file mode 100644 index 00000000000..f1ae663d5d8 --- /dev/null +++ b/ts/features/services/common/components/CardPressableBase.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { Pressable } from "react-native"; +import Animated from "react-native-reanimated"; +import { PressableBaseProps, WithTestID } from "@pagopa/io-app-design-system"; +import { useSpringPressScaleAnimation } from "../../../../components/ui/utils/hooks/useSpringPressScaleAnimation"; + +type CardPressableBaseProps = WithTestID; + +export const CardPressableBase = ({ + onPress, + testID, + accessibilityLabel, + children +}: React.PropsWithChildren) => { + const { onPressIn, onPressOut, animatedScaleStyle } = + useSpringPressScaleAnimation(); + + if (onPress === undefined) { + return <>{children}; + } + + return ( + + {children} + + ); +}; diff --git a/ts/features/services/home/components/InstitutionListItemSkeleton.tsx b/ts/features/services/common/components/InstitutionListSkeleton.tsx similarity index 65% rename from ts/features/services/home/components/InstitutionListItemSkeleton.tsx rename to ts/features/services/common/components/InstitutionListSkeleton.tsx index cbc8765ebf2..ca9c46203a6 100644 --- a/ts/features/services/home/components/InstitutionListItemSkeleton.tsx +++ b/ts/features/services/common/components/InstitutionListSkeleton.tsx @@ -1,6 +1,7 @@ import React from "react"; import { View } from "react-native"; import { + Divider, IOListItemStyles, IOListItemVisualParams, IOStyles, @@ -25,3 +26,20 @@ export const InstitutionListItemSkeleton = () => ( ); + +type InstitutionListSkeletonProps = { + size?: number; +}; + +export const InstitutionListSkeleton = ({ + size = 3 +}: InstitutionListSkeletonProps) => ( + + {Array.from({ length: size }).map((_, index) => ( + + + {index < size - 1 ? : undefined} + + ))} + +); diff --git a/ts/features/services/home/components/FeaturedInstitutionCard.tsx b/ts/features/services/home/components/FeaturedInstitutionCard.tsx index 025127a1966..f2bb41c542d 100644 --- a/ts/features/services/home/components/FeaturedInstitutionCard.tsx +++ b/ts/features/services/home/components/FeaturedInstitutionCard.tsx @@ -1,3 +1,6 @@ +import React from "react"; +import { Dimensions, StyleSheet, View } from "react-native"; +import Placeholder from "rn-placeholder"; import { Avatar, H6, @@ -7,18 +10,16 @@ import { VSpacer } from "@pagopa/io-app-design-system"; import { OrganizationFiscalCode } from "@pagopa/ts-commons/lib/strings"; -import React from "react"; -import { Dimensions, StyleSheet, View } from "react-native"; -import Placeholder from "rn-placeholder"; import { WithTestID } from "../../../../types/WithTestID"; import { logoForInstitution } from "../utils"; +import { CardPressableBase } from "../../common/components/CardPressableBase"; export type FeaturedInstitutionCardProps = WithTestID<{ id: string; name: string; accessibilityLabel?: string; isNew?: boolean; - onPress?: (id: string) => void; + onPress?: () => void; }>; export const CARD_WIDTH = @@ -59,28 +60,34 @@ const styles = StyleSheet.create({ }); const FeaturedInstitutionCard = (props: FeaturedInstitutionCardProps) => ( - - - - - - -
- {props.name} -
+ + + + + + +
+ {props.name} +
+
-
+ ); const FeaturedInstitutionCardSkeleton = ({ testID }: WithTestID) => ( diff --git a/ts/features/services/home/components/FeaturedInstitutionList.tsx b/ts/features/services/home/components/FeaturedInstitutionList.tsx new file mode 100644 index 00000000000..3cd8581f665 --- /dev/null +++ b/ts/features/services/home/components/FeaturedInstitutionList.tsx @@ -0,0 +1,76 @@ +import React, { useCallback, useMemo } from "react"; +import { ListItemHeader, VSpacer } from "@pagopa/io-app-design-system"; +import { Institution } from "../../../../../definitions/services/Institution"; +import I18n from "../../../../i18n"; +import { useIONavigation } from "../../../../navigation/params/AppParamsList"; +import { useIODispatch, useIOSelector } from "../../../../store/hooks"; +import { useOnFirstRender } from "../../../../utils/hooks/useOnFirstRender"; +import { SERVICES_ROUTES } from "../../common/navigation/routes"; +import { featuredInstitutionsGet } from "../store/actions"; +import { + featuredInstitutionsSelector, + isErrorFeaturedInstitutionsSelector, + isLoadingFeaturedInstitutionsSelector +} from "../store/reducers"; +import { + FeaturedInstitutionsCarousel, + FeaturedInstitutionsCarouselSkeleton +} from "./FeaturedInstitutionsCarousel"; + +export const FeaturedInstitutionList = () => { + const dispatch = useIODispatch(); + const navigation = useIONavigation(); + + const featuredInstitutions = useIOSelector(featuredInstitutionsSelector); + const isError = useIOSelector(isErrorFeaturedInstitutionsSelector); + const isLoading = useIOSelector(isLoadingFeaturedInstitutionsSelector); + + useOnFirstRender(() => dispatch(featuredInstitutionsGet.request())); + + const handlePress = useCallback( + ({ fiscal_code, name }: Institution) => { + navigation.navigate(SERVICES_ROUTES.SERVICES_NAVIGATOR, { + screen: SERVICES_ROUTES.INSTITUTION_SERVICES, + params: { + institutionId: fiscal_code, + institutionName: name + } + }); + }, + [navigation] + ); + + const mappedFeaturedInstitutions = useMemo( + () => + featuredInstitutions.map(props => ({ + ...props, + onPress: () => handlePress(props) + })), + [featuredInstitutions, handlePress] + ); + + const isVisible = useMemo( + () => isLoading || mappedFeaturedInstitutions.length > 0, + [isLoading, mappedFeaturedInstitutions] + ); + + if (isError || !isVisible) { + return null; + } + + return ( + <> + + {isLoading ? ( + + ) : ( + + )} + + + ); +}; diff --git a/ts/features/services/home/components/FeaturedServiceCard.tsx b/ts/features/services/home/components/FeaturedServiceCard.tsx index 0aab69075dd..ae8fc0dc18b 100644 --- a/ts/features/services/home/components/FeaturedServiceCard.tsx +++ b/ts/features/services/home/components/FeaturedServiceCard.tsx @@ -1,3 +1,6 @@ +import React from "react"; +import { StyleSheet, View } from "react-native"; +import Placeholder from "rn-placeholder"; import { Avatar, Badge, @@ -5,13 +8,12 @@ import { IOColors, IOSpacingScale, IOVisualCostants, + TestID, VSpacer } from "@pagopa/io-app-design-system"; -import React from "react"; -import { StyleSheet, View } from "react-native"; -import Placeholder from "rn-placeholder"; import I18n from "../../../../i18n"; import { WithTestID } from "../../../../types/WithTestID"; +import { CardPressableBase } from "../../common/components/CardPressableBase"; import { logoForService } from "../utils"; import OrganizationNameLabel from "./OrganizationNameLabel"; @@ -61,35 +63,43 @@ const styles = StyleSheet.create({ }); const FeaturedServiceCard = (props: FeaturedServiceCardProps) => ( - - - - {props.isNew && } - - -

- {props.name} -

- {props.organizationName && ( - <> - - - {props.organizationName} - - - )} + + + + {props.isNew && ( + + )} + + +

+ {props.name} +

+ {props.organizationName && ( + <> + + + {props.organizationName} + + + )} +
-
+ ); -const FeaturedServiceCardSkeleton = ({ testID }: WithTestID) => ( +const FeaturedServiceCardSkeleton = ({ testID }: TestID) => ( { + const dispatch = useIODispatch(); + const navigation = useIONavigation(); + + const featuredServices = useIOSelector(featuredServicesSelector); + const isError = useIOSelector(isErrorFeaturedServicesSelector); + const isLoading = useIOSelector(isLoadingFeaturedServicesSelector); + + useOnFirstRender(() => dispatch(featuredServicesGet.request())); + + const handlePress = useCallback( + (serviceId: string) => { + navigation.navigate(SERVICES_ROUTES.SERVICES_NAVIGATOR, { + screen: SERVICES_ROUTES.SERVICE_DETAIL, + params: { + serviceId: serviceId as NonEmptyString + } + }); + }, + [navigation] + ); + + const mappedFeaturedServices = useMemo( + () => + featuredServices.map(({ organization_name, ...rest }) => ({ + ...rest, + organizationName: organization_name, + onPress: () => handlePress(rest.id) + })), + [featuredServices, handlePress] + ); + + const isVisible = useMemo( + () => isLoading || mappedFeaturedServices.length > 0, + [isLoading, mappedFeaturedServices] + ); + + if (isError || !isVisible) { + return null; + } + + return ( + <> + + {isLoading ? ( + + ) : ( + + )} + + + ); +}; diff --git a/ts/features/services/home/components/ServicesHomeIntitutionList.tsx b/ts/features/services/home/components/ServicesHomeIntitutionList.tsx deleted file mode 100644 index 1717a758a65..00000000000 --- a/ts/features/services/home/components/ServicesHomeIntitutionList.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import React, { useCallback, useEffect } from "react"; -import { FlatList, ListRenderItemInfo } from "react-native"; -import { - Divider, - IOStyles, - IOToast, - ListItemHeader, - ListItemNav, - VSpacer -} from "@pagopa/io-app-design-system"; -import { Institution } from "../../../../../definitions/services/Institution"; -import I18n from "../../../../i18n"; -import { useIONavigation } from "../../../../navigation/params/AppParamsList"; -import { useOnFirstRender } from "../../../../utils/hooks/useOnFirstRender"; -import { useFirstRender } from "../../common/hooks/useFirstRender"; -import { SERVICES_ROUTES } from "../../common/navigation/routes"; -import { useInstitutionsFetcher } from "../hooks/useInstitutionsFetcher"; -import { logoForInstitution } from "../utils"; -import { InstitutionListItemSkeleton } from "./InstitutionListItemSkeleton"; - -export const ServicesHomeIntitutionList = () => { - const isFirstRender = useFirstRender(); - const navigation = useIONavigation(); - - const { - currentPage, - data, - fetchInstitutions, - isError, - isLoading, - isUpdating, - refreshInstitutions - } = useInstitutionsFetcher(); - - useOnFirstRender(() => fetchInstitutions(0)); - - useEffect(() => { - if (!isFirstRender && isError) { - IOToast.error(I18n.t("global.genericError")); - } - }, [isFirstRender, isError]); - - const handleEndReached = useCallback( - () => fetchInstitutions(currentPage + 1), - [currentPage, fetchInstitutions] - ); - - const navigateToInstitution = useCallback( - (institution: Institution) => - navigation.navigate(SERVICES_ROUTES.SERVICES_NAVIGATOR, { - screen: SERVICES_ROUTES.INSTITUTION_SERVICES, - params: { - institutionId: institution.id, - institutionName: institution.name - } - }), - [navigation] - ); - - const renderItem = useCallback( - ({ item }: ListRenderItemInfo) => ( - navigateToInstitution(item)} - accessibilityLabel={item.name} - avatarProps={{ - logoUri: logoForInstitution(item) - }} - /> - ), - [navigateToInstitution] - ); - - const renderListFooterComponent = useCallback(() => { - if (isUpdating && currentPage > 0) { - return ( - <> - - - - - - - ); - } - return ; - }, [currentPage, isUpdating]); - - const ListHeaderComponent = ( - - ); - - if (isFirstRender || isLoading) { - return ( - } - contentContainerStyle={IOStyles.horizontalContentPadding} - data={Array.from({ length: 5 })} - keyExtractor={(_, index) => `placeholder-${index}`} - renderItem={() => } - onRefresh={refreshInstitutions} - refreshing={isUpdating} - /> - ); - } - - return ( - } - contentContainerStyle={IOStyles.horizontalContentPadding} - data={data?.institutions || []} - keyExtractor={(item, index) => `institution-${item.id}-${index}`} - renderItem={renderItem} - onEndReached={handleEndReached} - onEndReachedThreshold={0.001} - onRefresh={refreshInstitutions} - refreshing={isUpdating} - ListFooterComponent={renderListFooterComponent} - /> - ); -}; diff --git a/ts/features/services/home/components/__tests__/FeaturedInstitutionCard.test.tsx b/ts/features/services/home/components/__tests__/FeaturedInstitutionCard.test.tsx new file mode 100644 index 00000000000..a8b25fe8012 --- /dev/null +++ b/ts/features/services/home/components/__tests__/FeaturedInstitutionCard.test.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { createStore } from "redux"; +import { applicationChangeState } from "../../../../../store/actions/application"; +import { appReducer } from "../../../../../store/reducers"; +import { GlobalState } from "../../../../../store/reducers/types"; +import { renderScreenWithNavigationStoreContext } from "../../../../../utils/testWrapper"; +import { SERVICES_ROUTES } from "../../../common/navigation/routes"; +import { + FeaturedInstitutionCard, + FeaturedInstitutionCardProps +} from "../FeaturedInstitutionCard"; + +const testID = "FeaturedInstitutionCardTestID"; + +describe("FeaturedInstitutionCard", () => { + it(`should match the snapshot`, () => { + const component = render({ + id: "1", + name: "### Institution ###", + accessibilityLabel: "### Accessibility Label ###", + onPress: () => undefined, + testID + }); + expect(component).toMatchSnapshot(); + }); + + it(`should render card without pressable wrapper`, () => { + const { queryByTestId } = render({ + id: "1", + name: "### Institution ###", + accessibilityLabel: "### Accessibility Label ###", + testID + }); + expect(queryByTestId(`${testID}-pressable`)).toBeNull(); + }); + + it(`should match the snapshot when isNew is true`, () => { + const component = render({ + id: "1", + name: "### Institution ###", + accessibilityLabel: "### Accessibility Label ###", + onPress: () => undefined, + isNew: true, + testID + }); + expect(component).toMatchSnapshot(); + }); +}); + +function render(props: FeaturedInstitutionCardProps) { + const globalState = appReducer(undefined, applicationChangeState("active")); + return renderScreenWithNavigationStoreContext( + () => , + SERVICES_ROUTES.SERVICES_HOME, + {}, + createStore(appReducer, globalState as any) + ); +} diff --git a/ts/features/services/home/components/__tests__/FeaturedServiceCard.test.tsx b/ts/features/services/home/components/__tests__/FeaturedServiceCard.test.tsx new file mode 100644 index 00000000000..800bb8b080f --- /dev/null +++ b/ts/features/services/home/components/__tests__/FeaturedServiceCard.test.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { createStore } from "redux"; +import { applicationChangeState } from "../../../../../store/actions/application"; +import { appReducer } from "../../../../../store/reducers"; +import { GlobalState } from "../../../../../store/reducers/types"; +import { renderScreenWithNavigationStoreContext } from "../../../../../utils/testWrapper"; +import { SERVICES_ROUTES } from "../../../common/navigation/routes"; +import { + FeaturedServiceCard, + FeaturedServiceCardProps +} from "../FeaturedServiceCard"; + +const testID = "FeaturedServiceCardTestID"; + +describe("FeaturedServiceCard", () => { + it(`should match the snapshot`, () => { + const component = render({ + id: "1", + name: "### Service ###", + accessibilityLabel: "### Accessibility Label ###", + onPress: () => undefined, + testID + }); + expect(component).toMatchSnapshot(); + }); + + it(`should render card without pressable wrapper`, () => { + const { queryByTestId } = render({ + id: "1", + name: "### Service ###", + accessibilityLabel: "### Accessibility Label ###", + testID + }); + expect(queryByTestId(`${testID}-pressable`)).toBeNull(); + }); + + it(`should match the snapshot when isNew is true`, () => { + const component = render({ + id: "1", + name: "### Service ###", + accessibilityLabel: "### Accessibility Label ###", + onPress: () => undefined, + isNew: true, + testID + }); + expect(component).toMatchSnapshot(); + }); +}); + +function render(props: FeaturedServiceCardProps) { + const globalState = appReducer(undefined, applicationChangeState("active")); + return renderScreenWithNavigationStoreContext( + () => , + SERVICES_ROUTES.SERVICES_HOME, + {}, + createStore(appReducer, globalState as any) + ); +} diff --git a/ts/features/services/home/components/__tests__/__snapshots__/FeaturedInstitutionCard.test.tsx.snap b/ts/features/services/home/components/__tests__/__snapshots__/FeaturedInstitutionCard.test.tsx.snap new file mode 100644 index 00000000000..e38333bde1b --- /dev/null +++ b/ts/features/services/home/components/__tests__/__snapshots__/FeaturedInstitutionCard.test.tsx.snap @@ -0,0 +1,990 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FeaturedInstitutionCard should match the snapshot 1`] = ` + + + + + + + + + + + + + + + SERVICES_HOME + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ### Institution ### + + + + + + + + + + + + + + + + + +`; + +exports[`FeaturedInstitutionCard should match the snapshot when isNew is true 1`] = ` + + + + + + + + + + + + + + + SERVICES_HOME + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ### Institution ### + + + + + + + + + + + + + + + + + +`; diff --git a/ts/features/services/home/components/__tests__/__snapshots__/FeaturedServiceCard.test.tsx.snap b/ts/features/services/home/components/__tests__/__snapshots__/FeaturedServiceCard.test.tsx.snap new file mode 100644 index 00000000000..f05754803c8 --- /dev/null +++ b/ts/features/services/home/components/__tests__/__snapshots__/FeaturedServiceCard.test.tsx.snap @@ -0,0 +1,1024 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FeaturedServiceCard should match the snapshot 1`] = ` + + + + + + + + + + + + + + + SERVICES_HOME + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ### Service ### + + + + + + + + + + + + + + + + +`; + +exports[`FeaturedServiceCard should match the snapshot when isNew is true 1`] = ` + + + + + + + + + + + + + + + SERVICES_HOME + + + + + + + + + + + + + + + + + + + + + + + + + + + + New + + + + + + ### Service ### + + + + + + + + + + + + + + + + +`; diff --git a/ts/features/services/home/hooks/useInstitutionsFetcher.tsx b/ts/features/services/home/hooks/useInstitutionsFetcher.tsx index b717d358fc1..19bbe29f640 100644 --- a/ts/features/services/home/hooks/useInstitutionsFetcher.tsx +++ b/ts/features/services/home/hooks/useInstitutionsFetcher.tsx @@ -1,3 +1,4 @@ +import { useCallback, useEffect, useState } from "react"; import { ScopeTypeEnum } from "../../../../../definitions/services/ScopeType"; import { useIODispatch, useIOSelector } from "../../../../store/hooks"; import { paginatedInstitutionsGet } from "../store/actions"; @@ -22,27 +23,44 @@ export const useInstitutionsFetcher = () => { const isUpdating = useIOSelector(isUpdatingPaginatedInstitutionsSelector); const isError = useIOSelector(isErrorPaginatedInstitutionsSelector); - const fetchPage = (page: number) => { - if (!isLoading && !isUpdating) { - dispatch( - paginatedInstitutionsGet.request({ - offset: page * LIMIT, - limit: LIMIT, - scope: ScopeTypeEnum.NATIONAL - }) - ); - } - }; + const [isRefreshing, setIsRefreshing] = useState(false); - const fetchInstitutions = (page: number) => { - if (isLastPage) { - return; + useEffect(() => { + if (isRefreshing && !isUpdating) { + setIsRefreshing(false); } + }, [isRefreshing, isUpdating]); - fetchPage(page); - }; + const fetchPage = useCallback( + (page: number) => { + if (!isLoading && !isUpdating) { + dispatch( + paginatedInstitutionsGet.request({ + offset: page * LIMIT, + limit: LIMIT, + scope: ScopeTypeEnum.NATIONAL + }) + ); + } + }, + [dispatch, isLoading, isUpdating] + ); + + const fetchInstitutions = useCallback( + (page: number) => { + if (isLastPage) { + return; + } + + fetchPage(page); + }, + [isLastPage, fetchPage] + ); - const refreshInstitutions = () => fetchPage(0); + const refreshInstitutions = useCallback(() => { + setIsRefreshing(true); + fetchPage(0); + }, [fetchPage]); return { currentPage, @@ -50,6 +68,7 @@ export const useInstitutionsFetcher = () => { isError, isLoading, isUpdating, + isRefreshing, fetchInstitutions, refreshInstitutions }; diff --git a/ts/features/services/home/screens/ServicesHomeScreen.tsx b/ts/features/services/home/screens/ServicesHomeScreen.tsx index e06678ab6a4..152b55ecf19 100644 --- a/ts/features/services/home/screens/ServicesHomeScreen.tsx +++ b/ts/features/services/home/screens/ServicesHomeScreen.tsx @@ -1,4 +1,141 @@ -import React from "react"; -import { ServicesHomeIntitutionList } from "../components/ServicesHomeIntitutionList"; +import React, { useCallback, useEffect } from "react"; +import { FlatList, ListRenderItemInfo, StyleSheet } from "react-native"; +import { + Divider, + IOStyles, + IOToast, + ListItemHeader, + ListItemNav, + VSpacer +} from "@pagopa/io-app-design-system"; +import I18n from "../../../../i18n"; +import { Institution } from "../../../../../definitions/services/Institution"; +import { useIONavigation } from "../../../../navigation/params/AppParamsList"; +import { useIODispatch } from "../../../../store/hooks"; +import { useOnFirstRender } from "../../../../utils/hooks/useOnFirstRender"; +import { InstitutionListSkeleton } from "../../common/components/InstitutionListSkeleton"; +import { useFirstRender } from "../../common/hooks/useFirstRender"; +import { SERVICES_ROUTES } from "../../common/navigation/routes"; +import { useInstitutionsFetcher } from "../hooks/useInstitutionsFetcher"; +import { featuredInstitutionsGet, featuredServicesGet } from "../store/actions"; +import { logoForInstitution } from "../utils"; +import { FeaturedInstitutionList } from "../components/FeaturedInstitutionList"; +import { FeaturedServiceList } from "../components/FeaturedServiceList"; -export const ServicesHomeScreen = () => ; +const styles = StyleSheet.create({ + scrollContentContainer: { + flexGrow: 1 + } +}); + +export const ServicesHomeScreen = () => { + const dispatch = useIODispatch(); + const navigation = useIONavigation(); + const isFirstRender = useFirstRender(); + + const { + currentPage, + data, + fetchInstitutions, + isError, + isLoading, + isUpdating, + isRefreshing, + refreshInstitutions + } = useInstitutionsFetcher(); + + useOnFirstRender(() => fetchInstitutions(0)); + + useEffect(() => { + if (!isFirstRender && isError) { + IOToast.error(I18n.t("global.genericError")); + } + }, [isFirstRender, isError]); + + const renderListEmptyComponent = useCallback(() => { + if (isFirstRender || isLoading) { + return ( + <> + + + + ); + } + return <>; + }, [isFirstRender, isLoading]); + + const renderListHeaderComponent = useCallback( + () => ( + <> + + + + + ), + [] + ); + + const renderListFooterComponent = useCallback(() => { + if (isUpdating && !isRefreshing) { + return ; + } + return ; + }, [isUpdating, isRefreshing]); + + const handleRefresh = useCallback(() => { + dispatch(featuredServicesGet.request()); + dispatch(featuredInstitutionsGet.request()); + refreshInstitutions(); + }, [dispatch, refreshInstitutions]); + + const handleEndReached = useCallback( + () => fetchInstitutions(currentPage + 1), + [currentPage, fetchInstitutions] + ); + + const navigateToInstitution = useCallback( + (institution: Institution) => + navigation.navigate(SERVICES_ROUTES.SERVICES_NAVIGATOR, { + screen: SERVICES_ROUTES.INSTITUTION_SERVICES, + params: { + institutionId: institution.id, + institutionName: institution.name + } + }), + [navigation] + ); + + const renderInstitutionItem = useCallback( + ({ item }: ListRenderItemInfo) => ( + navigateToInstitution(item)} + accessibilityLabel={item.name} + avatarProps={{ + logoUri: logoForInstitution(item) + }} + /> + ), + [navigateToInstitution] + ); + + return ( + } + ListEmptyComponent={renderListEmptyComponent} + ListFooterComponent={renderListFooterComponent} + ListHeaderComponent={renderListHeaderComponent} + contentContainerStyle={[ + styles.scrollContentContainer, + IOStyles.horizontalContentPadding + ]} + data={data?.institutions || []} + keyExtractor={(item, index) => `institution-${item.id}-${index}`} + onEndReached={handleEndReached} + onEndReachedThreshold={0.001} + onRefresh={handleRefresh} + refreshing={isRefreshing} + renderItem={renderInstitutionItem} + /> + ); +}; diff --git a/ts/features/services/home/store/reducers/__tests__/store.test.ts b/ts/features/services/home/store/reducers/__tests__/store.test.ts index e760008aaf6..2bcad4c329f 100644 --- a/ts/features/services/home/store/reducers/__tests__/store.test.ts +++ b/ts/features/services/home/store/reducers/__tests__/store.test.ts @@ -9,8 +9,6 @@ import { isLoadingFeaturedInstitutionsSelector, isLoadingFeaturedServicesSelector, isLoadingPaginatedInstitutionsSelector, - isUpdatingFeaturedInstitutionsSelector, - isUpdatingFeaturedServicesSelector, isUpdatingPaginatedInstitutionsSelector, paginatedInstitutionsCurrentPageSelector, paginatedInstitutionsLastPageSelector, @@ -492,21 +490,19 @@ describe("Services home featuredInstitutions selectors", () => { }) ) ); - expect(featuredInstitutions).toStrictEqual({ - institutions: MOCK_INSTITUTIONS - }); + expect(featuredInstitutions).toStrictEqual(MOCK_INSTITUTIONS); }); it("should return undefined when not pot.some", () => { expect( featuredInstitutionsSelector(appReducer(undefined, {} as Action)) - ).toBeUndefined(); + ).toStrictEqual([]); expect( featuredInstitutionsSelector( appReducer({} as GlobalState, featuredInstitutionsGet.request()) ) - ).toBeUndefined(); + ).toStrictEqual([]); expect( featuredInstitutionsSelector( @@ -515,7 +511,7 @@ describe("Services home featuredInstitutions selectors", () => { featuredInstitutionsGet.failure(MOCK_NETWORK_ERROR) ) ) - ).toBeUndefined(); + ).toStrictEqual([]); }); }); @@ -547,51 +543,6 @@ describe("Services home featuredInstitutions selectors", () => { }); }); - describe("isUpdatingFeaturedInstitutionsSelector", () => { - it("should return true when pot.updating", () => { - const state = appReducer(undefined, applicationChangeState("active")); - const store = createStore(appReducer, state as any); - - store.dispatch( - featuredInstitutionsGet.success({ - institutions: MOCK_INSTITUTIONS - }) - ); - store.dispatch(featuredInstitutionsGet.request()); - - const isUpdating = isUpdatingFeaturedInstitutionsSelector( - store.getState() - ); - - expect(isUpdating).toStrictEqual(true); - }); - - it("should return false when not pot.updating", () => { - expect( - isUpdatingFeaturedInstitutionsSelector( - appReducer(undefined, {} as Action) - ) - ).toStrictEqual(false); - - expect( - isUpdatingFeaturedInstitutionsSelector( - appReducer({} as GlobalState, featuredInstitutionsGet.request()) - ) - ).toStrictEqual(false); - - expect( - isUpdatingFeaturedInstitutionsSelector( - appReducer( - {} as GlobalState, - featuredInstitutionsGet.success({ - institutions: MOCK_INSTITUTIONS - }) - ) - ) - ).toStrictEqual(false); - }); - }); - describe("isErrorFeaturedInstitutionsSelector", () => { it("should return true when pot.error", () => { const isError = isErrorFeaturedInstitutionsSelector( @@ -678,21 +629,19 @@ describe("Services home featuredServices selectors", () => { }) ) ); - expect(featuredServices).toStrictEqual({ - services: MOCK_FEATURED_SERVICES - }); + expect(featuredServices).toStrictEqual(MOCK_FEATURED_SERVICES); }); it("should return undefined when not pot.some", () => { expect( featuredServicesSelector(appReducer(undefined, {} as Action)) - ).toBeUndefined(); + ).toStrictEqual([]); expect( featuredServicesSelector( appReducer({} as GlobalState, featuredServicesGet.request()) ) - ).toBeUndefined(); + ).toStrictEqual([]); expect( featuredServicesSelector( @@ -701,7 +650,7 @@ describe("Services home featuredServices selectors", () => { featuredServicesGet.failure(MOCK_NETWORK_ERROR) ) ) - ).toBeUndefined(); + ).toStrictEqual([]); }); }); @@ -731,47 +680,6 @@ describe("Services home featuredServices selectors", () => { }); }); - describe("isUpdatingFeaturedServicesSelector", () => { - it("should return true when pot.updating", () => { - const state = appReducer(undefined, applicationChangeState("active")); - const store = createStore(appReducer, state as any); - - store.dispatch( - featuredServicesGet.success({ - services: MOCK_FEATURED_SERVICES - }) - ); - store.dispatch(featuredServicesGet.request()); - - const isUpdating = isUpdatingFeaturedServicesSelector(store.getState()); - - expect(isUpdating).toStrictEqual(true); - }); - - it("should return false when not pot.updating", () => { - expect( - isUpdatingFeaturedServicesSelector(appReducer(undefined, {} as Action)) - ).toStrictEqual(false); - - expect( - isUpdatingFeaturedServicesSelector( - appReducer({} as GlobalState, featuredServicesGet.request()) - ) - ).toStrictEqual(false); - - expect( - isUpdatingFeaturedServicesSelector( - appReducer( - {} as GlobalState, - featuredServicesGet.success({ - services: MOCK_FEATURED_SERVICES - }) - ) - ) - ).toStrictEqual(false); - }); - }); - describe("isErrorFeaturedServicesSelector", () => { it("should return true when pot.error", () => { const isError = isErrorFeaturedServicesSelector( diff --git a/ts/features/services/home/store/reducers/index.ts b/ts/features/services/home/store/reducers/index.ts index faa7cbd604a..b406de31f9b 100644 --- a/ts/features/services/home/store/reducers/index.ts +++ b/ts/features/services/home/store/reducers/index.ts @@ -32,7 +32,7 @@ const homeReducer = ( action: Action ): ServicesHomeState => { switch (action.type) { - // Fetch Institutions actions + // Get Institutions actions case getType(paginatedInstitutionsGet.request): if (pot.isNone(state.paginatedInstitutions)) { return { @@ -159,7 +159,14 @@ export const featuredInstitutionsPotSelector = createSelector( export const featuredInstitutionsSelector = createSelector( featuredInstitutionsPotSelector, - pot.toUndefined + featuredInstitutionsPot => + pot.getOrElse( + pot.map( + featuredInstitutionsPot, + featuredInstitutions => featuredInstitutions.institutions + ), + [] + ) ); export const featuredServicesPotSelector = createSelector( @@ -169,7 +176,14 @@ export const featuredServicesPotSelector = createSelector( export const featuredServicesSelector = createSelector( featuredServicesPotSelector, - pot.toUndefined + featuredServicesPot => + pot.getOrElse( + pot.map( + featuredServicesPot, + featuredServices => featuredServices.services + ), + [] + ) ); export const isLoadingPaginatedInstitutionsSelector = (state: GlobalState) => @@ -184,18 +198,12 @@ export const isErrorPaginatedInstitutionsSelector = (state: GlobalState) => export const isLoadingFeaturedInstitutionsSelector = (state: GlobalState) => pipe(state, featuredInstitutionsPotSelector, pot.isLoading); -export const isUpdatingFeaturedInstitutionsSelector = (state: GlobalState) => - pipe(state, featuredInstitutionsPotSelector, pot.isUpdating); - export const isErrorFeaturedInstitutionsSelector = (state: GlobalState) => pipe(state, featuredInstitutionsPotSelector, pot.isError); export const isLoadingFeaturedServicesSelector = (state: GlobalState) => pipe(state, featuredServicesPotSelector, pot.isLoading); -export const isUpdatingFeaturedServicesSelector = (state: GlobalState) => - pipe(state, featuredServicesPotSelector, pot.isUpdating); - export const isErrorFeaturedServicesSelector = (state: GlobalState) => pipe(state, featuredServicesPotSelector, pot.isError);