From 629dedfe66f741c43fa6e2557ef8a2a27bb41a12 Mon Sep 17 00:00:00 2001
From: Michael <30682308+mike10ca@users.noreply.github.com>
Date: Fri, 9 Feb 2024 15:26:01 +0100
Subject: [PATCH 1/3] Tests: add csv import tests (#3232)
* tests: add csv import tests
* tests: change network from gnosis to etherium
---
cypress/e2e/pages/address_book.page.js | 81 ++++++++++++++-----
cypress/e2e/pages/sidebar.pages.js | 2 +-
cypress/e2e/regression/address_book.cy.js | 38 ++++++++-
cypress/e2e/regression/sidebar_2.cy.js | 15 ++--
cypress/e2e/smoke/address_book.cy.js | 39 ++++++++-
cypress/fixtures/address_book_addedsafes.csv | 2 +
cypress/fixtures/address_book_duplicated.csv | 3 +
cypress/fixtures/address_book_empty_test.csv | 1 +
cypress/fixtures/address_book_networks.csv | 5 ++
cypress/support/localstorage_data.js | 20 +----
.../address-book/ImportDialog/index.tsx | 14 +++-
src/components/common/EnhancedTable/index.tsx | 3 +-
src/components/tx/ErrorMessage/index.tsx | 2 +-
13 files changed, 172 insertions(+), 53 deletions(-)
create mode 100644 cypress/fixtures/address_book_addedsafes.csv
create mode 100644 cypress/fixtures/address_book_duplicated.csv
create mode 100644 cypress/fixtures/address_book_empty_test.csv
create mode 100644 cypress/fixtures/address_book_networks.csv
diff --git a/cypress/e2e/pages/address_book.page.js b/cypress/e2e/pages/address_book.page.js
index 611b7d826b..d333a0d505 100644
--- a/cypress/e2e/pages/address_book.page.js
+++ b/cypress/e2e/pages/address_book.page.js
@@ -1,41 +1,86 @@
-export const acceptSelection = 'Save settings'
-export const addressBook = 'Address book'
-const createEntryBtn = 'Create entry'
+import * as main from '../pages/main.page'
+export const addressBookRecipient = '[data-testid="address-book-recipient"]'
const beameriFrameContainer = '#beamerOverlay .iframeCointaner'
const beamerInput = 'input[id="beamer"]'
const nameInput = 'input[name="name"]'
const addressInput = 'input[name="address"]'
-export const addressBookRecipient = '[data-testid="address-book-recipient"]'
-const saveBtn = 'Save'
+const exportModalBtn = '[data-testid="export-modal-btn"]'
export const editEntryBtn = 'button[aria-label="Edit entry"]'
export const deleteEntryBtn = 'button[aria-label="Delete entry"]'
export const deleteEntryModalBtnSection = '.MuiDialogActions-root'
+const tableContainer = '[data-testid="table-container"]'
+const tableRow = '[data-testid="table-row"]'
+const importBtn = '[data-testid="import-btn"]'
+const cancelImportBtn = '[data-testid="cancel-btn"]'
+const uploadErrorMsg = '[data-testid="error-message"]'
+const modalSummaryMessage = '[data-testid="summary-message"]'
+
+export const acceptSelection = 'Save settings'
+export const addressBook = 'Address book'
+const createEntryBtn = 'Create entry'
export const delteEntryModaldeleteBtn = 'Delete'
-const importBtn = 'Import'
const exportBtn = 'Export'
-const exportModalBtn = '[data-testid="export-modal-btn"]'
+const saveBtn = 'Save'
const whatsNewBtnStr = "What's new"
const beamrCookiesStr = 'accept the "Beamer" cookies'
+const headerImportBtnStr = 'Import'
+
+export const emptyCSVFile = '../fixtures/address_book_empty_test.csv'
+export const nonCSVFile = '../fixtures/balances.json'
+export const duplicatedCSVFile = 'address_book_duplicated.csv'
+export const validCSVFile = '../fixtures/address_book_test.csv'
+export const networksCSVFile = '../fixtures/address_book_networks.csv'
+export const addedSafesCSVFile = '../fixtures/address_book_addedsafes.csv'
+
+export const entries = [
+ '0x6E834E9D04ad6b26e1525dE1a37BFd9b215f40B7',
+ 'test-sepolia-3',
+ '0xf405BC611F4a4c89CCB3E4d083099f9C36D966f8',
+ 'sepolia-test-4',
+ '0x03042B890b99552b60A073F808100517fb148F60',
+ 'sepolia-test-5',
+ '0xBd69b0a9DC90eB6F9bAc3E4a5875f437348b6415',
+ 'assets-test-sepolia',
+]
+
+export function verifyModalSummaryMessage(entryCount, chainCount) {
+ cy.get(modalSummaryMessage).should(
+ 'contain',
+ `Found ${entryCount} entries on ${chainCount} ${chainCount > 1 ? 'chains' : 'chain'}`,
+ )
+}
+export const uploadErrorMessages = {
+ fileType: 'File type must be text/csv',
+ emptyFile: 'No entries found in address book',
+}
+
+export function verifyUploadExportMessage(msg) {
+ main.verifyValuesExist(uploadErrorMsg, msg)
+}
+
+export function verifyImportBtnStatus(status) {
+ main.verifyElementsStatus([importBtn], status)
+}
+
+export function verifyNumberOfRows(number) {
+ main.verifyElementsCount(tableRow, number)
+}
export function clickOnImportFileBtn() {
- cy.contains(importBtn).click()
+ cy.contains(headerImportBtnStr).click()
}
-export function importFile() {
- cy.get('[type="file"]').attachFile('../fixtures/address_book_test.csv')
- // Import button should be enabled
- cy.get('.MuiDialogActions-root').contains('Import').should('not.be.disabled')
- cy.get('.MuiDialogActions-root').contains('Import').click()
+export function importCSVFile(file) {
+ cy.get('[type="file"]').attachFile(file)
}
-export function verifyImportModalIsClosed() {
- cy.get('Import address book').should('not.exist')
+export function clickOnImportBtn() {
+ cy.get(importBtn).click()
}
-export function verifyDataImported(name, address) {
- cy.contains(name).should('exist')
- cy.contains(address).should('exist')
+export function verifyDataImported(data) {
+ main.verifyValuesExist(tableContainer, data)
}
export function clickOnExportFileBtn() {
diff --git a/cypress/e2e/pages/sidebar.pages.js b/cypress/e2e/pages/sidebar.pages.js
index 5dd6d2bbf5..354d940178 100644
--- a/cypress/e2e/pages/sidebar.pages.js
+++ b/cypress/e2e/pages/sidebar.pages.js
@@ -29,7 +29,7 @@ const currencySection = '[data-testid="currency-section"]'
const missingSignatureInfo = '[data-testid="missing-signature-info"]'
const queuedTxInfo = '[data-testid="queued-tx-info"]'
-export const addedSafesGnosis = ['0x17b3...98C8', '0x11A6...F1BB', '0xB8d7...642A']
+export const addedSafesEth = ['0x8675...a19b']
export const addedSafesSepolia = ['0x6d0b...6dC1', '0x5912...fFdb', '0x0637...708e', '0xD157...DE9a']
export const sideBarListItems = ['Home', 'Assets', 'Transactions', 'Address book', 'Apps', 'Settings']
export const testSafeHeaderDetails = ['2/2', constants.SEPOLIA_TEST_SAFE_13_SHORT]
diff --git a/cypress/e2e/regression/address_book.cy.js b/cypress/e2e/regression/address_book.cy.js
index edfbc15ba4..49a669ce24 100644
--- a/cypress/e2e/regression/address_book.cy.js
+++ b/cypress/e2e/regression/address_book.cy.js
@@ -5,9 +5,11 @@ import * as constants from '../../support/constants'
import * as addressBook from '../../e2e/pages/address_book.page'
import * as main from '../../e2e/pages/main.page'
import * as ls from '../../support/localstorage_data.js'
+import * as sidebar from '../pages/sidebar.pages.js'
const NAME = 'Owner1'
const EDITED_NAME = 'Edited Owner1'
+const importedSafe = 'imported-safe'
describe('Address book tests', () => {
beforeEach(() => {
@@ -51,8 +53,7 @@ describe('Address book tests', () => {
cy.contains(constants.GNO_CSV_ENTRY.address).should('exist')
})
- // TODO: Change title in Testrail. New title "...exported"
- it('Verify the address book file can be downloaded', () => {
+ it('Verify the address book file can be exported', () => {
main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.dataSet).then(() => {
main
.isItemInLocalstorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.dataSet)
@@ -72,4 +73,37 @@ describe('Address book tests', () => {
})
})
})
+
+ it('Verify that importing a csv file does not alter addresses in the Address book not present in the file', () => {
+ main
+ .addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.sepoliaAddress1)
+ .then(() => {
+ main
+ .isItemInLocalstorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.sepoliaAddress1)
+ .then(() => {
+ cy.wait(1000)
+ cy.reload()
+ addressBook.clickOnImportFileBtn()
+ addressBook.importCSVFile(addressBook.validCSVFile)
+ addressBook.clickOnImportBtn()
+ addressBook.verifyDataImported([constants.RECIPIENT_ADDRESS])
+ })
+ })
+ })
+
+ it('Verify Safe name changes after uploading a csv file', () => {
+ main.addToLocalStorage(constants.localStorageKeys.SAFE_v2__addedSafes, ls.addedSafes.set4).then(() => {
+ main
+ .addToLocalStorage(constants.localStorageKeys.SAFE_v2__addressBook, ls.addressBookData.addedSafesImport)
+ .then(() => {
+ cy.wait(1000)
+ cy.reload()
+ addressBook.clickOnImportFileBtn()
+ addressBook.importCSVFile(addressBook.addedSafesCSVFile)
+ addressBook.clickOnImportBtn()
+ sidebar.openSidebar()
+ sidebar.verifyAddedSafesExist([importedSafe])
+ })
+ })
+ })
})
diff --git a/cypress/e2e/regression/sidebar_2.cy.js b/cypress/e2e/regression/sidebar_2.cy.js
index fef7fab98c..87ac18b101 100644
--- a/cypress/e2e/regression/sidebar_2.cy.js
+++ b/cypress/e2e/regression/sidebar_2.cy.js
@@ -5,8 +5,7 @@ import * as ls from '../../support/localstorage_data.js'
import * as assets from '../pages/assets.pages.js'
const newSafeName = 'Added safe 3'
-const oldSafeName = 'Added safe 900'
-const staticSafe100 = 'Added safe 100'
+const addedSafe900 = 'Added safe 900'
const staticSafe200 = 'Added safe 200'
describe('Sidebar added sidebar tests', () => {
@@ -21,31 +20,31 @@ describe('Sidebar added sidebar tests', () => {
it('Verify the safe added are listed in the sidebar', () => {
sideBar.openSidebar()
- sideBar.verifyAddedSafesExist(sideBar.addedSafesGnosis, sideBar.addedSafesSepolia)
+ sideBar.verifyAddedSafesExist(sideBar.addedSafesSepolia)
})
it('Verify Safes are separated by networks', () => {
sideBar.openSidebar()
- sideBar.verifySafesByNetwork(constants.networks.gnosis, sideBar.addedSafesGnosis)
+ sideBar.verifySafesByNetwork(constants.networks.ethereum, sideBar.addedSafesEth)
sideBar.verifySafesByNetwork(constants.networks.sepolia, sideBar.addedSafesSepolia)
})
it('Verify a safe can be renamed', () => {
sideBar.openSidebar()
- sideBar.renameSafeItem(oldSafeName, newSafeName)
+ sideBar.renameSafeItem(addedSafe900, newSafeName)
sideBar.clickOnSaveBtn()
sideBar.verifySafeNameExists(newSafeName)
})
it('Verify a safe can be removed', () => {
sideBar.openSidebar()
- sideBar.removeSafeItem(oldSafeName)
- sideBar.verifySafeRemoved([oldSafeName])
+ sideBar.removeSafeItem(addedSafe900)
+ sideBar.verifySafeRemoved([addedSafe900])
})
it('Verify the "Read only" tag if the connected user is not an owner of a safe', () => {
sideBar.openSidebar()
- sideBar.verifySafeReadOnlyState(staticSafe100)
+ sideBar.verifySafeReadOnlyState(addedSafe900)
})
it('Verify Fiat currency changes when edited in the assets tab', () => {
diff --git a/cypress/e2e/smoke/address_book.cy.js b/cypress/e2e/smoke/address_book.cy.js
index 2f396ab06d..90db240a85 100644
--- a/cypress/e2e/smoke/address_book.cy.js
+++ b/cypress/e2e/smoke/address_book.cy.js
@@ -6,6 +6,7 @@ import * as ls from '../../support/localstorage_data.js'
const NAME = 'Owner1'
const EDITED_NAME = 'Edited Owner1'
+const duplicateEntry = 'test-sepolia-90'
describe('[SMOKE] Address book tests', () => {
beforeEach(() => {
@@ -37,8 +38,40 @@ describe('[SMOKE] Address book tests', () => {
it('[SMOKE] Verify csv file can be imported', () => {
addressBook.clickOnImportFileBtn()
- addressBook.importFile()
- addressBook.verifyImportModalIsClosed()
- addressBook.verifyDataImported(constants.SEPOLIA_CSV_ENTRY.name, constants.SEPOLIA_CSV_ENTRY.address)
+ addressBook.importCSVFile(addressBook.validCSVFile)
+ addressBook.verifyImportBtnStatus(constants.enabledStates.enabled)
+ addressBook.clickOnImportBtn()
+ addressBook.verifyDataImported(addressBook.entries)
+ addressBook.verifyNumberOfRows(4)
+ })
+
+ it('[SMOKE] Import a csv file with an empty address/name/network in one row', () => {
+ addressBook.clickOnImportFileBtn()
+ addressBook.importCSVFile(addressBook.emptyCSVFile)
+ addressBook.verifyImportBtnStatus(constants.enabledStates.disabled)
+ addressBook.verifyUploadExportMessage([addressBook.uploadErrorMessages.emptyFile])
+ })
+
+ it('[SMOKE] Import a non-csv file', () => {
+ addressBook.clickOnImportFileBtn()
+ addressBook.importCSVFile(addressBook.nonCSVFile)
+ addressBook.verifyImportBtnStatus(constants.enabledStates.disabled)
+ addressBook.verifyUploadExportMessage([addressBook.uploadErrorMessages.fileType])
+ })
+
+ it('[SMOKE] Import a csv file with a repeated address and same network', () => {
+ addressBook.clickOnImportFileBtn()
+ addressBook.importCSVFile(addressBook.duplicatedCSVFile)
+ addressBook.verifyImportBtnStatus(constants.enabledStates.enabled)
+ addressBook.clickOnImportBtn()
+ addressBook.verifyDataImported([duplicateEntry])
+ addressBook.verifyNumberOfRows(1)
+ })
+
+ it('[SMOKE] Verify modal shows the amount of entries and networks detected', () => {
+ addressBook.clickOnImportFileBtn()
+ addressBook.importCSVFile(addressBook.networksCSVFile)
+ addressBook.verifyImportBtnStatus(constants.enabledStates.enabled)
+ addressBook.verifyModalSummaryMessage(4, 3)
})
})
diff --git a/cypress/fixtures/address_book_addedsafes.csv b/cypress/fixtures/address_book_addedsafes.csv
new file mode 100644
index 0000000000..afe54dcd76
--- /dev/null
+++ b/cypress/fixtures/address_book_addedsafes.csv
@@ -0,0 +1,2 @@
+address,name,chainId
+0x6d0b6F96f665Bb4490f9ddb2e450Da2f7e546dC1,imported-safe,11155111
\ No newline at end of file
diff --git a/cypress/fixtures/address_book_duplicated.csv b/cypress/fixtures/address_book_duplicated.csv
new file mode 100644
index 0000000000..2302d1848a
--- /dev/null
+++ b/cypress/fixtures/address_book_duplicated.csv
@@ -0,0 +1,3 @@
+address,name,chainId
+0x6E834E9D04ad6b26e1525dE1a37BFd9b215f40B7,test-sepolia-9,11155111
+0x6E834E9D04ad6b26e1525dE1a37BFd9b215f40B7,test-sepolia-90,11155111
\ No newline at end of file
diff --git a/cypress/fixtures/address_book_empty_test.csv b/cypress/fixtures/address_book_empty_test.csv
new file mode 100644
index 0000000000..4918b1de41
--- /dev/null
+++ b/cypress/fixtures/address_book_empty_test.csv
@@ -0,0 +1 @@
+address,name,chainId
\ No newline at end of file
diff --git a/cypress/fixtures/address_book_networks.csv b/cypress/fixtures/address_book_networks.csv
new file mode 100644
index 0000000000..4624dfb476
--- /dev/null
+++ b/cypress/fixtures/address_book_networks.csv
@@ -0,0 +1,5 @@
+address,name,chainId
+0x8675B754342754A30A2AeF474D114d8460bca19b,"mainnet safe ",1
+0xB8d760a90a5ed54D3c2b3EFC231277e99188642A,"xDai Safe B8", 100
+0x91e11585c114129f3Ec940Aa648A4ac13668d0c2,"Biance safe91", 56
+0x61a0c717d18232711bC788F19C9Cd56a43cc8872,"MM account 1", 1
\ No newline at end of file
diff --git a/cypress/support/localstorage_data.js b/cypress/support/localstorage_data.js
index 96c80044e6..78772bf0ce 100644
--- a/cypress/support/localstorage_data.js
+++ b/cypress/support/localstorage_data.js
@@ -317,10 +317,8 @@ export const addressBookData = {
},
},
addedSafes: {
- 100: {
- '0x17b34aEf1428A358bA2eA360a098b8A3BEb698C8': 'Added safe 1',
- '0x11A6B41322C57Bd0e56cEe06abB11A1E5c1FF1BB': 'Added safe 900',
- '0xB8d760a90a5ed54D3c2b3EFC231277e99188642A': 'Added safe 100',
+ 1: {
+ '0x8675B754342754A30A2AeF474D114d8460bca19b': 'Added safe 900',
},
11155111: {
'0x0A0EEb6fBCc7c82259E548Fc4617175A357b3e71': 'Added safe 200',
@@ -529,22 +527,12 @@ export const addedSafes = {
ethBalance: '0',
},
},
- 100: {
- '0x17b34aEf1428A358bA2eA360a098b8A3BEb698C8': {
+ 1: {
+ '0x8675B754342754A30A2AeF474D114d8460bca19b': {
owners: [{ value: '0x11B1D54B66e5e226D6f89069c21A569A22D98cfd' }],
threshold: 1,
ethBalance: '0.001000002',
},
- '0x11A6B41322C57Bd0e56cEe06abB11A1E5c1FF1BB': {
- owners: [{ value: '0x7724b234c9099C205F03b458944942bcEBA13408' }],
- threshold: 1,
- ethBalance: '0',
- },
- '0xB8d760a90a5ed54D3c2b3EFC231277e99188642A': {
- owners: [{ value: '0x11B1D54B66e5e226D6f89069c21A569A22D98cfd' }],
- threshold: 1,
- ethBalance: '0.92132507668989',
- },
},
},
set3: {
diff --git a/src/components/address-book/ImportDialog/index.tsx b/src/components/address-book/ImportDialog/index.tsx
index ac6d665eda..7b83a40d8b 100644
--- a/src/components/address-book/ImportDialog/index.tsx
+++ b/src/components/address-book/ImportDialog/index.tsx
@@ -128,7 +128,7 @@ const ImportDialog = ({ handleClose }: { handleClose: () => void }): ReactElemen
name: acceptedFile.name,
additionalInfo: formatFileSize(acceptedFile.size),
summary: [
-
+
{`Found ${entryCount} entries on ${chainCount} ${chainCount > 1 ? 'chains' : 'chain'}`}
,
],
@@ -163,8 +163,16 @@ const ImportDialog = ({ handleClose }: { handleClose: () => void }): ReactElemen
-
-
diff --git a/src/components/common/EnhancedTable/index.tsx b/src/components/common/EnhancedTable/index.tsx
index 33e6258a36..30a5130830 100644
--- a/src/components/common/EnhancedTable/index.tsx
+++ b/src/components/common/EnhancedTable/index.tsx
@@ -135,13 +135,14 @@ function EnhancedTable({ rows, headCells, mobileVariant }: EnhancedTableProps) {
return (
-
+
{pagedRows.length > 0 ? (
pagedRows.map((row, index) => (
+
Date: Fri, 9 Feb 2024 16:10:31 +0100
Subject: [PATCH 2/3] feat: [Counterfactual] Add backup option (#3202)
* feat: Create counterfactual 1/1 safes
* fix: Add feature flag
* fix: Lint issues
* fix: Use incremental saltNonce for all safe creations
* fix: Replace useCounterfactualBalance hook with get function and write tests
* refactor: Move creation logic out of Review component
* fix: useLoadBalance check for undefined value
* fix: Extract saltNonce, safeAddress calculation into a hook
* refactor: Rename redux slice
* fix: Show error message in case saltNonce can't be retrieved
* feat: Add backup option for counterfactual safes
* fix: Adjust wording
* fix: Remove feature flag check for recovery
* refactor: Extract file upload logic from safe loading component
* fix: Add recover option to welcome page
* fix: Fallback to readonly provider to fetch balances and cache response
* fix: Navigate to dashboard after recovery
* fix: Remove restore feature
* fix: link prevent default
---
.../dashboard/CreationDialog/index.tsx | 50 +++++++++++++++++-
.../counterfactual/__tests__/utils.test.ts | 52 +++++++++++++------
src/features/counterfactual/utils.ts | 34 +++++++++---
3 files changed, 110 insertions(+), 26 deletions(-)
diff --git a/src/components/dashboard/CreationDialog/index.tsx b/src/components/dashboard/CreationDialog/index.tsx
index 07a417f4e5..9133386e14 100644
--- a/src/components/dashboard/CreationDialog/index.tsx
+++ b/src/components/dashboard/CreationDialog/index.tsx
@@ -1,5 +1,10 @@
+import { selectUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice'
+import useChainId from '@/hooks/useChainId'
+import useSafeInfo from '@/hooks/useSafeInfo'
+import { useAppSelector } from '@/store'
+import type { PredictedSafeProps } from '@safe-global/protocol-kit'
import React, { type ElementType } from 'react'
-import { Box, Button, Dialog, DialogContent, Grid, SvgIcon, Typography } from '@mui/material'
+import { Alert, Box, Button, Dialog, DialogContent, Grid, Link, SvgIcon, Typography } from '@mui/material'
import { useRouter } from 'next/router'
import HomeIcon from '@/public/images/sidebar/home.svg'
@@ -27,11 +32,34 @@ const HintItem = ({ Icon, title, description }: { Icon: ElementType; title: stri
)
}
+const getExportFileName = () => {
+ const today = new Date().toISOString().slice(0, 10)
+ return `safe-backup-${today}.json`
+}
+
+const backupSafe = (chainId: string, safeAddress: string, undeployedSafe: PredictedSafeProps) => {
+ const data = JSON.stringify({ chainId, safeAddress, safeProps: undeployedSafe }, null, 2)
+
+ const blob = new Blob([data], { type: 'text/json' })
+ const link = document.createElement('a')
+
+ link.download = getExportFileName()
+ link.href = window.URL.createObjectURL(blob)
+ link.dataset.downloadurl = ['text/json', link.download, link.href].join(':')
+ link.dispatchEvent(new MouseEvent('click'))
+
+ // TODO: Track this as an event
+ // trackEvent(COUNTERFACTUAL_EVENTS.EXPORT_SAFE)
+}
+
const CreationDialog = () => {
const router = useRouter()
const [open, setOpen] = React.useState(true)
const [remoteSafeApps = []] = useRemoteSafeApps()
const chain = useCurrentChain()
+ const chainId = useChainId()
+ const { safeAddress } = useSafeInfo()
+ const undeployedSafe = useAppSelector((state) => selectUndeployedSafe(state, chainId, safeAddress))
const onClose = () => {
const { [CREATION_MODAL_QUERY_PARM]: _, ...query } = router.query
@@ -49,7 +77,8 @@ const CreationDialog = () => {
Congratulations on your first step to truly unlock ownership. Enjoy the experience and discover our app.
-
+
+
{
description="Have any questions? Check out our collection of articles."
/>
+
+ {undeployedSafe && (
+
+ We recommend{' '}
+ {
+ e.preventDefault()
+ backupSafe(chainId, safeAddress, undeployedSafe)
+ }}
+ >
+ backing up your Safe Account
+ {' '}
+ in case you lose access to this device.
+
+ )}
+
Got it
diff --git a/src/features/counterfactual/__tests__/utils.test.ts b/src/features/counterfactual/__tests__/utils.test.ts
index 917f4f3c2a..589372a73e 100644
--- a/src/features/counterfactual/__tests__/utils.test.ts
+++ b/src/features/counterfactual/__tests__/utils.test.ts
@@ -1,10 +1,11 @@
import { getCounterfactualBalance, getUndeployedSafeInfo } from '@/features/counterfactual/utils'
+import * as web3 from '@/hooks/wallets/web3'
import { chainBuilder } from '@/tests/builders/chains'
import { faker } from '@faker-js/faker'
import type { PredictedSafeProps } from '@safe-global/protocol-kit'
import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants'
import { TokenType } from '@safe-global/safe-gateway-typescript-sdk'
-import { BrowserProvider, type Eip1193Provider } from 'ethers'
+import { type BrowserProvider, type JsonRpcProvider } from 'ethers'
describe('Counterfactual utils', () => {
describe('getUndeployedSafeInfo', () => {
@@ -35,35 +36,54 @@ describe('Counterfactual utils', () => {
jest.clearAllMocks()
})
- it('should return undefined if there is no provider', () => {
+ it('should fall back to readonly provider if there is no provider', async () => {
+ const mockBalance = 123n
+ const mockReadOnlyProvider = {
+ getBalance: jest.fn(() => Promise.resolve(mockBalance)),
+ } as unknown as JsonRpcProvider
+ jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation(() => mockReadOnlyProvider)
+
const mockSafeAddress = faker.finance.ethereumAddress()
const mockChain = chainBuilder().build()
- const result = getCounterfactualBalance(mockSafeAddress, undefined, mockChain)
+ const result = await getCounterfactualBalance(mockSafeAddress, undefined, mockChain)
- expect(result).resolves.toBeUndefined()
+ expect(mockReadOnlyProvider.getBalance).toHaveBeenCalled()
+ expect(result).toEqual({
+ fiatTotal: '0',
+ items: [
+ {
+ tokenInfo: {
+ type: TokenType.NATIVE_TOKEN,
+ address: ZERO_ADDRESS,
+ ...mockChain.nativeCurrency,
+ },
+ balance: mockBalance.toString(),
+ fiatBalance: '0',
+ fiatConversion: '0',
+ },
+ ],
+ })
})
- it('should return undefined if there is no chain info', () => {
+ it('should return undefined if there is no chain info', async () => {
const mockSafeAddress = faker.finance.ethereumAddress()
- const mockProvider = new BrowserProvider(jest.fn() as unknown as Eip1193Provider)
- mockProvider.getBalance = jest.fn(() => Promise.resolve(1n))
+ const mockProvider = { getBalance: jest.fn(() => Promise.resolve(1n)) } as unknown as BrowserProvider
- const result = getCounterfactualBalance(mockSafeAddress, mockProvider, undefined)
+ const result = await getCounterfactualBalance(mockSafeAddress, mockProvider, undefined)
- expect(result).resolves.toBeUndefined()
+ expect(result).toBeUndefined()
})
- it('should return the native balance', () => {
+ it('should return the native balance', async () => {
const mockSafeAddress = faker.finance.ethereumAddress()
- const mockProvider = new BrowserProvider(jest.fn() as unknown as Eip1193Provider)
- const mockChain = chainBuilder().build()
const mockBalance = 1000000n
+ const mockProvider = { getBalance: jest.fn(() => Promise.resolve(mockBalance)) } as unknown as BrowserProvider
+ const mockChain = chainBuilder().build()
- mockProvider.getBalance = jest.fn(() => Promise.resolve(mockBalance))
-
- const result = getCounterfactualBalance(mockSafeAddress, mockProvider, mockChain)
+ const result = await getCounterfactualBalance(mockSafeAddress, mockProvider, mockChain)
- expect(result).resolves.toEqual({
+ expect(mockProvider.getBalance).toHaveBeenCalled()
+ expect(result).toEqual({
fiatTotal: '0',
items: [
{
diff --git a/src/features/counterfactual/utils.ts b/src/features/counterfactual/utils.ts
index 0439528c75..152e04da71 100644
--- a/src/features/counterfactual/utils.ts
+++ b/src/features/counterfactual/utils.ts
@@ -1,10 +1,13 @@
-import { LATEST_SAFE_VERSION } from '@/config/constants'
import type { NewSafeFormData } from '@/components/new-safe/create'
+import { CREATION_MODAL_QUERY_PARM } from '@/components/new-safe/create/logic'
+import { LATEST_SAFE_VERSION } from '@/config/constants'
+import { AppRoutes } from '@/config/routes'
+import { addUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice'
+import { getWeb3ReadOnly } from '@/hooks/wallets/web3'
+import ExternalStore from '@/services/ExternalStore'
import { type ConnectedWallet } from '@/hooks/wallets/useOnboard'
import { asError } from '@/services/exceptions/utils'
import { assertWalletChain, getUncheckedSafeSDK } from '@/services/tx/tx-sender/sdk'
-import { AppRoutes } from '@/config/routes'
-import { addUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice'
import { txDispatch, TxEvent } from '@/services/tx/txEvents'
import type { AppDispatch } from '@/store'
import { addOrUpdateSafe } from '@/store/addedSafesSlice'
@@ -117,10 +120,22 @@ export const deploySafeAndExecuteTx = async (
return dispatchTxExecutionAndDeploySafe(safeTx, txOptions, onboard, chainId, onSuccess)
}
-export const getCounterfactualBalance = async (safeAddress: string, provider?: BrowserProvider, chain?: ChainInfo) => {
- const balance = await provider?.getBalance(safeAddress)
+const { getStore: getNativeBalance, setStore: setNativeBalance } = new ExternalStore()
- if (balance === undefined || !chain) return
+export const getCounterfactualBalance = async (safeAddress: string, provider?: BrowserProvider, chain?: ChainInfo) => {
+ let balance: bigint | undefined
+
+ if (!chain) return undefined
+
+ // Fetch balance via the connected wallet.
+ // If there is no wallet connected we fetch and cache the balance instead
+ if (provider) {
+ balance = await provider.getBalance(safeAddress)
+ } else {
+ const cachedBalance = getNativeBalance()
+ balance = cachedBalance !== undefined ? cachedBalance : await getWeb3ReadOnly()?.getBalance(safeAddress)
+ setNativeBalance(balance)
+ }
return {
fiatTotal: '0',
@@ -131,7 +146,7 @@ export const getCounterfactualBalance = async (safeAddress: string, provider?: B
address: ZERO_ADDRESS,
...chain?.nativeCurrency,
},
- balance: balance.toString(),
+ balance: balance?.toString(),
fiatBalance: '0',
fiatConversion: '0',
},
@@ -176,5 +191,8 @@ export const createCounterfactualSafe = (
},
}),
)
- router.push({ pathname: AppRoutes.home, query: { safe: `${chain.shortName}:${safeAddress}` } })
+ router.push({
+ pathname: AppRoutes.home,
+ query: { safe: `${chain.shortName}:${safeAddress}`, [CREATION_MODAL_QUERY_PARM]: true },
+ })
}
From 784237314e55bf9a9faa3be811b6011fccf74ba0 Mon Sep 17 00:00:00 2001
From: Usame Algan <5880855+usame-algan@users.noreply.github.com>
Date: Mon, 12 Feb 2024 10:04:43 +0100
Subject: [PATCH 3/3] feat: [Counterfactual] Add pay now pay later option to
safe creation (#3222)
* feat: Add pay now pay later option to safe creation for counterfactual safes
* fix: Add tests for ReviewStep
* fix: Hide fee in pay now if can relay
* fix: Update design for pay now pay later block
---
src/components/new-safe/ReviewRow/index.tsx | 12 +-
.../create/steps/ReviewStep/index.test.tsx | 112 ++++++++++++++++-
.../create/steps/ReviewStep/index.tsx | 109 ++++++++++-------
.../create/steps/ReviewStep/styles.module.css | 13 ++
.../counterfactual/PayNowPayLater.tsx | 113 ++++++++++++++++++
src/features/counterfactual/styles.module.css | 32 +++++
6 files changed, 342 insertions(+), 49 deletions(-)
create mode 100644 src/features/counterfactual/PayNowPayLater.tsx
create mode 100644 src/features/counterfactual/styles.module.css
diff --git a/src/components/new-safe/ReviewRow/index.tsx b/src/components/new-safe/ReviewRow/index.tsx
index 7478c054c4..6f434c108c 100644
--- a/src/components/new-safe/ReviewRow/index.tsx
+++ b/src/components/new-safe/ReviewRow/index.tsx
@@ -1,13 +1,15 @@
import React, { type ReactElement } from 'react'
import { Grid, Typography } from '@mui/material'
-const ReviewRow = ({ name, value }: { name: string; value: ReactElement }) => {
+const ReviewRow = ({ name, value }: { name?: string; value: ReactElement }) => {
return (
<>
-
- {name}
-
-
+ {name && (
+
+ {name}
+
+ )}
+
{value}
>
diff --git a/src/components/new-safe/create/steps/ReviewStep/index.test.tsx b/src/components/new-safe/create/steps/ReviewStep/index.test.tsx
index 397e4bc18a..8ebfc9bdaa 100644
--- a/src/components/new-safe/create/steps/ReviewStep/index.test.tsx
+++ b/src/components/new-safe/create/steps/ReviewStep/index.test.tsx
@@ -1,10 +1,14 @@
+import type { NewSafeFormData } from '@/components/new-safe/create'
+import * as useChains from '@/hooks/useChains'
+import * as relay from '@/utils/relaying'
import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk'
import { render } from '@/tests/test-utils'
-import { NetworkFee } from '@/components/new-safe/create/steps/ReviewStep/index'
+import ReviewStep, { NetworkFee } from '@/components/new-safe/create/steps/ReviewStep/index'
import * as useWallet from '@/hooks/wallets/useWallet'
import { type ConnectedWallet } from '@/hooks/wallets/useOnboard'
-import { ONBOARD_MPC_MODULE_LABEL } from '@/services/mpc/SocialLoginModule'
+import * as socialLogin from '@/services/mpc/SocialLoginModule'
+import { act, fireEvent } from '@testing-library/react'
const mockChainInfo = {
chainId: '100',
@@ -25,14 +29,14 @@ describe('NetworkFee', () => {
})
it('displays a sponsored by message for social login', () => {
- jest.spyOn(useWallet, 'default').mockReturnValue({ label: ONBOARD_MPC_MODULE_LABEL } as unknown as ConnectedWallet)
+ jest.spyOn(useWallet, 'default').mockReturnValue({ label: 'Social Login' } as unknown as ConnectedWallet)
const result = render()
expect(result.getByText(/Your account is sponsored by Gnosis/)).toBeInTheDocument()
})
it('displays an error message for social login if there are no relays left', () => {
- jest.spyOn(useWallet, 'default').mockReturnValue({ label: ONBOARD_MPC_MODULE_LABEL } as unknown as ConnectedWallet)
+ jest.spyOn(useWallet, 'default').mockReturnValue({ label: 'Social Login' } as unknown as ConnectedWallet)
const result = render()
expect(
@@ -40,3 +44,103 @@ describe('NetworkFee', () => {
).toBeInTheDocument()
})
})
+
+describe('ReviewStep', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should display a pay now pay later option for counterfactual safe setups', () => {
+ const mockData: NewSafeFormData = {
+ name: 'Test',
+ threshold: 1,
+ owners: [{ name: '', address: '0x1' }],
+ saltNonce: 0,
+ }
+ jest.spyOn(useChains, 'useHasFeature').mockReturnValue(true)
+
+ const { getByText } = render(
+ ,
+ )
+
+ expect(getByText('Pay now')).toBeInTheDocument()
+ })
+
+ it('should not display the network fee for counterfactual safes', () => {
+ const mockData: NewSafeFormData = {
+ name: 'Test',
+ threshold: 1,
+ owners: [{ name: '', address: '0x1' }],
+ saltNonce: 0,
+ }
+ jest.spyOn(useChains, 'useHasFeature').mockReturnValue(true)
+
+ const { queryByText } = render(
+ ,
+ )
+
+ expect(queryByText('You will have to confirm a transaction and pay an estimated fee')).not.toBeInTheDocument()
+ })
+
+ it('should not display the execution method for counterfactual safes', () => {
+ const mockData: NewSafeFormData = {
+ name: 'Test',
+ threshold: 1,
+ owners: [{ name: '', address: '0x1' }],
+ saltNonce: 0,
+ }
+ jest.spyOn(useChains, 'useHasFeature').mockReturnValue(true)
+
+ const { queryByText } = render(
+ ,
+ )
+
+ expect(queryByText('Who will pay gas fees:')).not.toBeInTheDocument()
+ })
+
+ it('should display the network fee for counterfactual safes if the user selects pay now', async () => {
+ const mockData: NewSafeFormData = {
+ name: 'Test',
+ threshold: 1,
+ owners: [{ name: '', address: '0x1' }],
+ saltNonce: 0,
+ }
+ jest.spyOn(useChains, 'useHasFeature').mockReturnValue(true)
+
+ const { getByText } = render(
+ ,
+ )
+
+ const payNow = getByText('Pay now')
+
+ act(() => {
+ fireEvent.click(payNow)
+ })
+
+ expect(getByText(/You will have to confirm a transaction and pay an estimated fee/)).toBeInTheDocument()
+ })
+
+ it('should display the execution method for counterfactual safes if the user selects pay now and there is relaying', async () => {
+ const mockData: NewSafeFormData = {
+ name: 'Test',
+ threshold: 1,
+ owners: [{ name: '', address: '0x1' }],
+ saltNonce: 0,
+ }
+ jest.spyOn(useChains, 'useHasFeature').mockReturnValue(true)
+ jest.spyOn(relay, 'hasRemainingRelays').mockReturnValue(true)
+ jest.spyOn(socialLogin, 'isSocialLoginWallet').mockReturnValue(false)
+
+ const { getByText } = render(
+ ,
+ )
+
+ const payNow = getByText('Pay now')
+
+ act(() => {
+ fireEvent.click(payNow)
+ })
+
+ expect(getByText(/Who will pay gas fees:/)).toBeInTheDocument()
+ })
+})
diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx
index 585939ed23..dee132db0c 100644
--- a/src/components/new-safe/create/steps/ReviewStep/index.tsx
+++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx
@@ -1,52 +1,55 @@
-import { getAvailableSaltNonce } from '@/components/new-safe/create/logic/utils'
-import type { NamedAddress } from '@/components/new-safe/create/types'
-import ErrorMessage from '@/components/tx/ErrorMessage'
-import { createCounterfactualSafe } from '@/features/counterfactual/utils'
-import useWalletCanPay from '@/hooks/useWalletCanPay'
-import { useAppDispatch } from '@/store'
-import { FEATURES } from '@/utils/chains'
-import { useRouter } from 'next/router'
-import { useMemo, useState } from 'react'
-import { Button, Grid, Typography, Divider, Box, Alert } from '@mui/material'
-import lightPalette from '@/components/theme/lightPalette'
import ChainIndicator from '@/components/common/ChainIndicator'
+import type { NamedAddress } from '@/components/new-safe/create/types'
import EthHashInfo from '@/components/common/EthHashInfo'
-import { useCurrentChain, useHasFeature } from '@/hooks/useChains'
-import useGasPrice, { getTotalFeeFormatted } from '@/hooks/useGasPrice'
-import { useEstimateSafeCreationGas } from '@/components/new-safe/create/useEstimateSafeCreationGas'
+import { getTotalFeeFormatted } from '@/hooks/useGasPrice'
import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper'
import type { NewSafeFormData } from '@/components/new-safe/create'
+import { computeNewSafeAddress } from '@/components/new-safe/create/logic'
+import { getAvailableSaltNonce } from '@/components/new-safe/create/logic/utils'
+import NetworkWarning from '@/components/new-safe/create/NetworkWarning'
import css from '@/components/new-safe/create/steps/ReviewStep/styles.module.css'
import layoutCss from '@/components/new-safe/create/styles.module.css'
-import { getReadOnlyFallbackHandlerContract } from '@/services/contracts/safeContracts'
-import { computeNewSafeAddress } from '@/components/new-safe/create/logic'
-import useWallet from '@/hooks/wallets/useWallet'
-import { useWeb3 } from '@/hooks/wallets/web3'
+import { useEstimateSafeCreationGas } from '@/components/new-safe/create/useEstimateSafeCreationGas'
import useSyncSafeCreationStep from '@/components/new-safe/create/useSyncSafeCreationStep'
-import ArrowBackIcon from '@mui/icons-material/ArrowBack'
-import NetworkWarning from '@/components/new-safe/create/NetworkWarning'
-import useIsWrongChain from '@/hooks/useIsWrongChain'
import ReviewRow from '@/components/new-safe/ReviewRow'
-import { ExecutionMethodSelector, ExecutionMethod } from '@/components/tx/ExecutionMethodSelector'
-import { MAX_HOUR_RELAYS, useLeastRemainingRelays } from '@/hooks/useRemainingRelays'
-import classnames from 'classnames'
-import { hasRemainingRelays } from '@/utils/relaying'
-import { usePendingSafe } from '../StatusStep/usePendingSafe'
+import ErrorMessage from '@/components/tx/ErrorMessage'
+import { ExecutionMethod, ExecutionMethodSelector } from '@/components/tx/ExecutionMethodSelector'
+import { RELAY_SPONSORS } from '@/components/tx/SponsoredBy'
import { LATEST_SAFE_VERSION } from '@/config/constants'
+import PayNowPayLater, { PayMethod } from '@/features/counterfactual/PayNowPayLater'
+import { createCounterfactualSafe } from '@/features/counterfactual/utils'
+import { useCurrentChain, useHasFeature } from '@/hooks/useChains'
+import useGasPrice from '@/hooks/useGasPrice'
+import useIsWrongChain from '@/hooks/useIsWrongChain'
+import { MAX_HOUR_RELAYS, useLeastRemainingRelays } from '@/hooks/useRemainingRelays'
+import useWalletCanPay from '@/hooks/useWalletCanPay'
+import useWallet from '@/hooks/wallets/useWallet'
+import { useWeb3 } from '@/hooks/wallets/web3'
+import { getReadOnlyFallbackHandlerContract } from '@/services/contracts/safeContracts'
import { isSocialLoginWallet } from '@/services/mpc/SocialLoginModule'
-import { RELAY_SPONSORS } from '@/components/tx/SponsoredBy'
-import Image from 'next/image'
-import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk'
+import { useAppDispatch } from '@/store'
+import { FEATURES } from '@/utils/chains'
+import { hasRemainingRelays } from '@/utils/relaying'
+import ArrowBackIcon from '@mui/icons-material/ArrowBack'
+import { Alert, Box, Button, Divider, Grid, Typography } from '@mui/material'
import { type DeploySafeProps } from '@safe-global/protocol-kit'
+import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk'
+import classnames from 'classnames'
+import Image from 'next/image'
+import { useRouter } from 'next/router'
+import { useMemo, useState } from 'react'
+import { usePendingSafe } from '../StatusStep/usePendingSafe'
export const NetworkFee = ({
totalFee,
chain,
willRelay,
+ inline = false,
}: {
totalFee: string
chain: ChainInfo | undefined
willRelay: boolean
+ inline?: boolean
}) => {
const wallet = useWallet()
@@ -54,16 +57,8 @@ export const NetworkFee = ({
if (!isSocialLogin) {
return (
-
-
+
+
≈ {totalFee} {chain?.nativeCurrency.symbol}
@@ -156,6 +151,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps()
const isCounterfactualEnabled = useHasFeature(FEATURES.COUNTERFACTUAL)
@@ -211,7 +207,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps
+ {isCounterfactual && (
+ <>
+
+
+
+
+ {canRelay && !isSocialLogin && payMethod === PayMethod.PayNow && (
+
+
+ }
+ />
+
+ )}
+
+ {payMethod === PayMethod.PayNow && (
+
+
+ You will have to confirm a transaction and pay an estimated fee of{' '}
+ with your connected
+ wallet
+
+
+ )}
+
+ >
+ )}
+
{!isCounterfactual && (
<>
diff --git a/src/components/new-safe/create/steps/ReviewStep/styles.module.css b/src/components/new-safe/create/steps/ReviewStep/styles.module.css
index 2d4e1be420..9b87174229 100644
--- a/src/components/new-safe/create/steps/ReviewStep/styles.module.css
+++ b/src/components/new-safe/create/steps/ReviewStep/styles.module.css
@@ -13,3 +13,16 @@
.errorMessage {
margin-top: 0;
}
+
+.networkFee {
+ padding: var(--space-1);
+ background-color: var(--color-secondary-background);
+ color: var(--color-static-main);
+ width: fit-content;
+ border-radius: 6px;
+}
+
+.networkFeeInline {
+ padding: 2px 4px;
+ display: inline-flex;
+}
diff --git a/src/features/counterfactual/PayNowPayLater.tsx b/src/features/counterfactual/PayNowPayLater.tsx
new file mode 100644
index 0000000000..51da30bea6
--- /dev/null
+++ b/src/features/counterfactual/PayNowPayLater.tsx
@@ -0,0 +1,113 @@
+import type { ChangeEvent, Dispatch, SetStateAction } from 'react'
+import classnames from 'classnames'
+import { useCurrentChain } from '@/hooks/useChains'
+import CheckRoundedIcon from '@mui/icons-material/CheckRounded'
+import {
+ FormControl,
+ FormControlLabel,
+ List,
+ ListItem,
+ ListItemIcon,
+ Radio,
+ RadioGroup,
+ Typography,
+} from '@mui/material'
+
+import css from './styles.module.css'
+
+export const enum PayMethod {
+ PayNow = 'PayNow',
+ PayLater = 'PayLater',
+}
+
+const PayNowPayLater = ({
+ totalFee,
+ canRelay,
+ payMethod,
+ setPayMethod,
+}: {
+ totalFee: string
+ canRelay: boolean
+ payMethod: PayMethod
+ setPayMethod: Dispatch>
+}) => {
+ const chain = useCurrentChain()
+
+ const onChoosePayMethod = (_: ChangeEvent, newPayMethod: string) => {
+ setPayMethod(newPayMethod as PayMethod)
+ }
+
+ return (
+ <>
+
+ Before you continue
+
+
+
+
+
+
+
+ There will be a one-time network fee to activate your smart account wallet.
+
+
+
+
+
+
+
+ If you choose to pay later, the fee will be included with the first transaction you make.
+
+
+
+
+
+
+ Safe doesn't profit from the fees.
+
+
+
+
+
+ Pay now
+
+ {canRelay ? (
+ 'Sponsored free transaction'
+ ) : (
+ <>
+ ≈ {totalFee} {chain?.nativeCurrency.symbol}
+ >
+ )}
+
+ >
+ }
+ control={}
+ />
+
+
+ Pay later
+
+ with the first transaction
+
+ >
+ }
+ control={}
+ />
+
+
+ >
+ )
+}
+
+export default PayNowPayLater
diff --git a/src/features/counterfactual/styles.module.css b/src/features/counterfactual/styles.module.css
new file mode 100644
index 0000000000..a487c0b9fe
--- /dev/null
+++ b/src/features/counterfactual/styles.module.css
@@ -0,0 +1,32 @@
+.radioContainer {
+ border: 1px solid var(--color-border-light);
+ margin: 0;
+ border-radius: 6px;
+ height: 72px;
+ flex-basis: 72px;
+ padding: 0 var(--space-1);
+}
+
+.radioGroup {
+ gap: var(--space-2);
+ flex-wrap: wrap;
+}
+
+.active {
+ outline: 1px solid var(--color-primary-main);
+ border-color: var(--color-primary-main);
+}
+
+.active .radioTitle {
+ font-weight: bold;
+}
+
+.active .radioSubtitle {
+ color: var(--color-text-primary);
+}
+
+@media (max-width: 400px) {
+ .radioGroup {
+ flex-direction: column;
+ }
+}