From dee2c8703bb854fbe45737a9a53ffff5b71f4d19 Mon Sep 17 00:00:00 2001 From: Bruce Glazier Date: Mon, 15 Apr 2024 13:35:51 -0400 Subject: [PATCH] feat: Add app config switcher --- src/common/LoggedInProviders.tsx | 32 +++--- src/components/AppConfigSwitcher.tsx | 107 +++++++++++++++++++++ src/components/AppConfigSwitcherButton.tsx | 31 ++++++ src/hooks/rest-api.tsx | 13 ++- src/hooks/useAppConfig.test.tsx | 36 ++++--- src/hooks/useAppConfig.tsx | 71 +++++++++++++- src/hooks/useTabsConfig.test.tsx | 6 +- 7 files changed, 257 insertions(+), 39 deletions(-) create mode 100644 src/components/AppConfigSwitcher.tsx create mode 100644 src/components/AppConfigSwitcherButton.tsx diff --git a/src/common/LoggedInProviders.tsx b/src/common/LoggedInProviders.tsx index 5fa370e1..2175630e 100644 --- a/src/common/LoggedInProviders.tsx +++ b/src/common/LoggedInProviders.tsx @@ -9,7 +9,7 @@ import { PushNotificationsProvider } from '../hooks/usePushNotifications'; import { CircleTileContextProvider } from '../hooks/Circles/useActiveCircleTile'; import { OnboardingCourseContextProvider } from '../hooks/useOnboardingCourse'; import { useDeveloperConfig } from '../hooks/useDeveloperConfig'; -import { useAuth } from '../hooks'; +import { AppConfigContextProvider, useAuth } from '../hooks'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { NotLoggedInRootParamList } from '../navigators'; import { LoginScreen } from '../screens/LoginScreen'; @@ -52,20 +52,22 @@ export const LoggedInProviders = ({ children }: LoggedInProvidersProps) => { return ( - - - - - - - {children} - - - - - - - + + + + + + + + {children} + + + + + + + + ); diff --git a/src/components/AppConfigSwitcher.tsx b/src/components/AppConfigSwitcher.tsx new file mode 100644 index 00000000..9fe8dd7a --- /dev/null +++ b/src/components/AppConfigSwitcher.tsx @@ -0,0 +1,107 @@ +import * as React from 'react'; +import { + Appbar, + Drawer, + IconButton, + Portal, + Provider, +} from 'react-native-paper'; +import { useAppConfig, useAppConfigList } from '../hooks/useAppConfig'; +import { Modal, NativeEventEmitter, View } from 'react-native'; +import { createStyles } from './BrandConfigProvider/styles/createStyles'; +import { useStyles } from '../hooks/useStyles'; +import { useIcons } from './BrandConfigProvider'; + +const eventEmitter = new NativeEventEmitter({ + addListener: () => {}, + removeListeners: () => {}, +}); + +const showAppConfigSwitcher = 'showAppConfigSwitcher'; + +export const emitShowAppConfigSwitcher = () => + eventEmitter.emit(showAppConfigSwitcher); + +const AppConfigSwitcher = () => { + const configs = useAppConfigList(); + const { setAppConfigId, appConfigId } = useAppConfig(); + const [active, setActive] = React.useState(appConfigId); + const [visible, setVisible] = React.useState(false); + + const hideModal = () => setVisible(false); + const { styles } = useStyles(defaultStyles); + const { ArrowLeft } = useIcons(); + + React.useEffect(() => { + const subscription = eventEmitter.addListener(showAppConfigSwitcher, () => { + setVisible(true); + }); + + return () => { + subscription.remove(); + }; + }, []); + + return ( + + + + + + hideModal()} icon={ArrowLeft} /> + + + {configs?.map((config) => ( + { + setActive(config.id); + setAppConfigId(config.id); + setVisible(false); + }} + /> + ))} + + + + + + ); +}; + +export default AppConfigSwitcher; + +const defaultStyles = createStyles('AppConfigSwitcher', (theme) => ({ + modal: { + marginTop: 100, + marginBottom: 0, + }, + container: { + height: '100%', + flex: 1, + }, + header: {}, + titleText: { + ...theme.fonts.headlineMedium, + }, + itemText: {}, +})); + +declare module '@styles' { + interface ComponentStyles + extends ComponentNamedStyles {} +} + +export type AppConfigSwitcher = NamedStylesProp; diff --git a/src/components/AppConfigSwitcherButton.tsx b/src/components/AppConfigSwitcherButton.tsx new file mode 100644 index 00000000..33c95294 --- /dev/null +++ b/src/components/AppConfigSwitcherButton.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { IconButton } from 'react-native-paper'; +import { useIcons } from './BrandConfigProvider/icons/IconProvider'; +import { emitShowAppConfigSwitcher } from './AppConfigSwitcher'; +import { createStyles } from './BrandConfigProvider/styles/createStyles'; +import { useStyles } from '../hooks/useStyles'; + +const AppConfigSwitcherButton = () => { + const { Menu } = useIcons(); + const { styles } = useStyles(defaultStyles); + return ( + + ); +}; + +export default AppConfigSwitcherButton; + +const defaultStyles = createStyles('AppConfigSwitcherButton', () => ({ + button: {}, +})); + +declare module '@styles' { + interface ComponentStyles + extends ComponentNamedStyles {} +} + +export type AppConfigSwitcher = NamedStylesProp; diff --git a/src/hooks/rest-api.tsx b/src/hooks/rest-api.tsx index b7609f86..8a6079a0 100644 --- a/src/hooks/rest-api.tsx +++ b/src/hooks/rest-api.tsx @@ -2,10 +2,7 @@ import { createRestAPIHooks, RestAPIEndpoints } from '@lifeomic/react-client'; import { useHttpClient } from './useHttpClient'; import { AppConfig } from './useAppConfig'; import { APIQueryHooks, RequestPayloadOf } from '@lifeomic/one-query'; -import { - UseQueryOptions, - UseQueryResult, -} from '@tanstack/react-query/build/lib/types'; +import { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import { AxiosRequestConfig } from 'axios'; export type Overrides = { @@ -13,6 +10,14 @@ export type Overrides = { Request: {}; Response: AppConfig; }; + 'GET /v1/life-research/projects/:projectId/app-configs/:id': { + Request: {}; + Response: AppConfig; + }; + 'GET /v1/life-research/projects/:projectId/app-configs/list': { + Request: {}; + Response: { id: string; name: string }[]; + }; }; declare type RestrictedUseQueryOptions< diff --git a/src/hooks/useAppConfig.test.tsx b/src/hooks/useAppConfig.test.tsx index 2ae82bb7..b57fcf60 100644 --- a/src/hooks/useAppConfig.test.tsx +++ b/src/hooks/useAppConfig.test.tsx @@ -1,6 +1,10 @@ import React from 'react'; import { renderHook, waitFor } from '@testing-library/react-native'; -import { useAppConfig, AppTile } from './useAppConfig'; +import { + useAppConfig, + AppTile, + AppConfigContextProvider, +} from './useAppConfig'; import { useActiveProject } from './useActiveProject'; import { HttpClientContextProvider } from './useHttpClient'; import { createRestAPIMock } from '../test-utils/rest-api-mocking'; @@ -27,7 +31,9 @@ const renderHookInContext = async () => { wrapper: ({ children }) => ( - {children} + + {children} + ), @@ -42,17 +48,21 @@ beforeEach(() => { test('configured appTiles are returned', async () => { const mockAppTiles = ['appTile-1', 'appTile-2', 'appTile-3'].map(mockAppTile); - api.mock('GET /v1/life-research/projects/:projectId/app-config', { - status: 200, - data: { - homeTab: { - appTiles: mockAppTiles, - }, + + // Bug in mocker seems to not be able to tell /:id and /list apart + api.mockOrdered('GET /v1/life-research/projects/:projectId/app-configs/:id', [ + { status: 200, data: [{ name: 'Main Config', id: 'id' }] }, + { + status: 200, + data: { + homeTab: { + appTiles: mockAppTiles, + }, + } as any, }, - }); + ]); const { result } = await renderHookInContext(); - - await waitFor(() => { - expect(result.current.data?.homeTab?.appTiles).toEqual(mockAppTiles); - }); + await waitFor(() => + expect(result.current.data?.homeTab?.appTiles).toEqual(mockAppTiles), + ); }); diff --git a/src/hooks/useAppConfig.tsx b/src/hooks/useAppConfig.tsx index 4cece8dc..8a3620dd 100644 --- a/src/hooks/useAppConfig.tsx +++ b/src/hooks/useAppConfig.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import React, { createContext, useContext, useEffect, useState } from 'react'; import { useActiveProject } from './useActiveProject'; import { Trace } from '../components/MyData/LineChart/TraceLine'; import { SvgProps } from 'react-native-svg'; @@ -118,15 +118,27 @@ export interface AppConfig { }; } -export const useAppConfig = () => { +export const useAppConfigList = () => { const { activeProject } = useActiveProject(); const query = useRestQuery( - 'GET /v1/life-research/projects/:projectId/app-config', + 'GET /v1/life-research/projects/:projectId/app-configs/list', { projectId: activeProject.id }, + ); + + return query.data; +}; + +const useAppConfigById = (id?: string) => { + const { activeProject } = useActiveProject(); + + const query = useRestQuery( + 'GET /v1/life-research/projects/:projectId/app-configs/:id', + { projectId: activeProject.id, id: id! }, { // Longer stale time to avoid refetching every time someone changes pages. staleTime: ms('10m'), + enabled: !!id, }, ); @@ -136,3 +148,56 @@ export const useAppConfig = () => { return query; }; + +export type AppConfigContext = { + appConfigId: string | undefined; + setAppConfigId: (id: string) => void; + data: AppConfig | undefined; + isLoading: boolean; + isFetched: boolean; + error: any; +}; + +const AppConfigContext = createContext({ + appConfigId: undefined, + setAppConfigId: () => {}, + data: undefined, + isLoading: false, + isFetched: false, + error: undefined, +}); + +export const AppConfigContextProvider = ({ + children, +}: { + children?: React.ReactNode; +}) => { + const configList = useAppConfigList(); + const [appConfigId, setAppConfigId] = useState(); + + console.log('ConfigList', configList); + + useEffect(() => { + if (configList) { + setAppConfigId(configList[0]?.id); + } + }, [configList]); + + const query = useAppConfigById(appConfigId); + const context = { + appConfigId, + setAppConfigId, + data: query.data, + isLoading: query.isLoading, + isFetched: query.isFetched, + error: query.error, + }; + + return ( + + {children} + + ); +}; + +export const useAppConfig = () => useContext(AppConfigContext); diff --git a/src/hooks/useTabsConfig.test.tsx b/src/hooks/useTabsConfig.test.tsx index 3e558fb3..c0d73918 100644 --- a/src/hooks/useTabsConfig.test.tsx +++ b/src/hooks/useTabsConfig.test.tsx @@ -1,5 +1,5 @@ import { renderHook, render } from '@testing-library/react-native'; -import { useAppConfig } from './useAppConfig'; +import { AppConfigContext, useAppConfig } from './useAppConfig'; import { useDeveloperConfig } from './useDeveloperConfig'; import { useTabsConfig } from './useTabsConfig'; import { NavigationTab } from '../common'; @@ -14,9 +14,7 @@ jest.mock('./useDeveloperConfig', () => ({ useDeveloperConfig: jest.fn(), })); -const useAppConfigMock = useAppConfig as jest.Mock< - Partial> ->; +const useAppConfigMock = useAppConfig as jest.Mock>; const useDeveloperConfigMock = useDeveloperConfig as jest.Mock< ReturnType >;