Request for Proposal (Pt. 1 - Models & Admin) (#120)

* Convert constants into enums

* Initial RFP models and views.

* Fix model and enums

* RFP admin fully fleshed out.

* Fix tsc

* Fix tests and tsc

* Fix closed tag display

* Request for Proposal (Pt. 2 - Public View) (#125)

* RFP list page and backend endpoints. Scaffold of detail view.

* RFP detail view. Fix faulty addRfp action.

* Fix 0 showing up

* PR cleanup
This commit is contained in:
William O'Beirne 2019-01-30 12:59:15 -05:00 committed by GitHub
parent b0d16ace7d
commit 4091deaf2f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 1817 additions and 221 deletions

View File

@ -12,6 +12,9 @@ import UserDetail from 'components/UserDetail';
import Emails from 'components/Emails';
import Proposals from 'components/Proposals';
import ProposalDetail from 'components/ProposalDetail';
import RFPs from 'components/RFPs';
import RFPForm from 'components/RFPForm';
import RFPDetail from 'components/RFPDetail';
import 'styles/style.less';
@ -34,6 +37,10 @@ class Routes extends React.Component<Props> {
<Route path="/users" component={Users} />
<Route path="/proposals/:id" component={ProposalDetail} />
<Route path="/proposals" component={Proposals} />
<Route path="/rfps/new" component={RFPForm} />
<Route path="/rfps/:id/edit" component={RFPForm} />
<Route path="/rfps/:id" component={RFPDetail} />
<Route path="/rfps" component={RFPs} />
<Route path="/emails/:type?" component={Emails} />
</Switch>
)}

View File

@ -11,12 +11,13 @@
&-deet {
position: relative;
margin-bottom: 0.6rem;
margin-bottom: 1rem;
& > span {
font-size: 0.7rem;
position: absolute;
top: 0.85rem;
opacity: 0.8;
top: 1rem;
}
}

View File

@ -1,11 +1,17 @@
.ProposalItem {
& h1 {
& h2 {
font-size: 1.4rem;
margin-bottom: 0;
& .ant-tag {
vertical-align: text-top;
margin-top: 0.2rem;
margin-left: 0.5rem;
}
}
& p {
color: rgba(#000, 0.5);
margin: 0;
}
}

View File

@ -4,8 +4,8 @@ import { Popconfirm, Tag, Tooltip, List } from 'antd';
import { Link } from 'react-router-dom';
import store from 'src/store';
import { Proposal } from 'src/types';
import { getStatusById } from './STATUSES';
import { formatDateSeconds } from 'src/util/time';
import { PROPOSAL_STATUSES, getStatusById } from 'util/statuses';
import { formatDateSeconds } from 'util/time';
import './ProposalItem.less';
class ProposalItemNaked extends React.Component<Proposal> {
@ -14,33 +14,32 @@ class ProposalItemNaked extends React.Component<Proposal> {
};
render() {
const p = this.props;
const status = getStatusById(p.status);
const deleteAction = (
const status = getStatusById(PROPOSAL_STATUSES, p.status);
const actions = [
<Popconfirm
key="delete"
onConfirm={this.handleDelete}
title="Are you sure?"
okText="delete"
cancelText="cancel"
okText="Delete"
okType="danger"
placement="left"
>
<div>delete</div>
</Popconfirm>
);
const viewAction = <Link to={`/proposals/${p.proposalId}`}>view</Link>;
const actions = [viewAction, deleteAction];
<a>delete</a>
</Popconfirm>,
];
return (
<List.Item key={p.proposalId} className="ProposalItem" actions={actions}>
<div>
<h1>
{p.title || '(no title)'}{' '}
<Link to={`/proposals/${p.proposalId}`}>
<h2>
{p.title || '(no title)'}
<Tooltip title={status.hint}>
<Tag color={status.tagColor}>{status.tagDisplay}</Tag>
</Tooltip>
</h1>
<div>Created: {formatDateSeconds(p.dateCreated)}</div>
<div>{p.brief}</div>
</div>
</h2>
<p>Created: {formatDateSeconds(p.dateCreated)}</p>
<p>{p.brief}</p>
</Link>
</List.Item>
);
}

View File

@ -8,7 +8,7 @@ import { RouteComponentProps, withRouter } from 'react-router';
import store from 'src/store';
import ProposalItem from './ProposalItem';
import { PROPOSAL_STATUS, Proposal } from 'src/types';
import STATUSES, { getStatusById } from './STATUSES';
import { PROPOSAL_STATUSES, getStatusById } from 'util/statuses';
import './index.less';
interface Query {
@ -37,7 +37,7 @@ class ProposalsNaked extends React.Component<Props, State> {
const statusFilterMenu = (
<Menu onClick={this.handleFilterClick}>
{STATUSES.map(f => (
{PROPOSAL_STATUSES.map(f => (
<Menu.Item key={f.id}>{f.filterDisplay}</Menu.Item>
))}
</Menu>
@ -51,7 +51,7 @@ class ProposalsNaked extends React.Component<Props, State> {
Filter <Icon type="down" />
</Button>
</Dropdown>
<Button title="refresh" icon="reload" onClick={() => this.fetchProposals()} />
<Button title="refresh" icon="reload" onClick={this.fetchProposals} />
</div>
{!!statusFilters.length && (
<div className="Proposals-filters">
@ -60,7 +60,7 @@ class ProposalsNaked extends React.Component<Props, State> {
<Tag
key={sf}
onClose={() => this.handleFilterClose(sf)}
color={getStatusById(sf).tagColor}
color={getStatusById(PROPOSAL_STATUSES, sf).tagColor}
closable
>
status: {sf}

View File

@ -0,0 +1,38 @@
.RFPDetail {
h1 {
font-size: 1.5rem;
}
&-actions {
.ant-btn {
margin-top: 0.5rem;
&:first-child {
margin-top: 0;
}
}
}
&-deet {
position: relative;
margin-bottom: 1rem;
& > span {
font-size: 0.7rem;
position: absolute;
opacity: 0.8;
top: 1rem;
}
}
& .ant-card,
.ant-alert,
.ant-collapse {
margin-bottom: 16px;
button + button {
margin-left: 0.5rem;
}
}
}

View File

@ -0,0 +1,118 @@
import React from 'react';
import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router';
import { Link } from 'react-router-dom';
import { Row, Col, Collapse, Card, Button, Popconfirm, Spin } from 'antd';
import Exception from 'ant-design-pro/lib/Exception';
import Back from 'components/Back';
import Markdown from 'components/Markdown';
import { formatDateSeconds } from 'util/time';
import store from 'src/store';
import './index.less';
type Props = RouteComponentProps<{ id?: string }>;
class RFPDetail extends React.Component<Props> {
componentDidMount() {
if (!store.rfpsFetched) {
store.fetchRFPs();
}
}
render() {
if (!store.rfpsFetched) {
return <Spin />;
}
const rfp = this.getRFP();
if (!rfp) {
return <Exception type="404" desc="This RFP does not exist" />;
}
const renderDeetItem = (name: string, val: any) => (
<div className="RFPDetail-deet">
<span>{name}</span>
{val}
</div>
);
return (
<div className="RFPDetail">
<Back to="/rfps" text="RFPs" />
<h1>{rfp.title}</h1>
<Row gutter={16}>
{/* MAIN */}
<Col span={18}>
<Collapse defaultActiveKey={['brief', 'content']}>
<Collapse.Panel key="brief" header="brief">
{rfp.brief}
</Collapse.Panel>
<Collapse.Panel key="content" header="content">
<Markdown source={rfp.content} />
</Collapse.Panel>
<Collapse.Panel key="json" header="json">
<pre>{JSON.stringify(rfp, null, 4)}</pre>
</Collapse.Panel>
</Collapse>
</Col>
{/* RIGHT SIDE */}
<Col span={6}>
{/* ACTIONS */}
<Card className="RFPDetail-actions" size="small">
<Link to={`/rfps/${rfp.id}/edit`}>
<Button type="primary" icon="edit" block>
Edit
</Button>
</Link>
<Popconfirm
onConfirm={this.handleDelete}
title="Delete proposal?"
okText="delete"
cancelText="cancel"
>
<Button icon="delete" block>
Delete
</Button>
</Popconfirm>
</Card>
{/* DETAILS */}
<Card title="details" size="small">
{renderDeetItem('id', rfp.id)}
{renderDeetItem('created', formatDateSeconds(rfp.dateCreated))}
{renderDeetItem('status', rfp.status)}
{renderDeetItem('category', rfp.category)}
</Card>
{/* PROPOSALS */}
<Card title="Proposals" size="small">
{rfp.proposals.map(p => (
<Link to={`/proposals/${p.proposalId}`}>
<div>{p.title}</div>
<small>{p.brief}</small>
</Link>
))}
{!rfp.proposals.length && <div>No proposals (yet!)</div>}
</Card>
</Col>
</Row>
</div>
);
}
private getRFP = () => {
const rfpId = this.props.match.params.id;
if (rfpId) {
return store.rfps.find(rfp => rfp.id.toString() === rfpId);
}
};
private handleDelete = () => {
console.log('Delete');
};
}
export default withRouter(view(RFPDetail));

View File

@ -0,0 +1,21 @@
.RFPForm {
&-content {
&-preview {
font-size: 1rem;
overflow: auto;
// Taken from textarea to match it
padding: 4px 11px;
min-height: 136px;
max-height: 325px;
border: 1px solid #d9d9d9;
border-radius: 4px;
}
}
&-buttons {
.ant-btn {
margin-right: 0.5rem;
}
}
}

View File

@ -0,0 +1,213 @@
import React from 'react';
import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router';
import { Form, Input, Select, Icon, Button, message, Spin } from 'antd';
import Exception from 'ant-design-pro/lib/Exception';
import { FormComponentProps } from 'antd/lib/form';
import { PROPOSAL_CATEGORY, RFP_STATUS, RFPArgs } from 'src/types';
import { CATEGORY_UI } from 'util/ui';
import { typedKeys } from 'util/ts';
import { RFP_STATUSES, getStatusById } from 'util/statuses';
import Markdown from 'components/Markdown';
import Back from 'components/Back';
import store from 'src/store';
import './index.less';
type Props = FormComponentProps & RouteComponentProps<{ id?: string }>;
interface State {
isShowingPreview: boolean;
}
class RFPForm extends React.Component<Props, State> {
state: State = {
isShowingPreview: false,
};
constructor(props: Props) {
super(props);
const rfpId = this.getRFPId();
if (rfpId && !store.rfpsFetched) {
store.fetchRFPs();
}
}
render() {
const { isShowingPreview } = this.state;
const { getFieldDecorator, getFieldValue } = this.props.form;
let defaults: RFPArgs = {
title: '',
brief: '',
content: '',
category: '',
status: '',
};
const rfpId = this.getRFPId();
if (rfpId) {
if (!store.rfpsFetched) {
return <Spin />;
}
const rfp = store.rfps.find(r => r.id === rfpId);
if (rfp) {
defaults = {
title: rfp.title,
brief: rfp.brief,
content: rfp.content,
category: rfp.category,
status: rfp.status,
};
} else {
return <Exception type="404" desc="This RFP does not exist" />;
}
}
return (
<Form className="RFPForm" layout="vertical" onSubmit={this.handleSubmit}>
<Back to="/rfps" text="RFPs" />
<Form.Item label="Title">
{getFieldDecorator('title', {
initialValue: defaults.title,
rules: [
{ required: true, message: 'Title is required' },
{ max: 60, message: 'Max 60 chars' },
],
})(
<Input
autoComplete="off"
name="title"
placeholder="Max 60 chars"
size="large"
autoFocus
/>,
)}
</Form.Item>
{rfpId && (
<Form.Item label="Status">
{getFieldDecorator('status', {
initialValue: defaults.status,
rules: [{ required: true, message: 'Status is required' }],
})(
<Select size="large" placeholder="Select a status">
{typedKeys(RFP_STATUS).map(c => (
<Select.Option value={c} key={c}>
{getStatusById(RFP_STATUSES, c).tagDisplay}
</Select.Option>
))}
</Select>,
)}
</Form.Item>
)}
<Form.Item label="Category">
{getFieldDecorator('category', {
initialValue: defaults.category,
rules: [
{ required: true, message: 'Category is required' },
{ max: 60, message: 'Max 60 chars' },
],
})(
<Select size="large" placeholder="Select a category">
{typedKeys(PROPOSAL_CATEGORY).map(c => (
<Select.Option value={c} key={c}>
<Icon
type={CATEGORY_UI[c].icon}
style={{ color: CATEGORY_UI[c].color }}
/>{' '}
{CATEGORY_UI[c].label}
</Select.Option>
))}
</Select>,
)}
</Form.Item>
<Form.Item label="Brief description">
{getFieldDecorator('brief', {
initialValue: defaults.brief,
rules: [
{ required: true, message: 'Title is required' },
{ max: 200, message: 'Max 200 chars' },
],
})(<Input.TextArea rows={3} name="brief" placeholder="Max 200 chars" />)}
</Form.Item>
<Form.Item className="RFPForm-content" label="Content" required>
{/* Keep rendering even while hiding to not reset value */}
<div style={{ display: isShowingPreview ? 'none' : 'block' }}>
{getFieldDecorator('content', {
initialValue: defaults.content,
rules: [{ required: true, message: 'Content is required' }],
})(
<Input.TextArea
rows={8}
name="content"
placeholder="Preview will appear on the right"
autosize={{ minRows: 6, maxRows: 15 }}
/>,
)}
</div>
{isShowingPreview ? (
<>
<div className="RFPForm-content-preview">
<Markdown source={getFieldValue('content') || '_No content_'} />
</div>
<a className="RFPForm-content-previewToggle" onClick={this.togglePreview}>
Edit content
</a>
</>
) : (
<a className="RFPForm-content-previewToggle" onClick={this.togglePreview}>
Preview content
</a>
)}
</Form.Item>
<div className="RFPForm-buttons">
<Button type="primary" htmlType="submit" size="large">
Submit
</Button>
<Button type="ghost" size="large">
Cancel
</Button>
</div>
</Form>
);
}
private getRFPId = () => {
const rfpId = this.props.match.params.id;
if (rfpId) {
return parseInt(rfpId, 10);
}
};
private togglePreview = () => {
this.setState({ isShowingPreview: !this.state.isShowingPreview });
};
private handleSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
this.props.form.validateFieldsAndScroll(async (err: any, values: any) => {
if (err) return;
const rfpId = this.getRFPId();
let msg;
if (rfpId) {
await store.editRFP(rfpId, values);
msg = 'Successfully updated RFP';
} else {
await store.createRFP(values);
msg = 'Successfully created RFP. To publish, edit it and set status to "Live"';
}
if (store.rfpSaved) {
message.success(msg, 3);
this.props.history.replace('/rfps');
}
});
};
}
export default Form.create()(withRouter(view(RFPForm)));

View File

@ -0,0 +1,30 @@
.RFPs {
&-controls {
margin-bottom: 0.5rem;
& > * {
margin-right: 0.5rem;
}
}
&-list {
margin-top: 1rem;
&-rfp {
& h2 {
font-size: 1.4rem;
margin-bottom: 0;
& .ant-tag {
vertical-align: text-top;
margin-top: 0.2rem;
margin-left: 0.5rem;
}
}
& p {
color: rgba(#000, 0.5);
margin: 0;
}
}
}
}

View File

@ -0,0 +1,100 @@
import React from 'react';
import { view } from 'react-easy-state';
import { Button, List, Popconfirm, Spin, Tag, Tooltip, message } from 'antd';
import { RouteComponentProps, withRouter } from 'react-router';
import { Link } from 'react-router-dom';
import { RFP_STATUSES, getStatusById } from 'util/statuses';
import store from 'src/store';
import './index.less';
import { RFP } from 'src/types';
type Props = RouteComponentProps<any>;
interface State {
deletingId: number | null;
}
class RFPs extends React.Component<Props, State> {
state: State = {
deletingId: null,
};
componentDidMount() {
this.fetchRFPs();
}
render() {
const { rfps, rfpsFetching, rfpsFetched } = store;
const loading = !rfpsFetched || rfpsFetching;
return (
<div className="RFPs">
<div className="RFPs-controls">
<Link to="/rfps/new">
<Button>Create new RFP</Button>
</Link>
<Button title="refresh" icon="reload" onClick={this.fetchRFPs} />
</div>
<List
className="RFPs-list"
bordered
dataSource={rfps}
loading={loading}
renderItem={this.renderRFP}
/>
</div>
);
}
private fetchRFPs = () => {
store.fetchRFPs();
};
private renderRFP = (rfp: RFP) => {
const { deletingId } = this.state;
const actions = [
<Link key="edit" to={`/rfps/${rfp.id}/edit`}>
edit
</Link>,
<Popconfirm
key="delete"
title="Are you sure?"
okText="Delete"
okType="danger"
onConfirm={() => this.deleteRFP(rfp.id)}
placement="left"
>
<a>delete</a>
</Popconfirm>,
];
const status = getStatusById(RFP_STATUSES, rfp.status);
return (
<Spin key={rfp.id} spinning={deletingId === rfp.id}>
<List.Item className="RFPs-list-rfp" actions={actions}>
<Link to={`/rfps/${rfp.id}`}>
<h2>
{rfp.title || '(no title)'}
<Tooltip title={status.hint}>
<Tag color={status.tagColor}>{status.tagDisplay}</Tag>
</Tooltip>
</h2>
<p>{rfp.proposals.length} proposals submitted</p>
<p>{rfp.brief}</p>
</Link>
</List.Item>
</Spin>
);
};
private deleteRFP = (id: number) => {
this.setState({ deletingId: id }, async () => {
await store.deleteRFP(id);
if (store.rfpDeleted) {
message.success('Successfully deleted', 2);
}
this.setState({ deletingId: null });
});
};
}
export default withRouter(view(RFPs));

View File

@ -14,6 +14,7 @@ type Props = RouteComponentProps<any>;
class Template extends React.Component<Props> {
render() {
const { pathname } = this.props.location;
const pathbase = pathname.split('/')[1] || '/';
return (
<Layout className="Template">
{store.generalError.length > 0 && (
@ -31,34 +32,40 @@ class Template extends React.Component<Props> {
)}
<Sider className="Template-sider">
<div className="Template-sider-logo">ZF Grants</div>
<Menu theme="dark" mode="inline" selectedKeys={[pathname]}>
<Menu theme="dark" mode="inline" selectedKeys={[pathbase]}>
<Menu.Item key="/">
<Link to="/">
<Icon type="home" />
<span className="nav-text">home</span>
<span className="nav-text">Home</span>
</Link>
</Menu.Item>
<Menu.Item key="/users">
<Menu.Item key="users">
<Link to="/users">
<Icon type="user" />
<span className="nav-text">users</span>
<span className="nav-text">Users</span>
</Link>
</Menu.Item>
<Menu.Item key="/proposals">
<Menu.Item key="proposals">
<Link to="/proposals">
<Icon type="file" />
<span className="nav-text">proposals</span>
<span className="nav-text">Proposals</span>
</Link>
</Menu.Item>
<Menu.Item key="/emails">
<Menu.Item key="rfps">
<Link to="/rfps">
<Icon type="notification" />
<span className="nav-text">RFPs</span>
</Link>
</Menu.Item>
<Menu.Item key="emails">
<Link to="/emails">
<Icon type="mail" />
<span className="nav-text">emails</span>
<span className="nav-text">Emails</span>
</Link>
</Menu.Item>
<Menu.Item key="logout" onClick={store.logout}>
<Icon type="logout" />
<span className="nav-text">logout</span>
<span className="nav-text">Logout</span>
</Menu.Item>
</Menu>
</Sider>

View File

@ -1,6 +1,6 @@
import { store } from 'react-easy-state';
import axios, { AxiosError } from 'axios';
import { User, Proposal, EmailExample, PROPOSAL_STATUS } from './types';
import { User, Proposal, RFP, RFPArgs, EmailExample, PROPOSAL_STATUS } from './types';
// API
const api = axios.create({
@ -81,6 +81,25 @@ async function getEmailExample(type: string) {
return data;
}
async function getRFPs() {
const { data } = await api.get(`/admin/rfps`);
return data;
}
async function createRFP(args: RFPArgs) {
const { data } = await api.post('/admin/rfps', args);
return data;
}
async function editRFP(id: number, args: RFPArgs) {
const { data } = await api.put(`/admin/rfps/${id}`, args);
return data;
}
async function deleteRFP(id: number) {
await api.delete(`/admin/rfps/${id}`);
}
// STORE
const app = store({
hasCheckedLogin: false,
@ -108,6 +127,14 @@ const app = store({
proposalDetail: null as null | Proposal,
proposalDetailApproving: false,
rfps: [] as RFP[],
rfpsFetching: false,
rfpsFetched: false,
rfpSaving: false,
rfpSaved: false,
rfpDeleting: false,
rfpDeleted: false,
emailExamples: {} as { [type: string]: EmailExample },
removeGeneralError(i: number) {
@ -260,6 +287,55 @@ const app = store({
handleApiError(e);
}
},
async fetchRFPs() {
app.rfpsFetching = true;
try {
app.rfps = await getRFPs();
app.rfpsFetched = true;
} catch (e) {
handleApiError(e);
}
app.rfpsFetching = false;
},
async createRFP(args: RFPArgs) {
app.rfpSaving = true;
try {
const data = await createRFP(args);
app.rfps = [data, ...app.rfps];
app.rfpSaved = true;
} catch (e) {
handleApiError(e);
}
app.rfpSaving = false;
},
async editRFP(id: number, args: RFPArgs) {
app.rfpSaving = true;
app.rfpSaved = false;
try {
await editRFP(id, args);
app.rfpSaved = true;
await app.fetchRFPs();
} catch (e) {
handleApiError(e);
}
app.rfpSaving = false;
},
async deleteRFP(id: number) {
app.rfpDeleting = true;
app.rfpDeleted = false;
try {
await deleteRFP(id);
app.rfps = app.rfps.filter(rfp => rfp.id !== id);
app.rfpDeleted = true;
} catch (e) {
handleApiError(e);
}
app.rfpDeleting = false;
},
});
function handleApiError(e: AxiosError) {

View File

@ -13,7 +13,7 @@ export interface Milestone {
stage: string;
title: string;
}
// NOTE: sync with backend/grant/proposal/models.py STATUSES
// NOTE: sync with backend/grant/utils/enums.py ProposalStatus
export enum PROPOSAL_STATUS {
DRAFT = 'DRAFT',
PENDING = 'PENDING',
@ -72,6 +72,29 @@ export interface User {
comments: Comment[];
contributions: Contribution[];
}
// NOTE: sync with backend/grant/utils/enums.py RFPStatus
export enum RFP_STATUS {
DRAFT = 'DRAFT',
LIVE = 'LIVE',
CLOSED = 'CLOSED',
}
export interface RFP {
id: number;
dateCreated: number;
title: string;
brief: string;
content: string;
category: string;
status: string;
proposals: Proposal[];
}
export interface RFPArgs {
title: string;
brief: string;
content: string;
category: string;
status?: string;
}
export interface EmailExample {
info: {
@ -82,3 +105,12 @@ export interface EmailExample {
html: string;
text: string;
}
export enum PROPOSAL_CATEGORY {
DAPP = 'DAPP',
DEV_TOOL = 'DEV_TOOL',
CORE_DEV = 'CORE_DEV',
COMMUNITY = 'COMMUNITY',
DOCUMENTATION = 'DOCUMENTATION',
ACCESSIBILITY = 'ACCESSIBILITY',
}

View File

@ -1,14 +1,14 @@
import { PROPOSAL_STATUS } from 'src/types';
import { PROPOSAL_STATUS, RFP_STATUS } from 'src/types';
export interface ProposalStatusSoT {
id: PROPOSAL_STATUS;
export interface StatusSoT<E> {
id: E;
filterDisplay: string;
tagDisplay: string;
tagColor: string;
hint: string;
}
const STATUSES: ProposalStatusSoT[] = [
export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
{
id: PROPOSAL_STATUS.APPROVED,
filterDisplay: 'Status: approved',
@ -54,12 +54,35 @@ const STATUSES: ProposalStatusSoT[] = [
},
];
export const getStatusById = (id: PROPOSAL_STATUS) => {
const result = STATUSES.find(s => s.id === id);
export const RFP_STATUSES: Array<StatusSoT<RFP_STATUS>> = [
{
id: RFP_STATUS.DRAFT,
filterDisplay: 'Status: draft',
tagDisplay: 'Draft',
tagColor: '#ffaa00',
hint: 'RFP is currently being edited by admins and isnt visible to users.',
},
{
id: RFP_STATUS.LIVE,
filterDisplay: 'Status: live',
tagDisplay: 'Live',
tagColor: '#108ee9',
hint: 'RFP is live and users can submit proposals for it.',
},
{
id: RFP_STATUS.CLOSED,
filterDisplay: 'Status: closed',
tagDisplay: 'Closed',
tagColor: '#eb4118',
hint:
'RFP has been closed to new submissions and will no longer be listed, but can still be viewed, and associated proposals will remain open.',
},
];
export function getStatusById<E>(statuses: Array<StatusSoT<E>>, id: E) {
const result = statuses.find(s => s.id === id);
if (!result) {
throw Error(`getStatusById: could not find status for '${id}'`);
}
return result;
};
export default STATUSES;
}

4
admin/src/util/ts.ts Normal file
View File

@ -0,0 +1,4 @@
// This includes helper functions / types for Typescript
export function typedKeys<T extends object>(e: T): Array<keyof T> {
return Object.keys(e).map(k => k as keyof T);
}

43
admin/src/util/ui.ts Normal file
View File

@ -0,0 +1,43 @@
import { PROPOSAL_CATEGORY } from 'types';
interface EnumUI {
label: string;
color: string;
}
interface EnumUIWithIcon extends EnumUI {
icon: string;
}
export const CATEGORY_UI: { [key in PROPOSAL_CATEGORY]: EnumUIWithIcon } = {
DAPP: {
label: 'DApp',
color: '#8e44ad',
icon: 'appstore',
},
DEV_TOOL: {
label: 'Developer tool',
color: '#2c3e50',
icon: 'tool',
},
CORE_DEV: {
label: 'Core dev',
color: '#d35400',
icon: 'rocket',
},
COMMUNITY: {
label: 'Community',
color: '#27ae60',
icon: 'team',
},
DOCUMENTATION: {
label: 'Documentation',
color: '#95a5a6',
icon: 'paper-clip',
},
ACCESSIBILITY: {
label: 'Accessibility',
color: '#2980b9',
icon: 'eye-o',
},
};

View File

@ -10,10 +10,11 @@ from grant.proposal.models import (
proposals_schema,
proposal_schema,
user_proposal_contributions_schema,
PENDING
)
from grant.user.models import User, users_schema, user_schema
from grant.rfp.models import RFP, rfp_schema, rfps_schema
from grant.utils.admin import admin_auth_required, admin_is_authed, admin_login, admin_logout
from grant.utils.enums import ProposalStatus
from sqlalchemy import func, or_
from .example_emails import example_email_args
@ -53,7 +54,7 @@ def stats():
user_count = db.session.query(func.count(User.id)).scalar()
proposal_count = db.session.query(func.count(Proposal.id)).scalar()
proposal_pending_count = db.session.query(func.count(Proposal.id)) \
.filter(Proposal.status == PENDING) \
.filter(Proposal.status == ProposalStatus.PENDING) \
.scalar()
return {
"userCount": user_count,
@ -185,3 +186,83 @@ def get_email_example(type):
# Unserializable, so remove
email['info'].pop('subscription', None)
return email
# Requests for Proposal
@blueprint.route('/rfps', methods=['GET'])
@endpoint.api()
@admin_auth_required
def get_rfps():
rfps = RFP.query.all()
return rfps_schema.dump(rfps)
@blueprint.route('/rfps', methods=['POST'])
@endpoint.api(
parameter('title', type=str),
parameter('brief', type=str),
parameter('content', type=str),
parameter('category', type=str),
)
@admin_auth_required
def create_rfp(title, brief, content, category):
rfp = RFP(
title=title,
brief=brief,
content=content,
category=category,
)
db.session.add(rfp)
db.session.commit()
return rfp_schema.dump(rfp), 201
@blueprint.route('/rfps/<rfp_id>', methods=['GET'])
@endpoint.api()
@admin_auth_required
def get_rfp(rfp_id):
rfp = RFP.query.filter(RFP.id == rfp_id).first()
if not rfp:
return {"message": "No RFP matching that id"}, 404
return rfp_schema.dump(rfp)
@blueprint.route('/rfps/<rfp_id>', methods=['PUT'])
@endpoint.api(
parameter('title', type=str),
parameter('brief', type=str),
parameter('content', type=str),
parameter('category', type=str),
parameter('status', type=str),
)
@admin_auth_required
def update_rfp(rfp_id, title, brief, content, category, status):
rfp = RFP.query.filter(RFP.id == rfp_id).first()
if not rfp:
return {"message": "No RFP matching that id"}, 404
rfp.title = title
rfp.brief = brief
rfp.content = content
rfp.category = category
rfp.status = status
db.session.add(rfp)
db.session.commit()
return rfp_schema.dump(rfp)
@blueprint.route('/rfps/<rfp_id>', methods=['DELETE'])
@endpoint.api()
@admin_auth_required
def delete_rfp(rfp_id):
rfp = RFP.query.filter(RFP.id == rfp_id).first()
if not rfp:
return {"message": "No RFP matching that id"}, 404
db.session.delete(rfp)
db.session.commit()
return None, 200

View File

@ -5,7 +5,7 @@ from flask import Flask
from flask_cors import CORS
from flask_security import SQLAlchemyUserDatastore
from flask_sslify import SSLify
from grant import commands, proposal, user, comment, milestone, admin, email, blockchain, task
from grant import commands, proposal, user, comment, milestone, admin, email, blockchain, task, rfp
from grant.extensions import bcrypt, migrate, db, ma, security
from grant.settings import SENTRY_RELEASE, ENV
from sentry_sdk.integrations.flask import FlaskIntegration
@ -54,6 +54,7 @@ def register_blueprints(app):
app.register_blueprint(email.views.blueprint)
app.register_blueprint(blockchain.views.blueprint)
app.register_blueprint(task.views.blueprint)
app.register_blueprint(rfp.views.blueprint)
def register_shellcontext(app):

View File

@ -1,18 +1,17 @@
from grant.proposal.models import (
ProposalContribution,
proposal_contributions_schema,
PENDING,
CONFIRMED,
)
from grant.utils.requests import blockchain_post
from grant.utils.enums import ContributionStatus
def make_bootstrap_data():
pending_contributions = ProposalContribution.query \
.filter_by(status=PENDING) \
.filter_by(status=ContributionStatus.PENDING) \
.all()
latest_contribution = ProposalContribution.query \
.filter_by(status=CONFIRMED) \
.filter_by(status=ContributionStatus.CONFIRMED) \
.order_by(ProposalContribution.date_created.desc()) \
.first()
return {

View File

@ -1,5 +1,7 @@
import datetime
from functools import reduce
from sqlalchemy import func, or_
from sqlalchemy.ext.hybrid import hybrid_property
from grant.comment.models import Comment
from grant.email.send import send_email
@ -7,8 +9,7 @@ from grant.extensions import ma, db
from grant.utils.exceptions import ValidationException
from grant.utils.misc import dt_to_unix, make_url
from grant.utils.requests import blockchain_get
from sqlalchemy import func, or_
from sqlalchemy.ext.hybrid import hybrid_property
from grant.utils.enums import ProposalStatus, ProposalStage, Category, ContributionStatus
# Proposal states
DRAFT = 'DRAFT'
@ -109,7 +110,7 @@ class ProposalContribution(db.Model):
self.user_id = user_id
self.amount = amount
self.date_created = datetime.datetime.now()
self.status = PENDING
self.status = ContributionStatus.PENDING
@staticmethod
def get_existing_contribution(user_id: int, proposal_id: int, amount: str):
@ -117,19 +118,19 @@ class ProposalContribution(db.Model):
user_id=user_id,
proposal_id=proposal_id,
amount=amount,
status=PENDING,
status=ContributionStatus.PENDING,
).first()
@staticmethod
def get_by_userid(user_id):
return ProposalContribution.query \
.filter(ProposalContribution.user_id == user_id) \
.filter(ProposalContribution.status != DELETED) \
.filter(ProposalContribution.status != ContributionStatus.DELETED) \
.order_by(ProposalContribution.date_created.desc()) \
.all()
def confirm(self, tx_id: str, amount: str):
self.status = CONFIRMED
self.status = ContributionStatus.CONFIRMED
self.tx_id = tx_id
self.amount = amount
@ -168,7 +169,7 @@ class Proposal(db.Model):
def __init__(
self,
status: str = 'DRAFT',
status: str = ProposalStatus.DRAFT,
title: str = '',
brief: str = '',
content: str = '',
@ -196,10 +197,10 @@ class Proposal(db.Model):
category = proposal.get('category')
if title and len(title) > 60:
raise ValidationException("Proposal title cannot be longer than 60 characters")
if stage and stage not in PROPOSAL_STAGES:
raise ValidationException("Proposal stage {} not in {}".format(stage, PROPOSAL_STAGES))
if category and category not in CATEGORIES:
raise ValidationException("Category {} not in {}".format(category, CATEGORIES))
if stage and not ProposalStage.includes(stage):
raise ValidationException("Proposal stage {} is not a valid stage".format(stage))
if category and not Category.includes(category):
raise ValidationException("Category {} not a valid category".format(category))
def validate_publishable(self):
# Require certain fields
@ -219,7 +220,7 @@ class Proposal(db.Model):
)
@staticmethod
def get_by_user(user, statuses=[LIVE]):
def get_by_user(user, statuses=[ProposalStatus.LIVE]):
status_filter = or_(Proposal.status == v for v in statuses)
return Proposal.query \
.join(proposal_team) \
@ -256,21 +257,21 @@ class Proposal(db.Model):
def submit_for_approval(self):
self.validate_publishable()
allowed_statuses = [DRAFT, REJECTED]
allowed_statuses = [ProposalStatus.DRAFT, ProposalStatus.REJECTED]
# specific validation
if self.status not in allowed_statuses:
raise ValidationException(f"Proposal status must be {DRAFT} or {REJECTED} to submit for approval")
raise ValidationException(f"Proposal status must be draft or rejected to submit for approval")
self.status = PENDING
self.status = ProposalStatus.PENDING
def approve_pending(self, is_approve, reject_reason=None):
self.validate_publishable()
# specific validation
if not self.status == PENDING:
raise ValidationException(f"Proposal status must be {PENDING} to approve or reject")
if not self.status == ProposalStatus.PENDING:
raise ValidationException(f"Proposal must be pending to approve or reject")
if is_approve:
self.status = APPROVED
self.status = ProposalStatus.APPROVED
self.date_approved = datetime.datetime.now()
for t in self.team:
send_email(t.email_address, 'proposal_approved', {
@ -282,7 +283,7 @@ class Proposal(db.Model):
else:
if not reject_reason:
raise ValidationException("Please provide a reason for rejecting the proposal")
self.status = REJECTED
self.status = ProposalStatus.REJECTED
self.reject_reason = reject_reason
for t in self.team:
send_email(t.email_address, 'proposal_rejected', {
@ -295,16 +296,16 @@ class Proposal(db.Model):
def publish(self):
self.validate_publishable()
# specific validation
if not self.status == APPROVED:
raise ValidationException(f"Proposal status must be {APPROVED}")
if not self.status == ProposalStatus.APPROVED:
raise ValidationException(f"Proposal status must be approved")
self.date_published = datetime.datetime.now()
self.status = LIVE
self.status = ProposalStatus.LIVE
@hybrid_property
def contributed(self):
contributions = ProposalContribution.query \
.filter_by(proposal_id=self.id, status=CONFIRMED) \
.filter_by(proposal_id=self.id, status=ContributionStatus.CONFIRMED) \
.all()
funded = reduce(lambda prev, c: prev + float(c.amount), contributions, 0)
return str(funded)

View File

@ -9,6 +9,7 @@ from grant.user.models import User
from grant.utils.auth import requires_auth, requires_team_member_auth, get_authed_user, internal_webhook
from grant.utils.exceptions import ValidationException
from grant.utils.misc import is_email, make_url, from_zat, make_preview
from grant.utils.enums import ProposalStatus, ContributionStatus
from sqlalchemy import or_
from .models import (
@ -24,13 +25,6 @@ from .models import (
proposal_team_invite_schema,
proposal_proposal_contributions_schema,
db,
DRAFT,
PENDING,
APPROVED,
REJECTED,
LIVE,
DELETED,
CONFIRMED,
)
blueprint = Blueprint("proposal", __name__, url_prefix="/api/v1/proposals")
@ -41,8 +35,8 @@ blueprint = Blueprint("proposal", __name__, url_prefix="/api/v1/proposals")
def get_proposal(proposal_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if proposal:
if proposal.status != LIVE:
if proposal.status == DELETED:
if proposal.status != ProposalStatus.LIVE:
if proposal.status == ProposalStatus.DELETED:
return {"message": "Proposal was deleted"}, 404
authed_user = get_authed_user()
team_ids = list(x.id for x in proposal.team)
@ -136,13 +130,13 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id):
def get_proposals(stage):
if stage:
proposals = (
Proposal.query.filter_by(status=LIVE, stage=stage)
Proposal.query.filter_by(status=ProposalStatus.LIVE, stage=stage)
.order_by(Proposal.date_created.desc())
.all()
)
else:
proposals = (
Proposal.query.filter_by(status=LIVE)
Proposal.query.filter_by(status=ProposalStatus.LIVE)
.order_by(Proposal.date_created.desc())
.all()
)
@ -154,7 +148,7 @@ def get_proposals(stage):
@requires_auth
@endpoint.api()
def make_proposal_draft():
proposal = Proposal.create(status="DRAFT")
proposal = Proposal.create(status=ProposalStatus.DRAFT)
proposal.team.append(g.current_user)
db.session.add(proposal)
db.session.commit()
@ -167,11 +161,14 @@ def make_proposal_draft():
def get_proposal_drafts():
proposals = (
Proposal.query
.filter(or_(Proposal.status == DRAFT, Proposal.status == REJECTED))
.join(proposal_team)
.filter(proposal_team.c.user_id == g.current_user.id)
.order_by(Proposal.date_created.desc())
.all()
.filter(or_(
Proposal.status == ProposalStatus.DRAFT,
Proposal.status == ProposalStatus.REJECTED,
))
.join(proposal_team)
.filter(proposal_team.c.user_id == g.current_user.id)
.order_by(Proposal.date_created.desc())
.all()
)
return proposals_schema.dump(proposals), 200
@ -195,7 +192,6 @@ def update_proposal(milestones, proposal_id, **kwargs):
except ValidationException as e:
return {"message": "{}".format(str(e))}, 400
db.session.add(g.current_proposal)
# Delete & re-add milestones
[db.session.delete(x) for x in g.current_proposal.milestones]
if milestones:
@ -219,7 +215,12 @@ def update_proposal(milestones, proposal_id, **kwargs):
@requires_team_member_auth
@endpoint.api()
def delete_proposal(proposal_id):
deleteable_statuses = [DRAFT, PENDING, APPROVED, REJECTED]
deleteable_statuses = [
ProposalStatus.DRAFT,
ProposalStatus.PENDING,
ProposalStatus.APPROVED,
ProposalStatus.REJECTED,
]
status = g.current_proposal.status
if status not in deleteable_statuses:
return {"message": "Cannot delete proposals with %s status" % status}, 400
@ -366,12 +367,18 @@ def get_proposal_contributions(proposal_id):
return {"message": "No proposal matching id"}, 404
top_contributions = ProposalContribution.query \
.filter_by(proposal_id=proposal_id, status=CONFIRMED) \
.filter_by(
proposal_id=proposal_id,
status=ContributionStatus.CONFIRMED,
) \
.order_by(ProposalContribution.amount.desc()) \
.limit(5) \
.all()
latest_contributions = ProposalContribution.query \
.filter_by(proposal_id=proposal_id, status=CONFIRMED) \
.filter_by(
proposal_id=proposal_id,
status=ContributionStatus.CONFIRMED,
) \
.order_by(ProposalContribution.date_created.desc()) \
.limit(5) \
.all()
@ -441,7 +448,7 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
print(f'Unknown contribution {contribution_id} confirmed with txid {txid}')
return {"message": "No contribution matching id"}, 404
if contribution.status == CONFIRMED:
if contribution.status == ContributionStatus.CONFIRMED:
# Duplicates can happen, just return ok
return None, 200
@ -485,13 +492,13 @@ def delete_proposal_contribution(contribution_id):
if not contribution:
return {"message": "No contribution matching id"}, 404
if contribution.status == CONFIRMED:
if contribution.status == ContributionStatus.CONFIRMED:
return {"message": "Cannot delete confirmed contributions"}, 400
if contribution.user_id != g.current_user.id:
return {"message": "Must be the user of the contribution to delete it"}, 403
contribution.status = DELETED
contribution.status = ContributionStatus.DELETED
db.session.add(contribution)
db.session.commit()
return None, 202

View File

@ -0,0 +1,2 @@
from . import models
from . import views

View File

@ -0,0 +1,67 @@
import datetime
from grant.extensions import ma, db
from grant.utils.enums import RFPStatus
from grant.utils.misc import dt_to_unix
rfp_proposal = db.Table(
'rfp_proposal', db.Model.metadata,
db.Column('rfp_id', db.Integer, db.ForeignKey('rfp.id')),
db.Column('proposal_id', db.Integer, db.ForeignKey('proposal.id'), unique=True)
)
class RFP(db.Model):
__tablename__ = "rfp"
id = db.Column(db.Integer(), primary_key=True)
date_created = db.Column(db.DateTime)
title = db.Column(db.String(255), nullable=False)
brief = db.Column(db.String(255), nullable=False)
content = db.Column(db.Text, nullable=False)
category = db.Column(db.String(255), nullable=False)
status = db.Column(db.String(255), nullable=False)
# Relationships
proposals = db.relationship("Proposal", secondary=rfp_proposal)
def __init__(
self,
title: str,
brief: str,
content: str,
category: str,
status: str = RFPStatus.DRAFT,
):
self.date_created = datetime.datetime.now()
self.title = title
self.brief = brief
self.content = content
self.category = category
self.status = status
class RFPSchema(ma.Schema):
class Meta:
model = RFP
# Fields to expose
fields = (
"id",
"title",
"brief",
"content",
"category",
"status",
"date_created",
"proposals"
)
date_created = ma.Method("get_date_created")
proposals = ma.Nested("ProposalSchema", many=True)
def get_date_created(self, obj):
return dt_to_unix(obj.date_created)
rfp_schema = RFPSchema()
rfps_schema = RFPSchema(many=True)

View File

@ -0,0 +1,30 @@
from flask import Blueprint, g
from flask_yoloapi import endpoint, parameter
from sqlalchemy import or_
from grant.utils.enums import RFPStatus
from .models import RFP, rfp_schema, rfps_schema
blueprint = Blueprint("rfp", __name__, url_prefix="/api/v1/rfps")
@blueprint.route("/", methods=["GET"])
@endpoint.api()
def get_rfps():
rfps = RFP.query \
.filter(or_(
RFP.status == RFPStatus.LIVE,
RFP.status == RFPStatus.CLOSED,
)) \
.order_by(RFP.date_created.desc()) \
.all()
return rfps_schema.dump(rfps)
@blueprint.route("/<rfp_id>", methods=["GET"])
@endpoint.api()
def get_rfp(rfp_id):
rfp = RFP.query.filter_by(id=rfp_id).first()
if not rfp or rfp.status == RFPStatus.DRAFT:
return {"message": "No RFP with that ID"}, 404
return rfp_schema.dump(rfp)

View File

@ -11,15 +11,12 @@ from grant.proposal.models import (
ProposalContribution,
user_proposal_contributions_schema,
user_proposals_schema,
PENDING,
APPROVED,
REJECTED,
CONFIRMED
)
from grant.utils.auth import requires_auth, requires_same_user_auth, get_authed_user
from grant.utils.exceptions import ValidationException
from grant.utils.social import verify_social, get_social_login_url, VerifySocialException
from grant.utils.upload import remove_avatar, sign_avatar_upload, AvatarException
from grant.utils.enums import ProposalStatus, ContributionStatus
from .models import (
User,
@ -82,7 +79,7 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending):
if with_funded:
contributions = ProposalContribution.get_by_userid(user_id)
if not authed_user or user.id != authed_user.id:
contributions = [c for c in contributions if c.status == CONFIRMED]
contributions = [c for c in contributions if c.status == ContributionStatus.CONFIRMED]
contributions_dump = user_proposal_contributions_schema.dump(contributions)
result["contributions"] = contributions_dump
if with_comments:
@ -90,7 +87,11 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending):
comments_dump = user_comments_schema.dump(comments)
result["comments"] = comments_dump
if with_pending and authed_user and authed_user.id == user.id:
pending = Proposal.get_by_user(user, [PENDING, APPROVED, REJECTED])
pending = Proposal.get_by_user(user, [
ProposalStatus.PENDING,
ProposalStatus.APPROVED,
ProposalStatus.REJECTED,
])
pending_dump = user_proposals_schema.dump(pending)
result["pendingProposals"] = pending_dump
return result

View File

@ -0,0 +1,45 @@
# Our own Enum class with custom functionality, not Python's
class CustomEnum():
# Adds an .includes function that tests if a value is in enum
def includes(self, enum: str):
return hasattr(self, enum)
class ProposalStatusEnum(CustomEnum):
DRAFT = 'DRAFT'
PENDING = 'PENDING'
APPROVED = 'APPROVED'
REJECTED = 'REJECTED'
LIVE = 'LIVE'
DELETED = 'DELETED'
ProposalStatus = ProposalStatusEnum()
class ProposalStageEnum(CustomEnum):
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
COMPLETED = 'COMPLETED'
ProposalStage = ProposalStageEnum()
class CategoryEnum(CustomEnum):
DAPP = 'DAPP'
DEV_TOOL = 'DEV_TOOL'
CORE_DEV = 'CORE_DEV'
COMMUNITY = 'COMMUNITY'
DOCUMENTATION = 'DOCUMENTATION'
ACCESSIBILITY = 'ACCESSIBILITY'
Category = CategoryEnum()
class ContributionStatusEnum(CustomEnum):
PENDING = 'PENDING'
CONFIRMED = 'CONFIRMED'
DELETED = 'DELETED'
ContributionStatus = ContributionStatusEnum()
class RFPStatusEnum(CustomEnum):
DRAFT = 'DRAFT'
LIVE = 'LIVE'
CLOSED = 'CLOSED'
RFPStatus = RFPStatusEnum()

View File

@ -0,0 +1,45 @@
"""empty message
Revision ID: edf057ef742a
Revises: eddbe541cff1
Create Date: 2019-01-25 14:37:07.858965
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'edf057ef742a'
down_revision = 'eddbe541cff1'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('rfp',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=True),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('brief', sa.String(length=255), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('category', sa.String(length=255), nullable=False),
sa.Column('status', sa.String(length=255), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('rfp_proposal',
sa.Column('rfp_id', sa.Integer(), nullable=True),
sa.Column('proposal_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.ForeignKeyConstraint(['rfp_id'], ['rfp.id'], ),
sa.UniqueConstraint('proposal_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('rfp_proposal')
op.drop_table('rfp')
# ### end Alembic commands ###

View File

@ -1,4 +1,4 @@
from grant.proposal.models import APPROVED, REJECTED
from grant.utils.enums import ProposalStatus
from grant.utils.admin import generate_admin_password_hash
from mock import patch
@ -104,7 +104,7 @@ class TestAdminAPI(BaseProposalCreatorConfig):
data={"isApprove": True}
)
self.assert200(resp)
self.assertEqual(resp.json["status"], APPROVED)
self.assertEqual(resp.json["status"], ProposalStatus.APPROVED)
def test_reject_proposal(self):
self.login_admin()
@ -116,5 +116,5 @@ class TestAdminAPI(BaseProposalCreatorConfig):
data={"isApprove": False, "rejectReason": "Funnzies."}
)
self.assert200(resp)
self.assertEqual(resp.json["status"], REJECTED)
self.assertEqual(resp.json["status"], ProposalStatus.REJECTED)
self.assertEqual(resp.json["rejectReason"], "Funnzies.")

View File

@ -5,6 +5,7 @@ from grant.app import create_app
from grant.proposal.models import Proposal
from grant.task.jobs import ProposalReminder
from grant.user.models import User, SocialMedia, db, Avatar
from grant.utils.enums import ProposalStatus
from .test_data import test_user, test_other_user, test_proposal
@ -113,7 +114,7 @@ class BaseProposalCreatorConfig(BaseUserConfig):
def setUp(self):
super().setUp()
self._proposal = Proposal.create(
status="DRAFT",
status=ProposalStatus.DRAFT,
title=test_proposal["title"],
content=test_proposal["content"],
brief=test_proposal["brief"],
@ -125,7 +126,7 @@ class BaseProposalCreatorConfig(BaseUserConfig):
self._proposal.team.append(self.user)
db.session.add(self._proposal)
self._other_proposal = Proposal.create(status="DRAFT")
self._other_proposal = Proposal.create(status=ProposalStatus.DRAFT)
self._other_proposal.team.append(self.other_user)
db.session.add(self._other_proposal)
db.session.commit()

View File

@ -1,6 +1,7 @@
import json
from grant.proposal.models import Proposal, PENDING
from grant.proposal.models import Proposal
from grant.utils.enums import ProposalStatus
from ..config import BaseProposalCreatorConfig
from ..test_data import test_proposal
@ -81,7 +82,7 @@ class TestProposalAPI(BaseProposalCreatorConfig):
def test_invalid_status_proposal_draft_submit_for_approval(self):
self.login_default_user()
self.proposal.status = PENDING # should be DRAFT
self.proposal.status = ProposalStatus.PENDING # should be ProposalStatus.DRAFT
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
self.assert400(resp)
@ -105,7 +106,7 @@ class TestProposalAPI(BaseProposalCreatorConfig):
def test_invalid_status_proposal_publish_proposal(self):
self.login_default_user()
self.proposal.status = PENDING # should be APPROVED
self.proposal.status = ProposalStatus.PENDING # should be ProposalStatus.APPROVED
resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id))
self.assert400(resp)

View File

@ -1,6 +1,7 @@
import json
from grant.proposal.models import Proposal, db
from grant.utils.enums import ProposalStatus
from ..config import BaseUserConfig
from ..test_data import test_comment, test_reply
@ -9,9 +10,7 @@ from ..test_data import test_comment, test_reply
class TestProposalCommentAPI(BaseUserConfig):
def test_unauthorized_create_new_proposal_comment(self):
# no login
proposal = Proposal(
status="LIVE"
)
proposal = Proposal(status=ProposalStatus.LIVE)
db.session.add(proposal)
db.session.commit()
@ -24,9 +23,7 @@ class TestProposalCommentAPI(BaseUserConfig):
def test_create_new_proposal_comment(self):
self.login_default_user()
proposal = Proposal(
status="LIVE"
)
proposal = Proposal(status=ProposalStatus.LIVE)
db.session.add(proposal)
db.session.commit()
@ -48,9 +45,7 @@ class TestProposalCommentAPI(BaseUserConfig):
def test_create_new_proposal_comment_reply(self):
self.login_default_user()
proposal = Proposal(
status="LIVE"
)
proposal = Proposal(status=ProposalStatus.LIVE)
db.session.add(proposal)
db.session.commit()
proposal_id = proposal.id
@ -77,9 +72,7 @@ class TestProposalCommentAPI(BaseUserConfig):
def test_invalid_parent_comment_id_create_reply(self):
self.login_default_user()
proposal = Proposal(
status="LIVE"
)
proposal = Proposal(status=ProposalStatus.LIVE)
db.session.add(proposal)
db.session.commit()
proposal_id = proposal.id

View File

@ -2,6 +2,7 @@ import json
from mock import patch
from grant.proposal.models import Proposal
from grant.utils.enums import ProposalStatus
from ..config import BaseUserConfig
from ..test_data import test_proposal
from ..mocks import mock_request
@ -96,4 +97,4 @@ class TestProposalContributionAPI(BaseUserConfig):
contribution = contribution_res.json
self.assertEqual(contribution['id'], contribution_id)
self.assertEqual(contribution['status'], 'PENDING')
self.assertEqual(contribution['status'], ProposalStatus.PENDING)

View File

@ -1,6 +1,4 @@
import random
from grant.proposal.models import CATEGORIES
from grant.utils.enums import Category
test_user = {
@ -45,7 +43,7 @@ test_proposal = {
"title": "Give Me Money",
"brief": "$$$",
"milestones": milestones,
"category": random.choice(CATEGORIES),
"category": Category.ACCESSIBILITY,
"target": "123.456",
"payoutAddress": "123",
"deadlineDuration": 100

View File

@ -33,6 +33,8 @@ const VerifyEmail = loadable(() => import('pages/email-verify'), opts);
const Callback = loadable(() => import('pages/callback'), opts);
const RecoverEmail = loadable(() => import('pages/email-recover'), opts);
const UnsubscribeEmail = loadable(() => import('pages/email-unsubscribe'), opts);
const RFP = loadable(() => import('pages/rfp'), opts);
const RFPs = loadable(() => import('pages/rfps'), opts);
import 'styles/style.less';
import Loader from 'components/Loader';
@ -104,6 +106,27 @@ const routeConfigs: RouteConfig[] = [
title: 'Proposal',
},
},
{
// RFP list page,
route: {
path: '/requests',
component: RFPs,
exact: true,
},
template: {
title: 'Requests',
},
},
{
// RFP detail page
route: {
path: '/requests/:id',
component: RFP,
},
template: {
title: 'Request',
},
},
{
// Self profile
route: {

View File

@ -9,8 +9,9 @@ import {
SOCIAL_SERVICE,
ContributionWithAddresses,
EmailSubscriptions,
RFP,
} from 'types';
import { formatUserForPost, formatProposalFromGet, formatUserFromGet } from 'utils/api';
import { formatUserForPost, formatProposalFromGet, formatUserFromGet, formatRFPFromGet } from 'utils/api';
export function getProposals(): Promise<{ data: Proposal[] }> {
return axios.get('/api/v1/proposals/').then(res => {
@ -259,3 +260,17 @@ export function getProposalContribution(
): Promise<{ data: ContributionWithAddresses }> {
return axios.get(`/api/v1/proposals/${proposalId}/contributions/${contributionId}`);
}
export function getRFPs(): Promise<{ data: RFP[] }> {
return axios.get('/api/v1/rfps/').then(res => {
res.data = res.data.map(formatRFPFromGet);
return res;
});
}
export function getRFP(rfpId: number | string): Promise<{ data: RFP }> {
return axios.get(`/api/v1/rfps/${rfpId}`).then(res => {
res.data = formatRFPFromGet(res.data);
return res;
});
}

View File

@ -85,3 +85,9 @@ export const STAGE_UI: { [key in PROPOSAL_STAGE]: StageUI } = {
color: '#27ae60',
},
};
export enum RFP_STATUS {
DRAFT = 'DRAFT',
LIVE = 'LIVE',
CLOSED = 'CLOSED',
}

View File

@ -0,0 +1,50 @@
@import '~styles/variables.less';
.Card {
position: relative;
border: 1px solid #eee;
padding: 1rem 1rem 0;
border-radius: 2px;
margin-bottom: 1.5rem;
cursor: pointer;
transition-property: border-color, box-shadow, transform;
transition-duration: 100ms;
transition-timing-function: ease;
color: @text-color;
&:hover,
&:focus {
border: 1px solid #ccc;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.03);
transform: translateY(-2px);
}
&-title {
display: -webkit-box;
font-size: 1rem;
line-height: 1.3rem;
height: 2.6rem;
margin-bottom: 1rem;
text-overflow: ellipsis;
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
&-info {
display: flex;
justify-content: space-between;
margin: 0.5rem -1rem 0;
padding: 0.75rem 1rem;
border-top: 1px solid #eee;
background: #fafafa;
&-category {
border-radius: 4px;
}
&-created {
opacity: 0.6;
}
}
}

View File

@ -0,0 +1,51 @@
import React from 'react';
import moment from 'moment';
import classnames from 'classnames';
import { Icon } from 'antd';
import { PROPOSAL_CATEGORY, CATEGORY_UI } from 'api/constants';
import './index.less';
import { Link } from 'react-router-dom';
interface CardInfoProps {
category: PROPOSAL_CATEGORY;
time: number;
}
export const CardInfo: React.SFC<CardInfoProps> = ({ category, time }) => (
<div className="Card-info">
<div
className="ProposalCard-info-category"
style={{ color: CATEGORY_UI[category].color }}
>
<Icon type={CATEGORY_UI[category].icon} /> {CATEGORY_UI[category].label}
</div>
<div className="ProposalCard-info-created">
{moment(time).fromNow()}
</div>
</div>
);
interface CardProps {
to: string;
title: React.ReactNode;
children: React.ReactNode;
className?: string;
}
export class Card extends React.Component<CardProps> {
public static Info = CardInfo;
render() {
const { to, title, children, className } = this.props;
return (
<Link to={to}>
<div className={classnames('Card', className)}>
<h3 className="Card-title">{title}</h3>
{children}
</div>
</Link>
)
}
}
export default Card;

View File

@ -34,7 +34,10 @@ export default class Header extends React.Component<Props, State> {
<div className="Header-inner">
<div className="Header-links is-left is-desktop">
<Link to="/proposals" className="Header-links-link">
Browse
Proposals
</Link>
<Link to="/requests" className="Header-links-link">
Requests
</Link>
<Link to="/create" className="Header-links-link">
Start a Proposal

View File

@ -1,8 +1,9 @@
@import '~styles/variables.less';
@header-height: 62px;
@header-transition: 120ms;
@small-query: ~'(max-width: 660px)';
@big-query: ~'(min-width: 661px)';
@header-transition: 200ms;
@link-padding: 0.7rem;
@small-query: ~'(max-width: 820px)';
@big-query: ~'(min-width: 821px)';
.Header {
top: 0;
@ -65,15 +66,20 @@
&-links {
display: flex;
transition: transform @header-transition ease;
.is-transparent & {
transform: translateY(20%);
}
&.is-left {
justify-self: flex-start;
margin-left: -0.75rem;
margin-left: -@link-padding;
}
&.is-right {
justify-self: flex-end;
margin-right: -0.75rem;
margin-right: -@link-padding;
}
&.is-desktop {
@ -91,11 +97,9 @@
&-link {
display: block;
background: none;
padding: 0 0.75rem;
font-size: 1rem;
font-weight: 300;
padding: 0 @link-padding;
font-size: 0.9rem;
color: inherit;
letter-spacing: 0.05rem;
cursor: pointer;
opacity: 0.8;
transition: transform 100ms ease, opacity 100ms ease;

View File

@ -1,10 +1,9 @@
import React from 'react';
import classnames from 'classnames';
import { Progress, Icon } from 'antd';
import moment from 'moment';
import { Progress } from 'antd';
import { Redirect } from 'react-router-dom';
import { CATEGORY_UI } from 'api/constants';
import { Proposal } from 'types';
import Card from 'components/Card';
import UserAvatar from 'components/UserAvatar';
import UnitDisplay from 'components/UnitDisplay';
import './style.less';
@ -29,11 +28,11 @@ export class ProposalCard extends React.Component<Proposal> {
} = this.props;
return (
<div
<Card
className="ProposalCard"
onClick={() => this.setState({ redirect: `/proposals/${proposalUrlId}` })}
to={`/proposals/${proposalUrlId}`}
title={title}
>
<h3 className="ProposalCard-title">{title}</h3>
{contributionMatching > 0 && (
<div className="ProposalCard-ribbon">
<span>
@ -78,19 +77,8 @@ export class ProposalCard extends React.Component<Proposal> {
</div>
</div>
<div className="ProposalCard-address">{proposalAddress}</div>
<div className="ProposalCard-info">
<div
className="ProposalCard-info-category"
style={{ color: CATEGORY_UI[category].color }}
>
<Icon type={CATEGORY_UI[category].icon} /> {CATEGORY_UI[category].label}
</div>
<div className="ProposalCard-info-created">
{moment(dateCreated * 1000).fromNow()}
</div>
</div>
</div>
<Card.Info category={category} time={dateCreated * 1000} />
</Card>
);
}
}

View File

@ -1,24 +1,6 @@
@import '~styles/variables.less';
.ProposalCard {
position: relative;
background: white;
border: 1px solid #eee;
padding: 1rem 1rem 0;
border-radius: 2px;
margin-bottom: 1.5rem;
cursor: pointer;
transition-property: border-color, box-shadow, transform;
transition-duration: 100ms;
transition-timing-function: ease;
&:hover,
&:focus {
border: 1px solid #ccc;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.03);
transform: translateY(-2px);
}
&-ribbon {
position: absolute;
top: 0;
@ -47,18 +29,6 @@
}
}
&-title {
display: -webkit-box;
font-size: 1rem;
line-height: 1.3rem;
height: 2.6rem;
margin-bottom: 1rem;
text-overflow: ellipsis;
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
&-team {
display: flex;
justify-content: space-between;
@ -118,23 +88,6 @@
}
}
&-info {
display: flex;
justify-content: space-between;
margin: 0.5rem -1rem 0;
padding: 0.75rem 1rem;
border-top: 1px solid #eee;
background: #fafafa;
&-category {
border-radius: 4px;
}
&-created {
opacity: 0.6;
}
}
&-address {
font-size: 0.7rem;
margin-right: 2.5rem;

View File

@ -0,0 +1,36 @@
@import '~styles/variables.less';
.RFPDetail {
max-width: 780px;
margin: 0 auto;
&-top {
display: flex;
justify-content: space-between;
margin-bottom: 1.75rem;
font-size: 0.8rem;
&-back {
opacity: 0.7;
&:hover {
opacity: 1;
}
}
&-date {
opacity: 0.7;
}
}
&-title {
font-size: 2.4rem;
text-align: center;
font-weight: bold;
margin-bottom: 1.75rem;
}
&-content {
font-size: 1.15rem;
}
}

View File

@ -0,0 +1,72 @@
import React from 'react';
import moment from 'moment';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { Icon } from 'antd';
import Exception from 'ant-design-pro/lib/Exception';
import { fetchRfp } from 'modules/rfps/actions';
import { getRfp } from 'modules/rfps/selectors';
import { RFP } from 'types';
import { AppState } from 'store/reducers';
import Loader from 'components/Loader';
import Markdown from 'components/Markdown';
import './index.less';
interface OwnProps {
rfpId: number;
}
interface StateProps {
rfp: RFP | undefined;
isFetchingRfps: AppState['rfps']['isFetchingRfps'];
fetchRfpsError: AppState['rfps']['fetchRfpsError'];
}
interface DispatchProps {
fetchRfp: typeof fetchRfp;
}
type Props = OwnProps & StateProps & DispatchProps;
class RFPDetail extends React.Component<Props> {
componentDidMount() {
this.props.fetchRfp(this.props.rfpId);
}
render() {
const { rfp, isFetchingRfps } = this.props;
// Optimistically render rfp if we have it, but are updating it
if (!rfp) {
if (isFetchingRfps) {
return <Loader size="large" />;
} else {
return <Exception type="404" desc="No request could be found" />;
}
}
return (
<div className="RFPDetail">
<div className="RFPDetail-top">
<Link className="RFPDetail-top-back" to="/requests">
<Icon type="arrow-left" /> Back to Requests
</Link>
<div className="RFPDetail-top-date">
Opened {moment(rfp.dateCreated * 1000).format('LL')}
</div>
</div>
<h1 className="RFPDetail-title">{rfp.title}</h1>
<Markdown className="RFPDetail-content" source={rfp.content} />
</div>
);
}
}
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
(state, ownProps) => ({
rfp: getRfp(state, ownProps.rfpId),
isFetchingRfps: state.rfps.isFetchingRfps,
fetchRfpsError: state.rfps.fetchRfpsError,
}),
{ fetchRfp },
)(RFPDetail);

View File

@ -0,0 +1,12 @@
.RFPCard {
&-brief {
height: 1.6rem * 3;
line-height: 1.6rem;
margin-top: -1rem;
opacity: 0.8;
}
&-proposals {
opacity: 0.5;
}
}

View File

@ -0,0 +1,24 @@
import React from 'react';
import { RFP } from 'types';
import Card from 'components/Card';
import './RFPCard.less';
interface Props {
rfp: RFP;
}
export default class RFPCard extends React.Component<Props> {
render() {
const { id, title, brief, proposals, category, dateCreated } = this.props.rfp;
return (
<Card className="RFPCard" to={`/requests/${id}`} title={title}>
<p className="RFPCard-brief">{brief}</p>
<div className="RFPCard-proposals">
{proposals.length} proposals approved
</div>
<Card.Info category={category} time={dateCreated * 1000} />
</Card>
);
}
}

View File

@ -0,0 +1,57 @@
.RFPs {
&-about {
display: flex;
align-content: center;
max-width: 880px;
margin: 0 auto 3rem;
&-logo {
flex-shrink: 0;
width: 140px;
padding-right: 1.5rem;
margin-right: 1.5rem;
border-right: 1px solid rgba(#000, 0.05);
svg {
width: 100%;
height: auto;
}
}
&-text {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
&-title {
font-size: 1.6rem;
}
&-desc {
font-size: 1rem;
}
}
}
&-list {
max-width: 1180px;
margin: 2rem auto 0;
&-title {
font-size: 1.4rem;
margin-bottom: 1rem;
}
}
&-loading {
position: relative;
height: 12rem;
}
&-error {
max-width: 880px;
margin: 0 auto;
padding: 2rem 0;
}
}

View File

@ -0,0 +1,114 @@
import React from 'react';
import { connect } from 'react-redux';
import { Row, Col } from 'antd';
import { fetchRfps } from 'modules/rfps/actions';
import { AppState } from 'store/reducers';
import { RFP } from 'types';
import { RFP_STATUS } from 'api/constants';
import Loader from 'components/Loader';
import Placeholder from 'components/Placeholder';
import RFPCard from './RFPCard';
import ZCFLogo from 'static/images/zcf.svg';
import './index.less';
interface StateProps {
rfps: AppState['rfps']['rfps'];
isFetchingRfps: AppState['rfps']['isFetchingRfps'];
fetchRfpsError: AppState['rfps']['fetchRfpsError'];
}
interface DispatchProps {
fetchRfps: typeof fetchRfps;
}
type Props = StateProps & DispatchProps;
class RFPs extends React.Component<Props> {
componentDidMount() {
this.props.fetchRfps();
}
render() {
const { rfps, isFetchingRfps, fetchRfpsError } = this.props;
let rfpsEl;
if (fetchRfpsError) {
rfpsEl = (
<div className="RFPs-error">
<Placeholder
title="Something went wrong"
subtitle="We had an issue fetching requests, try again later"
/>
</div>
);
}
else if (!isFetchingRfps) {
rfpsEl = (
<div className="RFPs-loading">
<Loader size="large" />
</div>
);
}
else {
const live = rfps.filter(rfp => rfp.status === RFP_STATUS.LIVE);
const closed = rfps.filter(rfp => rfp.status === RFP_STATUS.CLOSED);
rfpsEl = <>
{this.renderRfpsList('Open Requests', live)}
{!!closed.length && this.renderRfpsList('Closed Requests', closed)}
</>;
}
return (
<div className="RFPs">
<div className="RFPs-about">
<div className="RFPs-about-logo">
<ZCFLogo />
</div>
<div className="RFPs-about-text">
<h2 className="RFPs-about-text-title">Zcash Foundation Requests</h2>
<p className="RFPs-about-text-desc">
The Zcash Foundation periodically makes requests for proposals
that solve high-priority needs in the Zcash ecosystem. These
proposals will typically receive large or matched contributions,
should they be approved by the foundation.
</p>
</div>
</div>
{rfpsEl}
</div>
);
}
private renderRfpsList = (title: string, rfps: RFP[]) => {
return (
<div className="RFPs-list">
<h3 className="RFPs-list-title">{title}</h3>
<div className="RFPs-list-rfps">
{rfps.length ? (
<Row gutter={20}>
{rfps.map(rfp => (
<Col xl={8} lg={12} md={24} key={rfp.id}>
<RFPCard key={rfp.id} rfp={rfp} />
</Col>
))}
</Row>
) : (
<Placeholder
title="No requests are currently active"
subtitle="Check back later for more opportunities"
/>
)}
</div>
</div>
);
};
}
export default connect<StateProps, DispatchProps, {}, AppState>(
state => ({
rfps: state.rfps.rfps,
isFetchingRfps: state.rfps.isFetchingRfps,
fetchRfpsError: state.rfps.fetchRfpsError,
}),
{ fetchRfps },
)(RFPs);

View File

@ -0,0 +1,26 @@
import types from './types';
import { getRFPs, getRFP } from 'api/api';
import { Dispatch } from 'redux';
import { RFP } from 'types';
export function fetchRfps() {
return async (dispatch: Dispatch<any>) => {
return dispatch({
type: types.FETCH_RFPS,
payload: async () => {
return (await getRFPs()).data;
},
});
};
}
export function fetchRfp(rfpId: RFP['id']) {
return async (dispatch: Dispatch<any>) => {
return dispatch({
type: types.FETCH_RFP,
payload: async () => {
return (await getRFP(rfpId)).data;
},
});
};
}

View File

@ -0,0 +1,7 @@
import reducers, { RFPState, INITIAL_STATE } from './reducers';
import * as rfpActions from './actions';
import * as rfpTypes from './types';
export { rfpActions, rfpTypes, RFPState, INITIAL_STATE };
export default reducers;

View File

@ -0,0 +1,67 @@
import types from './types';
import { RFP } from 'types';
export interface RFPState {
rfps: RFP[];
fetchRfpsError: null | string;
isFetchingRfps: boolean;
}
export const INITIAL_STATE: RFPState = {
rfps: [],
fetchRfpsError: null,
isFetchingRfps: false,
};
function addRfp(state: RFPState, payload: RFP) {
const existingProposal = state.rfps.find(
(rfp: RFP) => rfp.id === payload.id,
);
const rfps = [...state.rfps];
if (!existingProposal) {
rfps.push(payload);
} else {
const index = rfps.indexOf(existingProposal);
rfps[index] = payload;
}
return {
...state,
isFetchingRfps: false,
rfps,
};
}
export default (state: RFPState = INITIAL_STATE, action: any): RFPState => {
const { payload } = action;
switch (action.type) {
case types.FETCH_RFPS_PENDING:
case types.FETCH_RFP_PENDING:
return {
...state,
fetchRfpsError: null,
isFetchingRfps: true,
};
case types.FETCH_RFPS_REJECTED:
case types.FETCH_RFP_REJECTED:
return {
...state,
// TODO: Get action to send real error
fetchRfpsError: 'Failed to fetch rfps',
isFetchingRfps: false,
};
case types.FETCH_RFPS_FULFILLED:
return {
...state,
rfps: payload,
fetchRfpsError: null,
isFetchingRfps: true,
};
case types.FETCH_RFP_FULFILLED:
return addRfp(state, payload);
}
return state;
};

View File

@ -0,0 +1,6 @@
import { AppState } from 'store/reducers';
import { RFP } from 'types';
export function getRfp(state: AppState, rfpId: RFP['id']) {
return state.rfps.rfps.find(rfp => rfp.id === rfpId);
}

View File

@ -0,0 +1,14 @@
enum rfpTypes {
FETCH_RFPS = 'FETCH_RFPS',
FETCH_RFPS_FULFILLED = 'FETCH_RFPS_FULFILLED',
FETCH_RFPS_REJECTED = 'FETCH_RFPS_REJECTED',
FETCH_RFPS_PENDING = 'FETCH_RFPS_PENDING',
FETCH_RFP = 'FETCH_RFP',
FETCH_RFP_FULFILLED = 'FETCH_RFP_FULFILLED',
FETCH_RFP_REJECTED = 'FETCH_RFP_REJECTED',
FETCH_RFP_PENDING = 'FETCH_RFP_PENDING',
}
export default rfpTypes;

View File

@ -1,6 +1,6 @@
import React, { Component } from 'react';
import Proposal from 'components/Proposal';
import { extractProposalIdFromUrl } from 'utils/api';
import { extractIdFromSlug } from 'utils/api';
import { withRouter, RouteComponentProps } from 'react-router';
@ -11,7 +11,7 @@ class ProposalPage extends Component<RouteProps> {
super(props);
}
render() {
const proposalId = extractProposalIdFromUrl(this.props.match.params.id);
const proposalId = extractIdFromSlug(this.props.match.params.id);
return <Proposal proposalId={proposalId} />;
}
}

View File

@ -0,0 +1,19 @@
import React, { Component } from 'react';
import RFP from 'components/RFP';
import { extractIdFromSlug } from 'utils/api';
import { withRouter, RouteComponentProps } from 'react-router';
type RouteProps = RouteComponentProps<any>;
class ProposalPage extends Component<RouteProps> {
constructor(props: RouteProps) {
super(props);
}
render() {
const rfpId = extractIdFromSlug(this.props.match.params.id);
return <RFP rfpId={rfpId} />;
}
}
export default withRouter(ProposalPage);

View File

@ -0,0 +1,6 @@
import React from 'react';
import RFPs from 'components/RFPs';
const RFPsPage = () => <RFPs />;
export default RFPsPage;

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 154 197" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 49.3 (51167) - http://www.bohemiancoding.com/sketch -->
<title>Artboard</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Artboard" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M34.5068,63.5988 C36.6718,62.3588 38.8998,60.9618 41.0558,59.6108 C43.9138,57.8198 46.8708,55.9788 50.0388,54.2278 L50.1028,46.3408 C50.1638,38.7998 53.0248,31.7528 58.1588,26.4978 C63.1998,21.3388 69.8718,18.5658 76.8618,18.5928 C91.4988,18.7198 103.3048,31.3828 103.1808,46.8168 L103.1208,54.2428 C106.0318,55.9278 109.0548,57.7618 112.2698,59.8118 C115.3578,61.7798 118.1178,63.4738 120.7458,64.8018 L120.8898,46.9618 C121.0938,21.7658 101.4118,1.0948 77.0168,0.8838 C65.0608,0.7358 53.9318,5.4798 45.4908,14.1208 C37.1428,22.6658 32.4908,34.0568 32.3928,46.1978 L32.2458,64.5068 L32.8628,64.5118 C33.3998,64.2218 33.9448,63.9208 34.5068,63.5988" id="Fill-46" fill="#2D2A26"></path>
<path d="M78.3041,186.5145 C76.5871,187.5305 76.1891,187.2985 75.7311,187.0275 C67.5161,182.2105 60.7811,177.0615 55.1421,171.2835 C48.4661,164.4455 42.9731,156.7405 38.5541,148.0255 L38.5451,148.0295 C37.6721,146.6625 36.5341,145.7055 35.2561,145.3345 L32.8731,145.3345 C32.7631,145.3655 32.6531,145.3965 32.5431,145.4355 C30.1061,146.3055 28.8381,148.9995 29.6831,151.4795 C34.5991,161.3885 40.7831,170.1375 48.3611,177.9025 C54.5991,184.2925 61.9841,189.9525 70.9381,195.2025 C72.6801,196.2225 74.4931,196.7325 76.3671,196.7325 C78.5461,196.7325 80.8061,196.0445 83.1311,194.6685 C88.9231,191.2405 94.2881,187.3525 99.1831,183.0785 L99.1831,170.6175 L98.8671,170.3145 C92.9681,176.4795 86.0501,181.9295 78.3041,186.5145" id="Fill-48" fill="#CF8900"></path>
<path d="M79.3413,53.7624 L79.0913,53.6644 C77.3233,52.7554 75.3923,52.8554 73.3413,53.9674 C71.5893,54.8144 69.8283,55.6444 68.0663,56.4734 C63.9373,58.4174 59.6673,60.4294 55.5343,62.6704 C52.3733,64.3834 49.3343,66.2874 46.3953,68.1284 C44.1393,69.5424 41.8083,71.0014 39.5023,72.3224 C35.2033,74.7844 30.4853,77.1154 25.1143,77.4934 C18.8603,77.9364 14.7573,82.7214 15.1363,89.1314 C15.6903,98.5174 16.6323,106.9074 18.0163,114.7094 C18.6323,117.3144 20.2863,119.1174 22.7693,119.1174 C25.3813,119.1174 27.4983,116.9674 27.4983,114.3154 C27.4983,114.1054 27.4763,113.8454 27.4413,113.5904 C27.4083,113.4024 27.3743,113.2124 27.3413,113.0234 C27.3333,112.9914 27.3273,112.9534 27.3203,112.9234 L27.3233,112.9234 C26.0153,105.5214 25.1233,97.5324 24.5943,88.5734 C24.5033,87.0344 25.1253,86.9914 25.7813,86.9444 C33.0713,86.4294 39.1883,83.4204 44.2093,80.5444 C46.6783,79.1314 49.0913,77.6204 51.4253,76.1594 C54.3703,74.3144 57.1513,72.5704 60.0503,70.9984 C63.9473,68.8854 68.0933,66.9334 72.1023,65.0454 C73.6223,64.3304 75.1423,63.6154 76.6553,62.8884 C83.5603,65.6424 90.6423,69.3434 99.1833,74.6534 L99.1833,63.5734 C91.9693,59.3384 85.6403,56.2164 79.3413,53.7624" id="Fill-50" fill="#CF8900"></path>
<path d="M128.5428,88.5505 C128.3368,91.8375 128.1128,94.7785 127.8558,97.5445 C127.3558,102.9205 126.6708,108.0325 125.8028,112.9225 L125.8118,112.9235 C125.7908,113.0075 125.7708,113.1065 125.7518,113.2075 C125.7468,113.2355 125.7418,113.2635 125.7368,113.2905 C125.6778,113.6305 125.6348,114.0215 125.6348,114.3155 C125.6348,116.9675 127.7518,119.1175 130.3628,119.1175 C132.8188,119.1175 134.4618,117.3535 135.0928,114.7945 C136.0248,109.5795 136.7578,104.1365 137.2888,98.4205 C137.5558,95.5605 137.7878,92.5255 137.9988,89.1405 C138.3768,83.0745 134.5568,78.1645 129.1118,77.7195 C121.8198,77.1265 115.6568,73.8055 108.6578,69.4165 L108.6578,80.4605 C114.7988,83.9465 120.9738,86.5525 128.2278,87.1085 C128.3038,87.1775 128.5988,87.6525 128.5428,88.5505" id="Fill-52" fill="#2D2A26"></path>
<path d="M120.5892,145.4355 C120.4782,145.3965 120.3682,145.3655 120.2592,145.3345 L117.8752,145.3345 C116.5972,145.7055 115.4592,146.6625 114.5872,148.0295 L114.5492,148.0095 C112.7632,151.5125 110.7992,154.8685 108.6572,158.0645 L108.6572,173.6295 C114.4632,166.9965 119.4272,159.5495 123.4802,151.3745 C124.2552,148.9245 122.9922,146.2925 120.5892,145.4355" id="Fill-54" fill="#2D2A26"></path>
<polygon id="Fill-56" fill="#CF8900" points="0.6151 140.1086 0.6151 120.9346 13.3521 120.9346 13.3521 124.3036 4.8611 124.3036 4.8611 129.0146 12.2291 129.0146 12.2291 132.3836 4.8611 132.3836 4.8611 140.1086"></polygon>
<path d="M22.2813,136.9039 C24.3083,136.9039 26.0343,135.2599 26.0343,133.0689 C26.0343,130.9049 24.3083,129.2609 22.2813,129.2609 C20.2823,129.2609 18.5283,130.8779 18.5283,133.0689 C18.5283,135.2879 20.2823,136.9039 22.2813,136.9039 M22.2813,125.7549 C26.3903,125.7549 30.0063,128.7139 30.0063,133.0689 C30.0063,137.4519 26.3903,140.4099 22.2813,140.4099 C18.1993,140.4099 14.5563,137.4789 14.5563,133.0689 C14.5563,128.6859 18.1993,125.7549 22.2813,125.7549" id="Fill-58" fill="#CF8900"></path>
<path d="M45.919,140.1086 L41.838,140.1086 L41.838,138.1916 C40.66,139.8346 39.181,140.4096 37.483,140.4096 C33.538,140.4096 32.47,137.2596 32.47,133.6996 L32.47,126.0566 L36.551,126.0566 L36.551,133.6166 C36.551,135.5616 37.264,137.0406 39.154,137.0406 C41.044,137.0406 41.838,135.5336 41.838,133.5896 L41.838,126.0566 L45.919,126.0566 L45.919,140.1086 Z" id="Fill-60" fill="#CF8900"></path>
<path d="M49.4516,126.0565 L53.5336,126.0565 L53.5336,127.9735 C54.7106,126.3305 56.1896,125.7545 57.8876,125.7545 C61.8326,125.7545 62.9006,128.9055 62.9006,132.4665 L62.9006,140.1085 L58.8196,140.1085 L58.8196,132.5485 C58.8196,130.6035 58.1076,129.1245 56.2176,129.1245 C54.3276,129.1245 53.5336,130.6315 53.5336,132.5755 L53.5336,140.1085 L49.4516,140.1085 L49.4516,126.0565 Z" id="Fill-62" fill="#CF8900"></path>
<path d="M73.1168,137.0953 C75.1158,137.0953 76.7878,135.8083 76.7878,133.2613 C76.7878,130.6583 75.2258,129.0693 73.0068,129.0693 C70.7608,129.0693 69.5018,130.8773 69.5018,133.0413 C69.5018,135.2883 70.8708,137.0953 73.1168,137.0953 Z M80.7588,120.3043 L80.7588,140.1083 L76.6778,140.1083 L76.6778,138.0543 C75.6648,139.5053 74.3218,140.4103 72.2678,140.4103 C67.9948,140.4103 65.4198,137.1233 65.4198,133.0143 C65.4198,129.0693 67.8028,125.7553 72.1858,125.7553 C74.0758,125.7553 75.7188,126.6873 76.6778,127.9743 L76.6778,120.3043 L80.7588,120.3043 Z" id="Fill-64" fill="#CF8900"></path>
<path d="M91.1669,137.0953 C93.1659,137.0953 94.8379,135.8083 94.8379,133.2613 C94.8379,130.6583 93.2759,129.0693 91.0569,129.0693 C88.8109,129.0693 87.5519,130.8773 87.5519,133.0413 C87.5519,135.2883 88.9209,137.0953 91.1669,137.0953 Z M98.8089,126.0563 L98.8089,140.1083 L94.7279,140.1083 L94.7279,138.0543 C93.7149,139.5053 92.3719,140.4103 90.3179,140.4103 C86.0449,140.4103 83.4699,137.1233 83.4699,133.0143 C83.4699,129.0693 85.8529,125.7553 90.2359,125.7553 C92.1259,125.7553 93.7689,126.6873 94.7279,127.9743 L94.7279,126.0563 L98.8089,126.0563 Z" id="Fill-66" fill="#CF8900"></path>
<path d="M107.354,126.0565 L111.244,126.0565 L111.244,129.2615 L107.354,129.2615 L107.354,135.8355 C107.354,136.4375 107.6,137.0685 108.395,137.0685 C109.189,137.0685 109.463,136.4105 109.463,135.7525 C109.463,135.3425 109.353,134.7675 109.271,134.5475 L112.503,134.5475 C112.75,135.0685 112.832,135.7265 112.832,136.2195 C112.832,138.3005 111.462,140.4095 108.148,140.4095 C105.683,140.4095 103.272,139.5335 103.272,135.4515 L103.272,129.2615 L101.163,129.2615 L101.163,126.0565 L103.574,126.0565 L104.204,122.6055 L107.354,122.6055 L107.354,126.0565 Z" id="Fill-68" fill="#CF8900"></path>
<path d="M114.913,140.108 L118.994,140.108 L118.994,126.056 L114.913,126.056 L114.913,140.108 Z M116.994,120.003 C118.446,120.003 119.597,120.962 119.597,122.332 C119.597,123.674 118.446,124.605 116.994,124.605 C115.516,124.605 114.42,123.674 114.42,122.332 C114.42,120.962 115.516,120.003 116.994,120.003 Z" id="Fill-70" fill="#CF8900"></path>
<path d="M129.4021,136.9039 C131.4291,136.9039 133.1551,135.2599 133.1551,133.0689 C133.1551,130.9049 131.4291,129.2609 129.4021,129.2609 C127.4031,129.2609 125.6491,130.8779 125.6491,133.0689 C125.6491,135.2879 127.4031,136.9039 129.4021,136.9039 M129.4021,125.7549 C133.5111,125.7549 137.1271,128.7139 137.1271,133.0689 C137.1271,137.4519 133.5111,140.4099 129.4021,140.4099 C125.3201,140.4099 121.6771,137.4789 121.6771,133.0689 C121.6771,128.6859 125.3201,125.7549 129.4021,125.7549" id="Fill-72" fill="#CF8900"></path>
<path d="M139.7551,126.0565 L143.8371,126.0565 L143.8371,127.9735 C145.0141,126.3305 146.4931,125.7545 148.1911,125.7545 C152.1361,125.7545 153.2041,128.9055 153.2041,132.4665 L153.2041,140.1085 L149.1231,140.1085 L149.1231,132.5485 C149.1231,130.6035 148.4111,129.1245 146.5211,129.1245 C144.6311,129.1245 143.8371,130.6315 143.8371,132.5755 L143.8371,140.1085 L139.7551,140.1085 L139.7551,126.0565 Z" id="Fill-73" fill="#CF8900"></path>
<polygon id="Fill-74" fill="#000000" points="53.5601 113.1209 36.7961 113.1209 36.7961 110.3819 47.1771 97.3159 37.5911 97.3159 37.5911 93.9469 52.8751 93.9469 52.8751 96.7129 42.4111 109.7519 53.5601 109.7519"></polygon>
<path d="M62.9268,98.7677 C64.3788,98.7677 66.2688,99.0687 68.6788,100.8497 L66.8168,103.8357 C65.4468,102.6027 63.8308,102.4377 63.1188,102.4377 C60.7358,102.4377 59.1468,103.9447 59.1468,106.0817 C59.1468,108.2177 60.7358,109.7247 63.1188,109.7247 C63.8308,109.7247 65.4468,109.5597 66.8168,108.3277 L68.6788,111.3137 C66.2688,113.0937 64.3788,113.3947 62.9268,113.3947 C58.1058,113.3947 55.0658,110.2447 55.0658,106.0817 C55.0658,101.9177 58.1058,98.7677 62.9268,98.7677" id="Fill-75" fill="#000000"></path>
<path d="M77.7447,110.1079 C79.7437,110.1079 81.4157,108.8209 81.4157,106.2729 C81.4157,103.6709 79.8537,102.0819 77.6347,102.0819 C75.3887,102.0819 74.1297,103.8899 74.1297,106.0539 C74.1297,108.2999 75.4987,110.1079 77.7447,110.1079 Z M85.3867,99.0689 L85.3867,113.1209 L81.3057,113.1209 L81.3057,111.0669 C80.2927,112.5179 78.9497,113.4219 76.8957,113.4219 C72.6227,113.4219 70.0477,110.1349 70.0477,106.0259 C70.0477,102.0819 72.4307,98.7679 76.8137,98.7679 C78.7037,98.7679 80.3467,99.6989 81.3057,100.9859 L81.3057,99.0689 L85.3867,99.0689 Z" id="Fill-76" fill="#000000"></path>
<path d="M90.754,108.1631 C91.165,109.7791 92.59,110.2721 93.712,110.2721 C94.617,110.2721 95.548,109.9161 95.548,109.2041 C95.548,108.7381 95.274,108.4091 94.507,108.1351 L92.042,107.0951 C88.699,105.9171 88.536,103.6981 88.536,103.0681 C88.536,100.2471 91.028,98.7681 94.041,98.7681 C95.657,98.7681 97.794,99.2061 99.108,101.7261 L95.959,103.1771 C95.602,102.0541 94.589,101.8351 93.959,101.8351 C93.192,101.8351 92.398,102.2741 92.398,102.9861 C92.398,103.5881 93.001,103.9171 93.712,104.1911 L95.822,105.0131 C99.136,105.9721 99.437,108.1351 99.437,109.0671 C99.437,111.9431 96.78,113.4221 93.685,113.4221 C91.603,113.4221 88.755,112.7651 87.796,109.8341 L90.754,108.1631 Z" id="Fill-77" fill="#000000"></path>
<path d="M102.0932,93.3168 L106.1752,93.3168 L106.1752,100.9858 C107.3522,99.3428 108.8312,98.7678 110.5292,98.7678 C114.4742,98.7678 115.5422,101.9178 115.5422,105.4788 L115.5422,113.1208 L111.4612,113.1208 L111.4612,105.5608 C111.4612,103.6158 110.7492,102.1368 108.8592,102.1368 C106.9692,102.1368 106.1752,103.6438 106.1752,105.5888 L106.1752,113.1208 L102.0932,113.1208 L102.0932,93.3168 Z" id="Fill-78" fill="#000000"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -12,6 +12,7 @@ import authReducer, {
authPersistConfig,
} from 'modules/auth';
import users, { UsersState, INITIAL_STATE as usersInitialState } from 'modules/users';
import rfps, { RFPState, INITIAL_STATE as rfpsInitialState } from 'modules/rfps';
import history from './history';
export interface AppState {
@ -19,20 +20,23 @@ export interface AppState {
create: CreateState;
users: UsersState;
auth: AuthState;
rfps: RFPState;
router: RouterState;
}
export const combineInitialState: Partial<AppState> = {
export const combineInitialState: Omit<AppState, 'router'> = {
proposal: proposalInitialState,
create: createInitialState,
users: usersInitialState,
auth: authInitialState,
rfps: rfpsInitialState,
};
export default combineReducers<AppState>({
proposal,
create,
users,
rfps,
// Don't allow for redux-persist's _persist key to be touched in our code
auth: (persistReducer(authPersistConfig, authReducer) as any) as Reducer<AuthState>,
router: connectRouter(history),

View File

@ -1,5 +1,5 @@
import BN from 'bn.js';
import { User, Proposal, UserProposal, MILESTONE_STATE } from 'types';
import { User, Proposal, UserProposal, RFP, MILESTONE_STATE } from 'types';
import { UserState } from 'modules/users/reducers';
import { AppState } from 'store/reducers';
import { toZat } from './units';
@ -30,7 +30,7 @@ export function formatUserFromGet(user: UserState) {
export function formatProposalFromGet(p: any): Proposal {
const proposal = { ...p } as Proposal;
proposal.proposalUrlId = generateProposalUrl(proposal.proposalId, proposal.title);
proposal.proposalUrlId = generateSlugUrl(proposal.proposalId, proposal.title);
proposal.target = toZat(p.target);
proposal.funded = toZat(p.funded);
proposal.percentFunded = proposal.target.isZero()
@ -51,8 +51,13 @@ export function formatProposalFromGet(p: any): Proposal {
return proposal;
}
export function formatRFPFromGet(rfp: any): RFP {
rfp.proposals = rfp.proposals.map(formatProposalFromGet);
return rfp;
}
// TODO: i18n on case-by-case basis
export function generateProposalUrl(id: number, title: string) {
export function generateSlugUrl(id: number, title: string) {
const slug = title
.toLowerCase()
.replace(/[\s_]+/g, '-')
@ -63,12 +68,12 @@ export function generateProposalUrl(id: number, title: string) {
return `${id}-${slug}`;
}
export function extractProposalIdFromUrl(slug: string) {
const proposalId = parseInt(slug, 10);
if (isNaN(proposalId)) {
console.error('extractProposalIdFromUrl could not find id in : ' + slug);
export function extractIdFromSlug(slug: string) {
const id = parseInt(slug, 10);
if (isNaN(id)) {
console.error('extractIdFromSlug could not find id in : ' + slug);
}
return proposalId;
return id;
}
// pre-hydration massage (BNify JSONed BNs)

View File

@ -1,7 +1,7 @@
import { Store } from 'redux';
import { fetchUser } from 'modules/users/actions';
import { fetchProposals, fetchProposal } from 'modules/proposals/actions';
import { extractProposalIdFromUrl } from 'utils/api';
import { extractIdFromSlug } from 'utils/api';
const pathActions = [
{
@ -13,7 +13,7 @@ const pathActions = [
{
matcher: /^\/proposals\/(.+)$/,
action: (match: RegExpMatchArray, store: Store) => {
const proposalId = extractProposalIdFromUrl(match[1]);
const proposalId = extractIdFromSlug(match[1]);
if (proposalId) {
// return null for errors (404 most likely)
return store.dispatch<any>(fetchProposal(proposalId)).catch(() => null);

View File

@ -7,3 +7,4 @@ export * from './update';
export * from './proposal';
export * from './api';
export * from './email';
export * from './rfp';

13
frontend/types/rfp.ts Normal file
View File

@ -0,0 +1,13 @@
import { Proposal } from './proposal';
import { PROPOSAL_CATEGORY, RFP_STATUS } from 'api/constants';
export interface RFP {
id: number;
dateCreated: number;
title: string;
brief: string;
content: string;
category: PROPOSAL_CATEGORY;
status: RFP_STATUS;
proposals: Proposal[];
}