Merge branch 'develop' into proposal-arbiter

# Conflicts:
#	admin/src/components/Proposals/index.tsx
#	admin/src/store.ts
#	backend/grant/admin/views.py
#	backend/grant/proposal/models.py
This commit is contained in:
Aaron 2019-02-07 09:57:56 -06:00
commit 60575b4024
No known key found for this signature in database
GPG Key ID: 3B5B7597106F0A0E
38 changed files with 1211 additions and 307 deletions

View File

@ -15,6 +15,9 @@ import ProposalDetail from 'components/ProposalDetail';
import RFPs from 'components/RFPs';
import RFPForm from 'components/RFPForm';
import RFPDetail from 'components/RFPDetail';
import Contributions from 'components/Contributions';
import ContributionForm from 'components/ContributionForm';
import ContributionDetail from 'components/ContributionDetail';
import 'styles/style.less';
@ -41,6 +44,10 @@ class Routes extends React.Component<Props> {
<Route path="/rfps/:id/edit" component={RFPForm} />
<Route path="/rfps/:id" component={RFPDetail} />
<Route path="/rfps" component={RFPs} />
<Route path="/contributions/new" component={ContributionForm} />
<Route path="/contributions/:id/edit" component={ContributionForm} />
<Route path="/contributions/:id" component={ContributionDetail} />
<Route path="/contributions" component={Contributions} />
<Route path="/emails/:type?" component={Emails} />
</Switch>
)}

View File

@ -0,0 +1,32 @@
.ContributionDetail {
&-controls {
&-control + &-control {
margin-top: 0.8rem;
}
}
&-deet {
position: relative;
margin-bottom: 0.3rem;
overflow: auto;
&-value {
overflow: auto;
}
&-label {
font-size: 0.7rem;
opacity: 0.8;
}
}
& .ant-card,
.ant-alert,
.ant-collapse {
margin-bottom: 16px;
button + button {
margin-left: 0.5rem;
}
}
}

View File

@ -0,0 +1,95 @@
import React from 'react';
import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router';
import { Row, Col, Card, Button, Collapse, Input } from 'antd';
import store from 'src/store';
import { formatDateSeconds } from 'util/time';
import { Link } from 'react-router-dom';
import Back from 'components/Back';
import UserItem from 'components/Users/UserItem';
import ProposalItem from 'components/Proposals/ProposalItem';
import './index.less';
type Props = RouteComponentProps<any>;
class ContributionDetail extends React.Component<Props> {
componentDidMount() {
store.fetchContributionDetail(this.getIdFromQuery());
}
render() {
const id = this.getIdFromQuery();
const { contributionDetail: c, contributionDetailFetching } = store;
if (!c || (c && c.id !== id) || contributionDetailFetching) {
return 'loading proposal...';
}
const renderDeetItem = (label: string, val: React.ReactNode) => (
<div className="ContributionDetail-deet">
<div className="ContributionDetail-deet-value">
{val}
</div>
<div className="ContributionDetail-deet-label">
{label}
</div>
</div>
);
return (
<div className="ContributionDetail">
<Back to="/contributions" text="Contributions" />
<Row gutter={16}>
{/* MAIN */}
<Col span={18}>
<Collapse defaultActiveKey={['addresses', 'user', 'proposal']}>
<Collapse.Panel key="addresses" header="addresses">
<pre>{JSON.stringify(c.addresses, null, 4)}</pre>
</Collapse.Panel>
<Collapse.Panel key="user" header="user">
<UserItem {...c.user} />
</Collapse.Panel>
<Collapse.Panel key="proposal" header="proposal">
<ProposalItem {...c.proposal} />
</Collapse.Panel>
<Collapse.Panel key="json" header="json">
<pre>{JSON.stringify(c, null, 4)}</pre>
</Collapse.Panel>
</Collapse>
</Col>
{/* RIGHT SIDE */}
<Col span={6}>
{/* ACTIONS */}
<Card size="small" className="ContributionDetail-controls">
<Link to={`/contributions/${id}/edit`}>
<Button type="primary" block>Edit</Button>
</Link>
</Card>
{/* DETAILS */}
<Card title="Details" size="small">
{renderDeetItem('id', c.id)}
{renderDeetItem('created', formatDateSeconds(c.dateCreated))}
{renderDeetItem('status', c.status)}
{renderDeetItem('amount', c.amount)}
{renderDeetItem('txid', c.txId
? <Input size="small" value={c.txId} readOnly />
: <em>N/A</em>
)}
</Card>
</Col>
</Row>
</div>
);
}
private getIdFromQuery = () => {
return Number(this.props.match.params.id);
};
}
export default withRouter(view(ContributionDetail));

View File

@ -0,0 +1,9 @@
.ContributionForm {
&-buttons {
margin-top: 2rem;
.ant-btn {
margin-right: 0.5rem;
}
}
}

View File

@ -0,0 +1,170 @@
import React from 'react';
import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router';
import { Form, Input, Button, message, Spin } from 'antd';
import Exception from 'ant-design-pro/lib/Exception';
import { FormComponentProps } from 'antd/lib/form';
import { ContributionArgs, CONTRIBUTION_STATUS } from 'src/types';
import Back from 'components/Back';
import store from 'src/store';
import './index.less';
type Props = FormComponentProps & RouteComponentProps<{ id?: string }>;
class ContributionForm extends React.Component<Props> {
constructor(props: Props) {
super(props);
const id = this.getId();
if (id) {
store.fetchContributionDetail(id);
}
}
render() {
const { getFieldDecorator } = this.props.form;
let defaults: Partial<ContributionArgs> = {
proposalId: '',
userId: '',
amount: '',
txId: '',
};
const id = this.getId();
const contribution = this.getContribution();
if (id) {
if (store.contributionDetailFetching) {
return <Spin />;
}
if (!contribution) {
return <Exception type="404" desc="This contribution does not exist" />;
}
defaults = {
proposalId: contribution.proposal.proposalId,
userId: contribution.user.userid,
amount: contribution.amount,
txId: contribution.txId || '',
};
}
return (
<Form className="ContributionForm" layout="vertical" onSubmit={this.handleSubmit}>
<Back to="/contributions" text="Contributions" />
<Form.Item label="Proposal ID">
{getFieldDecorator('proposalId', {
initialValue: defaults.proposalId,
rules: [
{ required: true, message: 'Proposal ID is required' },
],
})(
<Input
autoComplete="off"
name="proposalId"
placeholder="Must be an existing proposal id"
autoFocus
/>,
)}
</Form.Item>
<Form.Item label="User ID">
{getFieldDecorator('userId', {
initialValue: defaults.userId,
rules: [
{ required: true, message: 'User ID is required' },
],
})(
<Input
autoComplete="off"
name="userId"
placeholder="Must be an existing user id"
/>,
)}
</Form.Item>
<Form.Item label="Contribution amount">
{getFieldDecorator('amount', {
initialValue: defaults.amount,
rules: [
{ required: true, message: 'Must have an amount specified' },
],
})(
<Input
autoComplete="off"
name="amount"
placeholder="Amount in ZEC, no more than 4 decimals"
/>,
)}
</Form.Item>
<Form.Item
label="Transaction ID"
help={`
Providing a txid will set status to CONFIRMED, leaving
blank will set status to PENDING.
`}>
{getFieldDecorator('txId', {
initialValue: defaults.txId,
})(
<Input
autoComplete="off"
name="amount"
placeholder="e.g. 7ae7bc1759a2bb9aa40b34daa3..."
/>,
)}
</Form.Item>
<div className="ContributionForm-buttons">
<Button type="primary" htmlType="submit" size="large">
Submit
</Button>
<Button type="ghost" size="large">
Cancel
</Button>
</div>
</Form>
);
}
private getId = () => {
const id = this.props.match.params.id;
if (id) {
return parseInt(id, 10);
}
};
private getContribution = () => {
const id = this.getId();
if (id && store.contributionDetail && store.contributionDetail.id === id) {
return store.contributionDetail;
}
};
private handleSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
this.props.form.validateFieldsAndScroll(async (err: any, values: any) => {
if (err) return;
const id = this.getId();
const args = {
...values,
status: values.txId
? CONTRIBUTION_STATUS.CONFIRMED
: CONTRIBUTION_STATUS.PENDING,
};
let msg;
if (id) {
await store.editContribution(id, args);
msg = 'Successfully updated contribution';
} else {
await store.createContribution(args);
msg = 'Successfully created contribution';
}
if (store.contributionSaved) {
message.success(msg, 3);
this.props.history.replace('/contributions');
}
});
};
}
export default Form.create()(withRouter(view(ContributionForm)));

View File

@ -0,0 +1,31 @@
.ContributionItem {
& h2 {
font-size: 1.1rem;
line-height: 1.8rem;
margin-bottom: 0.2rem;
> small {
opacity: 0.5;
}
& .ant-tag {
vertical-align: text-top;
margin-top: -0.1rem;
margin-left: 0.5rem;
}
}
& p {
font-size: 0.8rem;
color: rgba(#000, 0.5);
margin: 0;
> span {
margin-right: 0.5rem;
&:last-child {
margin: 0;
}
}
}
}

View File

@ -0,0 +1,38 @@
import React from 'react';
import { List, Tag, Tooltip } from 'antd';
import { Link } from 'react-router-dom';
import { Contribution } from 'src/types';
import { CONTRIBUTION_STATUSES, getStatusById } from 'util/statuses';
import { formatDateSeconds } from 'util/time';
import './ContributionItem.less';
interface Props {
contribution: Contribution;
}
export default class ContributionItem extends React.PureComponent<Props> {
render() {
const { id, amount, dateCreated, proposal, user } = this.props.contribution;
const status = getStatusById(CONTRIBUTION_STATUSES, this.props.contribution.status);
return (
<List.Item
className="ContributionItem"
actions={[<Link key="edit" to={`/contributions/${id}/edit`}>edit</Link>]}
>
<Link to={`/contributions/${id}`}>
<h2>
{user.displayName} <small>for</small> {proposal.title}
<Tooltip title={status.hint}>
<Tag color={status.tagColor}>{status.tagDisplay}</Tag>
</Tooltip>
</h2>
<p>
<span><strong>Amount:</strong> {amount} ZEC</span>
<span><strong>Created:</strong> {formatDateSeconds(dateCreated)}</span>
</p>
</Link>
</List.Item>
);
}
}

View File

@ -0,0 +1,38 @@
import React from 'react';
import { view } from 'react-easy-state';
import { Button } from 'antd';
import { Link } from 'react-router-dom';
import store from 'src/store';
import Pageable from 'components/Pageable';
import ContributionItem from './ContributionItem';
import { Contribution } from 'src/types';
import { PROPOSAL_STATUSES } from 'util/statuses';
class Contributions extends React.Component<{}> {
render() {
const { page } = store.contributions;
// NOTE: sync with /backend ... pagination.py ContributionPagination.SORT_MAP
const sorts = ['CREATED:DESC', 'CREATED:ASC', 'AMOUNT:DESC', 'AMOUNT:ASC'];
return (
<Pageable
page={page}
statuses={PROPOSAL_STATUSES}
sorts={sorts}
searchPlaceholder="Search amount or txid"
controlsExtra={
<Link to="/contributions/new">
<Button icon="plus">Create a contribution</Button>
</Link>
}
renderItem={(c: Contribution) =>
<ContributionItem key={c.id} contribution={c} />
}
handleSearch={store.fetchContributions}
handleChangeQuery={store.setContributionPageQuery}
handleResetQuery={store.resetContributionPageQuery}
/>
);
}
}
export default view(Contributions);

View File

@ -1,4 +1,4 @@
.Proposals {
.Pageable {
&-controls {
margin-bottom: 0.5rem;
@ -10,6 +10,10 @@
display: inline-block;
max-width: 300px;
}
&-extra {
float: right;
}
}
&-filters {

View File

@ -0,0 +1,180 @@
import React from 'react';
import qs from 'query-string';
import { uniq, without } from 'lodash';
import { Icon, Button, Dropdown, Menu, Tag, List, Input, Pagination } from 'antd';
import { ClickParam } from 'antd/lib/menu';
import { RouteComponentProps, withRouter } from 'react-router';
import { PageData, PageQuery } from 'src/types';
import { StatusSoT, getStatusById } from 'util/statuses';
import './index.less';
interface OwnProps<T> {
page: PageData<T>;
statuses: Array<StatusSoT<any>>;
sorts: string[];
searchPlaceholder?: string;
controlsExtra?: React.ReactNode;
renderItem(item: T): React.ReactNode;
handleSearch(): void;
handleChangeQuery(query: Partial<PageQuery>): void;
handleResetQuery(): void;
}
type Props<T> = OwnProps<T> & RouteComponentProps<any>;
class Pageable<T> extends React.Component<Props<T>, {}> {
componentDidMount() {
this.setQueryFromUrl();
this.props.handleSearch();
}
render() {
const { page, statuses, sorts, renderItem, searchPlaceholder, controlsExtra } = this.props;
const loading = !page.fetched || page.fetching;
const filters = page.filters
.filter(f => f.startsWith('STATUS_'))
.map(f => f.replace('STATUS_', ''));
const statusFilterMenu = (
<Menu onClick={this.handleFilterClick}>
{statuses.map(s => (
<Menu.Item key={s.id}>{s.filterDisplay}</Menu.Item>
))}
</Menu>
);
const sortMenu = (
<Menu onClick={this.handleSortClick}>
{sorts.map(s => (
<Menu.Item key={s}>{s}</Menu.Item>
))}
</Menu>
);
return (
<div className="Pageable">
<div className="Pageable-controls">
<Input.Search
className="Pageable-controls-search"
placeholder={searchPlaceholder}
onSearch={this.handleSearch}
/>
<Dropdown overlay={statusFilterMenu} trigger={['click']}>
<Button>
Filter <Icon type="down" />
</Button>
</Dropdown>
<Dropdown overlay={sortMenu} trigger={['click']}>
<Button>
{'Sort ' + page.sort} <Icon type="down" />
</Button>
</Dropdown>
<Button title="refresh" icon="reload" onClick={this.props.handleSearch} />
{controlsExtra && (
<div className="Pageable-controls-extra">
{controlsExtra}
</div>
)}
</div>
{page.search && (
<div>
Search: <b>{page.search}</b>
</div>
)}
{!!page.filters.length && (
<div className="Pageable-filters">
Filters:{' '}
{filters.map(sf => (
<Tag
key={sf}
onClose={() => this.handleFilterClose(sf)}
color={getStatusById(statuses, sf).tagColor}
closable
>
status: {sf}
</Tag>
))}
{filters.length > 1 && (
<Tag key="clear" onClick={this.handleFilterClear}>
clear
</Tag>
)}
</div>
)}
<List
className="Pageable-list"
bordered
dataSource={page.items}
loading={loading}
renderItem={renderItem}
/>
<div className="Pageable-pagination">
<Pagination
current={page.page}
total={page.total}
pageSize={page.pageSize}
onChange={this.handlePageChange}
hideOnSinglePage={true}
/>
</div>
</div>
);
}
private setQueryFromUrl = () => {
const { history, statuses, match, handleResetQuery, handleChangeQuery } = this.props;
const parsed = qs.parse(history.location.search);
// status filter
if (parsed.status) {
if (getStatusById(statuses, parsed.status)) {
// here we reset to normal page query params, we might want
// to do this every time we load or leave the component
handleResetQuery();
handleChangeQuery({ filters: [`STATUS_${parsed.status}`] });
}
history.replace(match.url); // remove qs
}
};
private handleSortClick = (e: ClickParam) => {
this.props.handleChangeQuery({ sort: e.key });
this.props.handleSearch();
};
private handleFilterClick = (e: ClickParam) => {
const { page, handleChangeQuery, handleSearch } = this.props;
handleChangeQuery({
filters: uniq([`STATUS_${e.key}`, ...page.filters])
});
handleSearch();
};
private handleFilterClose = (filter: string) => {
const { page, handleChangeQuery, handleSearch } = this.props;
handleChangeQuery({ filters: without(page.filters, `STATUS_${filter}`) });
handleSearch();
};
private handleFilterClear = () => {
this.props.handleChangeQuery({ filters: [] });
this.props.handleSearch();
};
private handleSearch = (s: string) => {
this.props.handleChangeQuery({ search: s });
this.props.handleSearch();
};
private handlePageChange = (p: number) => {
this.props.handleChangeQuery({ page: p });
this.props.handleSearch();
};
}
export default withRouter(Pageable);

View File

@ -1,208 +1,29 @@
import { uniq, without } from 'lodash';
import React from 'react';
import qs from 'query-string';
import { view } from 'react-easy-state';
import { Icon, Button, Dropdown, Menu, Tag, List, Input, Pagination } from 'antd';
import { ClickParam } from 'antd/lib/menu';
import { RouteComponentProps, withRouter } from 'react-router';
import store from 'src/store';
import ProposalItem from './ProposalItem';
import { PROPOSAL_STATUS, Proposal } from 'src/types';
import { PROPOSAL_STATUSES, getStatusById } from 'util/statuses';
import { PROPOSAL_OTHER_FILTERS, getProposalOtherFilterById } from 'util/filters';
import './index.less';
type Props = RouteComponentProps<any>;
class ProposalsNaked extends React.Component<Props, {}> {
componentDidMount() {
this.setStoreFromQueryString();
store.fetchProposals();
}
import Pageable from 'components/Pageable';
import { Proposal } from 'src/types';
import { PROPOSAL_STATUSES } from 'util/statuses';
class Proposals extends React.Component<{}> {
render() {
const { page } = store.proposals;
const loading = !page.fetched || page.fetching;
const filterCount = page.filters.status.length + page.filters.other.length;
const statusFilterMenu = (
<Menu onClick={this.handleFilterClick}>
{PROPOSAL_STATUSES.map(f => (
<Menu.Item key={'s_' + f.id}>{f.filterDisplay}</Menu.Item>
))}
{PROPOSAL_OTHER_FILTERS.map(f => (
<Menu.Item key={'o_' + f.id}>{f.filterDisplay}</Menu.Item>
))}
</Menu>
);
const sortMenu = (
<Menu onClick={this.handleSortClick}>
{/* NOTE: sync with /backend ... pagination.py ProposalPagination.SORT_MAP */}
{['CREATED:DESC', 'CREATED:ASC', 'PUBLISHED:DESC', 'PUBLISHED:ASC'].map(s => (
<Menu.Item key={s}>{s}</Menu.Item>
))}
</Menu>
);
// NOTE: sync with /backend ... pagination.py ProposalPagination.SORT_MAP
const sorts = ['CREATED:DESC', 'CREATED:ASC', 'PUBLISHED:DESC', 'PUBLISHED:ASC'];
return (
<div className="Proposals">
<div className="Proposals-controls">
<Input.Search
className="Proposals-controls-search"
placeholder="search titles"
onSearch={this.handleSearch}
/>
<Dropdown overlay={statusFilterMenu} trigger={['click']}>
<Button>
Filter <Icon type="down" />
</Button>
</Dropdown>
<Dropdown overlay={sortMenu} trigger={['click']}>
<Button>
{'Sort ' + store.proposals.page.sort} <Icon type="down" />
</Button>
</Dropdown>
<Button title="refresh" icon="reload" onClick={store.fetchProposals} />
</div>
{page.search && (
<div>
Search: <b>{page.search}</b>
</div>
)}
{!!filterCount && (
<div className="Proposals-filters">
Filters:{' '}
{page.filters.status.map(x => getStatusById(PROPOSAL_STATUSES, x)).map(sf => (
<Tag
key={sf.id}
onClose={() => this.handleStatusFilterClose(sf.id)}
color={sf.tagColor}
closable
>
{sf.filterDisplay}
</Tag>
))}
{page.filters.other.map(x => getProposalOtherFilterById(x)).map(of => (
<Tag
key={of.id}
onClose={() => this.handleOtherFilterClose(of.id)}
color={of.tagColor}
closable
>
{of.filterDisplay}
</Tag>
))}
{filterCount > 1 && (
<Tag key="clear" onClick={this.handleFilterClear}>
clear
</Tag>
)}
</div>
)}
<List
className="Proposals-list"
bordered
dataSource={page.items}
loading={loading}
renderItem={(p: Proposal) => <ProposalItem key={p.proposalId} {...p} />}
/>
<div className="Proposals-pagination">
<Pagination
current={page.page}
total={page.total}
pageSize={page.pageSize}
onChange={this.handlePageChange}
hideOnSinglePage={true}
/>
</div>
</div>
<Pageable
page={page}
statuses={PROPOSAL_STATUSES}
sorts={sorts}
searchPlaceholder="Search proposal titles"
renderItem={(p: Proposal) => <ProposalItem key={p.proposalId} {...p} />}
handleSearch={store.fetchProposals}
handleChangeQuery={store.setProposalPageQuery}
handleResetQuery={store.resetProposalPageQuery}
/>
);
}
private addStatusFilter = (filter: PROPOSAL_STATUS) => {
const { status } = store.proposals.page.filters;
store.proposals.page.filters.status = uniq([...status, filter]);
};
private removeStatusFilter = (filter: PROPOSAL_STATUS) => {
const { status } = store.proposals.page.filters;
store.proposals.page.filters.status = without(status, filter);
};
private addOtherFilter = (filter: string) => {
const { other } = store.proposals.page.filters;
store.proposals.page.filters.other = uniq([...other, filter]);
};
private removeOtherFilter = (filter: string) => {
const { other } = store.proposals.page.filters;
store.proposals.page.filters.other = without(other, filter);
};
private setStoreFromQueryString = () => {
const parsed = qs.parse(this.props.history.location.search);
// status filter
if (parsed.status || parsed.other) {
// here we reset to normal page query params, we might want
// to do this every time we load or leave the component
store.resetProposalPageQuery();
if (parsed.status && getStatusById(PROPOSAL_STATUSES, parsed.status)) {
this.addStatusFilter(parsed.status);
}
if (parsed.other && getProposalOtherFilterById(parsed.other)) {
this.addOtherFilter(parsed.other);
}
this.props.history.replace(this.props.match.url); // remove qs
}
};
private handleSortClick = (e: ClickParam) => {
store.proposals.page.sort = e.key;
store.fetchProposals();
};
private handleFilterClick = (e: ClickParam) => {
// tagged keys to differentiate filter types in antd dropdown
if (e.key.startsWith('s_')) {
this.addStatusFilter(e.key.replace('s_', '') as PROPOSAL_STATUS);
store.fetchProposals();
} else if (e.key.startsWith('o_')) {
this.addOtherFilter(e.key.replace('o_', ''));
store.fetchProposals();
}
};
private handleStatusFilterClose = (filter: PROPOSAL_STATUS) => {
this.removeStatusFilter(filter);
store.fetchProposals();
};
private handleOtherFilterClose = (filter: string) => {
this.removeOtherFilter(filter);
store.fetchProposals();
};
private handleFilterClear = () => {
store.proposals.page.filters = { status: [], other: [] };
store.fetchProposals();
};
private handleSearch = (s: string) => {
store.proposals.page.search = s;
store.fetchProposals();
};
private handlePageChange = (p: number) => {
store.proposals.page.page = p;
store.fetchProposals();
};
}
const Proposals = withRouter(view(ProposalsNaked));
export default Proposals;
export default view(Proposals);

View File

@ -57,6 +57,12 @@ class Template extends React.Component<Props> {
<span className="nav-text">RFPs</span>
</Link>
</Menu.Item>
<Menu.Item key="contributions">
<Link to="/contributions">
<Icon type="dollar" />
<span className="nav-text">Contributions</span>
</Link>
</Menu.Item>
<Menu.Item key="emails">
<Link to="/emails">
<Icon type="mail" />

View File

@ -4,11 +4,14 @@ import axios, { AxiosError } from 'axios';
import {
User,
Proposal,
Contribution,
ContributionArgs,
RFP,
RFPArgs,
EmailExample,
PageQuery,
PROPOSAL_STATUS,
PageData,
} from './types';
// API
@ -66,9 +69,7 @@ async function setArbiter(proposalId: number, userId: number) {
}
async function fetchProposals(params: Partial<PageQuery>) {
const { data } = await api.get('/admin/proposals', {
params,
});
const { data } = await api.get('/admin/proposals', { params });
return data;
}
@ -119,8 +120,29 @@ async function deleteRFP(id: number) {
await api.delete(`/admin/rfps/${id}`);
}
async function getContributions(params: PageQuery) {
const { data } = await api.get('/admin/contributions', { params });
return data;
}
async function getContribution(id: number) {
const { data } = await api.get(`/admin/contributions/${id}`);
return data;
}
async function createContribution(args: ContributionArgs) {
const { data } = await api.post('/admin/contributions', args);
return data;
}
async function editContribution(id: number, args: ContributionArgs) {
const { data } = await api.put(`/admin/contributions/${id}`, args);
return data;
}
// STORE
const app = store({
/*** DATA ***/
hasCheckedLogin: false,
isLoggedIn: false,
loginError: '',
@ -150,24 +172,11 @@ const app = store({
},
proposals: {
page: {
page: 1,
search: '',
sort: 'CREATED:DESC',
filters: {
status: [] as PROPOSAL_STATUS[],
other: [] as string[],
},
pageSize: 0,
total: 0,
items: [] as Proposal[],
fetching: false,
fetched: false,
},
page: createDefaultPageData<Proposal>('CREATED:DESC'),
},
proposalDetailFetching: false,
proposalDetail: null as null | Proposal,
proposalDetailFetching: false,
proposalDetailApproving: false,
rfps: [] as RFP[],
@ -178,8 +187,19 @@ const app = store({
rfpDeleting: false,
rfpDeleted: false,
contributions: {
page: createDefaultPageData<Contribution>('CREATED:DESC'),
},
contributionDetail: null as null | Contribution,
contributionDetailFetching: false,
contributionSaving: false,
contributionSaved: false,
emailExamples: {} as { [type: string]: EmailExample },
/*** ACTIONS ***/
removeGeneralError(i: number) {
app.generalError.splice(i, 1);
},
@ -204,6 +224,8 @@ const app = store({
}
},
// Auth
async checkLogin() {
app.isLoggedIn = await checkLogin();
app.hasCheckedLogin = true;
@ -236,6 +258,8 @@ const app = store({
app.statsFetching = false;
},
// Users
async fetchUsers() {
app.usersFetching = true;
try {
@ -271,6 +295,8 @@ const app = store({
app.userDeleting = false;
},
// Arbiters
async searchArbiters(search: string) {
app.arbitersSearch = {
...app.arbitersSearch,
@ -305,6 +331,8 @@ const app = store({
this.updateUserInStore(user);
},
// Proposals
async fetchProposals() {
app.proposals.page.fetching = true;
try {
@ -327,6 +355,13 @@ const app = store({
app.proposals.page.fetching = false;
},
setProposalPageQuery(query: Partial<PageQuery>) {
app.proposals.page = {
...app.proposals.page,
...query,
};
},
getProposalPageQuery() {
const pq = pick(app.proposals.page, ['page', 'search', 'filters', 'sort']) as any;
const pfx = (p: string) => (s: string) => p + s;
@ -338,13 +373,10 @@ const app = store({
},
resetProposalPageQuery() {
app.proposals.page = {
...app.proposals.page,
page: 1,
search: '',
sort: 'CREATED:DESC',
filters: { status: [], other: [] },
};
app.proposals.page.page = 1;
app.proposals.page.search = '';
app.proposals.page.sort = 'CREATED:DESC';
app.proposals.page.filters = [];
},
async fetchProposalDetail(id: number) {
@ -401,6 +433,8 @@ const app = store({
app.proposalDetailApproving = false;
},
// Email
async getEmailExample(type: string) {
try {
const example = await getEmailExample(type);
@ -413,6 +447,8 @@ const app = store({
}
},
// RFPs
async fetchRFPs() {
app.rfpsFetching = true;
try {
@ -461,8 +497,83 @@ const app = store({
}
app.rfpDeleting = false;
},
// Contributions
async fetchContributions() {
app.contributions.page.fetching = true;
try {
const page = await getContributions(app.getContributionPageQuery());
app.contributions.page = {
...app.contributions.page,
...page,
fetched: true,
};
} catch (e) {
handleApiError(e);
}
app.contributions.page.fetching = false;
},
setContributionPageQuery(query: Partial<PageQuery>) {
app.contributions.page = {
...app.contributions.page,
...query,
};
},
getContributionPageQuery() {
return pick(app.contributions.page, [
'page',
'search',
'filters',
'sort',
]) as PageQuery;
},
resetContributionPageQuery() {
app.contributions.page.page = 1;
app.contributions.page.search = '';
app.contributions.page.sort = 'CREATED:DESC';
app.contributions.page.filters = [];
},
async fetchContributionDetail(id: number) {
app.contributionDetailFetching = true;
try {
app.contributionDetail = await getContribution(id);
} catch (e) {
handleApiError(e);
}
app.contributionDetailFetching = false;
},
async editContribution(id: number, args: ContributionArgs) {
app.contributionSaving = true;
app.contributionSaved = false;
try {
await editContribution(id, args);
app.contributionSaved = true;
} catch (e) {
handleApiError(e);
}
app.contributionSaving = false;
},
async createContribution(args: ContributionArgs) {
app.contributionSaving = true;
app.contributionSaved = false;
try {
await createContribution(args);
app.contributionSaved = true;
} catch (e) {
handleApiError(e);
}
app.contributionSaving = false;
},
});
// Utils
function handleApiError(e: AxiosError) {
if (e.response && e.response.data!.message) {
app.generalError.push(e.response!.data.message);
@ -473,6 +584,21 @@ function handleApiError(e: AxiosError) {
}
}
function createDefaultPageData<T>(sort: string): PageData<T> {
return {
sort,
page: 1,
search: '',
filters: [] as string[],
pageSize: 0,
total: 0,
items: [] as T[],
fetching: false,
fetched: false,
};
}
// Attach to window for inspection
(window as any).appStore = app;
// check login status periodically

View File

@ -77,14 +77,32 @@ export interface Comment {
dateCreated: number;
content: string;
}
// NOTE: sync with backend/utils/enums.py
export enum CONTRIBUTION_STATUS {
PENDING = 'PENDING',
CONFIRMED = 'CONFIRMED',
DELETED = 'DELETED',
}
export interface Contribution {
id: number;
status: string;
status: CONTRIBUTION_STATUS;
txId: null | string;
amount: string;
dateCreated: number;
user: User;
proposal: Proposal;
addresses: {
transparent: string;
sprout: string;
memo: string;
};
}
export interface ContributionArgs {
proposalId: string | number;
userId: string | number;
amount: string;
status: string;
txId?: string;
}
export interface User {
accountAddress: string;
@ -124,3 +142,11 @@ export interface PageQuery {
search: string;
sort: string;
}
export interface PageData<T> extends PageQuery {
pageSize: number;
total: number;
items: T[];
fetching: boolean;
fetched: boolean;
}

View File

@ -1,4 +1,4 @@
import { PROPOSAL_STATUS, RFP_STATUS } from 'src/types';
import { PROPOSAL_STATUS, RFP_STATUS, CONTRIBUTION_STATUS } from 'src/types';
export interface StatusSoT<E> {
id: E;
@ -86,6 +86,30 @@ export const RFP_STATUSES: Array<StatusSoT<RFP_STATUS>> = [
},
];
export const CONTRIBUTION_STATUSES: Array<StatusSoT<CONTRIBUTION_STATUS>> = [
{
id: CONTRIBUTION_STATUS.PENDING,
filterDisplay: 'Status: pending',
tagDisplay: 'Pending',
tagColor: '#ffaa00',
hint: 'Contribution is currently waiting to be sent and confirmed on chain',
},
{
id: CONTRIBUTION_STATUS.CONFIRMED,
filterDisplay: 'Status: confirmed',
tagDisplay: 'Confirmed',
tagColor: '#108ee9',
hint: 'Contribution was confirmed on chain with multiple block confirmations',
},
{
id: CONTRIBUTION_STATUS.DELETED,
filterDisplay: 'Status: deleted',
tagDisplay: 'Closed',
tagColor: '#eb4118',
hint: 'User deleted the contribution before it was sent or confirmed',
},
]
export function getStatusById<E>(statuses: Array<StatusSoT<E>>, id: E) {
const result = statuses.find(s => s.id === id);
if (!result) {

View File

@ -87,7 +87,12 @@ For a full migration command reference, run `flask db --help`.
To create a proposal, run
flask create_proposal "FUNDING_REQUIRED" 1 123 "My Awesome Proposal" "### Hi! I have a great proposal"
flask create-proposal "FUNDING_REQUIRED" 1 123 "My Awesome Proposal" "### Hi! I have a great proposal"
To seed many proposals, run
flask create-proposals <number_of_proposals:int>
## S3 Storage Setup

View File

@ -1,6 +1,7 @@
from flask import Blueprint, request
from flask import Blueprint, request
from flask_yoloapi import endpoint, parameter
from decimal import Decimal
from grant.comment.models import Comment, user_comments_schema
from grant.email.send import generate_email, send_email
from grant.extensions import db
@ -9,13 +10,14 @@ from grant.proposal.models import (
ProposalContribution,
proposals_schema,
proposal_schema,
proposal_contribution_schema,
user_proposal_contributions_schema,
)
from grant.user.models import User, admin_users_schema, admin_user_schema
from grant.rfp.models import RFP, admin_rfp_schema, admin_rfps_schema
from grant.utils.admin import admin_auth_required, admin_is_authed, admin_login, admin_logout
from grant.utils.enums import ProposalStatus
from grant.utils.misc import make_url
from grant.utils.enums import ProposalStatus, ContributionStatus
from grant.utils import pagination
from sqlalchemy import func, or_
@ -343,3 +345,108 @@ def delete_rfp(rfp_id):
db.session.delete(rfp)
db.session.commit()
return None, 200
# Contributions
@blueprint.route('/contributions', methods=['GET'])
@endpoint.api(
parameter('page', type=int, required=False),
parameter('filters', type=list, required=False),
parameter('search', type=str, required=False),
parameter('sort', type=str, required=False)
)
@admin_auth_required
def get_contributions(page, filters, search, sort):
filters_workaround = request.args.getlist('filters[]')
page = pagination.contribution(
page=page,
filters=filters_workaround,
search=search,
sort=sort,
)
return page
@blueprint.route('/contributions', methods=['POST'])
@endpoint.api(
parameter('proposalId', type=int, required=True),
parameter('userId', type=int, required=True),
parameter('status', type=str, required=True),
parameter('amount', type=str, required=True),
parameter('txId', type=str, required=False),
)
@admin_auth_required
def create_contribution(proposal_id, user_id, status, amount, tx_id):
# Some fields set manually since we're admin, and normally don't do this
contribution = ProposalContribution(
proposal_id=proposal_id,
user_id=user_id,
amount=amount,
)
contribution.status = status
contribution.tx_id = tx_id
db.session.add(contribution)
db.session.commit()
return proposal_contribution_schema.dump(contribution), 200
@blueprint.route('/contributions/<contribution_id>', methods=['GET'])
@endpoint.api()
@admin_auth_required
def get_contribution(contribution_id):
contribution = ProposalContribution.query.filter(ProposalContribution.id == contribution_id).first()
if not contribution:
return {"message": "No contribution matching that id"}, 404
return proposal_contribution_schema.dump(contribution), 200
@blueprint.route('/contributions/<contribution_id>', methods=['PUT'])
@endpoint.api(
parameter('proposalId', type=int, required=False),
parameter('userId', type=int, required=False),
parameter('status', type=str, required=False),
parameter('amount', type=str, required=False),
parameter('txId', type=str, required=False),
)
@admin_auth_required
def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_id):
contribution = ProposalContribution.query.filter(ProposalContribution.id == contribution_id).first()
if not contribution:
return {"message": "No contribution matching that id"}, 404
print((contribution_id, proposal_id, user_id, status, amount, tx_id))
# Proposal ID (must belong to an existing proposal)
if proposal_id:
proposal = Proposal.query.filter(Proposal.id == proposal_id).first()
if not proposal:
return {"message": "No proposal matching that id"}, 400
contribution.proposal_id = proposal_id
# User ID (must belong to an existing user)
if user_id:
user = User.query.filter(User.id == user_id).first()
if not user:
return {"message": "No user matching that id"}, 400
contribution.user_id = user_id
# Status (must be in list of statuses)
if status:
if not ContributionStatus.includes(status):
return {"message": "Invalid status"}, 400
contribution.status = status
# Amount (must be a Decimal parseable)
if amount:
try:
contribution.amount = str(Decimal(amount))
except:
return {"message": "Amount could not be parsed as number"}, 400
# Transaction ID (no validation)
if tx_id:
contribution.tx_id = tx_id
db.session.add(contribution)
db.session.commit()
return proposal_contribution_schema.dump(contribution), 200

View File

@ -2,6 +2,7 @@ import datetime
from grant.extensions import ma, db
from grant.utils.exceptions import ValidationException
from grant.utils.misc import dt_to_unix
NOT_REQUESTED = 'NOT_REQUESTED'
ONGOING_VOTE = 'ONGOING_VOTE'
@ -64,6 +65,15 @@ class MilestoneSchema(ma.Schema):
"date_created",
)
date_created = ma.Method("get_date_created")
date_estimated = ma.Method("get_date_estimated")
def get_date_created(self, obj):
return dt_to_unix(obj.date_created)
def get_date_estimated(self, obj):
return dt_to_unix(obj.date_estimated) if obj.date_estimated else None
milestone_schema = MilestoneSchema()
milestones_schema = MilestoneSchema(many=True)

View File

@ -2,6 +2,7 @@ import datetime
from functools import reduce
from sqlalchemy import func, or_
from sqlalchemy.ext.hybrid import hybrid_property
from decimal import Decimal
from grant.comment.models import Comment
from grant.email.send import send_email
@ -103,6 +104,46 @@ class ProposalContribution(db.Model):
.order_by(ProposalContribution.date_created.desc()) \
.all()
@staticmethod
def validate(contribution):
proposal_id = contribution.get('proposal_id')
user_id = contribution.get('user_id')
status = contribution.get('status')
amount = contribution.get('amount')
tx_id = contribution.get('tx_id')
# Proposal ID (must belong to an existing proposal)
if proposal_id:
proposal = Proposal.query.filter(Proposal.id == proposal_id).first()
if not proposal:
raise ValidationException('No proposal matching that ID')
contribution.proposal_id = proposal_id
else:
raise ValidationException('Proposal ID is required')
# User ID (must belong to an existing user)
if user_id:
user = User.query.filter(User.id == user_id).first()
if not user:
raise ValidationException('No user matching that ID')
contribution.user_id = user_id
else:
raise ValidationException('User ID is required')
# Status (must be in list of statuses)
if status:
if not ContributionStatus.includes(status):
raise ValidationException('Invalid status')
contribution.status = status
else:
raise ValidationException('Status is required')
# Amount (must be a Decimal parseable)
if amount:
try:
contribution.amount = str(Decimal(amount))
except:
raise ValidationException('Amount must be a number')
else:
raise ValidationException('Amount is required')
def confirm(self, tx_id: str, amount: str):
self.status = ContributionStatus.CONFIRMED
self.tx_id = tx_id
@ -237,7 +278,7 @@ class Proposal(db.Model):
self.deadline_duration = deadline_duration
Proposal.validate(vars(self))
def create_contribution(self, user_id: int, amount: float):
def create_contribution(self, user_id: int, amount):
contribution = ProposalContribution(
proposal_id=self.id,
user_id=user_id,
@ -249,18 +290,16 @@ class Proposal(db.Model):
def get_staking_contribution(self, user_id: int):
contribution = None
remaining = PROPOSAL_STAKING_AMOUNT - float(self.contributed)
remaining = PROPOSAL_STAKING_AMOUNT - Decimal(self.contributed)
# check funding
if remaining > 0:
# find pending contribution for any user
# (always use full staking amout so we can find it)
# find pending contribution for any user of remaining amount
contribution = ProposalContribution.query.filter_by(
proposal_id=self.id,
amount=str(PROPOSAL_STAKING_AMOUNT),
status=ProposalStatus.PENDING,
).first()
if not contribution:
contribution = self.create_contribution(user_id, PROPOSAL_STAKING_AMOUNT)
contribution = self.create_contribution(user_id, str(remaining.normalize()))
return contribution
@ -319,14 +358,14 @@ class Proposal(db.Model):
contributions = ProposalContribution.query \
.filter_by(proposal_id=self.id, status=ContributionStatus.CONFIRMED) \
.all()
funded = reduce(lambda prev, c: prev + float(c.amount), contributions, 0)
funded = reduce(lambda prev, c: prev + Decimal(c.amount), contributions, 0)
return str(funded)
@hybrid_property
def funded(self):
target = float(self.target)
target = Decimal(self.target)
# apply matching multiplier
funded = float(self.contributed) * (1 + self.contribution_matching)
funded = Decimal(self.contributed) * Decimal(1 + self.contribution_matching)
# if funded > target, just set as target
if funded > target:
return str(target)
@ -335,7 +374,7 @@ class Proposal(db.Model):
@hybrid_property
def is_staked(self):
return float(self.contributed) >= PROPOSAL_STAKING_AMOUNT
return Decimal(self.contributed) >= PROPOSAL_STAKING_AMOUNT
class ProposalSchema(ma.Schema):

View File

@ -237,6 +237,7 @@ def delete_proposal(proposal_id):
ProposalStatus.PENDING,
ProposalStatus.APPROVED,
ProposalStatus.REJECTED,
ProposalStatus.STAKING,
]
status = g.current_proposal.status
if status not in deleteable_statuses:
@ -482,7 +483,7 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
if contribution.proposal.status == ProposalStatus.STAKING:
# fully staked, set status PENDING & notify user
if contribution.proposal.is_staked: # float(contribution.proposal.contributed) >= PROPOSAL_STAKING_AMOUNT:
if contribution.proposal.is_staked: # Decimal(contribution.proposal.contributed) >= PROPOSAL_STAKING_AMOUNT:
contribution.proposal.status = ProposalStatus.PENDING
db.session.add(contribution.proposal)
db.session.commit()
@ -493,7 +494,7 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
'proposal': contribution.proposal,
'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}',
'fully_staked': contribution.proposal.is_staked,
'stake_target': PROPOSAL_STAKING_AMOUNT
'stake_target': str(PROPOSAL_STAKING_AMOUNT.normalize()),
})
else:

View File

@ -7,6 +7,7 @@ For local development, use a .env file to set
environment variables.
"""
from environs import Env
from decimal import Decimal
env = Env()
env.read_env()
@ -54,7 +55,7 @@ ADMIN_PASS_HASH = env.str("ADMIN_PASS_HASH")
EXPLORER_URL = env.str("EXPLORER_URL", default="https://explorer.zcha.in/")
PROPOSAL_STAKING_AMOUNT = env.float("PROPOSAL_STAKING_AMOUNT")
PROPOSAL_STAKING_AMOUNT = Decimal(env.str("PROPOSAL_STAKING_AMOUNT"))
UI = {
'NAME': 'ZF Grants',

View File

@ -5,9 +5,10 @@
will now be forwarded to administrators for approval.
{% else %}
<strong>{{ args.proposal.title }}</strong> has been partially staked for
<strong>{{ args.contribution.amount }} ZEC</strong>. This is not enough to
fully stake the proposal. You must send at least
<strong>{{ args.stake_target }} ZEC</strong>.
<strong>{{ args.contribution.amount }} ZEC</strong> of the required
<strong>{{ args.stake_target}} ZEC</strong>.
You can send the remaining amount by going to your profile's "Pending" tab,
and clicking the "Stake" button next to the proposal.
{% endif %}
You can view your transaction below:
</p>

View File

@ -3,9 +3,9 @@
Your proposal will now be forwarded to administrators for approval.
{% else %}
{{ args.proposal.title }} has been partially staked for
{{ args.contribution.amount }} ZEC. This is not enough to
fully stake the proposal. You must send at least
{{ args.stake_target }} ZEC.
{{ args.contribution.amount }} ZEC of the required {{ args.stake_target}} ZEC.
You can send the remaining amount by going to your profile's "Pending" tab,
and clicking the "Stake" button next to the proposal.
{% endif %}
You can view your transaction here:

View File

@ -1,8 +1,8 @@
import abc
from sqlalchemy import or_, and_
from grant.proposal.models import db, ma, Proposal
from .enums import ProposalStatus, ProposalStage, Category
from grant.proposal.models import db, ma, Proposal, ProposalContribution, proposal_contributions_schema
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus
def extract_filters(sw, strings):
@ -110,7 +110,63 @@ class ProposalPagination(Pagination):
}
class ContributionPagination(Pagination):
def __init__(self):
self.FILTERS = [f'STATUS_{s}' for s in ContributionStatus.list()]
self.PAGE_SIZE = 9
self.SORT_MAP = {
'CREATED:DESC': ProposalContribution.date_created.desc(),
'CREATED:ASC': ProposalContribution.date_created,
'AMOUNT:DESC': ProposalContribution.amount.desc(),
'AMOUNT:ASC': ProposalContribution.amount,
}
def paginate(
self,
schema: ma.Schema=proposal_contributions_schema,
query: db.Query=None,
page: int=1,
filters: list=None,
search: str=None,
sort: str='PUBLISHED:DESC',
):
query = query or ProposalContribution.query
sort = sort or 'CREATED:DESC'
# FILTER
if filters:
self.validate_filters(filters)
status_filters = extract_filters('STATUS_', filters)
if status_filters:
query = query.filter(ProposalContribution.status.in_(status_filters))
# SORT (see self.SORT_MAP)
if sort:
self.validate_sort(sort)
query = query.order_by(self.SORT_MAP[sort])
# SEARCH can match txids or amounts
if search:
query = query.filter(or_(
ProposalContribution.amount.ilike(f'%{search}%'),
ProposalContribution.tx_id.ilike(f'%{search}%'),
))
res = query.paginate(page, self.PAGE_SIZE, False)
return {
'page': res.page,
'total': res.total,
'page_size': self.PAGE_SIZE,
'items': schema.dump(res.items),
'filters': filters,
'search': search,
'sort': sort
}
# expose pagination methods here
proposal = ProposalPagination().paginate
contribution = ContributionPagination().paginate
# comment = CommentPagination().paginate
# user = UserPagination().paginate

View File

@ -155,7 +155,7 @@ class BaseProposalCreatorConfig(BaseUserConfig):
# 2. get staking contribution
contribution = self.proposal.get_staking_contribution(self.user.id)
# 3. fake a confirmation
contribution.confirm(tx_id='tx', amount=str(PROPOSAL_STAKING_AMOUNT))
contribution.confirm(tx_id='tx', amount=str(PROPOSAL_STAKING_AMOUNT.normalize()))
db.session.add(contribution)
db.session.commit()
contribution = self.proposal.get_staking_contribution(self.user.id)

View File

@ -115,7 +115,7 @@ class TestProposalAPI(BaseProposalCreatorConfig):
resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}/stake")
print(resp)
self.assert200(resp)
self.assertEquals(resp.json['amount'], str(PROPOSAL_STAKING_AMOUNT))
self.assertEquals(resp.json['amount'], str(PROPOSAL_STAKING_AMOUNT.normalize()))
@patch('requests.get', side_effect=mock_blockchain_api_requests)
def test_proposal_stake_no_auth(self, mock_get):

View File

@ -8,10 +8,12 @@ import PaymentInfo from './PaymentInfo';
interface OwnProps {
isVisible: boolean;
contribution?: ContributionWithAddresses | Falsy;
proposalId?: number;
contributionId?: number;
amount?: string;
hasNoButtons?: boolean;
text?: React.ReactNode;
handleClose(): void;
}
@ -30,22 +32,32 @@ export default class ContributionModal extends React.Component<Props, State> {
error: null,
};
constructor(props: Props) {
super(props);
if (props.contribution) {
this.state = {
...this.state,
contribution: props.contribution,
};
}
}
componentWillUpdate(nextProps: Props) {
const { isVisible, proposalId, contributionId } = nextProps;
const { isVisible, proposalId, contributionId, contribution } = nextProps;
// When modal is opened and proposalId is provided or changed
if (isVisible && proposalId) {
if (
this.props.isVisible !== isVisible ||
proposalId !== this.props.proposalId
) {
if (this.props.isVisible !== isVisible || proposalId !== this.props.proposalId) {
this.fetchAddresses(proposalId, contributionId);
}
}
// If contribution is provided
if (contribution !== this.props.contribution) {
this.setState({ contribution: contribution || null });
}
}
render() {
const { isVisible, handleClose, hasNoButtons } = this.props;
const { isVisible, handleClose, hasNoButtons, text } = this.props;
const { hasSent, contribution, error } = this.state;
let content;
@ -68,7 +80,7 @@ export default class ContributionModal extends React.Component<Props, State> {
if (error) {
content = error;
} else {
content = <PaymentInfo contribution={contribution} />;
content = <PaymentInfo contribution={contribution} text={text} />;
}
}
@ -89,22 +101,16 @@ export default class ContributionModal extends React.Component<Props, State> {
);
}
private async fetchAddresses(
proposalId: number,
contributionId?: number,
) {
private async fetchAddresses(proposalId: number, contributionId?: number) {
try {
let res;
if (contributionId) {
res = await getProposalContribution(proposalId, contributionId);
} else {
res = await postProposalContribution(
proposalId,
this.props.amount || '0',
);
res = await postProposalContribution(proposalId, this.props.amount || '0');
}
this.setState({ contribution: res.data });
} catch(err) {
} catch (err) {
this.setState({ error: err.message });
}
}

View File

@ -18,7 +18,7 @@ const DEFAULT_STATE: State = {
{
title: '',
content: '',
dateEstimated: '',
dateEstimated: moment().unix(),
payoutPercent: '100',
immediatePayout: false,
},
@ -159,10 +159,21 @@ const MilestoneFields = ({
<DatePicker.MonthPicker
style={{ flex: 1, marginRight: '0.5rem' }}
placeholder="Expected completion date"
value={milestone.dateEstimated ? moment(milestone.dateEstimated) : undefined}
value={
milestone.dateEstimated ? moment(milestone.dateEstimated * 1000) : undefined
}
format="MMMM YYYY"
allowClear={false}
onChange={(_, dateEstimated) => onChange(index, { ...milestone, dateEstimated })}
onChange={time => onChange(index, { ...milestone, dateEstimated: time.unix() })}
disabled={milestone.immediatePayout}
disabledDate={current =>
current
? current <
moment()
.subtract(1, 'month')
.endOf('month')
: false
}
/>
<Input
min={1}
@ -186,6 +197,9 @@ const MilestoneFields = ({
onChange(index, {
...milestone,
immediatePayout: ev.target.checked,
dateEstimated: ev.target.checked
? moment().unix()
: milestone.dateEstimated,
})
}
>

View File

@ -182,7 +182,7 @@ const ReviewMilestones = ({
<div className="ReviewMilestone">
<div className="ReviewMilestone-title">{m.title}</div>
<div className="ReviewMilestone-info">
{moment(m.dateEstimated, 'MMMM YYYY').format('MMMM YYYY')}
{moment(m.dateEstimated * 1000).format('MMMM YYYY')}
{' '}
{m.payoutPercent}% of funds
</div>

View File

@ -1,3 +1,4 @@
import moment from 'moment';
import { PROPOSAL_CATEGORY } from 'api/constants';
import { ProposalDraft } from 'types';
@ -19,7 +20,9 @@ const createExampleProposal = (): Partial<ProposalDraft> => {
title: 'Initial Funding',
content:
'This will be used to pay for a professional designer to hand-craft each letter on the shirt.',
dateEstimated: 'October 2018',
dateEstimated: moment()
.add(1, 'month')
.unix(),
payoutPercent: '30',
immediatePayout: true,
},
@ -27,7 +30,9 @@ const createExampleProposal = (): Partial<ProposalDraft> => {
title: 'Test Prints',
content:
"We'll get test prints from 5 different factories and choose the highest quality shirts. Once we've decided, we'll order a full batch of prints.",
dateEstimated: 'November 2018',
dateEstimated: moment()
.add(2, 'month')
.unix(),
payoutPercent: '20',
immediatePayout: false,
},
@ -35,7 +40,9 @@ const createExampleProposal = (): Partial<ProposalDraft> => {
title: 'All Shirts Printed',
content:
"All of the shirts have been printed, hooray! They'll be given out at conferences and meetups.",
dateEstimated: 'December 2018',
dateEstimated: moment()
.add(3, 'month')
.unix(),
payoutPercent: '50',
immediatePayout: false,
},

View File

@ -1,15 +1,17 @@
import React, { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Button, Popconfirm, message, Tag } from 'antd';
import { UserProposal, STATUS } from 'types';
import { UserProposal, STATUS, ContributionWithAddresses } from 'types';
import ContributionModal from 'components/ContributionModal';
import { getProposalStakingContribution } from 'api/api';
import { deletePendingProposal, publishPendingProposal } from 'modules/users/actions';
import './ProfilePending.less';
import { connect } from 'react-redux';
import { AppState } from 'store/reducers';
import './ProfilePending.less';
interface OwnProps {
proposal: UserProposal;
onPublish: (id: UserProposal['proposalId']) => void;
onPublish(id: UserProposal['proposalId']): void;
}
interface StateProps {
@ -23,18 +25,24 @@ interface DispatchProps {
type Props = OwnProps & StateProps & DispatchProps;
const STATE = {
isDeleting: false,
isPublishing: false,
};
type State = typeof STATE;
interface State {
isDeleting: boolean;
isPublishing: boolean;
isLoadingStake: boolean;
stakeContribution: ContributionWithAddresses | null;
}
class ProfilePending extends React.Component<Props, State> {
state = STATE;
state: State = {
isDeleting: false,
isPublishing: false,
isLoadingStake: false,
stakeContribution: null,
};
render() {
const { status, title, proposalId, rejectReason } = this.props.proposal;
const { isDeleting, isPublishing } = this.state;
const { isDeleting, isPublishing, isLoadingStake, stakeContribution } = this.state;
const isDisableActions = isDeleting || isPublishing;
@ -105,6 +113,15 @@ class ProfilePending extends React.Component<Props, State> {
</Button>
</Link>
)}
{STATUS.STAKING === status && (
<Button
type="primary"
loading={isLoadingStake}
onClick={this.openStakingModal}
>
Stake
</Button>
)}
<Popconfirm
key="delete"
@ -116,6 +133,22 @@ class ProfilePending extends React.Component<Props, State> {
</Button>
</Popconfirm>
</div>
{STATUS.STAKING && (
<ContributionModal
isVisible={!!stakeContribution}
contribution={stakeContribution}
handleClose={this.closeStakingModal}
text={
<p>
Please send the staking contribution of{' '}
<b>{stakeContribution && stakeContribution.amount} ZEC</b> using the
instructions below. Once your payment has been sent and confirmed, you
will receive an email.
</p>
}
/>
)}
</div>
);
}
@ -152,6 +185,25 @@ class ProfilePending extends React.Component<Props, State> {
this.setState({ isDeleting: false });
}
};
private openStakingModal = async () => {
try {
this.setState({ isLoadingStake: true });
const res = await getProposalStakingContribution(this.props.proposal.proposalId);
this.setState({ stakeContribution: res.data }, () => {
this.setState({ isLoadingStake: false });
});
} catch (err) {
message.error(err.message, 3);
this.setState({ isLoadingStake: false });
}
};
private closeStakingModal = () =>
this.setState({
isLoadingStake: false,
stakeContribution: null,
});
}
export default connect<StateProps, DispatchProps, OwnProps, AppState>(

View File

@ -49,8 +49,9 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
let content;
if (proposal) {
const { target, funded, percentFunded } = proposal;
const datePublished = proposal.datePublished || Date.now() / 1000;
const isRaiseGoalReached = funded.gte(target);
const deadline = (proposal.dateCreated + proposal.deadlineDuration) * 1000;
const deadline = (datePublished + proposal.deadlineDuration) * 1000;
// TODO: Get values from proposal
console.warn('TODO: Get isFrozen from proposal data');
const isFrozen = false;
@ -66,7 +67,7 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
<div className="ProposalCampaignBlock-info">
<div className="ProposalCampaignBlock-info-label">Started</div>
<div className="ProposalCampaignBlock-info-value">
{moment(proposal.datePublished * 1000).fromNow()}
{moment(datePublished * 1000).fromNow()}
</div>
</div>
)}

View File

@ -98,7 +98,7 @@ class ProposalMilestones extends React.Component<Props, State> {
: milestoneStateToStepState[milestone.state];
const className = this.state.step === i ? 'is-active' : 'is-inactive';
const estimatedDate = moment(milestone.dateEstimated).format('MMMM YYYY');
const estimatedDate = moment(milestone.dateEstimated * 1000).format('MMMM YYYY');
const reward = (
<UnitDisplay value={milestone.amount} symbol="ZEC" displayShortBalance={4} />
);
@ -121,7 +121,7 @@ class ProposalMilestones extends React.Component<Props, State> {
message={
<span>
The team was awarded <strong>{reward}</strong>{' '}
{milestone.isImmediatePayout
{milestone.immediatePayout
? 'as an initial payout'
: // TODO: Add property for payout date on milestones
`on ${moment().format('MMM Do, YYYY')}`}
@ -164,7 +164,7 @@ class ProposalMilestones extends React.Component<Props, State> {
const statuses = (
<div className="ProposalMilestones-milestone-status">
{!milestone.isImmediatePayout && (
{!milestone.immediatePayout && (
<div>
Estimate: <strong>{estimatedDate}</strong>
</div>

View File

@ -19,6 +19,7 @@ export class ProposalCard extends React.Component<Proposal> {
proposalAddress,
proposalUrlId,
category,
datePublished,
dateCreated,
team,
target,
@ -28,11 +29,7 @@ export class ProposalCard extends React.Component<Proposal> {
} = this.props;
return (
<Card
className="ProposalCard"
to={`/proposals/${proposalUrlId}`}
title={title}
>
<Card className="ProposalCard" to={`/proposals/${proposalUrlId}`} title={title}>
{contributionMatching > 0 && (
<div className="ProposalCard-ribbon">
<span>
@ -77,7 +74,7 @@ export class ProposalCard extends React.Component<Proposal> {
</div>
</div>
<div className="ProposalCard-address">{proposalAddress}</div>
<Card.Info category={category} time={dateCreated * 1000} />
<Card.Info category={category} time={(datePublished || dateCreated) * 1000} />
</Card>
);
}

View File

@ -183,6 +183,7 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal {
payoutAddress: '0x0',
dateCreated: Date.now() / 1000,
datePublished: Date.now() / 1000,
dateApproved: Date.now() / 1000,
deadlineDuration: 86400 * 60,
target: toZat(draft.target),
funded: Zat('0'),
@ -198,7 +199,6 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal {
amount: toZat(target * (parseInt(m.payoutPercent, 10) / 100)),
dateEstimated: m.dateEstimated,
immediatePayout: m.immediatePayout,
isImmediatePayout: m.immediatePayout,
isPaid: false,
payoutPercent: m.payoutPercent.toString(),
state: MILESTONE_STATE.WAITING,

View File

@ -8,6 +8,7 @@ import {
} from 'types';
import { PROPOSAL_CATEGORY } from 'api/constants';
import BN from 'bn.js';
import moment from 'moment';
const oneZec = new BN('100000000');
@ -101,23 +102,22 @@ export function generateProposal({
const genMilestone = (
overrides: Partial<ProposalMilestone> = {},
): ProposalMilestone => {
const now = new Date();
if (overrides.index) {
const estimate = new Date(now.setMonth(now.getMonth() + overrides.index));
overrides.dateEstimated = estimate.toISOString();
overrides.dateEstimated = moment()
.add(overrides.index, 'month')
.unix();
}
const defaults: ProposalMilestone = {
title: 'Milestone A',
content: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua.`,
dateEstimated: '2018-10-01T00:00:00+00:00',
dateEstimated: moment().unix(),
immediatePayout: true,
index: 0,
state: MILESTONE_STATE.WAITING,
amount: amountBn,
isPaid: false,
isImmediatePayout: true,
payoutPercent: '33',
};
return { ...defaults, ...overrides };
@ -128,7 +128,6 @@ export function generateProposal({
index: i,
title: genMilestoneTitle(),
immediatePayout: i === 0,
isImmediatePayout: i === 0,
payoutRequestVoteDeadline: i !== 0 ? Date.now() + 3600000 : 0,
payoutPercent: '' + (1 / milestoneCount) * 100,
};
@ -149,6 +148,7 @@ export function generateProposal({
payoutAddress: 'z123',
dateCreated: created / 1000,
datePublished: created / 1000,
dateApproved: created / 1000,
deadlineDuration: 86400 * 60,
target: amountBn,
funded: fundedBn,

View File

@ -12,13 +12,12 @@ export interface Milestone {
state: MILESTONE_STATE;
amount: Zat;
isPaid: boolean;
isImmediatePayout: boolean;
immediatePayout: boolean;
dateEstimated: number;
}
export interface ProposalMilestone extends Milestone {
content: string;
immediatePayout: boolean;
dateEstimated: string;
payoutPercent: string;
title: string;
}
@ -26,7 +25,7 @@ export interface ProposalMilestone extends Milestone {
export interface CreateMilestone {
title: string;
content: string;
dateEstimated: string;
dateEstimated: number;
payoutPercent: string;
immediatePayout: boolean;
}

View File

@ -47,7 +47,8 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
percentFunded: number;
contributionMatching: number;
milestones: ProposalMilestone[];
datePublished: number;
datePublished: number | null;
dateApproved: number | null;
}
export interface TeamInviteWithProposal extends TeamInvite {