feat: unskinned app (#3)

This commit is contained in:
James Gareth Smith 2019-03-14 20:58:19 +02:00 committed by GitHub
parent 543c974802
commit a8b8378419
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 1055 additions and 10 deletions

View File

@ -5,6 +5,11 @@
"prettier/prettier": "error"
},
"env": {
"browser": true
"browser": true,
"es6": true,
"jest": true
},
"parserOptions": {
"ecmaVersion": 9
}
}

40
package-lock.json generated
View File

@ -3685,6 +3685,15 @@
"resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz",
"integrity": "sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg=="
},
"connected-react-router": {
"version": "6.3.2",
"resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.3.2.tgz",
"integrity": "sha512-YxrAfMExl/OBsi+ojA4ywZeC7cmQ52MnZ4bhzqLhhjuOiXcQogC4kW0kodouXAXrXDovb2l3yEhDfpH99/wYcw==",
"requires": {
"immutable": "^3.8.1",
"seamless-immutable": "^7.1.3"
}
},
"console-browserify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz",
@ -7933,6 +7942,11 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-1.10.0.tgz",
"integrity": "sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg=="
},
"immutable": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz",
"integrity": "sha1-wkOZUUVbs5kT2vKBN28VMOEErfM="
},
"import-cwd": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz",
@ -10285,6 +10299,11 @@
}
}
},
"moment": {
"version": "2.24.0",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz",
"integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg=="
},
"move-concurrently": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
@ -13882,10 +13901,18 @@
}
}
},
"react-router-redux": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/react-router-redux/-/react-router-redux-4.0.8.tgz",
"integrity": "sha1-InQDWWtRUeGCN32rg1tdRfD4BU4="
"react-router-dom": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-4.3.1.tgz",
"integrity": "sha512-c/MlywfxDdCp7EnB7YfPMOfMD3tOtIjrQlj/CKfNMBxdmpJP8xcz5P/UAFn3JbnQCNUxsHyVVqllF9LhgVyFCA==",
"requires": {
"history": "^4.7.2",
"invariant": "^2.2.4",
"loose-envify": "^1.3.1",
"prop-types": "^15.6.1",
"react-router": "^4.3.1",
"warning": "^4.0.1"
}
},
"react-scripts": {
"version": "2.1.8",
@ -15047,6 +15074,11 @@
"ajv-keywords": "^3.1.0"
}
},
"seamless-immutable": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/seamless-immutable/-/seamless-immutable-7.1.4.tgz",
"integrity": "sha512-XiUO1QP4ki4E2PHegiGAlu6r82o5A+6tRh7IkGGTVg/h+UoeX4nFBeCGPOhb4CYjvkqsfm/TUtvOMYC1xmV30A=="
},
"select-hose": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",

View File

@ -3,11 +3,15 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"connected-react-router": "^6.3.2",
"history": "^4.7.2",
"moment": "^2.24.0",
"prop-types": "^15.7.2",
"react": "^16.8.4",
"react-dom": "^16.8.4",
"react-redux": "^6.0.1",
"react-router": "^4.3.1",
"react-router-redux": "^4.0.8",
"react-router-dom": "^4.3.1",
"react-scripts": "2.1.8",
"redux": "^4.0.1",
"reselect": "^4.0.0"

View File

View File

@ -0,0 +1,14 @@
import React from 'react'
import { DappListModel } from '../../utils/models'
import DappListItem from '../DappListItem'
const DappList = props => {
const { dapps } = props
return dapps && dapps.map(dapp => <DappListItem {...dapp} key={dapp.name} />)
}
DappList.propTypes = {
dapps: DappListModel.isRequired,
}
export default DappList

View File

@ -0,0 +1,3 @@
import DappList from './DappList'
export default DappList

View File

@ -0,0 +1,11 @@
import React from 'react'
import { DappModel } from '../../utils/models'
const DappListItem = props => {
const { name } = props
return <div>{name}</div>
}
DappListItem.propTypes = DappModel
export default DappListItem

View File

@ -0,0 +1,3 @@
import DappListItem from './DappListItem'
export default DappListItem

View File

@ -0,0 +1,6 @@
export const EXCHANGES = 'EXCHANGES'
export const MARKETPLACES = 'MARKETPLACES'
export const GAMES = 'GAMES'
export const SOCIAL_NETWORKS = 'SOCIAL_NETWORKS'
export const MEDIA = 'MEDIA'
export const SOCIAL_UTILITIES = 'SOCIAL_UTILITIES'

369
src/common/data/dapps.js Normal file
View File

@ -0,0 +1,369 @@
import * as Categories from './categories'
const Dapps = [
{
name: 'Airswap',
url: 'https://instant.airswap.io/',
image: null,
description: 'Meet the future of trading.',
category: Categories.EXCHANGES,
dateAdded: null,
},
{
name: 'Bancor',
url: 'https://www.bancor.network/',
image: null,
description: 'Bancor is a decentralized liquidity network',
category: Categories.EXCHANGES,
dateAdded: null,
},
{
name: 'ERC dEX',
url: 'https://app.ercdex.com/',
description: 'Trustless trading has arrived on Ethereum',
image: null,
category: Categories.EXCHANGES,
dateAdded: null,
},
{
name: 'Kyber',
url: 'https://web3.kyber.network',
description:
'On-chain, instant and liquid platform for exchange and payment service',
image: null,
category: Categories.EXCHANGES,
dateAdded: null,
},
{
name: 'Oasis Direct',
url: 'https://oasis.direct/',
description: 'The first decentralized instant exchange',
image: null,
category: Categories.EXCHANGES,
dateAdded: null,
},
{
name: 'DAI by MakerDao',
url: 'https://dai.makerdao.com',
description: 'Stability for the blockchain',
image: null,
category: Categories.EXCHANGES,
dateAdded: null,
},
{
name: 'LocalEthereum',
url: 'https://localethereum.com/',
description: 'The smartest way to buy and sell Ether',
image: null,
category: Categories.EXCHANGES,
dateAdded: null,
},
{
name: 'Eth2phone',
url: 'https://eth2.io',
description: 'Send Ether by phone number',
image: null,
category: Categories.EXCHANGES,
dateAdded: null,
},
{
name: 'DDEX',
url: 'https://ddex.io/',
description:
'Instant, real-time order matching with secure on-chain settlement',
image: null,
category: Categories.EXCHANGES,
dateAdded: null,
},
{
name: 'EasyTrade',
url: 'https://easytrade.io',
description: 'One exchange for every token',
image: null,
category: Categories.EXCHANGES,
dateAdded: null,
},
{
name: 'slow.trade',
url: 'https://slow.trade/',
description:
'Trade fairly priced crypto assets on the first platform built with the DutchX protocol.',
image: null,
category: Categories.EXCHANGES,
dateAdded: null,
},
{
name: 'blockimmo',
url: 'https://blockimmo.ch',
description:
'blockimmo is a blockchain powered, regulated platform enabling shared property investments and ownership.',
image: null,
category: Categories.MARKETPLACES,
dateAdded: null,
},
{
name: 'CryptoCribs',
url: 'https://cryptocribs.com',
description: 'Travel the globe. Pay in crypto.',
image: null,
category: Categories.MARKETPLACES,
dateAdded: null,
},
{
name: 'Ethlance',
url: 'https://ethlance.com',
description:
'The future of work is now. Hire people or work yourself in return for ETH.',
image: null,
category: Categories.MARKETPLACES,
dateAdded: null,
},
{
name: 'OpenSea',
url: 'https://opensea.io',
description: 'The largest decentralized marketplace for cryptogoods',
image: null,
category: Categories.MARKETPLACES,
dateAdded: null,
},
{
name: 'Name Bazaar',
url: 'https://namebazaar.io',
description: 'ENS name marketplace',
image: null,
category: Categories.MARKETPLACES,
dateAdded: null,
},
{
name: 'The Bounties Network',
url: 'https://bounties.network/',
description: 'Bounties on any task, paid in any token',
image: null,
category: Categories.MARKETPLACES,
dateAdded: null,
},
{
name: 'Emoon',
url: 'https://www.emoon.io/',
description:
'A decentralized marketplace for buying & selling crypto assets',
image: null,
category: Categories.MARKETPLACES,
dateAdded: null,
},
{
name: 'SuperRare',
url: 'https://superrare.co/market',
description:
'Buy, sell and collect unique digital creations by artists around the world',
image: null,
category: Categories.MARKETPLACES,
dateAdded: null,
},
{
name: 'CryptoKitties',
url: 'https://www.cryptokitties.co',
description: 'Collect and breed adorable digital cats.',
image: null,
category: Categories.GAMES,
dateAdded: null,
},
{
name: 'CryptoFighters',
url: 'https://cryptofighters.io',
description: 'Collect train and fight digital fighters.',
image: null,
category: Categories.GAMES,
dateAdded: null,
},
{
name: 'Cryptographics',
url: 'https://cryptographics.app/',
description:
'Cryptographics is a digital art hub where artists, creators and collectors can submit asset packs, create unique cryptographics and trade them.',
image: null,
category: Categories.GAMES,
dateAdded: null,
},
{
name: 'CryptoPunks',
url: 'https://www.larvalabs.com/cryptopunks',
description: '10,000 unique collectible punks',
image: null,
category: Categories.GAMES,
dateAdded: null,
},
{
name: 'Crypto Takeovers',
url: 'https://cryptotakeovers.com/',
description: 'Predict and conquer the world. Make a crypto fortune.',
image: null,
category: Categories.GAMES,
dateAdded: null,
},
{
name: 'Decentraland',
url: 'https://market.decentraland.org/',
description:
'Decentraland is a virtual reality platform powered by the Ethereum blockchain.',
image: null,
category: Categories.GAMES,
dateAdded: null,
},
{
name: 'Dragonereum',
url: 'https://dapp.dragonereum.io',
description: 'Own and trade dragons, fight with other players.',
image: null,
category: Categories.GAMES,
dateAdded: null,
},
{
name: 'Etherbots',
url: 'https://etherbots.io/',
description: 'Robot wars on the Ethereum Platform',
image: null,
category: Categories.GAMES,
dateAdded: null,
},
{
name: 'Etheremon',
url: 'https://www.etheremon.com/',
description: 'Decentralized World of Ether Monsters',
image: null,
category: Categories.GAMES,
dateAdded: null,
},
{
name: 'CryptoStrikers',
url: 'https://www.cryptostrikers.com/',
description: 'The Beautiful (card) Game',
image: null,
category: Categories.GAMES,
dateAdded: null,
},
{
name: 'Cent',
url: 'https://beta.cent.co/',
description: 'Get wisdom, get money',
image: null,
category: Categories.SOCIAL_NETWORKS,
dateAdded: null,
},
{
name: 'Kickback',
url: 'https://kickback.events/',
description:
'Event no shows? No problem. Kickback asks event attendees to put skin in the game with Ethereum.',
image: null,
category: Categories.SOCIAL_NETWORKS,
dateAdded: null,
},
{
name: 'Peepeth',
url: 'https://peepeth.com/',
description: 'Blockchain-powered microblogging',
image: null,
category: Categories.SOCIAL_NETWORKS,
dateAdded: null,
},
{
name: 'Purrbook',
url: 'https://cryptopurr.co/',
description: 'A social network for CryptoKitties',
image: null,
category: Categories.SOCIAL_NETWORKS,
dateAdded: null,
},
{
name: 'livepeer.tv',
url: 'http://livepeer.tv/',
description: 'Decentralized video broadcasting',
image: null,
category: Categories.MEDIA,
dateAdded: null,
},
{
name: '3Box',
url: 'https://3box.io/',
description: 'Create and manage your Ethereum Profile.',
image: null,
category: Categories.SOCIAL_UTILITIES,
dateAdded: null,
},
{
name: 'Aragon',
url: 'https://mainnet.aragon.org/',
description: 'Build unstoppable organizations on Ethereum.',
image: null,
category: Categories.SOCIAL_UTILITIES,
dateAdded: null,
},
{
name: 'Civitas',
url: 'https://communities.colu.com/',
description: 'Blockchain-powered local communities',
image: null,
category: Categories.SOCIAL_UTILITIES,
dateAdded: null,
},
{
name: 'ETHLend',
url: 'https://app.ethlend.io',
description: 'Decentralized lending on Ethereum',
image: null,
category: Categories.SOCIAL_UTILITIES,
dateAdded: null,
},
{
name: 'Hexel',
url: 'https://www.onhexel.com/',
description: 'Create your own cryptocurrency',
image: null,
category: Categories.SOCIAL_UTILITIES,
dateAdded: null,
},
{
name: 'Livepeer',
url: 'https://explorer.livepeer.org/',
description: 'Decentralized video broadcasting',
image: null,
category: Categories.SOCIAL_UTILITIES,
dateAdded: null,
},
{
name: 'Smartz',
url: 'https://smartz.io',
description: 'Easy smart contract management',
image: null,
category: Categories.SOCIAL_UTILITIES,
dateAdded: null,
},
{
name: 'SNT Voting DApp',
url: 'https://vote.status.im',
description:
'Let your SNT be heard! Vote on decisions exclusive to SNT holders, or create a poll of your own.',
image: null,
category: Categories.SOCIAL_UTILITIES,
dateAdded: null,
},
{
name: 'Status Test DApp',
url: 'simpledapp.eth',
description: 'Request test assets and test basic web3 functionality.',
image: null,
category: Categories.SOCIAL_UTILITIES,
dateAdded: null,
},
]
export default Dapps

View File

@ -0,0 +1,11 @@
import { combineReducers } from 'redux'
import { connectRouter } from 'connected-react-router'
import dapps from '../../modules/Dapps/Dapps.reducer'
import selectedCategory from '../../modules/CategorySelector/CategorySelector.reducer'
export default history =>
combineReducers({
router: connectRouter(history),
dapps,
selectedCategory,
})

19
src/common/redux/store.js Normal file
View File

@ -0,0 +1,19 @@
import { compose, createStore, applyMiddleware } from 'redux'
import { routerMiddleware } from 'connected-react-router'
import { createBrowserHistory } from 'history'
import reducer from './reducers'
export const history = createBrowserHistory()
const composeWithDevTools =
/* eslint-disable-next-line no-underscore-dangle */
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
const configureStore = () =>
createStore(
reducer(history),
{},
composeWithDevTools(applyMiddleware(routerMiddleware(history))),
)
export default configureStore

View File

@ -0,0 +1,7 @@
import * as Categories from '../data/categories'
import humanise from './humanise'
export default Object.entries(Categories).map(entry => ({
key: entry[1],
value: humanise(entry[1]),
}))

View File

@ -0,0 +1,32 @@
import categories from './categories'
describe('categories', () => {
test('it should return the correct data structure of categories', () => {
expect(categories).toEqual([
{
key: 'EXCHANGES',
value: 'Exchanges',
},
{
key: 'MARKETPLACES',
value: 'Marketplaces',
},
{
key: 'GAMES',
value: 'Games',
},
{
key: 'SOCIAL_NETWORKS',
value: 'Social Networks',
},
{
key: 'MEDIA',
value: 'Media',
},
{
key: 'SOCIAL_UTILITIES',
value: 'Social Utilities',
},
])
})
})

View File

@ -0,0 +1,7 @@
const humanise = value =>
value
.split('_')
.map(word => `${word[0]}${word.slice(1).toLowerCase()}`)
.join(' ')
export default humanise

View File

@ -0,0 +1,11 @@
import humanise from './humanise'
describe('humanise', () => {
test('it should transform a constant string for human reading', () => {
const first = 'TEST'
const second = 'ANOTHER_TEST'
expect(humanise(first)).toEqual('Test')
expect(humanise(second)).toEqual('Another Test')
})
})

View File

@ -0,0 +1,12 @@
import PropTypes from 'prop-types'
export const DappModel = {
name: PropTypes.string,
url: PropTypes.string,
image: PropTypes.string,
description: PropTypes.string,
category: PropTypes.string,
dateAdded: PropTypes.string,
}
export const DappListModel = PropTypes.arrayOf(PropTypes.shape(DappModel))

View File

@ -0,0 +1,11 @@
export default (map, defaultState) => (currentState, action) => {
const state = !currentState ? defaultState : currentState
if (!action) {
return state
}
return Object.keys(map).includes(action.type)
? map[action.type](state, action.payload)
: state
}

View File

@ -0,0 +1,50 @@
import util from './reducer'
describe('reducer utility', () => {
const reducers = {
TEST_ACTION: (state, payload) => ({
...state,
...payload,
}),
}
const state = { foo: 'bar' }
const action = { type: 'TEST_ACTION', payload: { baz: true } }
const missingAction = { type: 'MISSING_ACTION' }
const reducer = util(reducers, state)
test("it should return the existing state when the action doesn't exist", () => {
// Given an existing state
// And an action we can't reduce
// We should expect the function to pass through the existing state
expect(reducer(state, missingAction)).toEqual({ foo: 'bar' })
})
test('it should call the correct reducer when the action exists', () => {
// Given an existing state
// And an action we can reduce
// We expect the function to return the correctly reduced new state
expect(reducer(state, action)).toEqual({
foo: 'bar',
baz: true,
})
})
test('it should return the default state if the existing state is null', () => {
// Given a null state
// And an action we can't reduce
// We expect the default state
expect(reducer(null, missingAction)).toEqual({ foo: 'bar' })
})
test('it should return the existing state if the action is null', () => {
// Given a null state
// And an action that is undefined
// We expect the default state
expect(reducer(state, undefined)).toEqual({ foo: 'bar' })
})
})

View File

@ -1,5 +1,17 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import { ConnectedRouter } from 'connected-react-router'
import App from './modules/App'
import configureStore, { history } from './common/redux/store'
ReactDOM.render(<App />, document.getElementById('root'))
const store = configureStore()
ReactDOM.render(
<Provider store={store}>
<ConnectedRouter history={history}>
<App />
</ConnectedRouter>
</Provider>,
document.getElementById('root'),
)

View File

@ -0,0 +1,15 @@
import React from 'react'
import { Route, Switch } from 'react-router-dom'
import Home from '../Home'
import Filtered from '../Filtered'
import RecentlyAdded from '../RecentlyAdded'
import Dapps from '../Dapps'
export default () => (
<Switch>
<Route exact path="/" component={Home} />
<Route path="/categories" component={Filtered} />
<Route path="/all" component={Dapps} />
<Route path="/recently-added" component={RecentlyAdded} />
</Switch>
)

3
src/modules/App/index.js Normal file
View File

@ -0,0 +1,3 @@
import Router from './Router'
export default Router

View File

@ -1,3 +0,0 @@
import React from 'react'
export default () => <h1>The app</h1>

View File

@ -0,0 +1,16 @@
import { connect } from 'react-redux'
import { push } from 'connected-react-router'
import { selectCategory } from '../CategorySelector/CategorySelector.reducer'
import Categories from './Categories'
const mapDispatchToProps = dispatch => ({
select: category => {
dispatch(push('/categories'))
dispatch(selectCategory(category))
},
})
export default connect(
null,
mapDispatchToProps,
)(Categories)

View File

@ -0,0 +1,28 @@
import React from 'react'
import PropTypes from 'prop-types'
import categories from '../../common/utils/categories'
const Categories = props => {
const { select } = props
const handleClick = category => select(category)
return (
<>
{categories.map(category => (
<button
key={category.key}
type="button"
onClick={handleClick.bind(this, category.key)}
>
{category.value}
</button>
))}
</>
)
}
Categories.propTypes = {
select: PropTypes.func.isRequired,
}
export default Categories

View File

@ -0,0 +1,3 @@
import Categories from './Categories.container'
export default Categories

View File

@ -0,0 +1,13 @@
import { connect } from 'react-redux'
import CategorySelector from './CategorySelector'
import { selectCategory } from './CategorySelector.reducer'
const mapStateToProps = state => ({ category: state.selectedCategory })
const mapDispatchToProps = dispatch => ({
select: category => dispatch(selectCategory(category)),
})
export default connect(
mapStateToProps,
mapDispatchToProps,
)(CategorySelector)

View File

@ -0,0 +1,30 @@
import React from 'react'
import PropTypes from 'prop-types'
import categories from '../../common/utils/categories'
const CategorySelector = props => {
const { category, select } = props
const handleChange = event => select(event.target.value)
return (
<select value={category || ''} onChange={handleChange}>
<option style={{ display: 'none' }} />
{categories.map(c => (
<option key={c.key} value={c.key}>
{c.value}
</option>
))}
</select>
)
}
CategorySelector.propTypes = {
category: PropTypes.string,
select: PropTypes.func.isRequired,
}
CategorySelector.defaultProps = {
category: null,
}
export default CategorySelector

View File

@ -0,0 +1,18 @@
import reducerUtil from '../../common/utils/reducer'
const UPDATE_CATEGORY = 'UPDATE_CATEGORY'
export const selectCategory = category => ({
type: UPDATE_CATEGORY,
payload: category,
})
const initialState = null
const categoryChange = (_, category) => category
const map = {
[UPDATE_CATEGORY]: categoryChange,
}
export default reducerUtil(map, initialState)

View File

@ -0,0 +1,3 @@
import CategorySelector from './CategorySelecter.container'
export default CategorySelector

View File

@ -0,0 +1,9 @@
import { connect } from 'react-redux'
import Dapps from './Dapps'
import selector from './Dapps.selector'
const mapStateToProps = state => ({
categories: selector(state),
})
export default connect(mapStateToProps)(Dapps)

View File

@ -0,0 +1,29 @@
import React from 'react'
import PropTypes from 'prop-types'
import { DappListModel } from '../../common/utils/models'
import DappList from '../../common/components/DappList'
import humanise from '../../common/utils/humanise'
const Dapps = props => {
const { categories } = props
return (
<>
<h1>All Dapps</h1>
{categories.map(category => (
<div key={category.category}>
<h2>{humanise(category.category)}</h2>
<DappList dapps={category.dapps} />
</div>
))}
</>
)
}
Dapps.propTypes = {
categories: PropTypes.arrayOf(
PropTypes.shape({ category: PropTypes.string, dapps: DappListModel }),
).isRequired,
}
export default Dapps

View File

@ -0,0 +1,3 @@
import dapps from '../../common/data/dapps'
export default (state = dapps) => state

View File

@ -0,0 +1,24 @@
import { createSelector } from 'reselect'
const getDapps = state => state.dapps
const categorisedDapps = createSelector(
[getDapps],
dapps =>
dapps.reduce((acc, current) => {
if (acc.some(i => i.category === current.category)) {
return acc.map(n =>
n.category === current.category
? {
...n,
dapps: [...n.dapps, current],
}
: n,
)
}
return [...acc, { category: current.category, dapps: [current] }]
}, []),
)
export default categorisedDapps

View File

@ -0,0 +1,46 @@
import selector from './Dapps.selector'
describe('Dapp selector', () => {
test('it should group the dapps by categories', () => {
// Given state with dapps
const state = {
dapps: [
{
name: 'DAPP_1',
category: 'CATEGORY_1',
},
{
name: 'DAPP_2',
category: 'CATEGORY_1',
},
{
name: 'DAPP_3',
category: 'CATEGORY_2',
},
{
name: 'DAPP_4',
category: 'CATEGORY_3',
},
],
}
// We should get back the dapps organised by category
expect(selector(state)).toEqual([
{
category: 'CATEGORY_1',
dapps: [
{ name: 'DAPP_1', category: 'CATEGORY_1' },
{ name: 'DAPP_2', category: 'CATEGORY_1' },
],
},
{
category: 'CATEGORY_2',
dapps: [{ name: 'DAPP_3', category: 'CATEGORY_2' }],
},
{
category: 'CATEGORY_3',
dapps: [{ name: 'DAPP_4', category: 'CATEGORY_3' }],
},
])
})
})

View File

@ -0,0 +1,3 @@
import Dapps from './Dapps.container'
export default Dapps

View File

@ -0,0 +1,9 @@
import { connect } from 'react-redux'
import Filtered from './Filtered'
import filteredDapps from './Filtered.selector'
const mapStateToProps = state => ({
dapps: filteredDapps(state),
})
export default connect(mapStateToProps)(Filtered)

View File

@ -0,0 +1,24 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { DappListModel } from '../../common/utils/models'
import CategorySelector from '../CategorySelector'
import DappList from '../../common/components/DappList'
const Filtered = props => {
const { dapps } = props
return (
<>
<h1>Filtered</h1>
<CategorySelector />
<Link to="/all">View all</Link>
<DappList dapps={dapps} />
</>
)
}
Filtered.propTypes = {
dapps: DappListModel.isRequired,
}
export default Filtered

View File

@ -0,0 +1,10 @@
import { createSelector } from 'reselect'
const getCategory = state => state.selectedCategory
const getDapps = state => state.dapps
export default createSelector(
[getCategory, getDapps],
(category, dapps) =>
category ? dapps.filter(dapp => dapp.category === category) : dapps,
)

View File

@ -0,0 +1,38 @@
import filteredDapps from './Filtered.selector'
describe('filteredDapps', () => {
const dapps = [
{
name: 'DAPP_1',
category: 'CATEGORY_1',
},
{
name: 'DAPP_2',
category: 'CATEGORY_2',
},
]
test('it should return all the dapps when the category is not set', () => {
// Given a state where the selected category is null
const state = {
dapps,
selectedCategory: null,
}
// We expect to get back all the dapps
expect(filteredDapps(state)).toEqual(dapps)
})
test('it should return only the matching dapps when the category is set', () => {
// Given a state where the selected category is set
const state = {
dapps,
selectedCategory: 'CATEGORY_1',
}
// We expect to get back only the matching dapps
expect(filteredDapps(state)).toEqual([
{ name: 'DAPP_1', category: 'CATEGORY_1' },
])
})
})

View File

@ -0,0 +1,3 @@
import Filtered from './Filtered.container'
export default Filtered

View File

@ -0,0 +1,6 @@
import { connect } from 'react-redux'
import Home from './Home'
const mapStateToProps = state => state
export default connect(mapStateToProps)(Home)

11
src/modules/Home/Home.jsx Normal file
View File

@ -0,0 +1,11 @@
import React from 'react'
import RecentlyAdded from '../RecentlyAdded'
import Categories from '../Categories'
export default () => (
<>
<h1>Home</h1>
<Categories />
<RecentlyAdded />
</>
)

View File

@ -0,0 +1,3 @@
import Home from './Home.container'
export default Home

View File

@ -0,0 +1,9 @@
import { connect } from 'react-redux'
import RecentlyAdded from './RecentlyAdded'
import recentDapps from './RecentlyAdded.selector'
const mapStateToProps = state => ({
dapps: recentDapps(state),
})
export default connect(mapStateToProps)(RecentlyAdded)

View File

@ -0,0 +1,22 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { DappListModel } from '../../common/utils/models'
import DappList from '../../common/components/DappList'
const RecentlyAdded = props => {
const { dapps } = props
return (
<>
<h1>Recently Added</h1>
<Link to="/all">View all</Link>
<DappList dapps={dapps} />
</>
)
}
RecentlyAdded.propTypes = {
dapps: DappListModel.isRequired,
}
export default RecentlyAdded

View File

@ -0,0 +1,11 @@
import { createSelector } from 'reselect'
import moment from 'moment'
const getDapps = state => state.dapps
const recentDapps = createSelector(
[getDapps],
dapps => [...dapps].sort((a, b) => moment(b.dateAdded).diff(a.dateAdded)),
)
export default recentDapps

View File

@ -0,0 +1,37 @@
import moment from 'moment'
import recentDapps from './RecentlyAdded.selector'
describe('recentDapps', () => {
test('it should return the dapps ordered by most recently added', () => {
// Given a state with multiple dapps
const today = moment().format()
const yesterday = moment()
.subtract(1, 'days')
.format()
const state = {
dapps: [
{
name: 'DAPP_1',
dateAdded: yesterday,
},
{
name: 'DAPP_2',
dateAdded: today,
},
],
}
// We expect to get the apps ordered by the dateAdded
expect(recentDapps(state)).toEqual([
{
name: 'DAPP_2',
dateAdded: today,
},
{
name: 'DAPP_1',
dateAdded: yesterday,
},
])
})
})

View File

@ -0,0 +1,3 @@
import RecentlyAdded from './RecentlyAdded.container'
export default RecentlyAdded