diff --git a/admin/src/Routes.tsx b/admin/src/Routes.tsx index 9db9693e..17fc3634 100644 --- a/admin/src/Routes.tsx +++ b/admin/src/Routes.tsx @@ -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 { + + + + )} diff --git a/admin/src/components/ProposalDetail/index.less b/admin/src/components/ProposalDetail/index.less index cef74417..e4263e1e 100644 --- a/admin/src/components/ProposalDetail/index.less +++ b/admin/src/components/ProposalDetail/index.less @@ -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; } } diff --git a/admin/src/components/Proposals/ProposalItem.less b/admin/src/components/Proposals/ProposalItem.less index ac5aaa95..e7d778bc 100644 --- a/admin/src/components/Proposals/ProposalItem.less +++ b/admin/src/components/Proposals/ProposalItem.less @@ -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; + } } diff --git a/admin/src/components/Proposals/ProposalItem.tsx b/admin/src/components/Proposals/ProposalItem.tsx index 97b7068c..3d88297d 100644 --- a/admin/src/components/Proposals/ProposalItem.tsx +++ b/admin/src/components/Proposals/ProposalItem.tsx @@ -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 { @@ -14,33 +14,32 @@ class ProposalItemNaked extends React.Component { }; render() { const p = this.props; - const status = getStatusById(p.status); - - const deleteAction = ( + const status = getStatusById(PROPOSAL_STATUSES, p.status); + const actions = [ -
delete
-
- ); - const viewAction = view; - const actions = [viewAction, deleteAction]; + delete + , + ]; return ( -
-

- {p.title || '(no title)'}{' '} + +

+ {p.title || '(no title)'} {status.tagDisplay} -

-
Created: {formatDateSeconds(p.dateCreated)}
-
{p.brief}
-
+ +

Created: {formatDateSeconds(p.dateCreated)}

+

{p.brief}

+
); } diff --git a/admin/src/components/Proposals/index.tsx b/admin/src/components/Proposals/index.tsx index de0d374f..c602e5b9 100644 --- a/admin/src/components/Proposals/index.tsx +++ b/admin/src/components/Proposals/index.tsx @@ -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 { const statusFilterMenu = ( - {STATUSES.map(f => ( + {PROPOSAL_STATUSES.map(f => ( {f.filterDisplay} ))} @@ -51,7 +51,7 @@ class ProposalsNaked extends React.Component { Filter - + + + + + + + {/* DETAILS */} + + {renderDeetItem('id', rfp.id)} + {renderDeetItem('created', formatDateSeconds(rfp.dateCreated))} + {renderDeetItem('status', rfp.status)} + {renderDeetItem('category', rfp.category)} + + + {/* PROPOSALS */} + + {rfp.proposals.map(p => ( + +
{p.title}
+ {p.brief} + + ))} + {!rfp.proposals.length &&
No proposals (yet!)
} +
+ + + + ); + } + + 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)); diff --git a/admin/src/components/RFPForm/index.less b/admin/src/components/RFPForm/index.less new file mode 100644 index 00000000..40df07c9 --- /dev/null +++ b/admin/src/components/RFPForm/index.less @@ -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; + } + } +} diff --git a/admin/src/components/RFPForm/index.tsx b/admin/src/components/RFPForm/index.tsx new file mode 100644 index 00000000..37c03b8f --- /dev/null +++ b/admin/src/components/RFPForm/index.tsx @@ -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 { + 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 ; + } + + 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 ; + } + } + + return ( +
+ + + {getFieldDecorator('title', { + initialValue: defaults.title, + rules: [ + { required: true, message: 'Title is required' }, + { max: 60, message: 'Max 60 chars' }, + ], + })( + , + )} + + + {rfpId && ( + + {getFieldDecorator('status', { + initialValue: defaults.status, + rules: [{ required: true, message: 'Status is required' }], + })( + , + )} + + )} + + + {getFieldDecorator('category', { + initialValue: defaults.category, + rules: [ + { required: true, message: 'Category is required' }, + { max: 60, message: 'Max 60 chars' }, + ], + })( + , + )} + + + + {getFieldDecorator('brief', { + initialValue: defaults.brief, + rules: [ + { required: true, message: 'Title is required' }, + { max: 200, message: 'Max 200 chars' }, + ], + })()} + + + + {/* Keep rendering even while hiding to not reset value */} +
+ {getFieldDecorator('content', { + initialValue: defaults.content, + rules: [{ required: true, message: 'Content is required' }], + })( + , + )} +
+ {isShowingPreview ? ( + <> +
+ +
+ + Edit content + + + ) : ( + + Preview content + + )} +
+ +
+ + +
+ + ); + } + + 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) => { + 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))); diff --git a/admin/src/components/RFPs/index.less b/admin/src/components/RFPs/index.less new file mode 100644 index 00000000..e5a75293 --- /dev/null +++ b/admin/src/components/RFPs/index.less @@ -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; + } + } + } +} diff --git a/admin/src/components/RFPs/index.tsx b/admin/src/components/RFPs/index.tsx new file mode 100644 index 00000000..f06ec2ae --- /dev/null +++ b/admin/src/components/RFPs/index.tsx @@ -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; + +interface State { + deletingId: number | null; +} + +class RFPs extends React.Component { + state: State = { + deletingId: null, + }; + + componentDidMount() { + this.fetchRFPs(); + } + + render() { + const { rfps, rfpsFetching, rfpsFetched } = store; + const loading = !rfpsFetched || rfpsFetching; + + return ( +
+
+ + + +
+ +
+ ); + } + + private fetchRFPs = () => { + store.fetchRFPs(); + }; + + private renderRFP = (rfp: RFP) => { + const { deletingId } = this.state; + const actions = [ + + edit + , + this.deleteRFP(rfp.id)} + placement="left" + > + delete + , + ]; + const status = getStatusById(RFP_STATUSES, rfp.status); + return ( + + + +

+ {rfp.title || '(no title)'} + + {status.tagDisplay} + +

+

{rfp.proposals.length} proposals submitted

+

{rfp.brief}

+ +
+
+ ); + }; + + 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)); diff --git a/admin/src/components/Template/index.tsx b/admin/src/components/Template/index.tsx index 2057e5b8..86a07d8e 100644 --- a/admin/src/components/Template/index.tsx +++ b/admin/src/components/Template/index.tsx @@ -14,6 +14,7 @@ type Props = RouteComponentProps; class Template extends React.Component { render() { const { pathname } = this.props.location; + const pathbase = pathname.split('/')[1] || '/'; return ( {store.generalError.length > 0 && ( @@ -31,34 +32,40 @@ class Template extends React.Component { )}
ZF Grants
- + - home + Home - + - users + Users - + - proposals + Proposals - + + + + RFPs + + + - emails + Emails - logout + Logout diff --git a/admin/src/store.ts b/admin/src/store.ts index a9942d95..fdf2be1f 100644 --- a/admin/src/store.ts +++ b/admin/src/store.ts @@ -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) { diff --git a/admin/src/types.ts b/admin/src/types.ts index cf34ca36..85399b06 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -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', +} diff --git a/admin/src/components/Proposals/STATUSES.ts b/admin/src/util/statuses.ts similarity index 58% rename from admin/src/components/Proposals/STATUSES.ts rename to admin/src/util/statuses.ts index b7c57477..5acb530b 100644 --- a/admin/src/components/Proposals/STATUSES.ts +++ b/admin/src/util/statuses.ts @@ -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 { + id: E; filterDisplay: string; tagDisplay: string; tagColor: string; hint: string; } -const STATUSES: ProposalStatusSoT[] = [ +export const PROPOSAL_STATUSES: Array> = [ { 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> = [ + { + id: RFP_STATUS.DRAFT, + filterDisplay: 'Status: draft', + tagDisplay: 'Draft', + tagColor: '#ffaa00', + hint: 'RFP is currently being edited by admins and isn’t 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(statuses: Array>, 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; +} diff --git a/admin/src/util/ts.ts b/admin/src/util/ts.ts new file mode 100644 index 00000000..4abdae82 --- /dev/null +++ b/admin/src/util/ts.ts @@ -0,0 +1,4 @@ +// This includes helper functions / types for Typescript +export function typedKeys(e: T): Array { + return Object.keys(e).map(k => k as keyof T); +} diff --git a/admin/src/util/ui.ts b/admin/src/util/ui.ts new file mode 100644 index 00000000..9a13a8f9 --- /dev/null +++ b/admin/src/util/ui.ts @@ -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', + }, +}; diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index cdcb78c9..e036d9d4 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -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/', 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/', 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/', 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 diff --git a/backend/grant/app.py b/backend/grant/app.py index 71363b0c..eacacfe0 100644 --- a/backend/grant/app.py +++ b/backend/grant/app.py @@ -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): diff --git a/backend/grant/blockchain/bootstrap.py b/backend/grant/blockchain/bootstrap.py index 0532a338..40f4af4b 100644 --- a/backend/grant/blockchain/bootstrap.py +++ b/backend/grant/blockchain/bootstrap.py @@ -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 { diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index c3586e87..8c25d347 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -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) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index b1239bdb..e5d1eb08 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -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 diff --git a/backend/grant/rfp/__init__.py b/backend/grant/rfp/__init__.py new file mode 100644 index 00000000..3b1476bb --- /dev/null +++ b/backend/grant/rfp/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import views diff --git a/backend/grant/rfp/models.py b/backend/grant/rfp/models.py new file mode 100644 index 00000000..1db50b55 --- /dev/null +++ b/backend/grant/rfp/models.py @@ -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) diff --git a/backend/grant/rfp/views.py b/backend/grant/rfp/views.py new file mode 100644 index 00000000..03c793b9 --- /dev/null +++ b/backend/grant/rfp/views.py @@ -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("/", 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) diff --git a/backend/grant/user/views.py b/backend/grant/user/views.py index c16193b2..d395e466 100644 --- a/backend/grant/user/views.py +++ b/backend/grant/user/views.py @@ -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 diff --git a/backend/grant/utils/enums.py b/backend/grant/utils/enums.py new file mode 100644 index 00000000..c37c058e --- /dev/null +++ b/backend/grant/utils/enums.py @@ -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() diff --git a/backend/migrations/versions/edf057ef742a_.py b/backend/migrations/versions/edf057ef742a_.py new file mode 100644 index 00000000..72548bdd --- /dev/null +++ b/backend/migrations/versions/edf057ef742a_.py @@ -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 ### diff --git a/backend/tests/admin/test_api.py b/backend/tests/admin/test_api.py index c8b6f1e3..8253d284 100644 --- a/backend/tests/admin/test_api.py +++ b/backend/tests/admin/test_api.py @@ -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.") diff --git a/backend/tests/config.py b/backend/tests/config.py index ff3414d8..84fc8451 100644 --- a/backend/tests/config.py +++ b/backend/tests/config.py @@ -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() diff --git a/backend/tests/proposal/test_api.py b/backend/tests/proposal/test_api.py index 759fcdbd..31894cec 100644 --- a/backend/tests/proposal/test_api.py +++ b/backend/tests/proposal/test_api.py @@ -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) diff --git a/backend/tests/proposal/test_comment_api.py b/backend/tests/proposal/test_comment_api.py index 3f7bd479..1fc53be6 100644 --- a/backend/tests/proposal/test_comment_api.py +++ b/backend/tests/proposal/test_comment_api.py @@ -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 diff --git a/backend/tests/proposal/test_contribution_api.py b/backend/tests/proposal/test_contribution_api.py index 220a744b..9f00467d 100644 --- a/backend/tests/proposal/test_contribution_api.py +++ b/backend/tests/proposal/test_contribution_api.py @@ -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) diff --git a/backend/tests/test_data.py b/backend/tests/test_data.py index 7b855c06..2e2faede 100644 --- a/backend/tests/test_data.py +++ b/backend/tests/test_data.py @@ -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 diff --git a/frontend/client/Routes.tsx b/frontend/client/Routes.tsx index ecf5599f..9a50557d 100644 --- a/frontend/client/Routes.tsx +++ b/frontend/client/Routes.tsx @@ -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: { diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index 9ae69785..9064dfd1 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -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; + }); +} diff --git a/frontend/client/api/constants.ts b/frontend/client/api/constants.ts index 75378712..d28644f2 100644 --- a/frontend/client/api/constants.ts +++ b/frontend/client/api/constants.ts @@ -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', +} diff --git a/frontend/client/components/Card/index.less b/frontend/client/components/Card/index.less new file mode 100644 index 00000000..b6d23467 --- /dev/null +++ b/frontend/client/components/Card/index.less @@ -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; + } + } +} diff --git a/frontend/client/components/Card/index.tsx b/frontend/client/components/Card/index.tsx new file mode 100644 index 00000000..193578e1 --- /dev/null +++ b/frontend/client/components/Card/index.tsx @@ -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 = ({ category, time }) => ( +
+
+ {CATEGORY_UI[category].label} +
+
+ {moment(time).fromNow()} +
+
+); + +interface CardProps { + to: string; + title: React.ReactNode; + children: React.ReactNode; + className?: string; +} + +export class Card extends React.Component { + public static Info = CardInfo; + + render() { + const { to, title, children, className } = this.props; + return ( + +
+

{title}

+ {children} +
+ + ) + } +} + +export default Card; diff --git a/frontend/client/components/Header/index.tsx b/frontend/client/components/Header/index.tsx index 7fa379e6..8830d6ce 100644 --- a/frontend/client/components/Header/index.tsx +++ b/frontend/client/components/Header/index.tsx @@ -34,7 +34,10 @@ export default class Header extends React.Component {
- Browse + Proposals + + + Requests Start a Proposal diff --git a/frontend/client/components/Header/style.less b/frontend/client/components/Header/style.less index 5b380863..cc053bd1 100644 --- a/frontend/client/components/Header/style.less +++ b/frontend/client/components/Header/style.less @@ -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; diff --git a/frontend/client/components/Proposals/ProposalCard/index.tsx b/frontend/client/components/Proposals/ProposalCard/index.tsx index c8b5a8de..f2f99fc1 100644 --- a/frontend/client/components/Proposals/ProposalCard/index.tsx +++ b/frontend/client/components/Proposals/ProposalCard/index.tsx @@ -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 { } = this.props; return ( -
this.setState({ redirect: `/proposals/${proposalUrlId}` })} + to={`/proposals/${proposalUrlId}`} + title={title} > -

{title}

{contributionMatching > 0 && (
@@ -78,19 +77,8 @@ export class ProposalCard extends React.Component {
{proposalAddress}
- -
-
- {CATEGORY_UI[category].label} -
-
- {moment(dateCreated * 1000).fromNow()} -
-
-
+ + ); } } diff --git a/frontend/client/components/Proposals/ProposalCard/style.less b/frontend/client/components/Proposals/ProposalCard/style.less index beb6c04f..cc4c5fc3 100644 --- a/frontend/client/components/Proposals/ProposalCard/style.less +++ b/frontend/client/components/Proposals/ProposalCard/style.less @@ -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; diff --git a/frontend/client/components/RFP/index.less b/frontend/client/components/RFP/index.less new file mode 100644 index 00000000..5577c021 --- /dev/null +++ b/frontend/client/components/RFP/index.less @@ -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; + } +} \ No newline at end of file diff --git a/frontend/client/components/RFP/index.tsx b/frontend/client/components/RFP/index.tsx new file mode 100644 index 00000000..2f36dcb9 --- /dev/null +++ b/frontend/client/components/RFP/index.tsx @@ -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 { + 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 ; + } else { + return ; + } + } + + return ( +
+
+ + Back to Requests + +
+ Opened {moment(rfp.dateCreated * 1000).format('LL')} +
+
+

{rfp.title}

+ +
+ ); + } +} + +export default connect( + (state, ownProps) => ({ + rfp: getRfp(state, ownProps.rfpId), + isFetchingRfps: state.rfps.isFetchingRfps, + fetchRfpsError: state.rfps.fetchRfpsError, + }), + { fetchRfp }, +)(RFPDetail); diff --git a/frontend/client/components/RFPs/RFPCard.less b/frontend/client/components/RFPs/RFPCard.less new file mode 100644 index 00000000..8c0f007d --- /dev/null +++ b/frontend/client/components/RFPs/RFPCard.less @@ -0,0 +1,12 @@ +.RFPCard { + &-brief { + height: 1.6rem * 3; + line-height: 1.6rem; + margin-top: -1rem; + opacity: 0.8; + } + + &-proposals { + opacity: 0.5; + } +} diff --git a/frontend/client/components/RFPs/RFPCard.tsx b/frontend/client/components/RFPs/RFPCard.tsx new file mode 100644 index 00000000..676959d6 --- /dev/null +++ b/frontend/client/components/RFPs/RFPCard.tsx @@ -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 { + render() { + const { id, title, brief, proposals, category, dateCreated } = this.props.rfp; + return ( + +

{brief}

+
+ {proposals.length} proposals approved +
+ + +
+ ); + } +} diff --git a/frontend/client/components/RFPs/index.less b/frontend/client/components/RFPs/index.less new file mode 100644 index 00000000..e02d96d0 --- /dev/null +++ b/frontend/client/components/RFPs/index.less @@ -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; + } +} \ No newline at end of file diff --git a/frontend/client/components/RFPs/index.tsx b/frontend/client/components/RFPs/index.tsx new file mode 100644 index 00000000..6a26a9cb --- /dev/null +++ b/frontend/client/components/RFPs/index.tsx @@ -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 { + componentDidMount() { + this.props.fetchRfps(); + } + + render() { + const { rfps, isFetchingRfps, fetchRfpsError } = this.props; + + let rfpsEl; + if (fetchRfpsError) { + rfpsEl = ( +
+ +
+ ); + } + else if (!isFetchingRfps) { + rfpsEl = ( +
+ +
+ ); + } + 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 ( +
+
+
+ +
+
+

Zcash Foundation Requests

+

+ 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. +

+
+
+ {rfpsEl} +
+ ); + } + + private renderRfpsList = (title: string, rfps: RFP[]) => { + return ( +
+

{title}

+
+ {rfps.length ? ( + + {rfps.map(rfp => ( + + + + ))} + + ) : ( + + )} +
+
+ ); + }; +} + +export default connect( + state => ({ + rfps: state.rfps.rfps, + isFetchingRfps: state.rfps.isFetchingRfps, + fetchRfpsError: state.rfps.fetchRfpsError, + }), + { fetchRfps }, +)(RFPs); diff --git a/frontend/client/modules/rfps/actions.ts b/frontend/client/modules/rfps/actions.ts new file mode 100644 index 00000000..c22831e0 --- /dev/null +++ b/frontend/client/modules/rfps/actions.ts @@ -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) => { + return dispatch({ + type: types.FETCH_RFPS, + payload: async () => { + return (await getRFPs()).data; + }, + }); + }; +} + +export function fetchRfp(rfpId: RFP['id']) { + return async (dispatch: Dispatch) => { + return dispatch({ + type: types.FETCH_RFP, + payload: async () => { + return (await getRFP(rfpId)).data; + }, + }); + }; +} diff --git a/frontend/client/modules/rfps/index.ts b/frontend/client/modules/rfps/index.ts new file mode 100644 index 00000000..faf5617c --- /dev/null +++ b/frontend/client/modules/rfps/index.ts @@ -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; diff --git a/frontend/client/modules/rfps/reducers.ts b/frontend/client/modules/rfps/reducers.ts new file mode 100644 index 00000000..32672d36 --- /dev/null +++ b/frontend/client/modules/rfps/reducers.ts @@ -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; +}; diff --git a/frontend/client/modules/rfps/selectors.ts b/frontend/client/modules/rfps/selectors.ts new file mode 100644 index 00000000..20b4355f --- /dev/null +++ b/frontend/client/modules/rfps/selectors.ts @@ -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); +} diff --git a/frontend/client/modules/rfps/types.ts b/frontend/client/modules/rfps/types.ts new file mode 100644 index 00000000..4bd1576b --- /dev/null +++ b/frontend/client/modules/rfps/types.ts @@ -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; + \ No newline at end of file diff --git a/frontend/client/pages/proposal.tsx b/frontend/client/pages/proposal.tsx index 11bf0411..93f10476 100644 --- a/frontend/client/pages/proposal.tsx +++ b/frontend/client/pages/proposal.tsx @@ -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 { super(props); } render() { - const proposalId = extractProposalIdFromUrl(this.props.match.params.id); + const proposalId = extractIdFromSlug(this.props.match.params.id); return ; } } diff --git a/frontend/client/pages/rfp.tsx b/frontend/client/pages/rfp.tsx new file mode 100644 index 00000000..1dbf1866 --- /dev/null +++ b/frontend/client/pages/rfp.tsx @@ -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; + +class ProposalPage extends Component { + constructor(props: RouteProps) { + super(props); + } + render() { + const rfpId = extractIdFromSlug(this.props.match.params.id); + return ; + } +} + +export default withRouter(ProposalPage); diff --git a/frontend/client/pages/rfps.tsx b/frontend/client/pages/rfps.tsx new file mode 100644 index 00000000..29ee2908 --- /dev/null +++ b/frontend/client/pages/rfps.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import RFPs from 'components/RFPs'; + +const RFPsPage = () => ; + +export default RFPsPage; diff --git a/frontend/client/static/images/zcf.svg b/frontend/client/static/images/zcf.svg new file mode 100644 index 00000000..a5cc929d --- /dev/null +++ b/frontend/client/static/images/zcf.svg @@ -0,0 +1,29 @@ + + + + Artboard + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/client/store/reducers.tsx b/frontend/client/store/reducers.tsx index 9713b8c6..d7db5dc8 100644 --- a/frontend/client/store/reducers.tsx +++ b/frontend/client/store/reducers.tsx @@ -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 = { +export const combineInitialState: Omit = { proposal: proposalInitialState, create: createInitialState, users: usersInitialState, auth: authInitialState, + rfps: rfpsInitialState, }; export default combineReducers({ 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, router: connectRouter(history), diff --git a/frontend/client/utils/api.ts b/frontend/client/utils/api.ts index b6c90838..5689180f 100644 --- a/frontend/client/utils/api.ts +++ b/frontend/client/utils/api.ts @@ -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) diff --git a/frontend/server/ssrAsync.ts b/frontend/server/ssrAsync.ts index 03734a3e..27403d11 100644 --- a/frontend/server/ssrAsync.ts +++ b/frontend/server/ssrAsync.ts @@ -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(fetchProposal(proposalId)).catch(() => null); diff --git a/frontend/types/index.ts b/frontend/types/index.ts index ddc58089..bafefe5f 100644 --- a/frontend/types/index.ts +++ b/frontend/types/index.ts @@ -7,3 +7,4 @@ export * from './update'; export * from './proposal'; export * from './api'; export * from './email'; +export * from './rfp'; diff --git a/frontend/types/rfp.ts b/frontend/types/rfp.ts new file mode 100644 index 00000000..cfa60fec --- /dev/null +++ b/frontend/types/rfp.ts @@ -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[]; +}