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