From 1832aeaa08d7205b05116ac414d40823b4a3a6ec Mon Sep 17 00:00:00 2001 From: Jean Regisser Date: Thu, 29 Oct 2020 16:01:31 +0100 Subject: [PATCH] [Wallet] Country feature flags and changes for countries where CP-DOTO is restricted (#5613) ### Description This PR covers the following changes: - adds country specific feature flags, based on the country of the entered phone number. Flags are defined in `src/flags.ts`, helpers are in `src/utils/countryFeatures.ts ` (there's a `useCountryFeatures` hook for easy usage within components). - adds the CELO balance to the menu, (hidden if amount is <= 0.001) - hides the CELO Buy/Sell buttons on the Exchange screen for CP-DOTO restricted countries (only PH for now). In that case we display a "Withdraw" button if the user has any CELO funds. See [designs on Figma](https://www.figma.com/file/zt7aTQ5wuXycIwxq5oAmF9/Wallet--Refresh?node-id=3136%3A0) (not entirely up-to-date with latest discussions): ### Other changes - Upgrade `react-native-reanimated` to fix issue with mock during jest tests - Add tests for previously uncovered components: - `DrawerNavigator` - `ExchangeHomeScreen` ### Tested **Menu (PH, CP-DOTO restricted) with/without CELO balance** **Exchange Home screen (PH, CP-DOTO restricted) with/without CELO balance** **Menu (US, non CP-DOTO restricted) with/without CELO balance** **Exchange Home screen (non CP-DOTO restricted) with/without CELO balance** ### Related issues - Fixes #5444 - Addresses part of #5425 (Adds CELO balance to the menu) ### Backwards compatibility Yes --- .../__mocks__/react-native-reanimated.js | 8 +- .../react-native-safe-area-context.ts | 6 + packages/mobile/ios/Podfile.lock | 4 +- packages/mobile/package.json | 2 +- .../mobile/src/components/CurrencyDisplay.tsx | 22 +- .../src/exchange/CeloExchangeButtons.tsx | 1 - .../mobile/src/exchange/CeloGoldOverview.tsx | 3 +- .../src/exchange/ExchangeHomeScreen.test.tsx | 128 +- .../src/exchange/ExchangeHomeScreen.tsx | 24 +- .../exchange/RestrictedCeloExchange.test.tsx | 43 + .../src/exchange/RestrictedCeloExchange.tsx | 49 + .../CeloExchangeButtons.test.tsx.snap | 3 - .../ExchangeHomeScreen.test.tsx.snap | 1122 +++++++++++++++-- packages/mobile/src/flags.ts | 8 + .../src/navigator/DrawerNavigator.test.tsx | 59 + .../mobile/src/navigator/DrawerNavigator.tsx | 44 +- .../mobile/src/utils/countryFeatures.test.ts | 33 + packages/mobile/src/utils/countryFeatures.ts | 33 + yarn.lock | 8 +- 19 files changed, 1415 insertions(+), 185 deletions(-) create mode 100644 packages/mobile/src/exchange/RestrictedCeloExchange.test.tsx create mode 100644 packages/mobile/src/exchange/RestrictedCeloExchange.tsx create mode 100644 packages/mobile/src/navigator/DrawerNavigator.test.tsx create mode 100644 packages/mobile/src/utils/countryFeatures.test.ts create mode 100644 packages/mobile/src/utils/countryFeatures.ts diff --git a/packages/mobile/__mocks__/react-native-reanimated.js b/packages/mobile/__mocks__/react-native-reanimated.js index ec88e413ef7..46e76b53a34 100644 --- a/packages/mobile/__mocks__/react-native-reanimated.js +++ b/packages/mobile/__mocks__/react-native-reanimated.js @@ -1 +1,7 @@ -module.exports = require('react-native-reanimated/mock') +const Reanimated = require('react-native-reanimated/mock') + +// The mock for `call` immediately calls the callback which is incorrect +// So we override it with a no-op +Reanimated.default.call = () => {} + +module.exports = Reanimated diff --git a/packages/mobile/__mocks__/react-native-safe-area-context.ts b/packages/mobile/__mocks__/react-native-safe-area-context.ts index 7086394dddf..3933b91337e 100644 --- a/packages/mobile/__mocks__/react-native-safe-area-context.ts +++ b/packages/mobile/__mocks__/react-native-safe-area-context.ts @@ -16,4 +16,10 @@ module.exports = { width, height, }), + useSafeArea: () => ({ + top: 0, + right: 0, + bottom: 0, + left: 0, + }), } diff --git a/packages/mobile/ios/Podfile.lock b/packages/mobile/ios/Podfile.lock index ffb88fef222..8227d896683 100644 --- a/packages/mobile/ios/Podfile.lock +++ b/packages/mobile/ios/Podfile.lock @@ -472,7 +472,7 @@ PODS: - React - RNPermissions (2.1.5): - React - - RNReanimated (1.9.0): + - RNReanimated (1.13.1): - React - RNScreens (2.7.0): - React @@ -837,7 +837,7 @@ SPEC CHECKSUMS: RNKeychain: bf2d7e9a0ae7a073c07770dd2aa6d11c67581733 RNLocalize: b6df30cc25ae736d37874f9bce13351db2f56796 RNPermissions: ad71dd4f767ec254f2cd57592fbee02afee75467 - RNReanimated: b5ccb50650ba06f6e749c7c329a1bc3ae0c88b43 + RNReanimated: dd8c286ab5dd4ba36d3a7fef8bff7e08711b5476 RNScreens: cf198f915f8a2bf163de94ca9f5bfc8d326c3706 RNSentry: 2bae4ffee2e66376ef42cb845a494c3bde17bc56 RNShare: fea1801315aa8875d6db73a4010b14afcd568765 diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 7bec4da753d..614f58578fa 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -116,7 +116,7 @@ "react-native-permissions": "^2.1.5", "react-native-progress": "^4.1.2", "react-native-qrcode-svg": "^6.0.6", - "react-native-reanimated": "^1.9.0", + "react-native-reanimated": "^1.13.1", "react-native-restart-android": "^0.0.7", "react-native-safe-area-context": "^3.0.6", "react-native-screens": "^2.7.0", diff --git a/packages/mobile/src/components/CurrencyDisplay.tsx b/packages/mobile/src/components/CurrencyDisplay.tsx index 42fc73657ec..ad6ec366f65 100644 --- a/packages/mobile/src/components/CurrencyDisplay.tsx +++ b/packages/mobile/src/components/CurrencyDisplay.tsx @@ -51,6 +51,7 @@ interface Props { hideFullCurrencyName: boolean style?: StyleProp currencyInfo?: CurrencyInfo + testID?: string } const BIG_SIGN_RATIO = 34 / 48 @@ -120,6 +121,17 @@ function getFormatFunction(formatType: FormatType): FormatFunction { } } +function getFullCurrencyName(currency: CURRENCY_ENUM | null) { + switch (currency) { + case CURRENCY_ENUM.DOLLAR: + return i18n.t('global:celoDollars') + case CURRENCY_ENUM.GOLD: + return i18n.t('global:celoGold') + default: + return null + } +} + export default function CurrencyDisplay({ type, size, @@ -134,6 +146,7 @@ export default function CurrencyDisplay({ hideFullCurrencyName, style, currencyInfo, + testID, }: Props) { let localCurrencyCode = useLocalCurrencyCode() let dollarToLocalRate = useDollarToLocalRate() @@ -169,6 +182,7 @@ export default function CurrencyDisplay({ const formattedValue = value && displayCurrency ? formatAmount(value.absoluteValue(), displayCurrency) : '-' const code = displayAmount?.currencyCode + const fullCurrencyName = getFullCurrencyName(displayCurrency) const color = useColors ? currency === CURRENCY_ENUM.GOLD @@ -189,7 +203,7 @@ export default function CurrencyDisplay({ const codeStyle = { fontSize: Math.round(fontSize * BIG_CODE_RATIO), lineHeight, color } return ( - + {!hideSign && ( {sign} @@ -213,14 +227,12 @@ export default function CurrencyDisplay({ } return ( - + {!hideSign && sign} {!hideSymbol && currencySymbol} {formattedValue} {!hideCode && !!code && ` ${code}`} - {!hideFullCurrencyName && - code === CURRENCIES[CURRENCY_ENUM.DOLLAR].code && - ` ${i18n.t('global:celoDollars')}`} + {!hideFullCurrencyName && !!fullCurrencyName && ` ${fullCurrencyName}`} ) } diff --git a/packages/mobile/src/exchange/CeloExchangeButtons.tsx b/packages/mobile/src/exchange/CeloExchangeButtons.tsx index a9ace2b535d..f4bcb24d7da 100644 --- a/packages/mobile/src/exchange/CeloExchangeButtons.tsx +++ b/packages/mobile/src/exchange/CeloExchangeButtons.tsx @@ -88,7 +88,6 @@ const styles = StyleSheet.create({ }, buttonContainer: { flexDirection: 'row', - flex: 1, marginTop: 24, marginBottom: 28, marginHorizontal: 12, diff --git a/packages/mobile/src/exchange/CeloGoldOverview.tsx b/packages/mobile/src/exchange/CeloGoldOverview.tsx index 58a4a4b37ae..b16774f4774 100644 --- a/packages/mobile/src/exchange/CeloGoldOverview.tsx +++ b/packages/mobile/src/exchange/CeloGoldOverview.tsx @@ -6,6 +6,7 @@ import React from 'react' import { Trans, WithTranslation } from 'react-i18next' import { StyleSheet, Text, View } from 'react-native' import CurrencyDisplay from 'src/components/CurrencyDisplay' +import { celoTokenBalanceSelector } from 'src/goldToken/selectors' import useBalanceAutoRefresh from 'src/home/useBalanceAutoRefresh' import { Namespaces, withTranslation } from 'src/i18n' import useSelector from 'src/redux/useSelector' @@ -18,7 +19,7 @@ type Props = WithTranslation & OwnProps export function CeloGoldOverview({ t, testID }: Props) { useBalanceAutoRefresh() - const goldBalance = useSelector((state) => state.goldToken.balance) + const goldBalance = useSelector(celoTokenBalanceSelector) const goldBalanceAmount = goldBalance ? { value: goldBalance, currencyCode: CURRENCIES[CURRENCY_ENUM.GOLD].code } diff --git a/packages/mobile/src/exchange/ExchangeHomeScreen.test.tsx b/packages/mobile/src/exchange/ExchangeHomeScreen.test.tsx index d342dff6d95..a7c59a8c6d4 100644 --- a/packages/mobile/src/exchange/ExchangeHomeScreen.test.tsx +++ b/packages/mobile/src/exchange/ExchangeHomeScreen.test.tsx @@ -1,66 +1,72 @@ -// need to mock this as it expects to be called inside a provider -jest.mock('src/exchange/CeloGoldOverview', () => ({ default: () => 'AccountOverviewComponent' })) -// import * as React from 'react' -// import { MockedProvider } from 'react-apollo/test-utils' -import 'react-native' -// import { Provider } from 'react-redux' -// import * as renderer from 'react-test-renderer' -// import configureMockStore from 'redux-mock-store' -// import thunk from 'redux-thunk' -// import { ExchangeHomeScreen } from 'src/exchange/ExchangeHomeScreen' -// import { transactionQuery } from 'src/home/WalletHome' -// import i18n from 'src/i18n' +import React from 'react' +import { fireEvent, render } from 'react-native-testing-library' +import { Provider } from 'react-redux' +import ExchangeHomeScreen from 'src/exchange/ExchangeHomeScreen' +import { Screens } from 'src/navigator/Screens' +import { createMockStore, getMockStackScreenProps } from 'test/utils' -// const middlewares = [thunk] -// const mockStore = configureMockStore(middlewares) +// Mock this for now, as we get apollo issues +jest.mock('src/transactions/TransactionsList') -// const newDollarBalance = '189.9' -// const newGoldBalance = '207.81' -// const exchangeRate = '2' +const mockScreenProps = getMockStackScreenProps(Screens.ExchangeHomeScreen) -// const mocks = [ -// { -// request: { -// query: transactionQuery, -// variables: { -// address: '', -// }, -// }, -// result: { -// data: { -// events: [], -// }, -// }, -// }, -// ] +describe('ExchangeHomeScreen', () => { + it('renders and behaves correctly for non CP-DOTO restricted countries', () => { + const store = createMockStore({ + goldToken: { balance: '2' }, + stableToken: { balance: '10' }, + exchange: { exchangeRatePair: { goldMaker: '0.11', dollarMaker: '10' } }, + }) -// failing due to various apollo issues with mocks -xit('renders correctly', () => { - // const store = mockStore({ - // account: { devModeActive: false }, - // transactions: { standbyTransactions: [] }, - // web3: { accounts: {} }, - // }) - // const tree = renderer.create( - // - // - // - // - // - // ) - // expect(tree).toMatchSnapshot() + const tree = render( + + + + ) + + expect(tree).toMatchSnapshot() + + jest.clearAllMocks() + fireEvent.press(tree.getByTestId('BuyCelo')) + expect(mockScreenProps.navigation.navigate).toHaveBeenCalledWith(Screens.ExchangeTradeScreen, { + makerTokenDisplay: { makerToken: 'Celo Dollar', makerTokenBalance: '10' }, + }) + + jest.clearAllMocks() + fireEvent.press(tree.getByTestId('SellCelo')) + expect(mockScreenProps.navigation.navigate).toHaveBeenCalledWith(Screens.ExchangeTradeScreen, { + makerTokenDisplay: { makerToken: 'Celo Gold', makerTokenBalance: '2' }, + }) + + jest.clearAllMocks() + fireEvent.press(tree.getByTestId('WithdrawCELO')) + expect(mockScreenProps.navigation.navigate).toHaveBeenCalledWith(Screens.WithdrawCeloScreen) + }) + + it('renders and behaves correctly for CP-DOTO restricted countries', () => { + const store = createMockStore({ + account: { + defaultCountryCode: '+63', // PH is restricted for CP-DOTO + }, + goldToken: { balance: '2' }, + stableToken: { balance: '10' }, + exchange: { exchangeRatePair: { goldMaker: '0.11', dollarMaker: '10' } }, + }) + + const tree = render( + + + + ) + + expect(tree).toMatchSnapshot() + + // Check we cannot buy/sell + expect(tree.queryByTestId('BuyCelo')).toBeFalsy() + expect(tree.queryByTestId('SellCelo')).toBeFalsy() + + // Check we can withdraw + fireEvent.press(tree.getByTestId('WithdrawCELO')) + expect(mockScreenProps.navigation.navigate).toHaveBeenCalledWith(Screens.WithdrawCeloScreen) + }) }) diff --git a/packages/mobile/src/exchange/ExchangeHomeScreen.tsx b/packages/mobile/src/exchange/ExchangeHomeScreen.tsx index 3c47f7115f4..571d2917516 100644 --- a/packages/mobile/src/exchange/ExchangeHomeScreen.tsx +++ b/packages/mobile/src/exchange/ExchangeHomeScreen.tsx @@ -21,6 +21,7 @@ import CeloGoldHistoryChart from 'src/exchange/CeloGoldHistoryChart' import CeloGoldOverview from 'src/exchange/CeloGoldOverview' import { useExchangeRate } from 'src/exchange/hooks' import { exchangeHistorySelector } from 'src/exchange/reducer' +import RestrictedCeloExchange from 'src/exchange/RestrictedCeloExchange' import { CURRENCY_ENUM } from 'src/geth/consts' import { Namespaces } from 'src/i18n' import InfoIcon from 'src/icons/InfoIcon' @@ -34,6 +35,7 @@ import { StackParamList } from 'src/navigator/types' import useSelector from 'src/redux/useSelector' import DisconnectBanner from 'src/shared/DisconnectBanner' import TransactionsList from 'src/transactions/TransactionsList' +import { useCountryFeatures } from 'src/utils/countryFeatures' import { goldToDollarAmount } from 'src/utils/currencyExchange' import { getLocalCurrencyDisplayValue } from 'src/utils/formatting' @@ -86,6 +88,8 @@ function ExchangeHomeScreen({ navigation }: Props) { const { t } = useTranslation(Namespaces.exchangeFlow9) + const { RESTRICTED_CP_DOTO } = useCountryFeatures() + // TODO: revert this back to `useLocalCurrencyCode()` when we have history data for cGDL to Local Currency. const localCurrencyCode = null const localExchangeRate = useSelector(getLocalCurrencyExchangeRate) @@ -167,16 +171,22 @@ function ExchangeHomeScreen({ navigation }: Props) { - + {RESTRICTED_CP_DOTO ? ( + + ) : ( + + )} - + {!RESTRICTED_CP_DOTO && ( + + )} diff --git a/packages/mobile/src/exchange/RestrictedCeloExchange.test.tsx b/packages/mobile/src/exchange/RestrictedCeloExchange.test.tsx new file mode 100644 index 00000000000..7f74f4c40f2 --- /dev/null +++ b/packages/mobile/src/exchange/RestrictedCeloExchange.test.tsx @@ -0,0 +1,43 @@ +import * as React from 'react' +import 'react-native' +import { fireEvent, render } from 'react-native-testing-library' +import { Provider } from 'react-redux' +import RestrictedCeloExchange from 'src/exchange/RestrictedCeloExchange' +import { createMockStore } from 'test/utils' + +describe('RestrictedCeloExchange', () => { + it('allows withdrawing if the balance is enough', () => { + const store = createMockStore({ + goldToken: { balance: '10' }, + stableToken: { balance: '10' }, + }) + const onPressWithdraw = jest.fn() + + const tree = render( + + + + ) + + expect(onPressWithdraw).not.toHaveBeenCalled() + + fireEvent.press(tree.getByTestId('WithdrawCELO')) + expect(onPressWithdraw).toHaveBeenCalled() + }) + + it('disallows withdrawing if the balance is NOT enough', () => { + const store = createMockStore({ + goldToken: { balance: '0.001' }, + }) + const onPressWithdraw = jest.fn() + + const tree = render( + + + + ) + + expect(onPressWithdraw).not.toHaveBeenCalled() + expect(tree.queryByTestId('WithdrawCELO')).toBeFalsy() + }) +}) diff --git a/packages/mobile/src/exchange/RestrictedCeloExchange.tsx b/packages/mobile/src/exchange/RestrictedCeloExchange.tsx new file mode 100644 index 00000000000..837025f6730 --- /dev/null +++ b/packages/mobile/src/exchange/RestrictedCeloExchange.tsx @@ -0,0 +1,49 @@ +import Button, { BtnSizes, BtnTypes } from '@celo/react-components/components/Button' +import { Spacing } from '@celo/react-components/styles/styles' +import BigNumber from 'bignumber.js' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { StyleSheet, View } from 'react-native' +import { useSelector } from 'react-redux' +import { GOLD_TRANSACTION_MIN_AMOUNT } from 'src/config' +import { celoTokenBalanceSelector } from 'src/goldToken/selectors' +import { Namespaces } from 'src/i18n' + +interface Props { + onPressWithdraw: () => void +} + +// Actions container for CP-DOTO restricted countries +export default function RestrictedCeloExchange({ onPressWithdraw }: Props) { + const { t } = useTranslation(Namespaces.exchangeFlow9) + + const goldBalance = useSelector(celoTokenBalanceSelector) + + const hasGold = new BigNumber(goldBalance || 0).isGreaterThan(GOLD_TRANSACTION_MIN_AMOUNT) + + if (!hasGold) { + return + } + + return ( +