Skip to content

Commit

Permalink
Merge branch 'master' into tab-bar-configurable-tab-fixes
Browse files Browse the repository at this point in the history
  • Loading branch information
sternetj committed Nov 8, 2023
2 parents 8d97d0d + e2c2504 commit 06b9ffa
Show file tree
Hide file tree
Showing 7 changed files with 89 additions and 112 deletions.
4 changes: 4 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ versions of this library.

### 3.x -> 4.x

- `react-native-mmkv` is now a peer dependency.

### 4.x -> 5.x

- `TabBar` default styles have changed. Run the app post upgrade if you use the
`TabBar` to examine updated styles and apply overrides as needed (note,
default tabs do not use `TabBar`).
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"react-native-gifted-chat": "^2.4.0",
"react-native-keychain": "^8.1.1",
"react-native-markdown-display": "^6.1.6",
"react-native-mmkv": "2.5.1",
"react-native-notifications": "^4.3.3",
"react-native-reanimated": "^3.5.4",
"react-native-safe-area-context": "^4.5.0",
Expand Down Expand Up @@ -129,6 +130,7 @@
"react-native-gifted-chat": "^2.4.0",
"react-native-keychain": ">=8.1.1",
"react-native-markdown-display": ">=6.1.6",
"react-native-mmkv": "^2.5.1",
"react-native-notifications": ">=4.3.3",
"react-native-reanimated": ">=3.5.4",
"react-native-safe-area-context": ">=4.5.0",
Expand Down
52 changes: 14 additions & 38 deletions src/hooks/useActiveAccount.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,22 @@ import React from 'react';
import { act, renderHook, waitFor } from '@testing-library/react-native';
import {
ActiveAccountContextProvider,
PREFERRED_ACCOUNT_ID_KEY,
useActiveAccount,
} from './useActiveAccount';
import AsyncStorage from '@react-native-async-storage/async-storage';
import AsyncStorageMock from '@react-native-async-storage/async-storage/jest/async-storage-mock';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import * as useAsyncStorage from './useAsyncStorage';
import { inviteNotifier } from '../components/Invitations/InviteNotifier';
import { useUser } from './useUser';
import { createRestAPIMock } from '../test-utils/rest-api-mocking';
import { _store } from './useStoredValue';

const api = createRestAPIMock();

jest.mock('./useUser', () => ({
useUser: jest.fn(),
}));

jest.mock('./useAuth', () => ({
useAuth: () => ({
isLoggedIn: true,
}),
}));

const useUserMock = useUser as jest.Mock;

let useAsyncStorageSpy = jest.spyOn(useAsyncStorage, 'useAsyncStorage');

const accountId1 = 'acct1';
const accountId2 = 'acct2';

Expand Down Expand Up @@ -64,15 +54,8 @@ beforeEach(() => {
data: { accounts: mockAccounts },
}),
);
useAsyncStorageSpy.mockReturnValue([
'',
(value: string) =>
AsyncStorage.setItem('selectedAccountId:mockUser', value),
true,
]);
useUserMock.mockReturnValue({
data: { id: 'mockUser' },
});

_store.clearAll();
});

test('without provider, methods fail', async () => {
Expand Down Expand Up @@ -159,23 +142,22 @@ test('provider allows for account override by id', async () => {
);
});

test('uses account from async storage', async () => {
useAsyncStorageSpy.mockRestore();

AsyncStorageMock.getItem = jest.fn().mockResolvedValueOnce(accountId1);
const { result, rerender } = await renderHookInContext();
test('uses account from mmkv', async () => {
_store.set(PREFERRED_ACCOUNT_ID_KEY, accountId1);
const { result, rerender, unmount } = await renderHookInContext();
await waitFor(() => result.current.isLoading === false);
rerender({});
expect(AsyncStorage.getItem).toBeCalledWith('selectedAccountId:mockUser');

await waitFor(() =>
expect(result.current).toMatchObject({
account: mockAccounts[0],
accountHeaders: { 'LifeOmic-Account': mockAccounts[0].id },
accountsWithProduct: mockAccounts,
}),
);
unmount();

AsyncStorageMock.getItem = jest.fn().mockResolvedValueOnce(accountId2);
_store.set(PREFERRED_ACCOUNT_ID_KEY, accountId2);
const { result: nextResult, rerender: nextRerender } =
await renderHookInContext();
await waitFor(() => nextResult.current.isLoading === false);
Expand All @@ -187,15 +169,12 @@ test('uses account from async storage', async () => {
accountsWithProduct: mockAccounts,
}),
);

useAsyncStorageSpy = jest.spyOn(useAsyncStorage, 'useAsyncStorage');
});

test('initial render writes selected account to async storage', async () => {
await renderHookInContext(accountId2);
await waitFor(() =>
expect(AsyncStorageMock.setItem).toBeCalledWith(
'selectedAccountId:mockUser',
expect(_store.getString(PREFERRED_ACCOUNT_ID_KEY)).toStrictEqual(
accountId2,
),
);
Expand All @@ -204,17 +183,15 @@ test('initial render writes selected account to async storage', async () => {
test('setAccountAccountId writes selected account to async storage', async () => {
const { result } = await renderHookInContext();
await waitFor(() =>
expect(AsyncStorageMock.setItem).toBeCalledWith(
'selectedAccountId:mockUser',
expect(_store.getString(PREFERRED_ACCOUNT_ID_KEY)).toStrictEqual(
accountId1,
),
);
await act(async () => {
result.current.setActiveAccountId(accountId2);
});
await waitFor(() =>
expect(AsyncStorageMock.setItem).toBeCalledWith(
'selectedAccountId:mockUser',
expect(_store.getString(PREFERRED_ACCOUNT_ID_KEY)).toStrictEqual(
accountId2,
),
);
Expand Down Expand Up @@ -242,8 +219,7 @@ test('handles accepted invites by refetching and setting account', async () => {
});

await waitFor(() =>
expect(AsyncStorageMock.setItem).toBeCalledWith(
'selectedAccountId:mockUser',
expect(_store.getString(PREFERRED_ACCOUNT_ID_KEY)).toStrictEqual(
invitedAccountId,
),
);
Expand Down
110 changes: 36 additions & 74 deletions src/hooks/useActiveAccount.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import React, {
createContext,
useEffect,
useState,
useContext,
useCallback,
useMemo,
} from 'react';
import React, { createContext, useEffect, useContext } from 'react';
import { Account } from '../types/rest-types';
import { useAsyncStorage } from './useAsyncStorage';
import { inviteNotifier } from '../components/Invitations/InviteNotifier';
import { ProjectInvite } from '../types';
import { useUser } from './useUser';
import { useRestCache, useRestQuery } from './rest-api';
import { useAuth } from './useAuth';
import { useStoredValue } from './useStoredValue';

export type ActiveAccountProps = {
account?: Account;
Expand All @@ -33,14 +25,7 @@ export const ActiveAccountContext = createContext({
isFetched: false,
} as ActiveAccountContextProps);

const selectedAccountIdKey = 'selectedAccountId';

const filterNonLRAccounts = (accounts?: Account[]) =>
accounts?.filter((a) => a.products?.indexOf('LR') > -1) || [];

const getValidAccount = (validAccounts?: Account[], accountId?: string) => {
return validAccounts?.find((a) => a.id === accountId);
};
export const PREFERRED_ACCOUNT_ID_KEY = 'preferred-account-id';

export const ActiveAccountContextProvider = ({
children,
Expand All @@ -57,83 +42,60 @@ export const ActiveAccountContextProvider = ({
const accountsResult = useRestQuery(
'GET /v1/accounts',
{},
{ select: (data) => data.accounts, enabled: isLoggedIn },
{
select: (data) => data.accounts.filter((a) => a.products.includes('LR')),
enabled: isLoggedIn,
},
);

const accountsWithProduct = filterNonLRAccounts(accountsResult.data);
const { data: userData } = useUser();
const userId = userData?.id;
const [selectedId, setSelectedId] = useState(accountIdToSelect);
const [storedAccountIdResult, setStoredAccountId, isStorageLoaded] =
useAsyncStorage(
`${selectedAccountIdKey}:${userId}`,
!!selectedAccountIdKey && !!userId,
);
const accountsWithProduct = accountsResult.data ?? [];
const [preferredId, setPreferredId] = useStoredValue(
PREFERRED_ACCOUNT_ID_KEY,
);
const cache = useRestCache();

/**
* Initial setting of activeAccount
*/
const activeAccount = useMemo<ActiveAccountProps>(() => {
if (
!userId || // require user id before reading/writing to storage
!isStorageLoaded ||
accountsWithProduct.length < 1 // no valid accounts found server side
) {
return {};
}

const accountToSelect = selectedId ?? storedAccountIdResult ?? undefined;

const selectedAccount =
getValidAccount(accountsWithProduct, accountToSelect) ??
accountsWithProduct[0];

return {
account: selectedAccount,
accountHeaders: {
'LifeOmic-Account': selectedAccount.id,
},
};
}, [
accountsWithProduct,
isStorageLoaded,
selectedId,
storedAccountIdResult,
userId,
]);

const selectedAccount = accountsWithProduct.length
? accountsWithProduct?.find(
// Prefer the prop override first. Otherwise, use the stored preference.
(a) => a.id === (accountIdToSelect || preferredId),
) ??
// Otherwise, use the first account in the list.
accountsWithProduct[0]
: undefined;

// Whenever the user's account changes, use the new account as
// the preferred account.
useEffect(() => {
if (activeAccount?.account?.id) {
setStoredAccountId(activeAccount.account.id);
if (selectedAccount?.id) {
setPreferredId(selectedAccount.id);
}
}, [activeAccount?.account?.id, setStoredAccountId]);

const setActiveAccountId = useCallback(async (accountId: string) => {
setSelectedId(accountId);
}, []);
}, [selectedAccount, setPreferredId]);

// Handle invite accept
// When the user accepts an invite to a new account, reset the cache,
// and also use _that_ account as the preferred account.
useEffect(() => {
const listener = async (acceptedInvite: ProjectInvite) => {
cache.resetQueries({ 'GET /v1/accounts': 'all' });
setSelectedId(acceptedInvite.account);
setPreferredId(acceptedInvite.account);
inviteNotifier.emit('inviteAccountSettled');
};
inviteNotifier.addListener('inviteAccepted', listener);
return () => {
inviteNotifier.removeListener('inviteAccepted', listener);
};
}, [cache]);
}, [cache, setPreferredId]);

return (
<ActiveAccountContext.Provider
value={{
...activeAccount,
account: selectedAccount,
accountHeaders: selectedAccount
? { 'LifeOmic-Account': selectedAccount.id }
: undefined,
accountsWithProduct,
setActiveAccountId,
isLoading: accountsResult.isLoading || !isStorageLoaded,
isFetched: accountsResult.isFetched && isStorageLoaded,
setActiveAccountId: setPreferredId,
isLoading: accountsResult.isLoading,
isFetched: accountsResult.isFetched,
error: accountsResult.error,
}}
>
Expand Down
13 changes: 13 additions & 0 deletions src/hooks/useStoredValue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { act, renderHook } from '@testing-library/react-native';
import { _store, useStoredValue } from './useStoredValue';

test('updates when someone else modifies the value', async () => {
const hook = renderHook(() => useStoredValue('test-key'));
expect(hook.result.current[0]).toBeUndefined();

act(() => {
_store.set('test-key', 'test-value');
});
hook.rerender({});
expect(hook.result.current[0]).toStrictEqual('test-value');
});
15 changes: 15 additions & 0 deletions src/hooks/useStoredValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { MMKV, useMMKVString } from 'react-native-mmkv';

/**
* Exported for testing purposes only.
*/
export const _store = new MMKV();

/**
* A `useState`-like hook that persists its values to disk.
*
* **IMPORTANT**: data persisted by this hook is stored _unencrypted_.
*/
export function useStoredValue(key: string) {
return useMMKVString(key, _store);
}
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -9703,6 +9703,11 @@ react-native-markdown-display@^6.1.6:
prop-types "^15.7.2"
react-native-fit-image "^1.5.5"

react-native-mmkv@2.5.1:
version "2.5.1"
resolved "https://registry.yarnpkg.com/react-native-mmkv/-/react-native-mmkv-2.5.1.tgz#29fc462077fab16a5e1b79570fbf8acaac9d87b4"
integrity sha512-5eQu25z3H6zf6w0NkJoTuFEFrbOu6luZxZ6+rK1W+XwY/rjPSFZFQPVtMaz3im90RbILFXXM/KrFGZrpaJJRoQ==

react-native-notifications@^4.3.3:
version "4.3.3"
resolved "https://registry.yarnpkg.com/react-native-notifications/-/react-native-notifications-4.3.3.tgz#5be05cb144127f4db518079dd202415af497dfcb"
Expand Down

0 comments on commit 06b9ffa

Please sign in to comment.