From 68c5f314a0949603a6de3982863344d5d8017c01 Mon Sep 17 00:00:00 2001 From: Maxime Le Duc Date: Mon, 16 Dec 2024 16:07:02 +0100 Subject: [PATCH 1/5] Feature: open/close modal in store --- src/libs/modals/modal.store.ts | 49 +++++++++++++++++++ .../usecases/close-modal.native.spec.ts | 24 +++++++++ src/libs/modals/usecases/close-modal.ts | 17 +++++++ .../modals/usecases/open-modal.native.spec.ts | 29 +++++++++++ src/libs/modals/usecases/open-modal.ts | 16 ++++++ 5 files changed, 135 insertions(+) create mode 100644 src/libs/modals/modal.store.ts create mode 100644 src/libs/modals/usecases/close-modal.native.spec.ts create mode 100644 src/libs/modals/usecases/close-modal.ts create mode 100644 src/libs/modals/usecases/open-modal.native.spec.ts create mode 100644 src/libs/modals/usecases/open-modal.ts diff --git a/src/libs/modals/modal.store.ts b/src/libs/modals/modal.store.ts new file mode 100644 index 00000000000..5c588f830c3 --- /dev/null +++ b/src/libs/modals/modal.store.ts @@ -0,0 +1,49 @@ +import { createStore } from 'libs/store/createStore' + +export type Modal

= { + key: string + params: P +} + +type State = { + modalOpened?: Modal + queue: Modal[] +} + +const defaultStore: State = { + modalOpened: undefined, + queue: [], +} + +const setActions = (set: (payload: (state: State) => State) => void) => ({ + openModal: (modal: Modal) => { + set((state) => ({ + ...state, + modalOpened: modal, + })) + }, + queueModal: (modal: Modal) => { + set((state) => ({ + ...state, + queue: [...state.queue, modal], + })) + }, + clearOpenedModal: () => { + set((state) => ({ + ...state, + modalOpened: undefined, + })) + }, + removeModalFromQueue: (modal: Modal) => { + set((state) => ({ + ...state, + queue: state.queue.filter((queuedModal) => queuedModal !== modal), + })) + }, +}) + +export const modalStore = createStore>( + 'modals', + defaultStore, + setActions +) diff --git a/src/libs/modals/usecases/close-modal.native.spec.ts b/src/libs/modals/usecases/close-modal.native.spec.ts new file mode 100644 index 00000000000..9bfc37aa3ae --- /dev/null +++ b/src/libs/modals/usecases/close-modal.native.spec.ts @@ -0,0 +1,24 @@ +import { modalStore } from '../modal.store' + +import { closeModal } from './close-modal' + +describe('Feature: Close modal', () => { + test('Modal is closed', () => { + const modal = { key: 'modal-key', params: { text: 'Hello world!' } } + modalStore.setState({ modalOpened: modal }) + + closeModal() + + expect(modalStore.getState().modalOpened).toBeUndefined() + }) + + test('Modal is opened from queue if there is one', () => { + const modal = { key: 'modal-key', params: { text: 'Hello world!' } } + const queuedModal = { key: 'queued-modal-key', params: { text: 'Hello world!' } } + modalStore.setState({ modalOpened: modal, queue: [queuedModal] }) + + closeModal() + + expect(modalStore.getState().modalOpened).toEqual(queuedModal) + }) +}) diff --git a/src/libs/modals/usecases/close-modal.ts b/src/libs/modals/usecases/close-modal.ts new file mode 100644 index 00000000000..6a8c22e3b71 --- /dev/null +++ b/src/libs/modals/usecases/close-modal.ts @@ -0,0 +1,17 @@ +import { modalStore } from '../modal.store' + +import { openModal } from './open-modal' +export const closeModal = () => { + const { + queue, + actions: { clearOpenedModal, removeModalFromQueue }, + } = modalStore.getState() + + clearOpenedModal() + const [nextModal] = queue + + if (nextModal) { + removeModalFromQueue(nextModal) + openModal(nextModal) + } +} diff --git a/src/libs/modals/usecases/open-modal.native.spec.ts b/src/libs/modals/usecases/open-modal.native.spec.ts new file mode 100644 index 00000000000..2291e57fdba --- /dev/null +++ b/src/libs/modals/usecases/open-modal.native.spec.ts @@ -0,0 +1,29 @@ +import { modalStore } from '../modal.store' + +import { openModal } from './open-modal' + +describe('Feature: Open modal', () => { + test('Modal is opened', () => { + openModal({ key: 'modal-key', params: { text: 'Hello world!' } }) + + expect(modalStore.getState().modalOpened).toEqual({ + key: 'modal-key', + params: { text: 'Hello world!' }, + }) + }) + + test('Modal is queued if another modal is opened', () => { + modalStore.setState({ + modalOpened: { key: 'modal-key-1', params: { text: 'Hello world!' } }, + }) + + openModal({ key: 'modal-key-1', params: { text: 'Hello world!' } }) + + expect(modalStore.getState().queue).toEqual([ + { + key: 'modal-key-1', + params: { text: 'Hello world!' }, + }, + ]) + }) +}) diff --git a/src/libs/modals/usecases/open-modal.ts b/src/libs/modals/usecases/open-modal.ts new file mode 100644 index 00000000000..bdc3fcbf9b9 --- /dev/null +++ b/src/libs/modals/usecases/open-modal.ts @@ -0,0 +1,16 @@ +import { Modal, modalStore } from '../modal.store' + +export const openModal = (modal: Modal) => { + const { + modalOpened, + actions: { openModal, queueModal }, + } = modalStore.getState() + const hasModalOpened = modalOpened !== undefined + + if (hasModalOpened) { + queueModal(modal) + return + } + + openModal(modal) +} From 0e1371eeb5c25685d4bf6a48ab7a2ffc55eea913 Mon Sep 17 00:00:00 2001 From: Maxime Le Duc Date: Mon, 16 Dec 2024 22:12:35 +0100 Subject: [PATCH 2/5] Use modal system for achievements modal --- src/features/home/pages/Home.tsx | 17 +++------ .../RootNavigator/RootNavigator.tsx | 11 ++++++ .../Modals/AchievementSuccessModal.tsx | 2 +- .../useShouldShowAchievementSuccessModal.tsx | 2 +- src/libs/modals/modal.creator.ts | 14 ++++++++ src/libs/modals/modal.renderer.tsx | 18 ++++++++++ src/libs/modals/modal.store.ts | 2 ++ .../modals/modals.factory.native.spec.tsx | 35 +++++++++++++++++++ src/libs/modals/modals.factory.ts | 26 ++++++++++++++ src/libs/modals/modals.ts | 5 +++ 10 files changed, 117 insertions(+), 15 deletions(-) create mode 100644 src/libs/modals/modal.creator.ts create mode 100644 src/libs/modals/modal.renderer.tsx create mode 100644 src/libs/modals/modals.factory.native.spec.tsx create mode 100644 src/libs/modals/modals.factory.ts create mode 100644 src/libs/modals/modals.ts diff --git a/src/features/home/pages/Home.tsx b/src/features/home/pages/Home.tsx index 52b11b41539..45b3c6215ab 100644 --- a/src/features/home/pages/Home.tsx +++ b/src/features/home/pages/Home.tsx @@ -12,7 +12,6 @@ import { HomeBanner } from 'features/home/components/modules/banners/HomeBanner' import { PERFORMANCE_HOME_CREATION, PERFORMANCE_HOME_LOADING } from 'features/home/constants' import { GenericHome } from 'features/home/pages/GenericHome' import { UseRouteType } from 'features/navigation/RootNavigator/types' -import { AchievementSuccessModal } from 'features/profile/components/Modals/AchievementSuccessModal' import { useShouldShowAchievementSuccessModal } from 'features/profile/components/Modals/useShouldShowAchievementSuccessModal' import { OnboardingSubscriptionModal } from 'features/subscription/components/modals/OnboardingSubscriptionModal' import { useOnboardingSubscriptionModal } from 'features/subscription/helpers/useOnboardingSubscriptionModal' @@ -22,6 +21,8 @@ import { RemoteStoreFeatureFlags } from 'libs/firebase/firestore/types' import { useFunctionOnce } from 'libs/hooks' import { useLocation } from 'libs/location' import { LocationMode } from 'libs/location/types' +import { achievementsModal } from 'libs/modals/modals' +import { openModal } from 'libs/modals/usecases/open-modal' import { getAppVersion } from 'libs/packageJson' import { BatchProfile } from 'libs/react-native-batch' import { startTransaction } from 'shared/performance/transactions' @@ -60,17 +61,12 @@ export const Home: FunctionComponent = () => { }) const { shouldShowAchievementSuccessModal, achievementsToShow } = useShouldShowAchievementSuccessModal() - const { - visible: visibleAchievementModal, - showModal: showAchievementModal, - hideModal: hideAchievementModal, - } = useModal(false) useEffect(() => { if (shouldShowAchievementSuccessModal) { - showAchievementModal() + openModal(achievementsModal({ names: achievementsToShow })) } - }, [shouldShowAchievementSuccessModal, showAchievementModal]) + }, [shouldShowAchievementSuccessModal, achievementsToShow]) const isReactionFeatureActive = useFeatureFlag(RemoteStoreFeatureFlags.WIP_REACTION_FEATURE) @@ -140,11 +136,6 @@ export const Home: FunctionComponent = () => { dismissModal={hideOnboardingSubscriptionModal} /> {isReactionFeatureActive ? : null} - ) } diff --git a/src/features/navigation/RootNavigator/RootNavigator.tsx b/src/features/navigation/RootNavigator/RootNavigator.tsx index db419315339..3cc4bee74f6 100644 --- a/src/features/navigation/RootNavigator/RootNavigator.tsx +++ b/src/features/navigation/RootNavigator/RootNavigator.tsx @@ -13,7 +13,12 @@ import { useInitialScreen } from 'features/navigation/RootNavigator/useInitialSc import { withWebWrapper } from 'features/navigation/RootNavigator/withWebWrapper' import { TabNavigationStateProvider } from 'features/navigation/TabBar/TabNavigationStateContext' import { VenueMapFiltersStackNavigator } from 'features/navigation/VenueMapFiltersStackNavigator/VenueMapFiltersStackNavigator' +import { AchievementSuccessModal } from 'features/profile/components/Modals/AchievementSuccessModal' import { AccessibilityRole } from 'libs/accessibilityRole/accessibilityRole' +import { ModalRenderer } from 'libs/modals/modal.renderer' +import { achievementsModal } from 'libs/modals/modals' +import { createModalFactory } from 'libs/modals/modals.factory' +import { closeModal } from 'libs/modals/usecases/close-modal' import { useSplashScreenContext } from 'libs/splashscreen' import { storage } from 'libs/storage' import { IconFactoryProvider } from 'ui/components/icons/IconFactoryProvider' @@ -29,6 +34,11 @@ import { RootStack } from './Stack' const isWeb = Platform.OS === 'web' +const modalFactory = createModalFactory() +modalFactory.add(achievementsModal, ({ params: { names } }) => ( + +)) + const RootStackNavigator = withWebWrapper( ({ initialRouteName }: { initialRouteName: RootScreenNames }) => { const { top } = useSafeAreaInsets() @@ -108,6 +118,7 @@ export const RootNavigator: React.ComponentType = () => { {/* The components below are those for which we do not want their rendering to happen while the splash is displayed. */} {isSplashScreenHidden ? : null} + ) } diff --git a/src/features/profile/components/Modals/AchievementSuccessModal.tsx b/src/features/profile/components/Modals/AchievementSuccessModal.tsx index 4f310ff4c90..37caeb78fd0 100644 --- a/src/features/profile/components/Modals/AchievementSuccessModal.tsx +++ b/src/features/profile/components/Modals/AchievementSuccessModal.tsx @@ -33,7 +33,7 @@ export const AchievementSuccessModal = ({ visible, hideModal, names }: Props) => // eslint-disable-next-line react-hooks/exhaustive-deps }, [visible]) - if (!visible || names.length <= 0) return null + if (!visible) return null const severalAchievementsUnlocked = names.length >= 2 diff --git a/src/features/profile/components/Modals/useShouldShowAchievementSuccessModal.tsx b/src/features/profile/components/Modals/useShouldShowAchievementSuccessModal.tsx index 9049d4306a2..f2967803ae1 100644 --- a/src/features/profile/components/Modals/useShouldShowAchievementSuccessModal.tsx +++ b/src/features/profile/components/Modals/useShouldShowAchievementSuccessModal.tsx @@ -31,5 +31,5 @@ export const useShouldShowAchievementSuccessModal = () => { if (unseenAchievements && unseenAchievements?.length > 0) achievementsToShow = unseenAchievements - return { shouldShowAchievementSuccessModal, achievementsToShow } + return { shouldShowAchievementSuccessModal: true, achievementsToShow } } diff --git a/src/libs/modals/modal.creator.ts b/src/libs/modals/modal.creator.ts new file mode 100644 index 00000000000..d0342599822 --- /dev/null +++ b/src/libs/modals/modal.creator.ts @@ -0,0 +1,14 @@ +import { Modal } from './modal.store' + +export type ModalCreator = ReturnType> + +export const createModal = (key: string) => { + const creator = (params: T): Modal => { + return { + key, + params, + } + } + creator.key = key + return creator +} diff --git a/src/libs/modals/modal.renderer.tsx b/src/libs/modals/modal.renderer.tsx new file mode 100644 index 00000000000..f6ac91d03e5 --- /dev/null +++ b/src/libs/modals/modal.renderer.tsx @@ -0,0 +1,18 @@ +import React, { FC } from 'react' + +import { useModalStore } from './modal.store' +import { ModalFactory } from './modals.factory' + +type Props = { modalFactory: ModalFactory } + +export const ModalRenderer: FC = ({ modalFactory }) => { + const modalOpened = useModalStore((state) => state.modalOpened) + + if (!modalOpened) { + return null + } + + const ModalRenderer = modalFactory.get(modalOpened) + + return +} diff --git a/src/libs/modals/modal.store.ts b/src/libs/modals/modal.store.ts index 5c588f830c3..74828e7b261 100644 --- a/src/libs/modals/modal.store.ts +++ b/src/libs/modals/modal.store.ts @@ -47,3 +47,5 @@ export const modalStore = createStore>( defaultStore, setActions ) + +export const useModalStore = modalStore diff --git a/src/libs/modals/modals.factory.native.spec.tsx b/src/libs/modals/modals.factory.native.spec.tsx new file mode 100644 index 00000000000..bf3de858b5f --- /dev/null +++ b/src/libs/modals/modals.factory.native.spec.tsx @@ -0,0 +1,35 @@ +import React from 'react' + +import { TypoDS } from 'ui/theme' + +import { createModal } from './modal.creator' +import { createModalFactory } from './modals.factory' + +describe('Feature: Modals render factory', () => { + test('Modal is added', () => { + const modalFactory = createModalFactory() + const modal = createModal('modal-key') + const render = () => Modal + modalFactory.add(modal, render) + + expect(modalFactory.get(modal(undefined))).toEqual(render) + }) + + test('Already exist error is thrown when modal already exist', () => { + const modalFactory = createModalFactory() + const modal = createModal('modal-key') + modalFactory.add(modal, () => Modal) + + expect(() => modalFactory.add(modal, () => Modal)).toThrowError( + `Modal with key\u00a0: ${modal.key} Already exist` + ) + }) + + test('Throw error when modal is not found', () => { + const modalFactory = createModalFactory() + const modal = createModal('modal-key') + expect(() => modalFactory.get(modal(undefined))).toThrowError( + `Modal with key\u00a0: ${modal.key} Not found` + ) + }) +}) diff --git a/src/libs/modals/modals.factory.ts b/src/libs/modals/modals.factory.ts new file mode 100644 index 00000000000..466d06c636a --- /dev/null +++ b/src/libs/modals/modals.factory.ts @@ -0,0 +1,26 @@ +import React from 'react' + +import { ModalCreator } from './modal.creator' +import { Modal } from './modal.store' + +export type ModalFactory = ReturnType + +export const createModalFactory = () => { + const modals: Map> = new Map() + + return { + add: (modalCreator: ModalCreator, element: React.FC<{ params: T }>) => { + if (modals.has(modalCreator.key)) { + throw new Error(`Modal with key\u00a0: ${modalCreator.key} Already exist`) + } + modals.set(modalCreator.key, element as React.FC<{ params: unknown }>) + }, + get: (modal: Modal) => { + const modalElement = modals.get(modal.key) + if (!modalElement) { + throw new Error(`Modal with key\u00a0: ${modal.key} Not found`) + } + return modalElement + }, + } +} diff --git a/src/libs/modals/modals.ts b/src/libs/modals/modals.ts new file mode 100644 index 00000000000..252d7a2e9ad --- /dev/null +++ b/src/libs/modals/modals.ts @@ -0,0 +1,5 @@ +import { AchievementEnum } from 'api/gen' + +import { createModal } from './modal.creator' + +export const achievementsModal = createModal<{ names: AchievementEnum[] }>('achievements') From 1b39457c3ccc64f0f636d97d69e12daa3897ee78 Mon Sep 17 00:00:00 2001 From: Maxime Le Duc Date: Tue, 17 Dec 2024 11:17:24 +0100 Subject: [PATCH 3/5] Refactor --- src/features/home/pages/Home.tsx | 5 ++-- .../RootNavigator/RootNavigator.tsx | 7 +++--- .../close-modal.native.spec.ts | 6 ++--- .../open-modal.native.spec.ts | 8 +++--- src/libs/modals/modal.renderer.tsx | 7 ++++-- src/libs/modals/modal.store.ts | 25 ++++++++++++++++--- src/libs/modals/modals.factory.ts | 7 ++++-- src/libs/modals/usecases/close-modal.ts | 17 ------------- src/libs/modals/usecases/open-modal.ts | 16 ------------ 9 files changed, 43 insertions(+), 55 deletions(-) rename src/libs/modals/{usecases => __tests__}/close-modal.native.spec.ts (87%) rename src/libs/modals/{usecases => __tests__}/open-modal.native.spec.ts (72%) delete mode 100644 src/libs/modals/usecases/close-modal.ts delete mode 100644 src/libs/modals/usecases/open-modal.ts diff --git a/src/features/home/pages/Home.tsx b/src/features/home/pages/Home.tsx index 45b3c6215ab..f0898f692a9 100644 --- a/src/features/home/pages/Home.tsx +++ b/src/features/home/pages/Home.tsx @@ -21,8 +21,8 @@ import { RemoteStoreFeatureFlags } from 'libs/firebase/firestore/types' import { useFunctionOnce } from 'libs/hooks' import { useLocation } from 'libs/location' import { LocationMode } from 'libs/location/types' +import { useModalActions } from 'libs/modals/modal.store' import { achievementsModal } from 'libs/modals/modals' -import { openModal } from 'libs/modals/usecases/open-modal' import { getAppVersion } from 'libs/packageJson' import { BatchProfile } from 'libs/react-native-batch' import { startTransaction } from 'shared/performance/transactions' @@ -36,6 +36,7 @@ const Header = () => ( ) export const Home: FunctionComponent = () => { + const { openModal } = useModalActions() const startPerfHomeLoadingOnce = useFunctionOnce(() => startTransaction(PERFORMANCE_HOME_LOADING)) const startPerfHomeCreationOnce = useFunctionOnce(() => startTransaction(PERFORMANCE_HOME_CREATION) @@ -66,7 +67,7 @@ export const Home: FunctionComponent = () => { if (shouldShowAchievementSuccessModal) { openModal(achievementsModal({ names: achievementsToShow })) } - }, [shouldShowAchievementSuccessModal, achievementsToShow]) + }, [shouldShowAchievementSuccessModal, achievementsToShow, openModal]) const isReactionFeatureActive = useFeatureFlag(RemoteStoreFeatureFlags.WIP_REACTION_FEATURE) diff --git a/src/features/navigation/RootNavigator/RootNavigator.tsx b/src/features/navigation/RootNavigator/RootNavigator.tsx index 3cc4bee74f6..e06864bcb8e 100644 --- a/src/features/navigation/RootNavigator/RootNavigator.tsx +++ b/src/features/navigation/RootNavigator/RootNavigator.tsx @@ -18,7 +18,6 @@ import { AccessibilityRole } from 'libs/accessibilityRole/accessibilityRole' import { ModalRenderer } from 'libs/modals/modal.renderer' import { achievementsModal } from 'libs/modals/modals' import { createModalFactory } from 'libs/modals/modals.factory' -import { closeModal } from 'libs/modals/usecases/close-modal' import { useSplashScreenContext } from 'libs/splashscreen' import { storage } from 'libs/storage' import { IconFactoryProvider } from 'ui/components/icons/IconFactoryProvider' @@ -35,9 +34,9 @@ import { RootStack } from './Stack' const isWeb = Platform.OS === 'web' const modalFactory = createModalFactory() -modalFactory.add(achievementsModal, ({ params: { names } }) => ( - -)) +modalFactory.add(achievementsModal, ({ params: { names }, close }) => { + return +}) const RootStackNavigator = withWebWrapper( ({ initialRouteName }: { initialRouteName: RootScreenNames }) => { diff --git a/src/libs/modals/usecases/close-modal.native.spec.ts b/src/libs/modals/__tests__/close-modal.native.spec.ts similarity index 87% rename from src/libs/modals/usecases/close-modal.native.spec.ts rename to src/libs/modals/__tests__/close-modal.native.spec.ts index 9bfc37aa3ae..d543cd152a8 100644 --- a/src/libs/modals/usecases/close-modal.native.spec.ts +++ b/src/libs/modals/__tests__/close-modal.native.spec.ts @@ -1,13 +1,11 @@ import { modalStore } from '../modal.store' -import { closeModal } from './close-modal' - describe('Feature: Close modal', () => { test('Modal is closed', () => { const modal = { key: 'modal-key', params: { text: 'Hello world!' } } modalStore.setState({ modalOpened: modal }) - closeModal() + modalStore.getState().actions.closeModal() expect(modalStore.getState().modalOpened).toBeUndefined() }) @@ -17,7 +15,7 @@ describe('Feature: Close modal', () => { const queuedModal = { key: 'queued-modal-key', params: { text: 'Hello world!' } } modalStore.setState({ modalOpened: modal, queue: [queuedModal] }) - closeModal() + modalStore.getState().actions.closeModal() expect(modalStore.getState().modalOpened).toEqual(queuedModal) }) diff --git a/src/libs/modals/usecases/open-modal.native.spec.ts b/src/libs/modals/__tests__/open-modal.native.spec.ts similarity index 72% rename from src/libs/modals/usecases/open-modal.native.spec.ts rename to src/libs/modals/__tests__/open-modal.native.spec.ts index 2291e57fdba..8f1fee3cb56 100644 --- a/src/libs/modals/usecases/open-modal.native.spec.ts +++ b/src/libs/modals/__tests__/open-modal.native.spec.ts @@ -1,10 +1,8 @@ import { modalStore } from '../modal.store' -import { openModal } from './open-modal' - describe('Feature: Open modal', () => { test('Modal is opened', () => { - openModal({ key: 'modal-key', params: { text: 'Hello world!' } }) + modalStore.getState().actions.openModal({ key: 'modal-key', params: { text: 'Hello world!' } }) expect(modalStore.getState().modalOpened).toEqual({ key: 'modal-key', @@ -17,7 +15,9 @@ describe('Feature: Open modal', () => { modalOpened: { key: 'modal-key-1', params: { text: 'Hello world!' } }, }) - openModal({ key: 'modal-key-1', params: { text: 'Hello world!' } }) + modalStore + .getState() + .actions.openModal({ key: 'modal-key-1', params: { text: 'Hello world!' } }) expect(modalStore.getState().queue).toEqual([ { diff --git a/src/libs/modals/modal.renderer.tsx b/src/libs/modals/modal.renderer.tsx index f6ac91d03e5..4ae3771a165 100644 --- a/src/libs/modals/modal.renderer.tsx +++ b/src/libs/modals/modal.renderer.tsx @@ -6,7 +6,10 @@ import { ModalFactory } from './modals.factory' type Props = { modalFactory: ModalFactory } export const ModalRenderer: FC = ({ modalFactory }) => { - const modalOpened = useModalStore((state) => state.modalOpened) + const { modalOpened, closeModal } = useModalStore((state) => ({ + modalOpened: state.modalOpened, + closeModal: state.actions.closeModal, + })) if (!modalOpened) { return null @@ -14,5 +17,5 @@ export const ModalRenderer: FC = ({ modalFactory }) => { const ModalRenderer = modalFactory.get(modalOpened) - return + return } diff --git a/src/libs/modals/modal.store.ts b/src/libs/modals/modal.store.ts index 74828e7b261..b53fceaf6f9 100644 --- a/src/libs/modals/modal.store.ts +++ b/src/libs/modals/modal.store.ts @@ -17,10 +17,26 @@ const defaultStore: State = { const setActions = (set: (payload: (state: State) => State) => void) => ({ openModal: (modal: Modal) => { - set((state) => ({ - ...state, - modalOpened: modal, - })) + set((state) => { + const hasModalOpened = state.modalOpened !== undefined + + return { + ...state, + modalOpened: modal, + queue: hasModalOpened ? [...state.queue, modal] : state.queue, + } + }) + }, + closeModal: () => { + set((state) => { + const [nextModal] = state.queue + + return { + ...state, + modalOpened: nextModal, + queue: state.queue.filter((queuedModal) => queuedModal !== nextModal), + } + }) }, queueModal: (modal: Modal) => { set((state) => ({ @@ -49,3 +65,4 @@ export const modalStore = createStore>( ) export const useModalStore = modalStore +export const useModalActions = () => modalStore((state) => state.actions) diff --git a/src/libs/modals/modals.factory.ts b/src/libs/modals/modals.factory.ts index 466d06c636a..623a5002ebf 100644 --- a/src/libs/modals/modals.factory.ts +++ b/src/libs/modals/modals.factory.ts @@ -6,10 +6,13 @@ import { Modal } from './modal.store' export type ModalFactory = ReturnType export const createModalFactory = () => { - const modals: Map> = new Map() + const modals: Map void }>> = new Map() return { - add: (modalCreator: ModalCreator, element: React.FC<{ params: T }>) => { + add: ( + modalCreator: ModalCreator, + element: React.FC<{ params: T; close: () => void }> + ) => { if (modals.has(modalCreator.key)) { throw new Error(`Modal with key\u00a0: ${modalCreator.key} Already exist`) } diff --git a/src/libs/modals/usecases/close-modal.ts b/src/libs/modals/usecases/close-modal.ts deleted file mode 100644 index 6a8c22e3b71..00000000000 --- a/src/libs/modals/usecases/close-modal.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { modalStore } from '../modal.store' - -import { openModal } from './open-modal' -export const closeModal = () => { - const { - queue, - actions: { clearOpenedModal, removeModalFromQueue }, - } = modalStore.getState() - - clearOpenedModal() - const [nextModal] = queue - - if (nextModal) { - removeModalFromQueue(nextModal) - openModal(nextModal) - } -} diff --git a/src/libs/modals/usecases/open-modal.ts b/src/libs/modals/usecases/open-modal.ts deleted file mode 100644 index bdc3fcbf9b9..00000000000 --- a/src/libs/modals/usecases/open-modal.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Modal, modalStore } from '../modal.store' - -export const openModal = (modal: Modal) => { - const { - modalOpened, - actions: { openModal, queueModal }, - } = modalStore.getState() - const hasModalOpened = modalOpened !== undefined - - if (hasModalOpened) { - queueModal(modal) - return - } - - openModal(modal) -} From cd0b049e1c2034adc158339c67543fbd5ccbcd7d Mon Sep 17 00:00:00 2001 From: Maxime Le Duc Date: Tue, 17 Dec 2024 11:25:04 +0100 Subject: [PATCH 4/5] Remove unused actions --- src/libs/modals/modal.store.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/libs/modals/modal.store.ts b/src/libs/modals/modal.store.ts index b53fceaf6f9..ba99ce8d71d 100644 --- a/src/libs/modals/modal.store.ts +++ b/src/libs/modals/modal.store.ts @@ -38,24 +38,6 @@ const setActions = (set: (payload: (state: State) => State) => void) => ({ } }) }, - queueModal: (modal: Modal) => { - set((state) => ({ - ...state, - queue: [...state.queue, modal], - })) - }, - clearOpenedModal: () => { - set((state) => ({ - ...state, - modalOpened: undefined, - })) - }, - removeModalFromQueue: (modal: Modal) => { - set((state) => ({ - ...state, - queue: state.queue.filter((queuedModal) => queuedModal !== modal), - })) - }, }) export const modalStore = createStore>( From 9bc0335fd842706a10ebbe2f7255ba3d50669780 Mon Sep 17 00:00:00 2001 From: Maxime Le Duc Date: Wed, 18 Dec 2024 14:11:55 +0100 Subject: [PATCH 5/5] Display reaction modal or achievements modal on first session --- .../IncomingReactionModalContainer.tsx | 49 ++++++++++++ src/features/home/pages/Home.tsx | 37 ++++++--- .../RootNavigator/RootNavigator.tsx | 9 +-- .../Modals/AchievementSuccessModal.tsx | 5 ++ .../useShouldShowAchievementSuccessModal.tsx | 51 ++++++------ .../ReactionChoiceModal.tsx | 5 ++ .../__tests__/modal-relative.native.spec.ts | 78 +++++++++++++++++++ src/libs/modals/modal.creator.ts | 17 ++++ src/libs/modals/modals.ts | 17 +++- 9 files changed, 224 insertions(+), 44 deletions(-) create mode 100644 src/libs/modals/__tests__/modal-relative.native.spec.ts diff --git a/src/features/home/components/IncomingReactionModalContainer/IncomingReactionModalContainer.tsx b/src/features/home/components/IncomingReactionModalContainer/IncomingReactionModalContainer.tsx index 88e387344b0..6ceeb50026d 100644 --- a/src/features/home/components/IncomingReactionModalContainer/IncomingReactionModalContainer.tsx +++ b/src/features/home/components/IncomingReactionModalContainer/IncomingReactionModalContainer.tsx @@ -9,10 +9,59 @@ import { ReactionChoiceModal } from 'features/reactions/components/ReactionChoic import { ReactionChoiceModalBodyEnum, ReactionFromEnum } from 'features/reactions/enum' import { OfferImageBasicProps } from 'features/reactions/types' import { formatToSlashedFrenchDate } from 'libs/dates' +import { useFeatureFlag } from 'libs/firebase/firestore/featureFlags/useFeatureFlag' +import { RemoteStoreFeatureFlags } from 'libs/firebase/firestore/types' import { useRemoteConfigContext } from 'libs/firebase/remoteConfig/RemoteConfigProvider' +import { reactionModal } from 'libs/modals/modals' import { useCategoryIdMapping, useSubcategoriesMapping } from 'libs/subcategories' import { useModal } from 'ui/components/modals/useModal' +export const useIncomingReactionModal = () => { + const isReactionFeatureActive = useFeatureFlag(RemoteStoreFeatureFlags.WIP_REACTION_FEATURE) + const { reactionCategories } = useRemoteConfigContext() + const { isCookiesListUpToDate, cookiesLastUpdate } = useIsCookiesListUpToDate() + const isCookieConsentChecked = cookiesLastUpdate && isCookiesListUpToDate + const { data: bookings } = useBookings() + const subcategoriesMapping = useSubcategoriesMapping() + const mapping = useCategoryIdMapping() + + const bookingsWithoutReaction = + bookings?.ended_bookings?.filter((booking) => + filterBookingsWithoutReaction(booking, subcategoriesMapping, reactionCategories) + ) ?? [] + + const offerImages: OfferImageBasicProps[] = bookingsWithoutReaction.map((current) => { + return { + imageUrl: current.stock.offer.image?.url ?? '', + categoryId: mapping[current.stock.offer.subcategoryId] ?? null, + } + }) + + const firstBooking = bookingsWithoutReaction[0] + const reactionChoiceModalBodyType = + bookingsWithoutReaction.length === 1 + ? ReactionChoiceModalBodyEnum.VALIDATION + : ReactionChoiceModalBodyEnum.REDIRECTION + + const getModal = () => { + if (!firstBooking || !isCookieConsentChecked || !isReactionFeatureActive) return + + const { stock, dateUsed } = firstBooking + const { offer } = stock + + return reactionModal({ + offer, + dateUsed: dateUsed ? `le ${formatToSlashedFrenchDate(dateUsed)}` : '', + defaultReaction: null, + from: ReactionFromEnum.HOME, + bodyType: reactionChoiceModalBodyType, + offerImages, + }) + } + + return getModal +} + export const IncomingReactionModalContainer = () => { const { reactionCategories } = useRemoteConfigContext() const { isCookiesListUpToDate, cookiesLastUpdate } = useIsCookiesListUpToDate() diff --git a/src/features/home/pages/Home.tsx b/src/features/home/pages/Home.tsx index f0898f692a9..789fa302468 100644 --- a/src/features/home/pages/Home.tsx +++ b/src/features/home/pages/Home.tsx @@ -7,7 +7,7 @@ import { useAuthContext } from 'features/auth/context/AuthContext' import { useBookings } from 'features/bookings/api' import { useHomepageData } from 'features/home/api/useHomepageData' import { HomeHeader } from 'features/home/components/headers/HomeHeader' -import { IncomingReactionModalContainer } from 'features/home/components/IncomingReactionModalContainer/IncomingReactionModalContainer' +import { useIncomingReactionModal } from 'features/home/components/IncomingReactionModalContainer/IncomingReactionModalContainer' import { HomeBanner } from 'features/home/components/modules/banners/HomeBanner' import { PERFORMANCE_HOME_CREATION, PERFORMANCE_HOME_LOADING } from 'features/home/constants' import { GenericHome } from 'features/home/pages/GenericHome' @@ -16,13 +16,12 @@ import { useShouldShowAchievementSuccessModal } from 'features/profile/component import { OnboardingSubscriptionModal } from 'features/subscription/components/modals/OnboardingSubscriptionModal' import { useOnboardingSubscriptionModal } from 'features/subscription/helpers/useOnboardingSubscriptionModal' import { analytics } from 'libs/analytics' -import { useFeatureFlag } from 'libs/firebase/firestore/featureFlags/useFeatureFlag' -import { RemoteStoreFeatureFlags } from 'libs/firebase/firestore/types' import { useFunctionOnce } from 'libs/hooks' import { useLocation } from 'libs/location' import { LocationMode } from 'libs/location/types' +import { createRelativeModals } from 'libs/modals/modal.creator' import { useModalActions } from 'libs/modals/modal.store' -import { achievementsModal } from 'libs/modals/modals' +import { achievementsModal, reactionModal } from 'libs/modals/modals' import { getAppVersion } from 'libs/packageJson' import { BatchProfile } from 'libs/react-native-batch' import { startTransaction } from 'shared/performance/transactions' @@ -35,12 +34,15 @@ const Header = () => ( ) +let hasShowSessionModal = false + export const Home: FunctionComponent = () => { const { openModal } = useModalActions() const startPerfHomeLoadingOnce = useFunctionOnce(() => startTransaction(PERFORMANCE_HOME_LOADING)) const startPerfHomeCreationOnce = useFunctionOnce(() => startTransaction(PERFORMANCE_HOME_CREATION) ) + startPerfHomeCreationOnce() startPerfHomeLoadingOnce() @@ -55,21 +57,33 @@ export const Home: FunctionComponent = () => { showModal: showOnboardingSubscriptionModal, hideModal: hideOnboardingSubscriptionModal, } = useModal(false) + useOnboardingSubscriptionModal({ isLoggedIn, userStatus: user?.status?.statusType, showOnboardingSubscriptionModal, }) - const { shouldShowAchievementSuccessModal, achievementsToShow } = - useShouldShowAchievementSuccessModal() + + const checkAchievementsModal = useShouldShowAchievementSuccessModal() + const checkReactionModal = useIncomingReactionModal() useEffect(() => { - if (shouldShowAchievementSuccessModal) { - openModal(achievementsModal({ names: achievementsToShow })) - } - }, [shouldShowAchievementSuccessModal, achievementsToShow, openModal]) + if (hasShowSessionModal) return + hasShowSessionModal = true + const modal = createRelativeModals( + { + creator: reactionModal, + check: checkReactionModal, + }, + { + creator: achievementsModal, + check: checkAchievementsModal, + } + ) - const isReactionFeatureActive = useFeatureFlag(RemoteStoreFeatureFlags.WIP_REACTION_FEATURE) + if (!modal) return + openModal(modal) + }, [openModal, checkReactionModal, checkAchievementsModal]) useEffect(() => { if (id) { @@ -136,7 +150,6 @@ export const Home: FunctionComponent = () => { visible={onboardingSubscriptionModalVisible} dismissModal={hideOnboardingSubscriptionModal} /> - {isReactionFeatureActive ? : null} ) } diff --git a/src/features/navigation/RootNavigator/RootNavigator.tsx b/src/features/navigation/RootNavigator/RootNavigator.tsx index e06864bcb8e..83060060e02 100644 --- a/src/features/navigation/RootNavigator/RootNavigator.tsx +++ b/src/features/navigation/RootNavigator/RootNavigator.tsx @@ -13,11 +13,9 @@ import { useInitialScreen } from 'features/navigation/RootNavigator/useInitialSc import { withWebWrapper } from 'features/navigation/RootNavigator/withWebWrapper' import { TabNavigationStateProvider } from 'features/navigation/TabBar/TabNavigationStateContext' import { VenueMapFiltersStackNavigator } from 'features/navigation/VenueMapFiltersStackNavigator/VenueMapFiltersStackNavigator' -import { AchievementSuccessModal } from 'features/profile/components/Modals/AchievementSuccessModal' import { AccessibilityRole } from 'libs/accessibilityRole/accessibilityRole' import { ModalRenderer } from 'libs/modals/modal.renderer' -import { achievementsModal } from 'libs/modals/modals' -import { createModalFactory } from 'libs/modals/modals.factory' +import { modalFactory } from 'libs/modals/modals' import { useSplashScreenContext } from 'libs/splashscreen' import { storage } from 'libs/storage' import { IconFactoryProvider } from 'ui/components/icons/IconFactoryProvider' @@ -33,11 +31,6 @@ import { RootStack } from './Stack' const isWeb = Platform.OS === 'web' -const modalFactory = createModalFactory() -modalFactory.add(achievementsModal, ({ params: { names }, close }) => { - return -}) - const RootStackNavigator = withWebWrapper( ({ initialRouteName }: { initialRouteName: RootScreenNames }) => { const { top } = useSafeAreaInsets() diff --git a/src/features/profile/components/Modals/AchievementSuccessModal.tsx b/src/features/profile/components/Modals/AchievementSuccessModal.tsx index 37caeb78fd0..e0459cc5296 100644 --- a/src/features/profile/components/Modals/AchievementSuccessModal.tsx +++ b/src/features/profile/components/Modals/AchievementSuccessModal.tsx @@ -4,6 +4,7 @@ import styled from 'styled-components/native' import { AchievementEnum } from 'api/gen' import { analytics } from 'libs/analytics' import LottieView from 'libs/lottie' +import { achievementsModal, modalFactory } from 'libs/modals/modals' import { ButtonPrimary } from 'ui/components/buttons/ButtonPrimary' import { ButtonTertiaryBlack } from 'ui/components/buttons/ButtonTertiaryBlack' import { AppInformationModal } from 'ui/components/modals/AppInformationModal' @@ -21,6 +22,10 @@ interface Props { names: AchievementEnum[] } +modalFactory.add(achievementsModal, ({ params: { names }, close }) => { + return +}) + export const AchievementSuccessModal = ({ visible, hideModal, names }: Props) => { const logoRef = useRef(null) diff --git a/src/features/profile/components/Modals/useShouldShowAchievementSuccessModal.tsx b/src/features/profile/components/Modals/useShouldShowAchievementSuccessModal.tsx index f2967803ae1..20fb90f78b2 100644 --- a/src/features/profile/components/Modals/useShouldShowAchievementSuccessModal.tsx +++ b/src/features/profile/components/Modals/useShouldShowAchievementSuccessModal.tsx @@ -3,33 +3,38 @@ import { useAuthContext } from 'features/auth/context/AuthContext' import { useFeatureFlag } from 'libs/firebase/firestore/featureFlags/useFeatureFlag' import { RemoteStoreFeatureFlags } from 'libs/firebase/firestore/types' import { useRemoteConfigContext } from 'libs/firebase/remoteConfig/RemoteConfigProvider' +import { achievementsModal } from 'libs/modals/modals' export const useShouldShowAchievementSuccessModal = () => { const areAchievementsEnabled = useFeatureFlag(RemoteStoreFeatureFlags.ENABLE_ACHIEVEMENTS) const { displayAchievements } = useRemoteConfigContext() const { user } = useAuthContext() - let shouldShowAchievementSuccessModal = false - let achievementsToShow: AchievementEnum[] = [] - if ( - !areAchievementsEnabled || - !displayAchievements || - !user?.achievements || - user?.achievements.length === 0 - ) - return { shouldShowAchievementSuccessModal, achievementsToShow } - - const isThereAtLeastOneUnseenAchievement = user?.achievements.some( - (achievement) => !achievement.seenDate - ) - if (isThereAtLeastOneUnseenAchievement) shouldShowAchievementSuccessModal = true - else return { shouldShowAchievementSuccessModal, achievementsToShow } - - const unseenAchievements = user?.achievements - .filter((achievement) => !achievement.seenDate) - .map((achievement) => achievement.name) - - if (unseenAchievements && unseenAchievements?.length > 0) achievementsToShow = unseenAchievements - - return { shouldShowAchievementSuccessModal: true, achievementsToShow } + const getModal = () => { + let achievementsToShow: AchievementEnum[] = [] + if ( + !areAchievementsEnabled || + !displayAchievements || + !user?.achievements || + user?.achievements.length === 0 + ) + return + + const isThereAtLeastOneUnseenAchievement = user?.achievements.some( + (achievement) => !achievement.seenDate + ) + + if (!isThereAtLeastOneUnseenAchievement) return + + const unseenAchievements = user?.achievements + .filter((achievement) => !achievement.seenDate) + .map((achievement) => achievement.name) + + if (unseenAchievements && unseenAchievements?.length > 0) + achievementsToShow = unseenAchievements + + return achievementsModal({ names: achievementsToShow }) + } + + return getModal } diff --git a/src/features/reactions/components/ReactionChoiceModal/ReactionChoiceModal.tsx b/src/features/reactions/components/ReactionChoiceModal/ReactionChoiceModal.tsx index 866e2875dde..01f4560f37e 100644 --- a/src/features/reactions/components/ReactionChoiceModal/ReactionChoiceModal.tsx +++ b/src/features/reactions/components/ReactionChoiceModal/ReactionChoiceModal.tsx @@ -17,6 +17,7 @@ import { ReactionChoiceModalBodyWithValidation } from 'features/reactions/compon import { ReactionChoiceModalBodyEnum, ReactionFromEnum } from 'features/reactions/enum' import { OfferImageBasicProps } from 'features/reactions/types' import { analytics } from 'libs/analytics' +import { modalFactory, reactionModal } from 'libs/modals/modals' import { ButtonPrimary } from 'ui/components/buttons/ButtonPrimary' import { ButtonTertiaryBlack } from 'ui/components/buttons/ButtonTertiaryBlack' import { AppModal } from 'ui/components/modals/AppModal' @@ -38,6 +39,10 @@ type Props = { offerImages?: OfferImageBasicProps[] } +modalFactory.add(reactionModal, ({ params, close }) => { + return +}) + export const ReactionChoiceModal: FunctionComponent = ({ offer, dateUsed, diff --git a/src/libs/modals/__tests__/modal-relative.native.spec.ts b/src/libs/modals/__tests__/modal-relative.native.spec.ts new file mode 100644 index 00000000000..78d1a584c58 --- /dev/null +++ b/src/libs/modals/__tests__/modal-relative.native.spec.ts @@ -0,0 +1,78 @@ +import { createModal, createRelativeModals } from '../modal.creator' + +describe('Feature: Modal relative', () => { + test('Modal is returned', () => { + const firstModal = createModal<{ title: string }>('first') + const relativeModal = createRelativeModals({ + creator: firstModal, + check() { + return firstModal({ title: 'hello world' }) + }, + }) + + expect(relativeModal).toEqual(firstModal({ title: 'hello world' })) + }) + + test('Second modal is returned when first modal has not to be open', () => { + const firstModal = createModal<{ title: string }>('first') + const secondModal = createModal<{ name: string }>('second') + const relativeModal = createRelativeModals( + { + creator: firstModal, + check() { + return undefined + }, + }, + { + creator: secondModal, + check() { + return secondModal({ name: 'hello world' }) + }, + } + ) + + expect(relativeModal).toEqual(secondModal({ name: 'hello world' })) + }) + + test('Return the first modal to be open', () => { + const firstModal = createModal<{ title: string }>('first') + const secondModal = createModal<{ name: string }>('second') + const relativeModal = createRelativeModals( + { + creator: firstModal, + check() { + return firstModal({ title: 'hello world' }) + }, + }, + { + creator: secondModal, + check() { + return secondModal({ name: 'hello world' }) + }, + } + ) + + expect(relativeModal).toEqual(firstModal({ title: 'hello world' })) + }) + + test('Return undefined when no modal has to be open', () => { + const firstModal = createModal<{ title: string }>('first') + const secondModal = createModal<{ name: string }>('second') + const relativeModal = createRelativeModals( + { + creator: firstModal, + check() { + return undefined + }, + }, + { + creator: secondModal, + check() { + return undefined + }, + } + ) + + expect(relativeModal).toBeUndefined() + }) +}) diff --git a/src/libs/modals/modal.creator.ts b/src/libs/modals/modal.creator.ts index d0342599822..0b66d3693aa 100644 --- a/src/libs/modals/modal.creator.ts +++ b/src/libs/modals/modal.creator.ts @@ -12,3 +12,20 @@ export const createModal = (key: string) => { creator.key = key return creator } + +type RelativeModal = { + creator: ModalCreator + check: () => Modal | undefined +} + +// Je n'arrive pas a faire en sorte que le type de retour soit le bon. Sans le any, le type de retour est incompatible +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const createRelativeModals = []>(...modals: T) => { + for (const modal of modals) { + const check = modal.check() + if (check) { + return check as ReturnType + } + } + return +} diff --git a/src/libs/modals/modals.ts b/src/libs/modals/modals.ts index 252d7a2e9ad..5b41cb9bc3c 100644 --- a/src/libs/modals/modals.ts +++ b/src/libs/modals/modals.ts @@ -1,5 +1,20 @@ -import { AchievementEnum } from 'api/gen' +import { AchievementEnum, BookingOfferResponse, OfferResponse, ReactionTypeEnum } from 'api/gen' +import { ReactionChoiceModalBodyEnum, ReactionFromEnum } from 'features/reactions/enum' +import { OfferImageBasicProps } from 'features/reactions/types' import { createModal } from './modal.creator' +import { createModalFactory } from './modals.factory' export const achievementsModal = createModal<{ names: AchievementEnum[] }>('achievements') + +// reactionModal à réadapter +export const reactionModal = createModal<{ + offer: OfferResponse | BookingOfferResponse + dateUsed: string + defaultReaction?: ReactionTypeEnum | null + from: ReactionFromEnum + bodyType: ReactionChoiceModalBodyEnum + offerImages?: OfferImageBasicProps[] +}>('reaction') + +export const modalFactory = createModalFactory()