Skip to content

Commit

Permalink
Merge pull request #545 from lifeomic/add-user-app-config-switching
Browse files Browse the repository at this point in the history
feat: Add app switcher button
  • Loading branch information
bruce-glazier committed Apr 15, 2024
2 parents d8cdc9f + dee2c87 commit f8b81a3
Show file tree
Hide file tree
Showing 7 changed files with 257 additions and 39 deletions.
32 changes: 17 additions & 15 deletions src/common/LoggedInProviders.tsx
Expand Up @@ -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';
Expand Down Expand Up @@ -52,20 +52,22 @@ export const LoggedInProviders = ({ children }: LoggedInProvidersProps) => {
return (
<InviteProvider>
<ActiveProjectContextProvider>
<TrackTileProvider>
<WearableLifecycleProvider>
<CircleTileContextProvider>
<OnboardingCourseContextProvider>
<PushNotificationsProvider config={pushNotificationsConfig}>
<NotificationsManagerProvider>
{children}
</NotificationsManagerProvider>
</PushNotificationsProvider>
<CreateEditPostModal />
</OnboardingCourseContextProvider>
</CircleTileContextProvider>
</WearableLifecycleProvider>
</TrackTileProvider>
<AppConfigContextProvider>
<TrackTileProvider>
<WearableLifecycleProvider>
<CircleTileContextProvider>
<OnboardingCourseContextProvider>
<PushNotificationsProvider config={pushNotificationsConfig}>
<NotificationsManagerProvider>
{children}
</NotificationsManagerProvider>
</PushNotificationsProvider>
<CreateEditPostModal />
</OnboardingCourseContextProvider>
</CircleTileContextProvider>
</WearableLifecycleProvider>
</TrackTileProvider>
</AppConfigContextProvider>
</ActiveProjectContextProvider>
</InviteProvider>
);
Expand Down
107 changes: 107 additions & 0 deletions 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 (
<Provider>
<Portal>
<Modal
visible={visible}
onDismiss={hideModal}
animationType={'slide'}
style={styles.modal}
>
<View style={styles.container}>
<Appbar.Header style={styles.header}>
<IconButton onPress={() => hideModal()} icon={ArrowLeft} />
</Appbar.Header>
<Drawer.Section
title="Adaptive Experiences: Tailored by Cohort"
showDivider={true}
titleMaxFontSizeMultiplier={4}
>
{configs?.map((config) => (
<Drawer.Item
key={config.id}
label={config.name}
active={active === config.id}
style={styles.itemText}
onPress={() => {
setActive(config.id);
setAppConfigId(config.id);
setVisible(false);
}}
/>
))}
</Drawer.Section>
</View>
</Modal>
</Portal>
</Provider>
);
};

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<typeof defaultStyles> {}
}

export type AppConfigSwitcher = NamedStylesProp<typeof defaultStyles>;
31 changes: 31 additions & 0 deletions 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 (
<IconButton
onPress={emitShowAppConfigSwitcher}
icon={Menu}
style={styles.button}
/>
);
};

export default AppConfigSwitcherButton;

const defaultStyles = createStyles('AppConfigSwitcherButton', () => ({
button: {},
}));

declare module '@styles' {
interface ComponentStyles
extends ComponentNamedStyles<typeof defaultStyles> {}
}

export type AppConfigSwitcher = NamedStylesProp<typeof defaultStyles>;
13 changes: 9 additions & 4 deletions src/hooks/rest-api.tsx
Expand Up @@ -2,17 +2,22 @@ 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 = {
'GET /v1/life-research/projects/:projectId/app-config': {
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<
Expand Down
36 changes: 23 additions & 13 deletions 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';
Expand All @@ -27,7 +31,9 @@ const renderHookInContext = async () => {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
<ActiveAccountProvider account="mockaccount">
<HttpClientContextProvider>{children}</HttpClientContextProvider>
<AppConfigContextProvider>
<HttpClientContextProvider>{children}</HttpClientContextProvider>
</AppConfigContextProvider>
</ActiveAccountProvider>
</QueryClientProvider>
),
Expand All @@ -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),
);
});
71 changes: 68 additions & 3 deletions 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';
Expand Down Expand Up @@ -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,
},
);

Expand All @@ -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<AppConfigContext>({
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<string | undefined>();

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 (
<AppConfigContext.Provider value={context}>
{children}
</AppConfigContext.Provider>
);
};

export const useAppConfig = () => useContext(AppConfigContext);
6 changes: 2 additions & 4 deletions 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';
Expand All @@ -14,9 +14,7 @@ jest.mock('./useDeveloperConfig', () => ({
useDeveloperConfig: jest.fn(),
}));

const useAppConfigMock = useAppConfig as jest.Mock<
Partial<ReturnType<typeof useAppConfig>>
>;
const useAppConfigMock = useAppConfig as jest.Mock<Partial<AppConfigContext>>;
const useDeveloperConfigMock = useDeveloperConfig as jest.Mock<
ReturnType<typeof useDeveloperConfig>
>;
Expand Down

0 comments on commit f8b81a3

Please sign in to comment.