feat: admin panel (#29)

* feat: added kudos wall

* feat: add admin forms

* feat: add forfeit and allocate functionality

* fix: moved leaderboard out of admin panel
This commit is contained in:
Richard Ramos 2019-05-14 13:35:10 -04:00 committed by GitHub
parent 7233fb934b
commit 9d983c2b9d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 318 additions and 108 deletions

View File

@ -91,3 +91,7 @@ $input-icon-height: 20px;
.btn {
padding: 11px 36px;
}
.text-small {
font-size: 13px;
}

View File

@ -8,6 +8,7 @@ import EmbarkJS from 'Embark/EmbarkJS';
import { isAdmin } from './services/Meritocracy';
import Header from './components/Header';
import Home from './components/Home';
import Leaderboard from './components/Leaderboard';
import Admin from './components/Admin';
const MAINNET = 1;
@ -65,8 +66,8 @@ class App extends React.Component {
<Header isUserAdmin={isUserAdmin} />
<Switch>
<Route exact path="/" component={Home} />
<Route path="/leaderboard" component={Leaderboard} />
{isUserAdmin && <Route exact path="/admin" component={Admin} />}
<Redirect to="/404" />
</Switch>
</ThemeProvider>

View File

@ -1,21 +1,24 @@
/* global web3 */
import React, { Fragment } from 'react';
import { Button, Form, Alert, ListGroup, OverlayTrigger, Tooltip, Modal, Tabs, Tab, Table } from 'react-bootstrap';
import { Button, Form, Alert, ListGroup, OverlayTrigger, Tooltip, Modal, Tabs, Tab } from 'react-bootstrap';
import ValidatedForm from 'react-validation/build/form';
import Input from 'react-validation/build/input';
import { required, isAddress } from '../validators';
import { required, isAddress, isNumber, higherThan } from '../validators';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrash } from '@fortawesome/free-solid-svg-icons';
import { addContributor, getFormattedContributorList, removeContributor, getContributorData } from '../services/Meritocracy';
import { sortByAlpha, sortByAttribute, sortNullableArray } from '../utils';
import {
addContributor,
getFormattedContributorList,
removeContributor,
forfeitAllocation,
lastForfeited,
allocate
} from '../services/Meritocracy';
import { sortByAlpha } from '../utils';
import moment from 'moment';
import './admin.scss';
const sort = (orderBy) => {
if(orderBy === 'praises') return sortNullableArray('praises');
if(orderBy === 'label') return sortByAlpha('label');
return sortByAttribute(orderBy);
};
class Admin extends React.Component {
state = {
contributorName: '',
@ -27,7 +30,9 @@ class Admin extends React.Component {
showDeleteModal: false,
focusedContributorIndex: -1,
sortBy: 'label',
tab: 'admin'
tab: 'admin',
sntPerContributor: 0,
lastForfeited: null
};
async componentDidMount() {
@ -36,15 +41,7 @@ class Admin extends React.Component {
this.setState({ busy: false, contributorList });
// TODO: this can be replaced by event sourcing
contributorList.forEach(contrib => {
getContributorData(contrib.value)
.then(data => {
contrib = Object.assign(contrib, data);
this.setState({contributorList});
});
});
this.getLastForfeitDate();
} catch (error) {
this.setState({ errorMsg: error.message || error });
}
@ -54,9 +51,14 @@ class Admin extends React.Component {
this.setState({ [name]: e.target.value });
};
getLastForfeitDate = async () => {
const date = await lastForfeited();
this.setState({ lastForfeited: date });
};
addContributor = async e => {
e.preventDefault();
this.setState({ busy: true, successMsg: '' });
this.setState({ busy: true, successMsg: '', error: '' });
try {
await addContributor(this.state.contributorName, this.state.contributorAddress);
@ -69,6 +71,41 @@ class Admin extends React.Component {
}
};
allocateFunds = async e => {
e.preventDefault();
/* eslint-disable-next-line no-alert*/
if (!confirm('Are you sure?')) return;
this.setState({ busy: true, successMsg: '', error: '' });
const { contributorList, sntPerContributor } = this.state;
const sntAmount = web3.utils.toWei((contributorList.length * parseInt(sntPerContributor, 10)).toString(), 'ether');
try {
await allocate(sntAmount);
this.setState({ busy: false, successMsg: 'Funds allocated!' });
} catch (error) {
this.setState({ error: error.message || error, busy: false });
}
};
forfeit = async e => {
e.preventDefault();
/* eslint-disable-next-line no-alert*/
if (!confirm('Are you sure?')) return;
this.setState({ busy: true, successMsg: '' });
try {
await forfeitAllocation();
await this.getLastForfeitDate();
this.setState({ busy: false, successMsg: 'Funds forfeited!' });
} catch (error) {
this.setState({ error: error.message || error, busy: false });
}
};
removeContributor = (e, contributorIndex) => {
e.preventDefault();
this.setState({ focusedContributorIndex: contributorIndex, showDeleteModal: true });
@ -93,12 +130,9 @@ class Admin extends React.Component {
this.setState({ showDeleteModal: false });
};
sortBy = (order) => () => {
this.setState({sortBy: order});
}
render() {
const {
lastForfeited,
contributorAddress,
contributorName,
error,
@ -106,17 +140,19 @@ class Admin extends React.Component {
contributorList,
successMsg,
focusedContributorIndex,
sortBy,
tab
tab,
sntPerContributor
} = this.state;
const currentContributor = focusedContributorIndex > -1 ? contributorList[focusedContributorIndex] : {};
const sortedContributorList = contributorList.sort(sort(sortBy));
const nextForfeit = (lastForfeited ? lastForfeited * 1000 : new Date().getTime()) + 86400 * 6 * 1000;
const nextForfeitDate =
new Date(nextForfeit).toLocaleDateString() + ' ' + new Date(nextForfeit).toLocaleTimeString();
return (
<Fragment>
<Tabs className="home-tabs mb-3" activeKey={tab} onSelect={tab => this.setState({ tab })}>
<Tab eventKey="admin" title="Admin Panel" className="admin-panel">
<h2>Admin Panel</h2>
<Tab eventKey="admin" title="Contributors" className="admin-panel">
<h2>Contributors</h2>
{error && <Alert variant="danger">{error}</Alert>}
{successMsg && <Alert variant="success">{successMsg}</Alert>}
{busy && <Alert variant="primary">Working...</Alert>}
@ -149,11 +185,13 @@ class Admin extends React.Component {
Add
</Button>
</ValidatedForm>
<hr className="mt-5 mb-5" />
<h3>Contributor List</h3>
<ListGroup>
{contributorList.sort(sortByAlpha('label')).map((contributor, idx) => (
<ListGroup.Item key={contributor.value} action className="contributor-item">
<span className="font-weight-bold">{contributor.label}:</span> {contributor.value}
<span className="font-weight-bold">{contributor.label}:</span>{' '}
<span className="text-small">{contributor.value}</span>
<div className="contributor-controls float-right">
<OverlayTrigger placement="top" overlay={<Tooltip>Delete contributor</Tooltip>}>
<FontAwesomeIcon
@ -167,31 +205,44 @@ class Admin extends React.Component {
))}
</ListGroup>
</Tab>
<Tab eventKey="leaderboard" title="Leaderboard" className="leaderboard-panel">
<Table striped bordered hover responsive size="sm">
<thead>
<tr>
<th onClick={this.sortBy('label')}>Contributor</th>
<th onClick={this.sortBy('allocation')}>Allocation</th>
<th onClick={this.sortBy('totalReceived')}>SNT Received</th>
<th onClick={this.sortBy('totalForfeited')}>SNT Forfeited</th>
<th onClick={this.sortBy('praises')}>Praises Received</th>
</tr>
</thead>
<tbody>
{
sortedContributorList.map((contrib, i) => (
<tr key={i}>
<td>{contrib.label}</td>
<td>{contrib.allocation}</td>
<td>{contrib.totalReceived}</td>
<td>{contrib.totalForfeited}</td>
<td>{contrib.praises ? contrib.praises.length : 0}</td>
</tr>
))
}
</tbody>
</Table>
<Tab eventKey="allocation" title="Allocation" className="allocation-panel">
<h2>Allocation</h2>
{error && <Alert variant="danger">{error}</Alert>}
{successMsg && <Alert variant="success">{successMsg}</Alert>}
{busy && <Alert variant="primary">Working...</Alert>}
<ValidatedForm>
<Form.Group controlId="fundAllocation">
<Form.Label>SNT per contributor</Form.Label>
<Form.Text className="text-muted">
Total: {contributorList.length * parseInt(sntPerContributor, 10) || 0} SNT
</Form.Text>
<Input
type="text"
placeholder="0"
value={sntPerContributor}
onChange={e => this.onChange('sntPerContributor', e)}
className="form-control"
validations={[required, isNumber, higherThan.bind(null, 0)]}
/>
</Form.Group>
<Button variant="primary" onClick={this.allocateFunds}>
Allocate Funds
</Button>
</ValidatedForm>
<hr className="mt-5 mb-5" />
<ValidatedForm>
<Form.Group>
<Button variant="primary" disabled={nextForfeit > new Date().getTime()} onClick={this.forfeit}>
Forfeit Allocation
</Button>
{lastForfeited && (
<Form.Text className="text-muted">
Forfeited {moment.unix(lastForfeited).fromNow()}.<br />{' '}
{nextForfeit > new Date().getTime() && 'Can be forfeited on ' + nextForfeitDate}
</Form.Text>
)}
</Form.Group>
</ValidatedForm>
</Tab>
</Tabs>
<Modal show={this.state.showDeleteModal} onHide={this.handleClose}>

View File

@ -10,18 +10,16 @@ const Header = ({ isUserAdmin }) => (
<img alt="Logo" src={logo} className="mr-3" />
Status Meritocracy
</Navbar.Brand>
{isUserAdmin && (
<React.Fragment>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav">
<Nav className="mr-auto">
<Nav.Link href="#/">Home</Nav.Link>
<Nav.Link href="#/admin">Admin</Nav.Link>
<Nav.Link href="#/wall">The Wall</Nav.Link>
</Nav>
</Navbar.Collapse>
</React.Fragment>
)}
<React.Fragment>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav">
<Nav className="mr-auto">
<Nav.Link href="#/">Home</Nav.Link>
<Nav.Link href="#/leaderboard">Leaderboard</Nav.Link>
{isUserAdmin && <Nav.Link href="#/admin">Admin</Nav.Link>}
</Nav>
</Navbar.Collapse>
</React.Fragment>
</Navbar>
);

View File

@ -10,7 +10,8 @@ import Loading from './Loading';
import Complete from './Complete';
import Error from './Error';
import Withdrawal from './Withdrawal';
import {sortByAlpha, sortByAttribute} from '../utils';
import { sortByAlpha, sortByAttribute } from '../utils';
import Praise from './Praise';
/*
TODO:
- list praise for contributor
@ -49,16 +50,15 @@ class Home extends React.Component {
async componentDidMount() {
try {
const contributorList = (await getFormattedContributorList());
const contributorList = await getFormattedContributorList();
const currentContributor = await getCurrentContributorData();
this.setState({ busy: false, currentContributor, contributorList: contributorList.sort(sortByAlpha('label'))});
getAllPraises().then(praises => {
this.setState({praises: praises.sort(sortByAttribute('time'))});
});
this.setState({ busy: false, currentContributor, contributorList: contributorList.sort(sortByAlpha('label')) });
getAllPraises().then(praises => {
this.setState({ praises: praises.sort(sortByAttribute('time')) });
});
} catch (error) {
this.setState({ errorMsg: error.message || error });
}
@ -213,7 +213,7 @@ class Home extends React.Component {
onChangeAward={this.handleAwardChange}
onSelectContributor={this.handleContributorSelection}
onClickPlus5={this.handlePlus5}
contributorList={contributorList}
contributorList={contributorList.filter(x => x.value !== currentContributor.addr)}
selectedContributors={selectedContributors}
award={award}
isChecked={checkbox}
@ -239,14 +239,16 @@ class Home extends React.Component {
</Tab>
<Tab eventKey="wall" title="Wall">
<Container className="pt-4">
{praises.map((item, i) => <Praise key={i} individual={false} contributorList={contributorList} item={item} />)}
{praises.map((item, i) => (
<Praise key={i} individual={false} contributorList={contributorList} item={item} />
))}
</Container>
</Tab>
<Tab eventKey="withdraw" title="Withdraw" className="withdraw-panel">
{step === 'HOME' && (
<Withdrawal
onClick={this.withdrawTokens}
totalReceived={currentContributor.totalReceived}
received={currentContributor.received}
allocation={currentContributor.allocation}
contributorList={contributorList}
praises={currentContributor.praises}

View File

@ -0,0 +1,73 @@
import React from 'react';
import { Table } from 'react-bootstrap';
import { getFormattedContributorList, getContributorData } from '../services/Meritocracy';
import { sortByAlpha, sortByAttribute, sortNullableArray } from '../utils';
const sort = orderBy => {
if (orderBy === 'praises') return sortNullableArray('praises');
if (orderBy === 'label') return sortByAlpha('label');
return sortByAttribute(orderBy);
};
class Leaderboard extends React.Component {
state = {
contributorList: [],
sortBy: 'label',
errorMsg: ''
};
async componentDidMount() {
try {
const contributorList = await getFormattedContributorList();
this.setState({ contributorList });
// TODO: this can be replaced by event sourcing
contributorList.forEach(contrib => {
getContributorData(contrib.value).then(data => {
contrib = Object.assign(contrib, data);
this.setState({ contributorList });
});
});
} catch (error) {
this.setState({ errorMsg: error.message || error });
}
}
sortBy = order => () => {
this.setState({ sortBy: order });
};
render() {
const { contributorList, sortBy } = this.state;
const sortedContributorList = contributorList.sort(sort(sortBy));
return (
<React.Fragment>
<h2>Leaderboard</h2>
<Table striped bordered hover responsive size="sm">
<thead>
<tr>
<th onClick={this.sortBy('label')}>Contributor</th>
<th onClick={this.sortBy('allocation')}>Allocation</th>
<th onClick={this.sortBy('totalReceived')}>SNT Received</th>
<th onClick={this.sortBy('totalForfeited')}>SNT Forfeited</th>
<th onClick={this.sortBy('praises')}>Praises Received</th>
</tr>
</thead>
<tbody>
{sortedContributorList.map((contrib, i) => (
<tr key={i}>
<td>{contrib.label}</td>
<td>{contrib.allocation}</td>
<td>{contrib.totalReceived}</td>
<td>{contrib.totalForfeited}</td>
<td>{contrib.praises ? contrib.praises.length : 0}</td>
</tr>
))}
</tbody>
</Table>
</React.Fragment>
);
}
}
export default Leaderboard;

View File

@ -1,10 +1,11 @@
/* global web3 */
import React, {Fragment} from 'react';
import React, { Fragment } from 'react';
import moment from 'moment';
import Address from './Address';
import { Row, Col } from 'react-bootstrap';
import PropTypes from 'prop-types';
const Praise = ({contributorList, item, individual}) => {
const Praise = ({ contributorList, item, individual }) => {
const name = contributorList.find(x => x.value === item.author);
const date = moment.unix(item.time).fromNow();
return (
@ -12,14 +13,16 @@ const Praise = ({contributorList, item, individual}) => {
<Col className="mb-4 text-muted">
{!item.praise && (
<Fragment>
{(name && name.label) || <Address value={item.author} compact={true} />} {individual ? "has sent you" : "sent"}{' '}
{web3.utils.fromWei(item.amount, 'ether')} SNT {!individual && <span>to {item.destination}</span>}, <small>{date}</small>
{(name && name.label) || <Address value={item.author} compact={true} />}{' '}
{individual ? 'has sent you' : 'sent'} {web3.utils.fromWei(item.amount, 'ether')} SNT{' '}
{!individual && <span>to {item.destination}</span>}, <small>{date}</small>
</Fragment>
)}
{item.praise && (
<Fragment>
{(name && name.label) || <Address value={item.author} compact={true} />}{!individual && <span> to {item.destination}</span>}, <small>{date}</small>
{(name && name.label) || <Address value={item.author} compact={true} />}
{!individual && <span> to {item.destination}</span>}, <small>{date}</small>
<div className="chatBubble p-3">
&quot;{item.praise}&quot;
<small className="float-right">{web3.utils.fromWei(item.amount, 'ether')} SNT</small>
@ -31,4 +34,10 @@ const Praise = ({contributorList, item, individual}) => {
);
};
Praise.propTypes = {
contributorList: PropTypes.array,
individual: PropTypes.bool,
item: PropTypes.object
};
export default Praise;

View File

@ -5,12 +5,12 @@ import Praise from './Praise';
import './withdrawal.scss';
const Withdrawal = ({ totalReceived, allocation, onClick, contributorList, praises }) => (
const Withdrawal = ({ received, allocation, onClick, contributorList, praises }) => (
<Fragment>
<div className="text-center p-4">
<p className="text-muted mb-0 mt-5">You have been awarded</p>
<p className="awarded mb-0">
{totalReceived || 0} <span className="text-muted">SNT</span>
{received || 0} <span className="text-muted">SNT</span>
</p>
<p className="text-muted">Available for withdraw</p>
</div>
@ -22,15 +22,15 @@ const Withdrawal = ({ totalReceived, allocation, onClick, contributorList, prais
<p className="text-center">
<Button
variant={allocation !== '0' || totalReceived === '0' ? 'secondary' : 'primary'}
variant={allocation !== '0' || received === '0' ? 'secondary' : 'primary'}
onClick={onClick}
disabled={allocation !== '0' || totalReceived === '0'}
disabled={allocation !== '0' || received === '0'}
>
Withdraw
</Button>
</p>
{totalReceived !== '0' && parseInt(allocation, 10) > 0 && (
{received !== '0' && parseInt(allocation, 10) > 0 && (
<div className="text-muted text-left border rounded p-2 mb-2 learn-more">
<img src={info} alt="" />
<p className="m-0 p-0">

View File

@ -1,4 +1,5 @@
.chatBubble {
background: #ECEFFC;
border-radius: 8px;
word-wrap: break-word;
}

View File

@ -1,5 +1,7 @@
/*global web3*/
import Meritocracy from 'Embark/contracts/Meritocracy';
import SNT from 'Embark/contracts/SNT';
import EmbarkJS from 'Embark/EmbarkJS';
let contributorList;
@ -82,9 +84,7 @@ export async function getFormattedContributorList(hash) {
list = list.map(prepareOptions);
const registry = await Meritocracy.methods.getRegistry().call({ from: mainAccount });
list = list.filter(
contributorData => registry.includes(contributorData.value) && contributorData.value !== mainAccount
);
list = list.filter(contributorData => registry.includes(contributorData.value));
resolve(list);
} catch (error) {
@ -96,6 +96,79 @@ export async function getFormattedContributorList(hash) {
});
}
export function forfeitAllocation() {
return new Promise(async (resolve, reject) => {
const mainAccount = web3.eth.defaultAccount;
try {
const toSend = Meritocracy.methods.forfeitAllocations();
let gas = await toSend.estimateGas({ from: mainAccount });
const receipt = await toSend.send({ from: mainAccount, gas: gas + 1000 });
resolve(receipt);
} catch (error) {
const message = 'Error forfeiting allocation';
console.error(message);
console.error(error);
reject(message);
}
});
}
export function allocate(sntAmount) {
return new Promise(async (resolve, reject) => {
const mainAccount = web3.eth.defaultAccount;
try {
let toSend, gas;
const balance = web3.utils.toBN(await SNT.methods.balanceOf(mainAccount).call());
const allowance = web3.utils.toBN(await SNT.methods.allowance(mainAccount, Meritocracy.options.address).call());
if (balance.lt(web3.utils.toBN(sntAmount))) {
throw new Error('Not enough SNT');
}
if (allowance.gt(web3.utils.toBN('0')) && allowance.lt(web3.utils.toBN(sntAmount))) {
alert('Reset allowance to 0');
toSend = SNT.methods.approve(Meritocracy.options.address, '0');
gas = await toSend.estimateGas({ from: mainAccount });
await toSend.send({ from: mainAccount, gas: gas + 1000 });
}
if (allowance.eq(web3.utils.toBN('0'))) {
alert(`Approving ${web3.utils.fromWei(sntAmount, 'ether')} to meritocracy contract`);
toSend = SNT.methods.approve(Meritocracy.options.address, sntAmount);
gas = await toSend.estimateGas({ from: mainAccount });
await toSend.send({ from: mainAccount, gas: gas + 1000 });
}
alert('Allocating SNT');
toSend = Meritocracy.methods.allocate(sntAmount);
gas = await toSend.estimateGas({ from: mainAccount });
await toSend.send({ from: mainAccount, gas: gas + 1000 });
resolve(true);
} catch (error) {
let message;
if (error.message === 'Not enough SNT') {
message = 'Not enough SNT';
} else {
message = 'Error forfeiting allocation';
}
console.error(message);
console.error(error);
reject(message);
}
});
}
export function lastForfeited() {
return new Promise(async resolve => {
const date = await Meritocracy.methods.lastForfeit().call();
resolve(date);
});
}
const prepareOptions = option => {
if (option.value.match(/^0x[0-9A-Za-z]{40}$/)) {
// Address
@ -110,7 +183,7 @@ const prepareOptions = option => {
return option;
};
export async function getCurrentContributorData(){
export async function getCurrentContributorData() {
const mainAccount = web3.eth.defaultAccount;
const contribData = await getContributorData(mainAccount);
return contribData;
@ -120,7 +193,7 @@ export async function getContributorData(_address) {
const currentContributor = await getContributor(_address);
let praises = [];
for(let i = 0; i < currentContributor.praiseNum; i++){
for (let i = 0; i < currentContributor.praiseNum; i++) {
praises.push(Meritocracy.methods.getStatus(_address, i).call());
}
@ -129,30 +202,29 @@ export async function getContributorData(_address) {
}
const contribData = contributorList.find(x => x.value === _address);
if(contribData) currentContributor.name = contribData.label;
if (contribData) currentContributor.name = contribData.label;
currentContributor.praises = await Promise.all(praises);
currentContributor.allocation = web3.utils.fromWei(currentContributor.allocation, "ether");
currentContributor.totalForfeited = web3.utils.fromWei(currentContributor.totalForfeited, "ether");
currentContributor.totalReceived = web3.utils.fromWei(currentContributor.totalReceived, "ether");
currentContributor.received = web3.utils.fromWei(currentContributor.received, "ether");
currentContributor.allocation = web3.utils.fromWei(currentContributor.allocation, 'ether');
currentContributor.totalForfeited = web3.utils.fromWei(currentContributor.totalForfeited, 'ether');
currentContributor.totalReceived = web3.utils.fromWei(currentContributor.totalReceived, 'ether');
currentContributor.received = web3.utils.fromWei(currentContributor.received, 'ether');
return currentContributor;
}
export function getAllPraises() {
return new Promise(function(resolve) {
let praisesPromises = [];
let praiseNumPromises = [];
for(let i = 0; i < contributorList.length; i++){
for (let i = 0; i < contributorList.length; i++) {
praiseNumPromises.push(Meritocracy.methods.getStatusLength(contributorList[i].value).call());
}
Promise.all(praiseNumPromises).then(praiseNums => {
for(let i = 0; i < contributorList.length; i++){
for (let i = 0; i < contributorList.length; i++) {
let currPraises = [];
for(let j = 0; j < praiseNums[i]; j++){
for (let j = 0; j < praiseNums[i]; j++) {
currPraises.push(Meritocracy.methods.getStatus(contributorList[i].value, j).call());
}
praisesPromises.push(currPraises);
@ -165,17 +237,16 @@ export function getAllPraises() {
);
allPromises.then(praises => {
for(let i = 0; i < praises.length; i++){
for (let i = 0; i < praises.length; i++) {
praises[i] = praises[i].map(x => {
x.destination = contributorList[i].label;
return x;
});
});
}
resolve(praises.flat());
});
});
});
}
export async function getContributor(_address) {

View File

@ -24,7 +24,7 @@ export const isInteger = value => {
};
export const isNumber = value => {
if (Number.isNaN(value)) {
if (isNaN(value)) {
return (
<Form.Control.Feedback type="invalid" className="d-block">
This field needs to be an number