Feat/375: Risk notice dialog (#1096)

* feat: add risk warning modal WIP

* feat: add risk notice modal and fix broken mobile dialog styles

* fix: format

* fix: lint

* fix: format

* fix: dialog scrollbar

* fix: style issues

* fix: styles

* fix: spacing

* fix: more style fixes

* fix: more spacing

* fix: format

* fix: move logic into risk dialog from global app

* fix: brance yourselves, more style updates

* feat: add test for risk dialog
This commit is contained in:
botond 2022-09-02 15:53:38 +01:00 committed by GitHub
parent 75e3f327ff
commit 9bcb923cc0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 248 additions and 41 deletions

View File

@ -0,0 +1 @@
export * from './risk-notice-dialog';

View File

@ -0,0 +1,78 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { RiskNoticeDialog } from './risk-notice-dialog';
import { Networks, EnvironmentProvider } from '@vegaprotocol/environment';
import { useGlobalStore } from '../../stores';
beforeEach(() => {
localStorage.clear();
useGlobalStore.setState((state) => ({
...state,
vegaRiskNoticeDialog: false,
}));
});
const mockEnvDefinitions = {
VEGA_CONFIG_URL: 'https://config.url',
VEGA_URL: 'https://test.url',
VEGA_NETWORKS: JSON.stringify({}),
};
describe('Risk notice dialog', () => {
it.each`
assertion | network
${'displays'} | ${Networks.MAINNET}
${'does not display'} | ${Networks.CUSTOM}
${'does not display'} | ${Networks.DEVNET}
${'does not display'} | ${Networks.STAGNET3}
${'does not display'} | ${Networks.TESTNET}
`(
'$assertion the risk notice on $network',
async ({ assertion, network }) => {
render(
<EnvironmentProvider
definitions={{ ...mockEnvDefinitions, VEGA_ENV: network }}
config={{ hosts: [] }}
>
<RiskNoticeDialog />
</EnvironmentProvider>
);
if (assertion === 'displays') {
// eslint-disable-next-line jest/no-conditional-expect
expect(screen.queryByText('WARNING')).toBeInTheDocument();
} else {
// eslint-disable-next-line jest/no-conditional-expect
expect(screen.queryByText('WARNING')).not.toBeInTheDocument();
}
}
);
it("doesn't display the risk notice when previously acknowledged", () => {
const { rerender } = render(
<EnvironmentProvider
definitions={{ ...mockEnvDefinitions, VEGA_ENV: Networks.MAINNET }}
config={{ hosts: [] }}
>
<RiskNoticeDialog />
</EnvironmentProvider>
);
expect(screen.queryByText('WARNING')).toBeInTheDocument();
const button = screen.getByRole('button', {
name: 'I understand, Continue',
});
fireEvent.click(button);
rerender(
<EnvironmentProvider
definitions={{ ...mockEnvDefinitions, VEGA_ENV: Networks.MAINNET }}
config={{ hosts: [] }}
>
<RiskNoticeDialog />
</EnvironmentProvider>
);
expect(screen.queryByText('WARNING')).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,52 @@
import { useEffect } from 'react';
import { t } from '@vegaprotocol/react-helpers';
import { Dialog, Button } from '@vegaprotocol/ui-toolkit';
import { LocalStorage } from '@vegaprotocol/react-helpers';
import { useEnvironment, Networks } from '@vegaprotocol/environment';
import { useGlobalStore } from '../../stores';
export const RISK_ACCEPTED_KEY = 'vega-risk-accepted';
export const RiskNoticeDialog = () => {
const store = useGlobalStore();
const { VEGA_ENV } = useEnvironment();
useEffect(() => {
const isRiskAccepted = LocalStorage.getItem(RISK_ACCEPTED_KEY) === 'true';
if (!isRiskAccepted && VEGA_ENV === Networks.MAINNET) {
store.setVegaRiskNoticeDialog(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [store.setVegaRiskNoticeDialog, VEGA_ENV]);
const handleAcceptRisk = () => {
store.setVegaRiskNoticeDialog(false);
LocalStorage.setItem(RISK_ACCEPTED_KEY, 'true');
};
return (
<Dialog
open={store.vegaRiskNoticeDialog}
title={t('WARNING')}
size="medium"
>
<h4 className="text-xl mb-2 mt-4">
{t('Regulation may apply to use of this app')}
</h4>
<p className="text-base mb-6">
{t(
'This decentralised application allows you to connect to and use publicly available blockchain services operated by third parties that may include trading, financial products, or other services that may be subject to legal and regulatory restrictions in your jurisdiction. This application is a front end only and does not hold any funds or provide any products or services. It is available to anyone with an internet connection via IPFS and other methods, and the ability to access it does not imply any right to use any services or that it is legal for you to do so. By using this application you accept that it is your responsibility to ensure that your use of the application and any blockchain services accessed through it is compliant with applicable laws and regulations in your jusrisdiction.'
)}
</p>
<h4 className="text-xl mb-2">
{t('Technical and financial risk of loss')}
</h4>
<p className="text-base mb-8">
{t(
'The public blockchain services accessible via this decentralised application are operated by third parties and may carry significant risks including the potential loss of all funds that you deposit or hold with these services. Technical risks include the risk of loss in the event of the failure or compromise of the public blockchain infrastructure or smart contracts that provide any services you use. Financial risks include but are not limited to losses due to volatility, excessive leverage, low liquidity, and your own lack of understanding of the services you use. By using this decentralised application you accept that it is your responsibility to ensure that you understand any services you use and the technical and financial risks inherent in your use. Do not risk what you cannot afford to lose.'
)}
</p>
<Button onClick={handleAcceptRisk}>{t('I understand, Continue')}</Button>
</Dialog>
);
};

View File

@ -1,4 +1,5 @@
import type { AppProps } from 'next/app'; import type { AppProps } from 'next/app';
import Head from 'next/head';
import { Navbar } from '../components/navbar'; import { Navbar } from '../components/navbar';
import { t, ThemeContext, useThemeSwitcher } from '@vegaprotocol/react-helpers'; import { t, ThemeContext, useThemeSwitcher } from '@vegaprotocol/react-helpers';
import { import {
@ -9,6 +10,7 @@ import {
import { EnvironmentProvider } from '@vegaprotocol/environment'; import { EnvironmentProvider } from '@vegaprotocol/environment';
import { Connectors } from '../lib/vega-connectors'; import { Connectors } from '../lib/vega-connectors';
import { AppLoader } from '../components/app-loader'; import { AppLoader } from '../components/app-loader';
import { RiskNoticeDialog } from '../components/risk-notice-dialog';
import './styles.css'; import './styles.css';
import { useGlobalStore } from '../stores'; import { useGlobalStore } from '../stores';
import { import {
@ -16,7 +18,6 @@ import {
useAssetDetailsDialogStore, useAssetDetailsDialogStore,
} from '@vegaprotocol/assets'; } from '@vegaprotocol/assets';
import { Footer } from '../components/footer'; import { Footer } from '../components/footer';
import Head from 'next/head';
function AppBody({ Component, pageProps }: AppProps) { function AppBody({ Component, pageProps }: AppProps) {
const store = useGlobalStore(); const store = useGlobalStore();
@ -54,6 +55,7 @@ function AppBody({ Component, pageProps }: AppProps) {
open={isAssetDetailsDialogOpen} open={isAssetDetailsDialogOpen}
onChange={(open) => setAssetDetailsDialogOpen(open)} onChange={(open) => setAssetDetailsDialogOpen(open)}
/> />
<RiskNoticeDialog />
</AppLoader> </AppLoader>
</div> </div>
</ThemeContext.Provider> </ThemeContext.Provider>

View File

@ -9,13 +9,16 @@ export function Index() {
// The default market selected in the platform behind the overlay // The default market selected in the platform behind the overlay
// should be the oldest market that is currently trading in continuous mode(i.e. not in auction). // should be the oldest market that is currently trading in continuous mode(i.e. not in auction).
const { data, error, loading } = useMarketList(); const { data, error, loading } = useMarketList();
const setLandingDialog = useGlobalStore((state) => state.setLandingDialog); const { vegaRiskNoticeDialog, setLandingDialog } = useGlobalStore(
(store) => store
);
useEffect(() => { useEffect(() => {
setLandingDialog(true);
if (data) { if (data) {
const marketId = data[0]?.id; const marketId = data[0]?.id;
// If a default market is found, go to it with the landing dialog open
if (marketId) { if (marketId) {
setLandingDialog(true); setLandingDialog(true);
replace(`/markets/${marketId}`); replace(`/markets/${marketId}`);
@ -25,7 +28,7 @@ export function Index() {
replace('/markets'); replace('/markets');
} }
} }
}, [data, replace, setLandingDialog]); }, [data, replace, vegaRiskNoticeDialog, setLandingDialog]);
return ( return (
<AsyncRenderer data={data} loading={loading} error={error}> <AsyncRenderer data={data} loading={loading} error={error}>

View File

@ -123,7 +123,7 @@ const MarketPage = ({ id }: { id?: string }) => {
<TradePanels market={market} /> <TradePanels market={market} />
)} )}
<SelectMarketDialog <SelectMarketDialog
dialogOpen={store.landingDialog} dialogOpen={store.landingDialog && !store.vegaRiskNoticeDialog}
setDialogOpen={(isOpen: boolean) => setDialogOpen={(isOpen: boolean) =>
store.setLandingDialog(isOpen) store.setLandingDialog(isOpen)
} }

View File

@ -23,3 +23,19 @@ global.DOMRect = class DOMRect {
return JSON.stringify(this); return JSON.stringify(this);
} }
}; };
// Based on the official jest docs
// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // Deprecated
removeListener: jest.fn(), // Deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});

View File

@ -9,6 +9,8 @@ interface GlobalStore {
setVegaNetworkSwitcherDialog: (isOpen: boolean) => void; setVegaNetworkSwitcherDialog: (isOpen: boolean) => void;
landingDialog: boolean; landingDialog: boolean;
setLandingDialog: (isOpen: boolean) => void; setLandingDialog: (isOpen: boolean) => void;
vegaRiskNoticeDialog: boolean;
setVegaRiskNoticeDialog: (isOpen: boolean) => void;
marketId: string | null; marketId: string | null;
setMarketId: (marketId: string) => void; setMarketId: (marketId: string) => void;
} }
@ -30,6 +32,10 @@ export const useGlobalStore = create<GlobalStore>((set) => ({
setLandingDialog: (isOpen: boolean) => { setLandingDialog: (isOpen: boolean) => {
set({ landingDialog: isOpen }); set({ landingDialog: isOpen });
}, },
vegaRiskNoticeDialog: false,
setVegaRiskNoticeDialog: (isOpen: boolean) => {
set({ vegaRiskNoticeDialog: isOpen });
},
marketId: null, marketId: null,
setMarketId: (id: string) => { setMarketId: (id: string) => {
set({ marketId: id }); set({ marketId: id });

View File

@ -69,13 +69,17 @@ describe('useConfig hook', () => {
...mockEnvironment, ...mockEnvironment,
VEGA_CONFIG_URL: undefined, VEGA_CONFIG_URL: undefined,
}; };
const { result } = renderHook(() => useConfig(mockEnvWithoutUrl, onError)); const { result } = renderHook(() =>
useConfig({ environment: mockEnvWithoutUrl }, onError)
);
expect(result.current.config).toBe(undefined); expect(result.current.config).toBe(undefined);
}); });
it('fetches configuration from the provided url', async () => { it('fetches configuration from the provided url', async () => {
const { result } = renderHook(() => useConfig(mockEnvironment, onError)); const { result } = renderHook(() =>
useConfig({ environment: mockEnvironment }, onError)
);
await waitFor(() => { await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(mockEnvironment.VEGA_CONFIG_URL); expect(fetch).toHaveBeenCalledWith(mockEnvironment.VEGA_CONFIG_URL);
@ -85,7 +89,7 @@ describe('useConfig hook', () => {
it('caches the configuration', async () => { it('caches the configuration', async () => {
const { result: firstResult } = renderHook(() => const { result: firstResult } = renderHook(() =>
useConfig(mockEnvironment, onError) useConfig({ environment: mockEnvironment }, onError)
); );
await waitFor(() => { await waitFor(() => {
@ -94,7 +98,7 @@ describe('useConfig hook', () => {
}); });
const { result: secondResult } = renderHook(() => const { result: secondResult } = renderHook(() =>
useConfig(mockEnvironment, onError) useConfig({ environment: mockEnvironment }, onError)
); );
expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledTimes(1);
@ -105,7 +109,9 @@ describe('useConfig hook', () => {
// @ts-ignore typescript doesn't recognise the mocked instance // @ts-ignore typescript doesn't recognise the mocked instance
global.fetch.mockImplementation(() => Promise.reject()); global.fetch.mockImplementation(() => Promise.reject());
const { result } = renderHook(() => useConfig(mockEnvironment, onError)); const { result } = renderHook(() =>
useConfig({ environment: mockEnvironment }, onError)
);
await waitFor(() => { await waitFor(() => {
expect(result.current.config).toEqual({ hosts: [] }); expect(result.current.config).toEqual({ hosts: [] });
@ -122,11 +128,31 @@ describe('useConfig hook', () => {
}) })
); );
const { result } = renderHook(() => useConfig(mockEnvironment, onError)); const { result } = renderHook(() =>
useConfig({ environment: mockEnvironment }, onError)
);
await waitFor(() => { await waitFor(() => {
expect(result.current.config).toBe(undefined); expect(result.current.config).toBe(undefined);
expect(onError).toHaveBeenCalledWith(ErrorType.CONFIG_VALIDATION_ERROR); expect(onError).toHaveBeenCalledWith(ErrorType.CONFIG_VALIDATION_ERROR);
}); });
}); });
it('returns the default config without getting it from the network when provided', async () => {
const defaultConfig = { hosts: ['https://default.url'] };
const { result } = renderHook(() =>
useConfig(
{
environment: mockEnvironment,
defaultConfig,
},
onError
)
);
await waitFor(() => {
expect(result.current.config).toBe(defaultConfig);
expect(global.fetch).not.toHaveBeenCalled();
});
});
}); });

View File

@ -46,13 +46,18 @@ const getCachedConfig = (env: Networks, envUrl?: string) => {
return undefined; return undefined;
}; };
type UseConfigOptions = {
environment: EnvironmentWithOptionalUrl;
defaultConfig?: Configuration;
};
export const useConfig = ( export const useConfig = (
environment: EnvironmentWithOptionalUrl, { environment, defaultConfig }: UseConfigOptions,
onError: (errorType: ErrorType) => void onError: (errorType: ErrorType) => void
) => { ) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [config, setConfig] = useState<Configuration | undefined>( const [config, setConfig] = useState<Configuration | undefined>(
getCachedConfig(environment.VEGA_ENV, environment.VEGA_URL) defaultConfig ?? getCachedConfig(environment.VEGA_ENV, environment.VEGA_URL)
); );
useEffect(() => { useEffect(() => {

View File

@ -12,9 +12,16 @@ import {
getIsNodeLoading, getIsNodeLoading,
} from '../utils/validate-node'; } from '../utils/validate-node';
import { ErrorType } from '../types'; import { ErrorType } from '../types';
import type { Environment, Networks, RawEnvironment, NodeData } from '../types'; import type {
Environment,
Networks,
RawEnvironment,
NodeData,
Configuration,
} from '../types';
type EnvironmentProviderProps = { type EnvironmentProviderProps = {
config?: Configuration;
definitions?: Partial<RawEnvironment>; definitions?: Partial<RawEnvironment>;
children?: ReactNode; children?: ReactNode;
}; };
@ -37,6 +44,7 @@ const hasFailedLoading = (env: Networks, node: NodeData) =>
getErrorType(env, node) !== null; getErrorType(env, node) !== null;
export const EnvironmentProvider = ({ export const EnvironmentProvider = ({
config: defaultConfig,
definitions, definitions,
children, children,
}: EnvironmentProviderProps) => { }: EnvironmentProviderProps) => {
@ -45,15 +53,18 @@ export const EnvironmentProvider = ({
const [environment, updateEnvironment] = useState<Environment>( const [environment, updateEnvironment] = useState<Environment>(
compileEnvironment(definitions) compileEnvironment(definitions)
); );
const { loading, config } = useConfig(environment, (errorType) => { const { loading, config } = useConfig(
if (!environment.VEGA_URL) { { environment, defaultConfig },
setNetworkError(errorType); (errorType) => {
setNodeSwitcherOpen(true); if (!environment.VEGA_URL) {
} else { setNetworkError(errorType);
const error = getErrorByType(errorType, environment.VEGA_ENV); setNodeSwitcherOpen(true);
error && console.warn(error.headline); } else {
const error = getErrorByType(errorType, environment.VEGA_ENV);
error && console.warn(error.headline);
}
} }
}); );
const { state: nodes, clients } = useNodes(config); const { state: nodes, clients } = useNodes(config);
const nodeKeys = Object.keys(nodes); const nodeKeys = Object.keys(nodes);

View File

@ -26,8 +26,12 @@ export function Dialog({
size = 'small', size = 'small',
}: DialogProps) { }: DialogProps) {
const contentClasses = classNames( const contentClasses = classNames(
'fixed relative top-0 left-0 z-20 flex items-center justify-center',
'w-full h-full'
);
const wrapperClasses = classNames(
// Positions the modal in the center of screen // Positions the modal in the center of screen
'z-20 fixed rounded inset-x-1/2 top-[10vh] translate-x-[-50%]', 'z-20 fixed rounded top-[10vh] max-w-[90vw]',
// Dimensions // Dimensions
'max-w-[90vw] p-4 md:p-8', 'max-w-[90vw] p-4 md:p-8',
// Need to apply background and text colors again as content is rendered in a portal // Need to apply background and text colors again as content is rendered in a portal
@ -38,6 +42,7 @@ export function Dialog({
'w-[720px] lg:w-[940px]': size === 'medium', 'w-[720px] lg:w-[940px]': size === 'medium',
} }
); );
return ( return (
<DialogPrimitives.Root open={open} onOpenChange={(x) => onChange?.(x)}> <DialogPrimitives.Root open={open} onOpenChange={(x) => onChange?.(x)}>
<DialogPrimitives.Portal> <DialogPrimitives.Portal>
@ -46,24 +51,26 @@ export function Dialog({
data-testid="dialog-overlay" data-testid="dialog-overlay"
/> />
<DialogPrimitives.Content className={contentClasses}> <DialogPrimitives.Content className={contentClasses}>
<DialogPrimitives.Close <div className={wrapperClasses}>
className="absolute p-2 top-0 right-0 md:top-2 md:right-2" <DialogPrimitives.Close
data-testid="dialog-close" className="absolute p-2 top-0 right-0 md:top-2 md:right-2"
> data-testid="dialog-close"
<Icon name="cross" /> >
</DialogPrimitives.Close> <Icon name="cross" />
<div className="flex gap-4 max-w-full"> </DialogPrimitives.Close>
{icon && <div className="pt-2 fill-current">{icon}</div>} <div className="flex gap-4 max-w-full">
<div data-testid="dialog-content" className="flex-1"> {icon && <div className="pt-2 fill-current">{icon}</div>}
{title && ( <div data-testid="dialog-content" className="flex-1">
<h1 {title && (
className="text-xl uppercase mb-4 pr-2" <h1
data-testid="dialog-title" className="text-xl uppercase mb-4 pr-2"
> data-testid="dialog-title"
{title} >
</h1> {title}
)} </h1>
<div>{children}</div> )}
<div>{children}</div>
</div>
</div> </div>
</div> </div>
</DialogPrimitives.Content> </DialogPrimitives.Content>