provider splitup and refactor

This commit is contained in:
Matthias Kretschmann 2020-02-25 04:10:06 +01:00
parent c3edae6280
commit 2efee0ec73
Signed by: m
GPG Key ID: 606EEEF3C479A91F
16 changed files with 213 additions and 144 deletions

View File

@ -30,11 +30,13 @@
"license": "MIT",
"dependencies": {
"@coingecko/cryptoformat": "^0.3.4",
"axios": "^0.19.2",
"electron-is-dev": "^1.1.0",
"electron-next": "^3.1.5",
"electron-store": "^5.1.0",
"electron-store": "^5.1.1",
"ethereum-address": "^0.0.4",
"ethereum-blockies": "github:MyEtherWallet/blockies",
"ethjs-unit": "^0.1.6",
"ms": "^2.1.2",
"shortid": "^2.2.15"
},
@ -43,15 +45,15 @@
"@babel/preset-env": "^7.8.4",
"@jest-runner/electron": "^2.0.3",
"@react-mock/state": "^0.1.8",
"@svgr/webpack": "^5.1.0",
"@svgr/webpack": "^5.2.0",
"@testing-library/jest-dom": "^5.1.1",
"@testing-library/react": "^9.4.0",
"@testing-library/react": "^9.4.1",
"auto-changelog": "^1.16.2",
"babel-eslint": "^10.0.3",
"babel-jest": "^25.1.0",
"copy": "^0.3.2",
"cross-env": "^7.0.0",
"electron": "^8.0.0",
"electron": "^8.0.1",
"electron-builder": "^22.3.2",
"electron-devtools-installer": "^2.2.4",
"eslint": "^6.8.0",
@ -59,14 +61,14 @@
"eslint-plugin-react": "^7.18.3",
"identity-obj-proxy": "^3.0.0",
"jest": "^25.1.0",
"next": "^9.2.1",
"next": "^9.2.2",
"prettier": "^1.19.1",
"prettier-stylelint": "^0.4.2",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-pose": "^4.0.10",
"release-it": "^12.4.3",
"stylelint": "^13.1.0",
"release-it": "^12.6.1",
"stylelint": "^13.2.0",
"stylelint-config-css-modules": "^2.2.0",
"stylelint-config-standard": "^20.0.0"
},

View File

@ -3,6 +3,7 @@ import PropTypes from 'prop-types'
import posed, { PoseGroup } from 'react-pose'
import shortid from 'shortid'
import AppProvider from './store/AppProvider'
import PriceProvider from './store/PriceProvider'
import { defaultAnimation } from './components/Animations'
import Titlebar from './components/Titlebar'
import styles from './Layout.module.css'
@ -11,14 +12,16 @@ const Animation = posed.div(defaultAnimation)
export default function Layout({ children }) {
return (
<AppProvider>
{process.platform === 'darwin' && <Titlebar />}
<div className={styles.app}>
<PoseGroup animateOnMount>
<Animation key={shortid.generate()}>{children}</Animation>
</PoseGroup>
</div>
</AppProvider>
<PriceProvider>
<AppProvider>
{process.platform === 'darwin' && <Titlebar />}
<div className={styles.app}>
<PoseGroup animateOnMount>
<Animation key={shortid.generate()}>{children}</Animation>
</PoseGroup>
</div>
</AppProvider>
</PriceProvider>
)
}

View File

@ -1,7 +1,7 @@
import React, { useContext } from 'react'
import PropTypes from 'prop-types'
import posed, { PoseGroup } from 'react-pose'
import { AppContext } from '../../store/createContext'
import { AppContext, PriceContext } from '../../store/createContext'
import { cryptoFormatter } from '../../../utils'
import stylesIndex from '../../pages/index.module.css'
import styles from './Ticker.module.css'
@ -10,7 +10,7 @@ import { fadeIn } from '../Animations'
const Item = posed.div(fadeIn)
const Change = ({ currency }) => {
const { priceChanges } = useContext(AppContext)
const { priceChanges } = useContext(PriceContext)
const isNegative = JSON.stringify(priceChanges[currency]).startsWith('-')
let classes = isNegative ? styles.negative : styles.positive
@ -27,13 +27,10 @@ Change.propTypes = {
}
const Items = () => {
const {
prices,
needsConfig,
currency,
toggleCurrencies,
accentColor
} = useContext(AppContext)
const { prices } = useContext(PriceContext)
const { needsConfig, currency, toggleCurrencies, accentColor } = useContext(
AppContext
)
const activeStyle = {
backgroundColor: accentColor,

View File

@ -2,9 +2,9 @@ import React, { useEffect } from 'react'
import PropTypes from 'prop-types'
import Router from 'next/router'
// import { ipcRenderer } from 'electron'
import Layout from '../Layout'
import '../global.css'
import Layout from '../Layout'
export default function App({ Component, pageProps }) {
useEffect(() => {

View File

@ -1,120 +1,81 @@
import React, { PureComponent } from 'react'
import React, { useContext, useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import ms from 'ms'
// import { ipcRenderer } from 'electron'
import Store from 'electron-store'
import { AppContext } from './createContext'
import unit from 'ethjs-unit'
import { AppContext, PriceContext } from './createContext'
import { fetchData } from '../../utils'
import { refreshInterval, conversions, oceanTokenContract } from '../../config'
// construct initial prices Map to get consistent
// order for Ticker and Touchbar
let pricesMap = new Map()
pricesMap.set('ocean', 1)
conversions.map(key => pricesMap.set(key, 0))
async function getBalance(account) {
const json = await fetchData(
`https://api.etherscan.io/api?module=account&action=tokenbalance&contractaddress=${oceanTokenContract}&address=${account}&tag=latest`
)
export default class AppProvider extends PureComponent {
static propTypes = {
children: PropTypes.any.isRequired
}
const balance = unit.fromWei(`${json.result}`, 'ether')
return balance
}
store = process.env.NODE_ENV === 'test' ? new Store() : global.store
export default function AppProvider({ children }) {
const { prices } = useContext(PriceContext)
const [isLoading, setIsLoading] = useState(true)
const [accounts, setAccounts] = useState([])
const [needsConfig, setNeedsConfig] = useState(false)
const [currency, setCurrency] = useState('ocean')
const [accentColor, setAccentColor] = useState('#f6388a')
state = {
isLoading: true,
accounts: [],
currency: 'ocean',
needsConfig: false,
prices: pricesMap,
priceChanges: Object.assign(
...conversions.map(key => ({
[key]: 0
}))
),
toggleCurrencies: currency => this.toggleCurrencies(currency),
setBalances: () => this.setBalances(),
accentColor: '#f6388a'
}
async componentDidMount() {
useEffect(() => {
// listener for accent color
global.ipcRenderer.on('accent-color', (evt, accentColor) => {
this.setState({ accentColor })
setAccentColor(accentColor)
})
}, [])
// listener for touchbar
global.ipcRenderer.on('setCurrency', (evt, currency) =>
this.state.toggleCurrencies(currency)
)
useEffect(() => {
async function init() {
await setBalances()
setIsLoading(false)
const newPrizes = await this.fetchAndSetPrices()
this.setState({ prices: newPrizes })
// listener for touchbar
global.ipcRenderer.on('setCurrency', (evt, currency) =>
toggleCurrencies(currency)
)
}
await this.setBalances()
init()
setInterval(init, ms(refreshInterval))
setInterval(this.fetchAndSetPrices, ms(refreshInterval))
setInterval(this.setBalances, ms(refreshInterval))
return () => {
clearInterval(init)
}
}, [prices])
this.setState({ isLoading: false })
}
getAccounts() {
function getAccounts() {
let accountsPref
const store = process.env.NODE_ENV === 'test' ? new Store() : global.store
if (this.store.has('accounts')) {
accountsPref = this.store.get('accounts')
!accountsPref.length
? this.setState({ needsConfig: true })
: this.setState({ needsConfig: false })
if (store.has('accounts')) {
accountsPref = store.get('accounts')
!accountsPref.length ? setNeedsConfig(true) : setNeedsConfig(false)
} else {
accountsPref = []
this.setState({ needsConfig: true })
setNeedsConfig(true)
}
return accountsPref
}
async getBalance(account) {
const json = await fetchData(
`https://api.etherscan.io/api?module=account&action=tokenbalance&contractaddress=${oceanTokenContract}&address=${account}&tag=latest`
)
const balance = json.result / 1e18 // Convert from vodka 10^18
return balance
}
fetchAndSetPrices = async () => {
const currencies = conversions.join(',')
const json = await fetchData(
`https://api.coingecko.com/api/v3/simple/price?ids=ocean-protocol&vs_currencies=${currencies}&include_24hr_change=true`
)
let newPrices = new Map(this.state.prices) // make a shallow copy of the Map
conversions.map(key => newPrices.set(key, json['ocean-protocol'][key])) // modify the copy
const newPriceChanges = await Object.assign(
...conversions.map(key => ({
[key]: json['ocean-protocol'][key + '_24h_change']
}))
)
global.ipcRenderer.send('prices-updated', Array.from(newPrices)) // convert Map to array, ipc messages seem to kill it
this.setState({ prices: newPrices, priceChanges: newPriceChanges })
return newPrices
}
setBalances = async () => {
const accountsPref = await this.getAccounts()
async function setBalances() {
let newAccounts = []
const accountsPref = await getAccounts()
for (const account of accountsPref) {
const oceanBalance = await this.getBalance(account)
const oceanBalance = await getBalance(account)
const conversionsBalance = Object.assign(
...conversions.map(key => ({
[key]: oceanBalance * this.state.prices.get(key) || 0
[key]: oceanBalance * prices.get(key) || 0
}))
)
@ -129,22 +90,30 @@ export default class AppProvider extends PureComponent {
newAccounts.push(newAccount)
}
if (newAccounts !== this.state.accounts) {
this.setState({ accounts: newAccounts })
if (newAccounts !== accounts) {
setAccounts(newAccounts)
}
}
toggleCurrencies(currency) {
const pricesNew = Array.from(this.state.prices)
function toggleCurrencies(currency) {
const pricesNew = Array.from(prices)
global.ipcRenderer.send('currency-updated', pricesNew, currency)
this.setState({ currency })
setCurrency(currency)
}
render() {
return (
<AppContext.Provider value={this.state}>
{this.props.children}
</AppContext.Provider>
)
const context = {
isLoading,
accounts,
currency,
needsConfig,
accentColor,
toggleCurrencies: currency => toggleCurrencies(currency),
setBalances: () => setBalances()
}
return <AppContext.Provider value={context}>{children}</AppContext.Provider>
}
AppProvider.propTypes = {
children: PropTypes.any.isRequired
}

View File

@ -0,0 +1,67 @@
import React, { useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import ms from 'ms'
import { PriceContext } from './createContext'
import { fetchData } from '../../utils'
import { refreshInterval, conversions } from '../../config'
export default function PriceProvider({ children }) {
// construct initial prices Map to get consistent
// order for Ticker and Touchbar
let pricesMap = new Map()
pricesMap.set('ocean', 1)
conversions.map(key => pricesMap.set(key, 0))
const [prices, setPrices] = useState(pricesMap)
const [priceChanges, setPriceChanges] = useState(
Object.assign(
...conversions.map(key => ({
[key]: 0
}))
)
)
async function fetchAndSetPrices() {
const currencies = conversions.join(',')
const json = await fetchData(
`https://api.coingecko.com/api/v3/simple/price?ids=ocean-protocol&vs_currencies=${currencies}&include_24hr_change=true`
)
let newPrices = new Map(prices) // make a shallow copy of the Map
conversions.map(key => newPrices.set(key, json['ocean-protocol'][key])) // modify the copy
const newPriceChanges = await Object.assign(
...conversions.map(key => ({
[key]: json['ocean-protocol'][key + '_24h_change']
}))
)
return { newPrices, newPriceChanges }
}
useEffect(() => {
async function init() {
const { newPrices, newPriceChanges } = await fetchAndSetPrices()
setPrices(newPrices)
setPriceChanges(newPriceChanges)
global.ipcRenderer.send('prices-updated', Array.from(newPrices)) // convert Map to array, ipc messages seem to kill it
}
init()
setInterval(init, ms(refreshInterval))
return () => {
clearInterval(init)
}
}, [])
return (
<PriceContext.Provider value={{ prices, priceChanges }}>
{children}
</PriceContext.Provider>
)
}
PriceProvider.propTypes = {
children: PropTypes.any.isRequired
}

View File

@ -1,5 +1,6 @@
import { createContext } from 'react'
const AppContext = createContext()
const PriceContext = createContext()
export { AppContext }
export { AppContext, PriceContext }

View File

@ -1,20 +1,18 @@
const { app, shell } = require('electron')
const { formatCurrency } = require('@coingecko/cryptoformat')
const axios = require('axios')
const fetchData = async url => {
try {
const response = await fetch(url)
const response = await axios(url)
if (response.status !== 200) {
return console.log('Non-200 response: ' + response.status) // eslint-disable-line
return console.error('Non-200 response: ' + response.status)
}
const json = await response.json()
if (!json) return
return json
return response.data
} catch (error) {
console.log('Error parsing json:' + error) // eslint-disable-line
console.error('Error parsing json: ' + error.message)
}
}

View File

@ -1,10 +1,11 @@
import React from 'react'
import { render } from '@testing-library/react'
import { render, wait } from '@testing-library/react'
import Layout from '../src/renderer/Layout'
describe('Layout', () => {
it('renders correctly', () => {
it('renders correctly', async () => {
const { container } = render(<Layout>Hello</Layout>)
await wait()
expect(container.firstChild).toBeInTheDocument()
})
})

View File

@ -12,7 +12,12 @@ const priceChanges = {
eth: -17.538786176215627
}
export default {
export const priceContext = {
prices,
priceChanges
}
export const appContext = {
accentColor: '#0a5fff',
accounts: [
{
@ -28,7 +33,5 @@ export default {
],
currency: 'ocean',
isLoading: false,
needsConfig: false,
prices,
priceChanges
needsConfig: false
}

View File

@ -11,6 +11,10 @@ const remote = {
getCurrentWindow: jest.fn()
}
const ipcRenderer = {
on: jest.fn()
}
// for the shell module above
const shell = {
openExternal: jest.fn()
@ -19,5 +23,6 @@ const shell = {
module.exports = {
electron,
remote,
shell
shell,
ipcRenderer
}

View File

@ -0,0 +1,3 @@
module.exports = {
has: () => jest.fn()
}

10
tests/__mocks__/global.js Normal file
View File

@ -0,0 +1,10 @@
const global = {
ipcRenderer: {
on: () => jest.fn()
},
store: {
has: () => jest.fn()
}
}
module.exports = global

View File

@ -19,7 +19,7 @@ module.exports = {
'<rootDir>/coverage'
],
testURL: 'http://localhost',
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'],
setupFilesAfterEnv: ['<rootDir>/tests/setupTests.js'],
runner: '@jest-runner/electron',
testEnvironment: '@jest-runner/electron/environment',
coverageDirectory: '../../coverage/',

View File

@ -1,15 +1,20 @@
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { AppContext } from '../../src/renderer/store/createContext'
import context from '../__fixtures__/context'
import {
AppContext,
PriceContext
} from '../../src/renderer/store/createContext'
import { appContext, priceContext } from '../__fixtures__/context'
import Home from '../../src/renderer/pages/index'
describe('Home', () => {
it('renders correctly', () => {
const { container, getByText } = render(
<AppContext.Provider value={context}>
<Home />
</AppContext.Provider>
<PriceContext.Provider value={priceContext}>
<AppContext.Provider value={appContext}>
<Home />
</AppContext.Provider>
</PriceContext.Provider>
)
expect(container.firstChild).toBeInTheDocument()
fireEvent.click(getByText(/Ξ/))
@ -18,9 +23,11 @@ describe('Home', () => {
it('renders Welcome without config', () => {
const { container } = render(
<AppContext.Provider value={{ ...context, needsConfig: true }}>
<Home />
</AppContext.Provider>
<PriceContext.Provider value={priceContext}>
<AppContext.Provider value={{ ...appContext, needsConfig: true }}>
<Home />
</AppContext.Provider>
</PriceContext.Provider>
)
expect(container.firstChild).toHaveTextContent(
'Add your first address to get started.'

3
tests/setupTests.js Normal file
View File

@ -0,0 +1,3 @@
import '@testing-library/jest-dom/extend-expect'
jest.mock('electron-store')