diff --git a/package.json b/package.json index ec70dd8f..38bc13b6 100644 --- a/package.json +++ b/package.json @@ -214,6 +214,7 @@ "react-ga": "3.3.0", "react-hot-loader": "4.13.0", "react-intersection-observer": "^8.31.0", + "react-papaparse": "^3.14.0", "react-qr-reader": "^2.2.1", "react-redux": "7.2.3", "react-router-dom": "5.2.0", diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx index 5a6dcc33..9ba7d185 100644 --- a/src/components/Modal/index.tsx +++ b/src/components/Modal/index.tsx @@ -184,13 +184,21 @@ const Body = ({ children, withoutPadding = false }: BodyProps): ReactElement => ) /*** Footer ***/ -const FooterSection = styled.div` +const FooterSection = styled.div<{ + withoutPadding: FooterProps['withoutPadding'] + withoutBorder: FooterProps['withoutBorder'] +}>` display: flex; justify-content: center; - border-top: 2px solid ${({ theme }) => theme.colors.separator}; - padding: 24px; + border-top: ${({ withoutBorder }) => (withoutBorder ? 0 : `2px solid ${({ theme }) => theme.colors.separator}`)}; + padding: ${({ withoutPadding }) => (withoutPadding ? 0 : '24px')}; ` +interface FooterProps { + withoutPadding?: boolean + withoutBorder?: boolean +} + const ButtonStyled = styled(Button)` &.MuiButtonBase-root { margin: 0 10px; @@ -240,8 +248,10 @@ interface FooterProps { children: ReactNode | ReactNodeArray } -const Footer = ({ children }: FooterProps): ReactElement => ( - {children} +const Footer = ({ children, withoutPadding = false, withoutBorder = false }: FooterProps): ReactElement => ( + + {children} + ) Footer.Buttons = Buttons diff --git a/src/logic/notifications/notificationBuilder.tsx b/src/logic/notifications/notificationBuilder.tsx index fae0a821..91249a22 100644 --- a/src/logic/notifications/notificationBuilder.tsx +++ b/src/logic/notifications/notificationBuilder.tsx @@ -153,6 +153,17 @@ const addressBookDeleteEntry = { afterExecutionError: null, } +const addressBookExportEntries = { + beforeExecution: null, + afterRejection: null, + waitingConfirmation: null, + afterExecution: { + noMoreConfirmationsNeeded: NOTIFICATIONS.ADDRESS_BOOK_EXPORT_ENTRIES_SUCCESS, + moreConfirmationsNeeded: null, + }, + afterExecutionError: NOTIFICATIONS.ADDRESS_BOOK_EXPORT_ENTRIES_ERROR, +} + export const getNotificationsFromTxType: any = (txType, origin) => { let notificationsQueue @@ -205,6 +216,10 @@ export const getNotificationsFromTxType: any = (txType, origin) => { notificationsQueue = addressBookDeleteEntry break } + case TX_NOTIFICATION_TYPES.ADDRESSBOOK_EXPORT_ENTRIES: { + notificationsQueue = addressBookExportEntries + break + } default: { notificationsQueue = defaultNotificationsQueue break diff --git a/src/logic/notifications/notificationTypes.ts b/src/logic/notifications/notificationTypes.ts index efc6a302..f01b0d27 100644 --- a/src/logic/notifications/notificationTypes.ts +++ b/src/logic/notifications/notificationTypes.ts @@ -52,6 +52,8 @@ const NOTIFICATION_IDS = { ADDRESS_BOOK_NEW_ENTRY_SUCCESS: 'ADDRESS_BOOK_NEW_ENTRY_SUCCESS', ADDRESS_BOOK_EDIT_ENTRY_SUCCESS: 'ADDRESS_BOOK_EDIT_ENTRY_SUCCESS', ADDRESS_BOOK_DELETE_ENTRY_SUCCESS: 'ADDRESS_BOOK_DELETE_ENTRY_SUCCESS', + ADDRESS_BOOK_EXPORT_ENTRIES_SUCCESS: 'ADDRESS_BOOK_EXPORT_ENTRIES_SUCCESS', + ADDRESS_BOOK_EXPORT_ENTRIES_ERROR: 'ADDRESS_BOOK_EXPORT_ENTRIES_ERROR', SAFE_NEW_VERSION_AVAILABLE: 'SAFE_NEW_VERSION_AVAILABLE', } @@ -206,6 +208,14 @@ export const NOTIFICATIONS: Record = { message: 'Entry deleted successfully', options: { variant: SUCCESS, persist: false, preventDuplicate: false }, }, + ADDRESS_BOOK_EXPORT_ENTRIES_SUCCESS: { + message: 'Address book exported', + options: { variant: SUCCESS, persist: false, preventDuplicate: false }, + }, + ADDRESS_BOOK_EXPORT_ENTRIES_ERROR: { + message: 'An error occurred while generating the address book CSV.', + options: { variant: ERROR, persist: false, preventDuplicate: false }, + }, // Safe Version SAFE_NEW_VERSION_AVAILABLE: { diff --git a/src/logic/safe/transactions/notifiedTransactions.ts b/src/logic/safe/transactions/notifiedTransactions.ts index f166bbab..9f18650e 100644 --- a/src/logic/safe/transactions/notifiedTransactions.ts +++ b/src/logic/safe/transactions/notifiedTransactions.ts @@ -11,4 +11,5 @@ export const TX_NOTIFICATION_TYPES = { ADDRESSBOOK_NEW_ENTRY: 'ADDRESSBOOK_NEW_ENTRY', ADDRESSBOOK_EDIT_ENTRY: 'ADDRESSBOOK_EDIT_ENTRY', ADDRESSBOOK_DELETE_ENTRY: 'ADDRESSBOOK_DELETE_ENTRY', + ADDRESSBOOK_EXPORT_ENTRIES: 'ADDRESSBOOK_EXPORT_ENTRIES', } diff --git a/src/routes/safe/components/AddressBook/ExportEntriesModal/assets/error.svg b/src/routes/safe/components/AddressBook/ExportEntriesModal/assets/error.svg new file mode 100644 index 00000000..0b2b8dd4 --- /dev/null +++ b/src/routes/safe/components/AddressBook/ExportEntriesModal/assets/error.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/routes/safe/components/AddressBook/ExportEntriesModal/assets/success.svg b/src/routes/safe/components/AddressBook/ExportEntriesModal/assets/success.svg new file mode 100644 index 00000000..09f27d51 --- /dev/null +++ b/src/routes/safe/components/AddressBook/ExportEntriesModal/assets/success.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/routes/safe/components/AddressBook/ExportEntriesModal/assets/wait.svg b/src/routes/safe/components/AddressBook/ExportEntriesModal/assets/wait.svg new file mode 100644 index 00000000..9b8677de --- /dev/null +++ b/src/routes/safe/components/AddressBook/ExportEntriesModal/assets/wait.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/routes/safe/components/AddressBook/ExportEntriesModal/index.tsx b/src/routes/safe/components/AddressBook/ExportEntriesModal/index.tsx new file mode 100644 index 00000000..dfd022b2 --- /dev/null +++ b/src/routes/safe/components/AddressBook/ExportEntriesModal/index.tsx @@ -0,0 +1,145 @@ +import React, { ReactElement, useEffect, useState } from 'react' +import { format } from 'date-fns' +import { useSelector, useDispatch } from 'react-redux' +import { CSVDownloader, jsonToCSV } from 'react-papaparse' +import { Button, Loader, Text } from '@gnosis.pm/safe-react-components' +import styled from 'styled-components' + +import { enhanceSnackbarForAction, getNotificationsFromTxType } from 'src/logic/notifications' +import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackbar' +import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions' + +import { addressBookSelector } from 'src/logic/addressBook/store/selectors' +import { AddressBookState } from 'src/logic/addressBook/model/addressBook' + +import { lg, md, background } from 'src/theme/variables' + +import { Modal } from 'src/components/Modal' +import Img from 'src/components/layout/Img' +import Row from 'src/components/layout/Row' + +import SuccessSvg from './assets/success.svg' +import ErrorSvg from './assets/error.svg' +import LoadingSvg from './assets/wait.svg' + +type ExportEntriesModalProps = { + isOpen: boolean + onClose: () => void +} + +const ImageContainer = styled(Row)` + padding: ${md} ${lg}; + justify-content: center; +` + +const InfoContainer = styled(Row)` + background-color: ${background}; + flex-direction: column; + justify-content: center; + padding: ${lg}; + text-align: center; +` + +const BodyImage = styled.div` + grid-row: 1; +` +const StyledLoader = styled(Loader)` + margin-right: 5px; +` +const StyledCSVLink = styled(CSVDownloader)` + display: flex; + flex: 1; + justify-content: center; +` + +export const ExportEntriesModal = ({ isOpen, onClose }: ExportEntriesModalProps): ReactElement => { + const dispatch = useDispatch() + const addressBook: AddressBookState = useSelector(addressBookSelector) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [csvData, setCsvData] = useState('') + const [doRetry, setDoRetry] = useState(false) + + const date = format(new Date(), 'yyyy-MM-dd') + + const handleClose = () => + //This timeout prevents modal to be closed abruptly + setTimeout(() => { + if (!loading) { + const notification = getNotificationsFromTxType(TX_NOTIFICATION_TYPES.ADDRESSBOOK_EXPORT_ENTRIES) + const action = error + ? notification.afterExecution.afterExecutionError + : notification.afterExecution.noMoreConfirmationsNeeded + dispatch(enqueueSnackbar(enhanceSnackbarForAction(action))) + } + onClose() + }, 600) + + useEffect(() => { + const handleCsvData = () => { + if (!isOpen && !doRetry) return + setLoading(true) + setError('') + try { + setCsvData(jsonToCSV(addressBook)) + } catch (e) { + setLoading(false) + setError(e.message) + return + } + setLoading(false) + setDoRetry(false) + } + + handleCsvData() + }, [addressBook, isOpen, doRetry, csvData]) + + return ( + + + Export address book + + + + + Export + + + + + {!error ? ( + + You're about to export a CSV file with{' '} + + {addressBook.length} address book entries + + . + + ) : ( + + An error occurred while generating the address book CSV. + + )} + + + + + + + + + + + ) +} diff --git a/src/routes/safe/components/AddressBook/index.tsx b/src/routes/safe/components/AddressBook/index.tsx index 21533407..74986921 100644 --- a/src/routes/safe/components/AddressBook/index.tsx +++ b/src/routes/safe/components/AddressBook/index.tsx @@ -24,6 +24,7 @@ import { updateAddressBookEntry } from 'src/logic/addressBook/store/actions/upda import { addressBookSelector } from 'src/logic/addressBook/store/selectors' import { isUserAnOwnerOfAnySafe, sameAddress } from 'src/logic/wallets/ethAddresses' import { CreateEditEntryModal } from 'src/routes/safe/components/AddressBook/CreateEditEntryModal' +import { ExportEntriesModal } from 'src/routes/safe/components/AddressBook/ExportEntriesModal' import DeleteEntryModal from 'src/routes/safe/components/AddressBook/DeleteEntryModal' import { AB_ADDRESS_ID, @@ -77,6 +78,7 @@ const AddressBookTable = (): ReactElement => { const [selectedEntry, setSelectedEntry] = useState(initialEntryState) const [editCreateEntryModalOpen, setEditCreateEntryModalOpen] = useState(false) const [deleteEntryModalOpen, setDeleteEntryModalOpen] = useState(false) + const [exportEntriesModalOpen, setExportEntriesModalOpen] = useState(false) const [sendFundsModalOpen, setSendFundsModalOpen] = useState(false) const { trackEvent } = useAnalytics() @@ -144,7 +146,7 @@ const AddressBookTable = (): ReactElement => { { setSelectedEntry(initialEntryState) - setEditCreateEntryModalOpen(true) + setExportEntriesModalOpen(true) }} color="primary" iconType="exportImg" @@ -283,6 +285,7 @@ const AddressBookTable = (): ReactElement => { isOpen={deleteEntryModalOpen} onClose={() => setDeleteEntryModalOpen(false)} /> + setExportEntriesModalOpen(false)} />