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 }) => { - +