diff --git a/app/package.json b/app/package.json
index 8ab1b5af2..033000dec 100644
--- a/app/package.json
+++ b/app/package.json
@@ -32,10 +32,12 @@
"mobx": "5.15.4",
"mobx-react-lite": "2.0.6",
"mobx-utils": "5.5.7",
+ "rc-tooltip": "4.2.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-i18next": "11.4.0",
"react-scripts": "3.4.1",
+ "react-toastify": "6.0.5",
"react-virtualized": "^9.21.2"
},
"devDependencies": {
diff --git a/app/src/App.scss b/app/src/App.scss
index accde9a75..30880b37d 100644
--- a/app/src/App.scss
+++ b/app/src/App.scss
@@ -14,6 +14,12 @@
@import '../node_modules/bootstrap/scss/grid';
@import '../node_modules/bootstrap/scss/custom-forms';
+// rc-tooltip component styles
+@import '../node_modules/rc-tooltip/assets/bootstrap_white.css';
+
+// react-toastify styles
+@import '../node_modules/react-toastify/dist/ReactToastify.css';
+
@font-face {
font-family: 'OpenSans Light';
src: url('./assets/fonts/OpenSans-Light.ttf') format('truetype');
diff --git a/app/src/App.tsx b/app/src/App.tsx
index 4e316845b..13f46d1f8 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import './App.scss';
import { createStore, StoreProvider } from 'store';
+import AlertContainer from 'components/common/AlertContainer';
import { Layout } from 'components/layout';
import Pages from 'components/Pages';
import { ThemeProvider } from 'components/theme';
@@ -13,6 +14,7 @@ const App = () => {
+
diff --git a/app/src/__stories__/AlertContainer.stories.tsx b/app/src/__stories__/AlertContainer.stories.tsx
new file mode 100644
index 000000000..063bb7774
--- /dev/null
+++ b/app/src/__stories__/AlertContainer.stories.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { useStore } from 'store';
+import AlertContainer from 'components/common/AlertContainer';
+import { Button } from 'components/common/base';
+
+export default {
+ title: 'Components/Alerts',
+ component: AlertContainer,
+ parameters: { centered: true },
+};
+
+export const Default = () => {
+ const store = useStore();
+ const handleClick = () => {
+ store.uiStore.notify(
+ 'This is a sample message to be displayed inside of a toast alert',
+ 'Sample Alert Title',
+ );
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+};
diff --git a/app/src/__stories__/Tip.stories.tsx b/app/src/__stories__/Tip.stories.tsx
new file mode 100644
index 000000000..68279d130
--- /dev/null
+++ b/app/src/__stories__/Tip.stories.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import Tip from 'components/common/Tip';
+
+export default {
+ title: 'Components/Tooltip',
+ component: Tip,
+ parameters: { contained: true },
+};
+
+const placements = [
+ 'top',
+ 'topRight',
+ 'right',
+ 'bottomRight',
+ 'bottom',
+ 'bottomLeft',
+ 'left',
+ 'topLeft',
+];
+
+export const Placements = () => {
+ return (
+
+ {placements.map(p => (
+
+ {p}
+
+ ))}
+
+ );
+};
diff --git a/app/src/__tests__/components/common/AlertContainer.spec.tsx b/app/src/__tests__/components/common/AlertContainer.spec.tsx
new file mode 100644
index 000000000..72368a6aa
--- /dev/null
+++ b/app/src/__tests__/components/common/AlertContainer.spec.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import { renderWithProviders } from 'util/tests';
+import { createStore, Store } from 'store';
+import AlertContainer from 'components/common/AlertContainer';
+
+describe('AlertContainer component', () => {
+ let store: Store;
+
+ const render = () => {
+ store = createStore();
+ return renderWithProviders(, store);
+ };
+
+ it('should display an alert when added to the store', async () => {
+ const { findByText } = render();
+ store.uiStore.notify('test error', 'test title');
+ expect(await findByText('test error')).toBeInTheDocument();
+ expect(await findByText('test title')).toBeInTheDocument();
+ });
+});
diff --git a/app/src/__tests__/components/common/Tip.spec.tsx b/app/src/__tests__/components/common/Tip.spec.tsx
new file mode 100644
index 000000000..62a85e1b6
--- /dev/null
+++ b/app/src/__tests__/components/common/Tip.spec.tsx
@@ -0,0 +1,49 @@
+import React from 'react';
+import { fireEvent, waitFor } from '@testing-library/react';
+import { renderWithProviders } from 'util/tests';
+import { createStore, Store } from 'store';
+import Tip from 'components/common/Tip';
+
+describe('Tip component', () => {
+ let store: Store;
+
+ const render = (placement?: string) => {
+ store = createStore();
+ const cmp = (
+
+ test content
+
+ );
+ return renderWithProviders(cmp, store);
+ };
+
+ it('should display a tooltip on hover', async () => {
+ const { getByText } = render();
+ fireEvent.mouseEnter(getByText('test content'));
+ expect(getByText('test tip')).toBeInTheDocument();
+ });
+
+ it('should display a tooltip on bottom', async () => {
+ const { getByText, container } = render('bottom');
+ fireEvent.mouseEnter(getByText('test content'));
+ waitFor(() => {
+ expect(container.querySelector('.rc-tooltip-placement-bottom')).toBeInTheDocument();
+ });
+ });
+
+ it('should display a tooltip on left', async () => {
+ const { getByText, container } = render('left');
+ fireEvent.mouseEnter(getByText('test content'));
+ waitFor(() => {
+ expect(container.querySelector('.rc-tooltip-placement-left')).toBeInTheDocument();
+ });
+ });
+
+ it('should display a tooltip on right', async () => {
+ const { getByText, container } = render('right');
+ fireEvent.mouseEnter(getByText('test content'));
+ waitFor(() => {
+ expect(container.querySelector('.rc-tooltip-placement-right')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/app/src/__tests__/components/layout/Layout.spec.tsx b/app/src/__tests__/components/layout/Layout.spec.tsx
index 3df99bc20..a78e80f9b 100644
--- a/app/src/__tests__/components/layout/Layout.spec.tsx
+++ b/app/src/__tests__/components/layout/Layout.spec.tsx
@@ -9,16 +9,16 @@ describe('Layout component', () => {
};
it('should display the hamburger menu', () => {
- const { getByTitle } = render();
- expect(getByTitle('menu')).toBeInTheDocument();
+ const { getByText } = render();
+ expect(getByText('menu.svg')).toBeInTheDocument();
});
it('should toggle collapsed state', () => {
- const { getByTitle, store } = render();
+ const { getByText, store } = render();
expect(store.settingsStore.sidebarVisible).toBe(true);
- fireEvent.click(getByTitle('menu'));
+ fireEvent.click(getByText('menu.svg'));
expect(store.settingsStore.sidebarVisible).toBe(false);
- fireEvent.click(getByTitle('menu'));
+ fireEvent.click(getByText('menu.svg'));
expect(store.settingsStore.sidebarVisible).toBe(true);
});
diff --git a/app/src/__tests__/components/loop/SwapWizard.spec.tsx b/app/src/__tests__/components/loop/SwapWizard.spec.tsx
index 6a0ecc34e..5e3b8463c 100644
--- a/app/src/__tests__/components/loop/SwapWizard.spec.tsx
+++ b/app/src/__tests__/components/loop/SwapWizard.spec.tsx
@@ -107,11 +107,5 @@ describe('SwapWizard component', () => {
const { getByText } = render();
expect(getByText('Configuring Loops')).toBeInTheDocument();
});
-
- it('should display an error message', () => {
- store.buildSwapStore.swapError = new Error('error-test');
- const { getByText } = render();
- expect(getByText('error-test')).toBeInTheDocument();
- });
});
});
diff --git a/app/src/__tests__/store/buildSwapStore.spec.ts b/app/src/__tests__/store/buildSwapStore.spec.ts
index c6ac2d988..1bf276b3d 100644
--- a/app/src/__tests__/store/buildSwapStore.spec.ts
+++ b/app/src/__tests__/store/buildSwapStore.spec.ts
@@ -1,3 +1,4 @@
+import { values } from 'mobx';
import { SwapDirection } from 'types/state';
import { grpc } from '@improbable-eng/grpc-web';
import { waitFor } from '@testing-library/react';
@@ -42,6 +43,19 @@ describe('BuildSwapStore', () => {
expect(store.terms.out).toEqual({ min: 250000, max: 1000000 });
});
+ it('should handle errors fetching loop terms', async () => {
+ grpcMock.unary.mockImplementationOnce(desc => {
+ if (desc.methodName === 'GetLoopInTerms') throw new Error('test-err');
+ return undefined as any;
+ });
+ expect(rootStore.uiStore.alerts.size).toBe(0);
+ await store.getTerms();
+ await waitFor(() => {
+ expect(rootStore.uiStore.alerts.size).toBe(1);
+ expect(values(rootStore.uiStore.alerts)[0].message).toBe('test-err');
+ });
+ });
+
it('should adjust the amount after fetching the loop terms', async () => {
store.setAmount(100);
await store.getTerms();
@@ -78,6 +92,21 @@ describe('BuildSwapStore', () => {
expect(store.quote.prepayAmount).toEqual(1337);
});
+ it('should handle errors fetching loop quote', async () => {
+ grpcMock.unary.mockImplementationOnce(desc => {
+ if (desc.methodName === 'LoopOutQuote') throw new Error('test-err');
+ return undefined as any;
+ });
+ store.setDirection(SwapDirection.OUT);
+ store.setAmount(600);
+ expect(rootStore.uiStore.alerts.size).toBe(0);
+ await store.getQuote();
+ await waitFor(() => {
+ expect(rootStore.uiStore.alerts.size).toBe(1);
+ expect(values(rootStore.uiStore.alerts)[0].message).toBe('test-err');
+ });
+ });
+
it('should perform a loop in', async () => {
store.setDirection(SwapDirection.IN);
store.setAmount(600);
@@ -129,18 +158,18 @@ describe('BuildSwapStore', () => {
await waitFor(() => expect(deadline).toEqual(0));
});
- it('should handle loop errors', async () => {
+ it('should handle errors when performing a loop', async () => {
grpcMock.unary.mockImplementationOnce(desc => {
- if (desc.methodName === 'LoopIn') throw new Error('asdf');
+ if (desc.methodName === 'LoopIn') throw new Error('test-err');
return undefined as any;
});
store.setDirection(SwapDirection.IN);
store.setAmount(600);
-
- expect(store.swapError).toBeUndefined();
+ expect(rootStore.uiStore.alerts.size).toBe(0);
store.requestSwap();
await waitFor(() => {
- expect(store.swapError).toBeDefined();
+ expect(rootStore.uiStore.alerts.size).toBe(1);
+ expect(values(rootStore.uiStore.alerts)[0].message).toBe('test-err');
});
});
diff --git a/app/src/__tests__/store/channelStore.spec.ts b/app/src/__tests__/store/channelStore.spec.ts
index 665dafb67..c443dab05 100644
--- a/app/src/__tests__/store/channelStore.spec.ts
+++ b/app/src/__tests__/store/channelStore.spec.ts
@@ -1,12 +1,16 @@
import { observable, ObservableMap, values } from 'mobx';
+import { grpc } from '@improbable-eng/grpc-web';
+import { waitFor } from '@testing-library/react';
import { BalanceMode } from 'util/constants';
import { lndListChannels } from 'util/tests/sampleData';
-import { createStore, SettingsStore } from 'store';
+import { createStore, Store } from 'store';
import Channel from 'store/models/channel';
import ChannelStore from 'store/stores/channelStore';
+const grpcMock = grpc as jest.Mocked;
+
describe('ChannelStore', () => {
- let settingsStore: SettingsStore;
+ let rootStore: Store;
let store: ChannelStore;
const channelSubset = (channels: ObservableMap) => {
@@ -20,9 +24,8 @@ describe('ChannelStore', () => {
};
beforeEach(() => {
- const rootStore = createStore();
+ rootStore = createStore();
store = rootStore.channelStore;
- settingsStore = rootStore.settingsStore;
});
it('should fetch list of channels', async () => {
@@ -31,6 +34,19 @@ describe('ChannelStore', () => {
expect(store.channels.size).toEqual(lndListChannels.channelsList.length);
});
+ it('should handle errors fetching channels', async () => {
+ grpcMock.unary.mockImplementationOnce(desc => {
+ if (desc.methodName === 'ListChannels') throw new Error('test-err');
+ return undefined as any;
+ });
+ expect(rootStore.uiStore.alerts.size).toBe(0);
+ await store.fetchChannels();
+ await waitFor(() => {
+ expect(rootStore.uiStore.alerts.size).toBe(1);
+ expect(values(rootStore.uiStore.alerts)[0].message).toBe('test-err');
+ });
+ });
+
it('should update existing channels with the same id', async () => {
expect(store.channels.size).toEqual(0);
await store.fetchChannels();
@@ -47,7 +63,7 @@ describe('ChannelStore', () => {
it('should sort channels correctly when using receive mode', async () => {
await store.fetchChannels();
- settingsStore.setBalanceMode(BalanceMode.receive);
+ rootStore.settingsStore.setBalanceMode(BalanceMode.receive);
store.channels = channelSubset(store.channels);
store.sortedChannels.forEach((c, i) => {
if (i === 0) return;
@@ -59,7 +75,7 @@ describe('ChannelStore', () => {
it('should sort channels correctly when using send mode', async () => {
await store.fetchChannels();
- settingsStore.setBalanceMode(BalanceMode.send);
+ rootStore.settingsStore.setBalanceMode(BalanceMode.send);
store.channels = channelSubset(store.channels);
store.sortedChannels.forEach((c, i) => {
if (i === 0) return;
@@ -71,7 +87,7 @@ describe('ChannelStore', () => {
it('should sort channels correctly when using routing mode', async () => {
await store.fetchChannels();
- settingsStore.setBalanceMode(BalanceMode.routing);
+ rootStore.settingsStore.setBalanceMode(BalanceMode.routing);
store.channels = channelSubset(store.channels);
store.sortedChannels.forEach((c, i) => {
if (i === 0) return;
diff --git a/app/src/__tests__/store/nodeStore.spec.ts b/app/src/__tests__/store/nodeStore.spec.ts
index b4b4dedc6..c9e08da5c 100644
--- a/app/src/__tests__/store/nodeStore.spec.ts
+++ b/app/src/__tests__/store/nodeStore.spec.ts
@@ -1,11 +1,18 @@
+import { values } from 'mobx';
+import { grpc } from '@improbable-eng/grpc-web';
+import { waitFor } from '@testing-library/react';
import { lndChannelBalance, lndWalletBalance } from 'util/tests/sampleData';
-import { createStore, NodeStore } from 'store';
+import { createStore, NodeStore, Store } from 'store';
+
+const grpcMock = grpc as jest.Mocked;
describe('NodeStore', () => {
+ let rootStore: Store;
let store: NodeStore;
beforeEach(() => {
- store = createStore().nodeStore;
+ rootStore = createStore();
+ store = rootStore.nodeStore;
});
it('should fetch node balances', async () => {
@@ -15,4 +22,17 @@ describe('NodeStore', () => {
expect(store.wallet.channelBalance).toEqual(lndChannelBalance.balance);
expect(store.wallet.walletBalance).toEqual(lndWalletBalance.totalBalance);
});
+
+ it('should handle errors fetching channels', async () => {
+ grpcMock.unary.mockImplementationOnce(desc => {
+ if (desc.methodName === 'ChannelBalance') throw new Error('test-err');
+ return undefined as any;
+ });
+ expect(rootStore.uiStore.alerts.size).toBe(0);
+ await store.fetchBalances();
+ await waitFor(() => {
+ expect(rootStore.uiStore.alerts.size).toBe(1);
+ expect(values(rootStore.uiStore.alerts)[0].message).toBe('test-err');
+ });
+ });
});
diff --git a/app/src/__tests__/store/swapStore.spec.ts b/app/src/__tests__/store/swapStore.spec.ts
index 294604a71..2e1cf7781 100644
--- a/app/src/__tests__/store/swapStore.spec.ts
+++ b/app/src/__tests__/store/swapStore.spec.ts
@@ -1,13 +1,18 @@
+import { values } from 'mobx';
import * as LOOP from 'types/generated/loop_pb';
+import { grpc } from '@improbable-eng/grpc-web';
import { waitFor } from '@testing-library/react';
import { loopListSwaps } from 'util/tests/sampleData';
-import { createStore, SwapStore } from 'store';
+import { createStore, Store, SwapStore } from 'store';
+
+const grpcMock = grpc as jest.Mocked;
describe('SwapStore', () => {
+ let rootStore: Store;
let store: SwapStore;
beforeEach(async () => {
- const rootStore = createStore();
+ rootStore = createStore();
store = rootStore.swapStore;
});
@@ -17,6 +22,19 @@ describe('SwapStore', () => {
expect(store.sortedSwaps).toHaveLength(7);
});
+ it('should handle errors fetching channels', async () => {
+ grpcMock.unary.mockImplementationOnce(desc => {
+ if (desc.methodName === 'ListSwaps') throw new Error('test-err');
+ return undefined as any;
+ });
+ expect(rootStore.uiStore.alerts.size).toBe(0);
+ await store.fetchSwaps();
+ await waitFor(() => {
+ expect(rootStore.uiStore.alerts.size).toBe(1);
+ expect(values(rootStore.uiStore.alerts)[0].message).toBe('test-err');
+ });
+ });
+
it('should update existing swaps with the same id', async () => {
expect(store.swaps.size).toEqual(0);
await store.fetchSwaps();
diff --git a/app/src/__tests__/store/uiStore.spec.ts b/app/src/__tests__/store/uiStore.spec.ts
new file mode 100644
index 000000000..1024d161b
--- /dev/null
+++ b/app/src/__tests__/store/uiStore.spec.ts
@@ -0,0 +1,29 @@
+import { values } from 'mobx';
+import { createStore, UiStore } from 'store';
+
+describe('UiStore', () => {
+ let store: UiStore;
+
+ beforeEach(() => {
+ store = createStore().uiStore;
+ });
+
+ it('should add an alert', async () => {
+ expect(store.alerts.size).toBe(0);
+ store.notify('test message', 'test title');
+ expect(store.alerts.size).toBe(1);
+ const alert = values(store.alerts)[0];
+ expect(alert.message).toBe('test message');
+ expect(alert.title).toBe('test title');
+ expect(alert.type).toBe('error');
+ });
+
+ it('should clear an alert', () => {
+ expect(store.alerts.size).toBe(0);
+ store.notify('test message', 'test title');
+ expect(store.alerts.size).toBe(1);
+ const alert = values(store.alerts)[0];
+ store.clearAlert(alert.id);
+ expect(store.alerts.size).toBe(0);
+ });
+});
diff --git a/app/src/assets/icons/bolt.svg b/app/src/assets/icons/bolt.svg
index 3e747e6dd..a98273562 100644
--- a/app/src/assets/icons/bolt.svg
+++ b/app/src/assets/icons/bolt.svg
@@ -10,7 +10,7 @@
-
+
diff --git a/app/src/assets/icons/menu.svg b/app/src/assets/icons/menu.svg
index 8574e8b8b..ab0e97410 100644
--- a/app/src/assets/icons/menu.svg
+++ b/app/src/assets/icons/menu.svg
@@ -10,7 +10,7 @@
-
+
diff --git a/app/src/components/NodeStatus.tsx b/app/src/components/NodeStatus.tsx
index 343d19182..fee5434a0 100644
--- a/app/src/components/NodeStatus.tsx
+++ b/app/src/components/NodeStatus.tsx
@@ -4,6 +4,7 @@ import { usePrefixedTranslation } from 'hooks';
import { useStore } from 'store';
import { HeaderFour, Jumbo, Small } from 'components/common/text';
import { Bitcoin, Bolt } from './common/icons';
+import Tip from './common/Tip';
import Unit from './common/Unit';
import { styled } from './theme';
@@ -31,18 +32,22 @@ const NodeStatus: React.FC = () => {
return (
{l('title')}
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
diff --git a/app/src/components/common/AlertContainer.tsx b/app/src/components/common/AlertContainer.tsx
new file mode 100644
index 000000000..bf3dd7e5e
--- /dev/null
+++ b/app/src/components/common/AlertContainer.tsx
@@ -0,0 +1,105 @@
+import React, { useEffect } from 'react';
+import { toast, ToastContainer } from 'react-toastify';
+import { values } from 'mobx';
+import { observer } from 'mobx-react-lite';
+import { Alert } from 'types/state';
+import { useStore } from 'store';
+import { styled } from 'components/theme';
+import { Close } from './icons';
+
+const Styled = {
+ Body: styled.div`
+ margin-right: 10px;
+ `,
+ Title: styled.div`
+ font-family: ${props => props.theme.fonts.open.semiBold};
+ font-size: ${props => props.theme.sizes.xs};
+ text-transform: uppercase;
+ `,
+ Message: styled.div`
+ font-size: ${props => props.theme.sizes.xs};
+ `,
+ CloseIcon: styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 18px;
+ height: 18px;
+ border: 1px solid ${props => props.theme.colors.offWhite};
+ border-radius: 18px;
+ transition: background-color 0.3s;
+
+ &:hover {
+ color: ${props => props.theme.colors.blue};
+ background-color: ${props => props.theme.colors.offWhite};
+ }
+
+ svg {
+ width: 12px;
+ height: 12px;
+ padding: 0;
+ }
+ `,
+ Container: styled(ToastContainer)`
+ .Toastify__toast {
+ border-radius: 4px;
+ }
+ .Toastify__toast--error {
+ color: ${props => props.theme.colors.offWhite};
+ background-color: ${props => props.theme.colors.pink};
+ }
+ `,
+};
+
+interface AlertToastProps {
+ alert: Alert;
+ onClose: (id: number) => void;
+}
+
+/**
+ * The content to be rendered inside of the toast
+ */
+const AlertToast: React.FC = ({ alert, onClose }) => {
+ // use useEffect to only run the side-effect one time
+ useEffect(() => {
+ const { id, type, message, title } = alert;
+ // create a component to display inside of the toast
+ const { Body, Title, Message } = Styled;
+ const body = (
+
+ {title && {title}}
+ {message}
+
+ );
+ // display the toast popup containing the styled body
+ toast(body, { type, onClose: () => onClose(id) });
+ }, [alert, onClose]);
+
+ // do not render anything to the dom. the toast() func will display the content
+ return null;
+};
+
+/**
+ * A wrapper around the ToastContainer to add custom styling. Also renders
+ * each toast message based on the alerts in the mobx store
+ */
+const AlertContainer: React.FC = () => {
+ const { uiStore } = useStore();
+
+ const { Container, CloseIcon } = Styled;
+ const closeButton = (
+
+
+
+ );
+ return (
+ <>
+ {values(uiStore.alerts).map(n => (
+
+ ))}
+
+ >
+ );
+};
+
+export default observer(AlertContainer);
diff --git a/app/src/components/common/PageHeader.tsx b/app/src/components/common/PageHeader.tsx
index 302ba9e8e..90ff7b484 100644
--- a/app/src/components/common/PageHeader.tsx
+++ b/app/src/components/common/PageHeader.tsx
@@ -1,8 +1,10 @@
import React from 'react';
import { observer } from 'mobx-react-lite';
+import { usePrefixedTranslation } from 'hooks';
import { styled } from 'components/theme';
import { ArrowLeft, Clock, Download } from './icons';
import { HeaderThree } from './text';
+import Tip from './Tip';
const Styled = {
Wrapper: styled.div`
@@ -52,6 +54,8 @@ const PageHeader: React.FC = ({
onHistoryClick,
onExportClick,
}) => {
+ const { l } = usePrefixedTranslation('cmps.common.PageHeader');
+
const { Wrapper, Left, Center, Right, BackLink } = Styled;
return (
@@ -67,8 +71,16 @@ const PageHeader: React.FC = ({
{title}
- {onHistoryClick && }
- {onExportClick && }
+ {onHistoryClick && (
+
+
+
+ )}
+ {onExportClick && (
+
+
+
+ )}
);
diff --git a/app/src/components/common/Tile.tsx b/app/src/components/common/Tile.tsx
index 0ea0fc211..b3f4d0e8c 100644
--- a/app/src/components/common/Tile.tsx
+++ b/app/src/components/common/Tile.tsx
@@ -1,7 +1,9 @@
import React, { ReactNode } from 'react';
+import { usePrefixedTranslation } from 'hooks';
import { styled } from 'components/theme';
import { Maximize } from './icons';
import { HeaderFour } from './text';
+import Tip from './Tip';
const Styled = {
TileWrap: styled.div`
@@ -19,11 +21,9 @@ const Styled = {
height: 20px;
padding: 4px;
margin-top: -5px;
- cursor: pointer;
&:hover {
border-radius: 24px;
- background-color: ${props => props.theme.colors.purple};
}
`,
Text: styled.div`
@@ -52,13 +52,18 @@ interface Props {
}
const Tile: React.FC = ({ title, text, onMaximizeClick, children }) => {
- const { TileWrap, Header, MaximizeIcon, Text } = Styled;
+ const { l } = usePrefixedTranslation('cmps.common.Tile');
+ const { TileWrap, Header, MaximizeIcon, Text } = Styled;
return (
{title}
- {onMaximizeClick && }
+ {onMaximizeClick && (
+
+
+
+ )}
{text ? {text} : children}
diff --git a/app/src/components/common/Tip.tsx b/app/src/components/common/Tip.tsx
new file mode 100644
index 000000000..5022896cb
--- /dev/null
+++ b/app/src/components/common/Tip.tsx
@@ -0,0 +1,104 @@
+import React from 'react';
+import Tooltip from 'rc-tooltip';
+import { TooltipProps } from 'rc-tooltip/lib/Tooltip';
+import { styled } from 'components/theme';
+
+/**
+ * Returns an offset array to create a 10px gap between the
+ * tooltip arrow and the target element
+ * @param placement the placement of the tooltip
+ */
+const getOffset = (placement: string) => {
+ let offset: number[] | undefined = undefined;
+
+ switch (placement) {
+ case 'top':
+ case 'topLeft':
+ case 'topRight':
+ offset = [0, 10];
+ break;
+ case 'bottom':
+ case 'bottomLeft':
+ case 'bottomRight':
+ offset = [0, -10];
+ break;
+ case 'left':
+ offset = [10, 0];
+ break;
+ case 'right':
+ offset = [-10, 0];
+ break;
+ }
+
+ return offset;
+};
+
+interface Props extends TooltipProps {
+ className?: string;
+}
+
+/**
+ * Wrap the Tooltip component to add some reusable configuration
+ * for all tooltips throughout the entire site and to pass
+ * className as overlayClassName to the Tooltip component
+ */
+const TooltipWrapper: React.FC = ({
+ className,
+ placement = 'top',
+ children,
+ ...props
+}) => {
+ const targetOffset = getOffset(placement);
+ return (
+
+ {children}
+
+ );
+};
+
+/**
+ * Style our wrapper component so that our styles can be passed into the
+ * `overlayClassName` prop of the Tooltip component. We cannot style the
+ * Tooltip component directly because it does not accept a `className`
+ * prop. So we basically proxy the className using the TooltipWrapper
+ * above, then export this styled component for the rest of the app to use
+ */
+const Tip = styled(TooltipWrapper)`
+ color: ${props => props.theme.colors.blue};
+ font-family: ${props => props.theme.fonts.open.semiBold};
+ font-size: ${props => props.theme.sizes.xs};
+ text-transform: uppercase;
+ opacity: 0.95;
+
+ &.rc-tooltip-placement-bottom .rc-tooltip-arrow,
+ &.rc-tooltip-placement-bottomLeft .rc-tooltip-arrow,
+ &.rc-tooltip-placement-bottomRight .rc-tooltip-arrow {
+ border-bottom-color: ${props => props.theme.colors.white};
+ }
+
+ &.rc-tooltip-placement-top .rc-tooltip-arrow,
+ &.rc-tooltip-placement-topLeft .rc-tooltip-arrow,
+ &.rc-tooltip-placement-topRight .rc-tooltip-arrow {
+ border-top-color: ${props => props.theme.colors.white};
+ }
+
+ &.rc-tooltip-placement-left .rc-tooltip-arrow {
+ border-left-color: ${props => props.theme.colors.white};
+ }
+
+ &.rc-tooltip-placement-right .rc-tooltip-arrow {
+ border-right-color: ${props => props.theme.colors.white};
+ }
+
+ .rc-tooltip-inner {
+ text-align: center;
+ border: 1px solid ${props => props.theme.colors.white};
+ }
+`;
+
+export default Tip;
diff --git a/app/src/components/common/icons.tsx b/app/src/components/common/icons.tsx
index c4e1f05d6..b591b28c0 100644
--- a/app/src/components/common/icons.tsx
+++ b/app/src/components/common/icons.tsx
@@ -22,6 +22,7 @@ interface IconProps {
const Icon = styled.span`
display: inline-block;
padding: 6px;
+ transition: all 0.3s;
${props =>
props.onClick &&
@@ -29,7 +30,8 @@ const Icon = styled.span`
border-radius: 36px;
cursor: pointer;
&:hover {
- background-color: ${props.theme.colors.purple};
+ color: ${props.theme.colors.blue};
+ background-color: ${props.theme.colors.offWhite};
}
`}
diff --git a/app/src/components/common/text.tsx b/app/src/components/common/text.tsx
index 8b5c8a90f..9f6cd57a2 100644
--- a/app/src/components/common/text.tsx
+++ b/app/src/components/common/text.tsx
@@ -38,7 +38,7 @@ export const HeaderFive = styled.h5`
font-size: ${props => props.theme.sizes.m};
`;
-export const Small = styled.p`
+export const Small = styled.span`
font-size: ${props => props.theme.sizes.xs};
line-height: 20px;
`;
diff --git a/app/src/components/layout/Layout.tsx b/app/src/components/layout/Layout.tsx
index 51ae7e6d6..4e9b989c2 100644
--- a/app/src/components/layout/Layout.tsx
+++ b/app/src/components/layout/Layout.tsx
@@ -21,13 +21,16 @@ const Styled = {
Hamburger: styled.span`
display: inline-block;
position: absolute;
- top: 20px;
- left: 20px;
+ top: 35px;
+ left: 10px;
z-index: 1;
- cursor: pointer;
+ padding: 4px;
&:hover {
- opacity: 0.8;
+ color: ${props => props.theme.colors.blue};
+ background-color: ${props => props.theme.colors.offWhite};
+ border-radius: 24px;
+ cursor: pointer;
}
`,
Aside: styled.aside`
@@ -49,7 +52,7 @@ const Styled = {
`,
Content: styled.div`
margin-left: ${props => (props.collapsed ? '0' : '285px')};
- padding: 15px;
+ padding: 0 15px;
transition: all 0.2s;
`,
};
@@ -62,7 +65,7 @@ const Layout: React.FC = ({ children }) => {
-
+