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:
commit
60575b4024
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
|
@ -0,0 +1,9 @@
|
|||
.ContributionForm {
|
||||
&-buttons {
|
||||
margin-top: 2rem;
|
||||
|
||||
.ant-btn {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)));
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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 {
|
|
@ -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);
|
|
@ -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);
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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:
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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>(
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in New Issue