diff --git a/admin/src/Routes.tsx b/admin/src/Routes.tsx index 24a2cbcd..01f84bd2 100644 --- a/admin/src/Routes.tsx +++ b/admin/src/Routes.tsx @@ -13,6 +13,8 @@ import UserDetail from 'components/UserDetail'; import Emails from 'components/Emails'; import Proposals from 'components/Proposals'; import ProposalDetail from 'components/ProposalDetail'; +import CCRs from 'components/CCRs'; +import CCRDetail from 'components/CCRDetail'; import RFPs from 'components/RFPs'; import RFPForm from 'components/RFPForm'; import RFPDetail from 'components/RFPDetail'; @@ -47,6 +49,8 @@ class Routes extends React.Component { + + diff --git a/admin/src/components/CCRDetail/index.less b/admin/src/components/CCRDetail/index.less new file mode 100644 index 00000000..5789dcfc --- /dev/null +++ b/admin/src/components/CCRDetail/index.less @@ -0,0 +1,50 @@ +.CCRDetail { + h1 { + font-size: 1.5rem; + } + + &-controls { + &-control + &-control { + margin-left: 0 !important; + margin-top: 0.8rem; + } + } + + &-deet { + position: relative; + margin-bottom: 1rem; + + & > span { + font-size: 0.7rem; + position: absolute; + opacity: 0.8; + bottom: -0.7rem; + } + } + + & .ant-card, + .ant-alert, + .ant-collapse { + margin-bottom: 16px; + } + + &-popover { + &-overlay { + max-width: 400px; + } + } + + &-alert { + & pre { + margin: 1rem 0; + overflow: hidden; + word-break: break-all; + white-space: inherit; + } + } + + &-review { + margin-right: 0.5rem; + margin-bottom: 0.25rem; + } +} diff --git a/admin/src/components/CCRDetail/index.tsx b/admin/src/components/CCRDetail/index.tsx new file mode 100644 index 00000000..860d4d87 --- /dev/null +++ b/admin/src/components/CCRDetail/index.tsx @@ -0,0 +1,221 @@ +import React from 'react'; +import { view } from 'react-easy-state'; +import { RouteComponentProps, withRouter } from 'react-router'; +import { Alert, Button, Card, Col, Collapse, message, Row } from 'antd'; +import TextArea from 'antd/lib/input/TextArea'; +import store from 'src/store'; +import { formatDateSeconds } from 'util/time'; +import { CCR_STATUS } from 'src/types'; +import Back from 'components/Back'; +import Markdown from 'components/Markdown'; +import FeedbackModal from '../FeedbackModal'; +import './index.less'; +import { Link } from 'react-router-dom'; + +type Props = RouteComponentProps; + +const STATE = { + paidTxId: '', + showCancelAndRefundPopover: false, + showChangeToAcceptedWithFundingPopover: false, +}; + +type State = typeof STATE; + +class CCRDetailNaked extends React.Component { + state = STATE; + rejectInput: null | TextArea = null; + + componentDidMount() { + this.loadDetail(); + } + + render() { + const id = this.getIdFromQuery(); + const { ccrDetail: c, ccrDetailFetching } = store; + + if (!c || (c && c.ccrId !== id) || ccrDetailFetching) { + return 'loading ccr...'; + } + + const renderApproved = () => + c.status === CCR_STATUS.APPROVED && ( + + ); + + const renderReview = () => + c.status === CCR_STATUS.PENDING && ( + +

+ Please review this Community Created Request and render your judgment. +

+ + + + } + /> + ); + + const renderRejected = () => + c.status === CCR_STATUS.REJECTED && ( + +

+ This CCR has changes requested. The team will be able to re-submit it for + approval should they desire to do so. +

+ Reason: +
+ {c.rejectReason} + + } + /> + ); + + const renderDeetItem = (name: string, val: any) => ( +
+ {name} + {val}   +
+ ); + + return ( +
+ +

{c.title}

+ + {/* MAIN */} + + {renderApproved()} + {renderReview()} + {renderRejected()} + + + + {c.brief} + + + + + + + + + + + +
{JSON.stringify(c, null, 4)}
+
+
+ + + {/* RIGHT SIDE */} + + {c.rfp && ( + + This CCR has been accepted and is instantiated as an RFP{' '} + here. + + } + type="info" + showIcon + /> + )} + + {/* DETAILS */} + + {renderDeetItem('id', c.ccrId)} + {renderDeetItem('created', formatDateSeconds(c.dateCreated))} + {renderDeetItem( + 'published', + c.datePublished ? formatDateSeconds(c.datePublished) : 'n/a', + )} + + {renderDeetItem( + 'status', + c.status === CCR_STATUS.LIVE ? 'Accepted/Generated RFP' : c.status, + )} + {renderDeetItem('target', c.target)} + + + +
+ {c.author.displayName} +
+
+ + +
+
+ ); + } + + private getIdFromQuery = () => { + return Number(this.props.match.params.id); + }; + + private loadDetail = () => { + store.fetchCCRDetail(this.getIdFromQuery()); + }; + + private handleApprove = async () => { + await store.approveCCR(true); + if (store.ccrCreatedRFPId) { + message.success('Successfully created RFP from CCR!', 1); + setTimeout( + () => this.props.history.replace(`/rfps/${store.ccrCreatedRFPId}/edit`), + 1500, + ); + } + }; + + private handleReject = async (reason: string) => { + await store.approveCCR(false, reason); + message.info('CCR changes requested'); + }; +} + +const CCRDetail = withRouter(view(CCRDetailNaked)); +export default CCRDetail; diff --git a/admin/src/components/CCRs/CCRItem.less b/admin/src/components/CCRs/CCRItem.less new file mode 100644 index 00000000..c9e18451 --- /dev/null +++ b/admin/src/components/CCRs/CCRItem.less @@ -0,0 +1,16 @@ +.CCRItem { + & h2 { + font-size: 1.4rem; + margin-bottom: 0; + + & .ant-tag { + vertical-align: text-top; + margin: 0.2rem 0 0 0.5rem; + } + } + + & p { + color: rgba(#000, 0.5); + margin: 0; + } +} diff --git a/admin/src/components/CCRs/CCRItem.tsx b/admin/src/components/CCRs/CCRItem.tsx new file mode 100644 index 00000000..67775e59 --- /dev/null +++ b/admin/src/components/CCRs/CCRItem.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { view } from 'react-easy-state'; +import { Tag, Tooltip, List } from 'antd'; +import { Link } from 'react-router-dom'; +import { CCR } from 'src/types'; +import { CCR_STATUSES, getStatusById } from 'util/statuses'; +import { formatDateSeconds } from 'util/time'; +import './CCRItem.less'; + +class CCRItemNaked extends React.Component { + render() { + const props = this.props; + const status = getStatusById(CCR_STATUSES, props.status); + + return ( + + +

+ {props.title || '(no title)'} + + + {status.tagDisplay === 'Live' + ? 'Accepted/Generated RFP' + : status.tagDisplay} + + +

+

Created: {formatDateSeconds(props.dateCreated)}

+

{props.brief}

+ +
+ ); + } +} + +const CCRItem = view(CCRItemNaked); +export default CCRItem; diff --git a/admin/src/components/CCRs/index.tsx b/admin/src/components/CCRs/index.tsx new file mode 100644 index 00000000..9eb5c059 --- /dev/null +++ b/admin/src/components/CCRs/index.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { view } from 'react-easy-state'; +import store from 'src/store'; +import CCRItem from './CCRItem'; +import Pageable from 'components/Pageable'; +import { CCR } from 'src/types'; +import { ccrFilters } from 'util/filters'; + +class CCRs extends React.Component<{}> { + render() { + const { page } = store.ccrs; + // NOTE: sync with /backend ... pagination.py CCRPagination.SORT_MAP + const sorts = ['CREATED:DESC', 'CREATED:ASC']; + return ( + } + handleSearch={store.fetchCCRs} + handleChangeQuery={store.setCCRPageQuery} + handleResetQuery={store.resetCCRPageQuery} + /> + ); + } +} + +export default view(CCRs); diff --git a/admin/src/components/Home/index.tsx b/admin/src/components/Home/index.tsx index beb850bc..fd6eeffa 100644 --- a/admin/src/components/Home/index.tsx +++ b/admin/src/components/Home/index.tsx @@ -14,6 +14,7 @@ class Home extends React.Component { const { userCount, proposalCount, + ccrPendingCount, proposalPendingCount, proposalNoArbiterCount, proposalMilestonePayoutsCount, @@ -21,6 +22,13 @@ class Home extends React.Component { } = store.stats; const actionItems = [ + !!ccrPendingCount && ( +
+ There are {ccrPendingCount} community + created requests waiting for review.{' '} + Click here to view them. +
+ ), !!proposalPendingCount && (
There are {proposalPendingCount}{' '} diff --git a/admin/src/components/RFPDetail/index.tsx b/admin/src/components/RFPDetail/index.tsx index e9e34982..9f1e7e16 100644 --- a/admin/src/components/RFPDetail/index.tsx +++ b/admin/src/components/RFPDetail/index.tsx @@ -2,7 +2,7 @@ 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 { Row, Col, Collapse, Card, Button, Popconfirm, Spin, Alert } from 'antd'; import Exception from 'ant-design-pro/lib/Exception'; import Back from 'components/Back'; import Markdown from 'components/Markdown'; @@ -69,6 +69,20 @@ class RFPDetail extends React.Component { {/* RIGHT SIDE */} + {rfp.ccr && ( + + This RFP has been generated from a CCR{' '} + here. + + } + type="info" + showIcon + /> + )} + {/* ACTIONS */} diff --git a/admin/src/components/Template/index.tsx b/admin/src/components/Template/index.tsx index 4954f3a2..5d5aad58 100644 --- a/admin/src/components/Template/index.tsx +++ b/admin/src/components/Template/index.tsx @@ -51,6 +51,12 @@ class Template extends React.Component { Proposals + + + + CCRs + + diff --git a/admin/src/store.ts b/admin/src/store.ts index e6607ad6..9dcb2587 100644 --- a/admin/src/store.ts +++ b/admin/src/store.ts @@ -4,6 +4,7 @@ import axios, { AxiosError } from 'axios'; import { User, Proposal, + CCR, Contribution, ContributionArgs, RFP, @@ -149,8 +150,8 @@ async function cancelProposal(id: number) { } async function changeProposalToAcceptedWithFunding(id: number) { - const { data } = await api.put(`/admin/proposals/${id}/accept/fund`) - return data + const { data } = await api.put(`/admin/proposals/${id}/accept/fund`); + return data; } async function fetchComments(params: Partial) { @@ -176,6 +177,28 @@ async function getEmailExample(type: string) { return data; } +async function fetchCCRDetail(id: number) { + const { data } = await api.get(`/admin/ccrs/${id}`); + return data; +} + +async function approveCCR(id: number, isAccepted: boolean, rejectReason?: string) { + const { data } = await api.put(`/admin/ccrs/${id}/accept`, { + isAccepted, + rejectReason, + }); + return data; +} + +async function fetchCCRs(params: Partial) { + const { data } = await api.get(`/admin/ccrs`, { params }); + return data; +} + +async function deleteCCR(id: number) { + await api.delete(`/admin/ccrs/${id}`); +} + async function getRFPs() { const { data } = await api.get(`/admin/rfps`); return data; @@ -229,6 +252,7 @@ const app = store({ stats: { userCount: 0, proposalCount: 0, + ccrPendingCount: 0, proposalPendingCount: 0, proposalNoArbiterCount: 0, proposalMilestonePayoutsCount: 0, @@ -295,6 +319,24 @@ const app = store({ proposalDetailUpdated: false, proposalDetailChangingToAcceptedWithFunding: false, + ccrs: { + page: createDefaultPageData('CREATED:DESC'), + }, + ccrSaving: false, + ccrSaved: false, + ccrDeleting: false, + ccrDeleted: false, + + ccrDetail: null as null | CCR, + ccrDetailFetching: false, + ccrDetailApproving: false, + ccrDetailMarkingMilestonePaid: false, + ccrDetailCanceling: false, + ccrDetailUpdating: false, + ccrDetailUpdated: false, + ccrDetailChangingToAcceptedWithFunding: false, + ccrCreatedRFPId: null, + comments: { page: createDefaultPageData('CREATED:DESC'), }, @@ -494,6 +536,53 @@ const app = store({ app.arbiterSaving = false; }, + // CCRS + + async fetchCCRs() { + return await pageFetch(app.ccrs, fetchCCRs); + }, + + setCCRPageQuery(params: Partial) { + setPageParams(app.ccrs, params); + }, + + resetCCRPageQuery() { + resetPageParams(app.ccrs); + }, + + async fetchCCRDetail(id: number) { + app.ccrDetailFetching = true; + try { + app.ccrDetail = await fetchCCRDetail(id); + } catch (e) { + handleApiError(e); + } + app.ccrDetailFetching = false; + }, + + async approveCCR(isAccepted: boolean, rejectReason?: string) { + if (!app.ccrDetail) { + const m = 'store.approveCCR(): Expected ccrDetail to be populated!'; + app.generalError.push(m); + console.error(m); + return; + } + app.ccrCreatedRFPId = null; + app.ccrDetailApproving = true; + try { + const { ccrId } = app.ccrDetail; + const res = await approveCCR(ccrId, isAccepted, rejectReason); + await app.fetchCCRs(); + await app.fetchRFPs(); + if (isAccepted) { + app.ccrCreatedRFPId = res.rfpId; + } + } catch (e) { + handleApiError(e); + } + app.ccrDetailApproving = false; + }, + // Proposals async fetchProposals() { @@ -548,7 +637,11 @@ const app = store({ } }, - async approveProposal(isAccepted: boolean, withFunding: boolean, rejectReason?: string) { + async approveProposal( + isAccepted: boolean, + withFunding: boolean, + rejectReason?: string, + ) { if (!app.proposalDetail) { const m = 'store.approveProposal(): Expected proposalDetail to be populated!'; app.generalError.push(m); @@ -558,7 +651,12 @@ const app = store({ app.proposalDetailApproving = true; try { const { proposalId } = app.proposalDetail; - const res = await approveProposal(proposalId, isAccepted, withFunding, rejectReason); + const res = await approveProposal( + proposalId, + isAccepted, + withFunding, + rejectReason, + ); app.updateProposalInStore(res); } catch (e) { handleApiError(e); @@ -578,16 +676,16 @@ const app = store({ }, async changeProposalToAcceptedWithFunding(id: number) { - app.proposalDetailChangingToAcceptedWithFunding = true + app.proposalDetailChangingToAcceptedWithFunding = true; try { - const res = await changeProposalToAcceptedWithFunding(id) - app.updateProposalInStore(res) + const res = await changeProposalToAcceptedWithFunding(id); + app.updateProposalInStore(res); } catch (e) { - handleApiError(e) + handleApiError(e); } - app.proposalDetailChangingToAcceptedWithFunding = false + app.proposalDetailChangingToAcceptedWithFunding = false; }, async markMilestonePaid(proposalId: number, milestoneId: number, txId: string) { diff --git a/admin/src/types.ts b/admin/src/types.ts index c28a77e4..dd73c51f 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -48,6 +48,7 @@ export interface RFP { bounty: string | null; dateCloses: number | null; isVersionTwo: boolean; + ccr?: CCR; } export interface RFPArgs { title: string; @@ -200,6 +201,30 @@ export enum PROPOSAL_CATEGORY { ACCESSIBILITY = 'ACCESSIBILITY', } +export enum CCR_STATUS { + DRAFT = 'DRAFT', + PENDING = 'PENDING', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', + LIVE = 'LIVE', + DELETED = 'DELETED', +} + +export interface CCR { + ccrId: number; + brief: string; + status: CCR_STATUS; + dateCreated: number; + dateApproved: number; + datePublished: number; + title: string; + content: string; + target: string; + rejectReason: string; + rfp?: RFP; + author: User; +} + export interface PageQuery { page: number; filters: string[]; diff --git a/admin/src/util/filters.ts b/admin/src/util/filters.ts index 8815e1cf..c726296b 100644 --- a/admin/src/util/filters.ts +++ b/admin/src/util/filters.ts @@ -5,6 +5,7 @@ import { PROPOSAL_ARBITER_STATUSES, MILESTONE_STAGES, PROPOSAL_STAGES, + CCR_STATUSES, } from './statuses'; export interface Filter { @@ -94,6 +95,20 @@ export const rfpFilters: Filters = { getById: getFilterById(RFP_FILTERS), }; +// CCR + +const CCR_FILTERS = CCR_STATUSES.map(c => ({ + id: `STATUS_${c.id}`, + display: `Status: ${c.tagDisplay}`, + color: c.tagColor, + group: 'Status', +})); + +export const ccrFilters: Filters = { + list: CCR_FILTERS, + getById: getFilterById(CCR_FILTERS), +}; + // Contribution const CONTRIBUTION_FILTERS = CONTRIBUTION_STATUSES.map(s => ({ diff --git a/admin/src/util/statuses.ts b/admin/src/util/statuses.ts index e769c188..c001044b 100644 --- a/admin/src/util/statuses.ts +++ b/admin/src/util/statuses.ts @@ -1,5 +1,6 @@ import { PROPOSAL_STATUS, + CCR_STATUS, RFP_STATUS, CONTRIBUTION_STATUS, PROPOSAL_ARBITER_STATUS, @@ -48,6 +49,46 @@ export const MILESTONE_STAGES: Array> = [ }, ]; +export const CCR_STATUSES: Array> = [ + { + id: CCR_STATUS.APPROVED, + tagDisplay: 'Approved', + tagColor: '#afd500', + hint: 'Request has been approved and is awaiting being published by user.', + }, + { + id: CCR_STATUS.DELETED, + tagDisplay: 'Deleted', + tagColor: '#bebebe', + hint: 'Request has been deleted and is not visible on the platform.', + }, + { + id: CCR_STATUS.DRAFT, + tagDisplay: 'Draft', + tagColor: '#8d8d8d', + hint: 'Request is being created by the user.', + }, + { + id: CCR_STATUS.LIVE, + tagDisplay: 'Live', + tagColor: '#108ee9', + hint: 'Request is live on the platform.', + }, + { + id: CCR_STATUS.PENDING, + tagDisplay: 'Awaiting Approval', + tagColor: '#ffaa00', + hint: 'User is waiting for admin to approve or request changes to this Request.', + }, + { + id: CCR_STATUS.REJECTED, + tagDisplay: 'Changes Requested', + tagColor: '#eb4118', + hint: + 'Admin has requested changes for this Request. User may adjust it and resubmit for approval.', + }, +]; + export const PROPOSAL_STATUSES: Array> = [ { id: PROPOSAL_STATUS.APPROVED, diff --git a/backend/.env.example b/backend/.env.example index ef3a6010..b5f09516 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -39,4 +39,4 @@ EXPLORER_URL="https://chain.so/tx/ZECTEST/" PROPOSAL_STAKING_AMOUNT=0.025 # Maximum amount for a proposal target, keep in sync with frontend .env -PROPOSAL_TARGET_MAX=500000 +PROPOSAL_TARGET_MAX=999999 diff --git a/backend/README.md b/backend/README.md index f585f594..fecc3b42 100644 --- a/backend/README.md +++ b/backend/README.md @@ -69,6 +69,10 @@ To run all tests, run flask test +To run only select test, Flask allows you to match against the test filename with ``-t` like so: + + flask test -t proposal + ## Migrations Whenever a database migration needs to be made. Run the following commands diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index 6b444dae..6fee7186 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -8,6 +8,7 @@ from sqlalchemy import func, or_, text import grant.utils.admin as admin import grant.utils.auth as auth +from grant.ccr.models import CCR, ccrs_schema, ccr_schema from grant.comment.models import Comment, user_comments_schema, admin_comments_schema, admin_comment_schema from grant.email.send import generate_email, send_email from grant.extensions import db @@ -26,7 +27,6 @@ from grant.proposal.models import ( from grant.rfp.models import RFP, admin_rfp_schema, admin_rfps_schema from grant.user.models import User, UserSettings, admin_users_schema, admin_user_schema from grant.utils import pagination -from grant.utils.enums import Category from grant.utils.enums import ( ProposalStatus, ProposalStage, @@ -34,6 +34,7 @@ from grant.utils.enums import ( ProposalArbiterStatus, MilestoneStage, RFPStatus, + CCRStatus ) from grant.utils.misc import make_url, make_explore_url from .example_emails import example_email_args @@ -137,6 +138,9 @@ def logout(): def stats(): user_count = db.session.query(func.count(User.id)).scalar() proposal_count = db.session.query(func.count(Proposal.id)).scalar() + ccr_pending_count = db.session.query(func.count(CCR.id)) \ + .filter(CCR.status == CCRStatus.PENDING) \ + .scalar() proposal_pending_count = db.session.query(func.count(Proposal.id)) \ .filter(Proposal.status == ProposalStatus.PENDING) \ .scalar() @@ -160,15 +164,16 @@ def stats(): .filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \ .join(Proposal) \ .filter(or_( - Proposal.stage == ProposalStage.FAILED, - Proposal.stage == ProposalStage.CANCELED, - )) \ + Proposal.stage == ProposalStage.FAILED, + Proposal.stage == ProposalStage.CANCELED, + )) \ .join(ProposalContribution.user) \ .join(UserSettings) \ .filter(UserSettings.refund_address != None) \ .scalar() return { "userCount": user_count, + "ccrPendingCount": ccr_pending_count, "proposalCount": proposal_count, "proposalPendingCount": proposal_pending_count, "proposalNoArbiterCount": proposal_no_arbiter_count, @@ -314,9 +319,9 @@ def set_arbiter(proposal_id, user_id): db.session.commit() return { - 'proposal': proposal_schema.dump(proposal), - 'user': admin_user_schema.dump(user) - }, 200 + 'proposal': proposal_schema.dump(proposal), + 'user': admin_user_schema.dump(user) + }, 200 # PROPOSALS @@ -473,6 +478,64 @@ def get_email_example(type): return email +# CCRs + + +@blueprint.route("/ccrs", methods=["GET"]) +@query(paginated_fields) +@admin.admin_auth_required +def get_ccrs(page, filters, search, sort): + filters_workaround = request.args.getlist('filters[]') + page = pagination.ccr( + schema=ccrs_schema, + query=CCR.query, + page=page, + filters=filters_workaround, + search=search, + sort=sort, + ) + return page + + +@blueprint.route('/ccrs/', methods=['DELETE']) +@admin.admin_auth_required +def delete_ccr(ccr_id): + ccr = CCR.query.filter(CCR.id == ccr_id).first() + if not ccr: + return {"message": "No CCR matching that id"}, 404 + + db.session.delete(ccr) + db.session.commit() + return {"message": "ok"}, 200 + + +@blueprint.route('/ccrs/', methods=['GET']) +@admin.admin_auth_required +def get_ccr(id): + ccr = CCR.query.filter(CCR.id == id).first() + if ccr: + return ccr_schema.dump(ccr) + return {"message": f"Could not find ccr with id {id}"}, 404 + + +@blueprint.route('/ccrs//accept', methods=['PUT']) +@body({ + "isAccepted": fields.Bool(required=True), + "rejectReason": fields.Str(required=False, missing=None) +}) +@admin.admin_auth_required +def approve_ccr(ccr_id, is_accepted, reject_reason=None): + ccr = CCR.query.filter_by(id=ccr_id).first() + if ccr: + rfp_id = ccr.approve_pending(is_accepted, reject_reason) + if is_accepted: + return {"rfpId": rfp_id}, 201 + else: + return ccr_schema.dump(ccr) + + return {"message": "No CCR found."}, 404 + + # Requests for Proposal @@ -602,7 +665,7 @@ def create_contribution(proposal_id, user_id, status, amount, tx_id): db.session.add(contribution) db.session.flush() - #TODO: should this stay? + # TODO: should this stay? contribution.proposal.set_pending_when_ready() db.session.commit() @@ -726,7 +789,6 @@ def edit_comment(comment_id, hidden, reported): @blueprint.route("/financials", methods=["GET"]) @admin.admin_auth_required def financials(): - nfmt = '999999.99999999' # smallest unit of ZEC def sql_pc(where: str): @@ -758,7 +820,8 @@ def financials(): 'total': str(ex(sql_pc("status = 'CONFIRMED' AND staking = FALSE"))), 'staking': str(ex(sql_pc("status = 'CONFIRMED' AND staking = TRUE"))), 'funding': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage = 'FUNDING_REQUIRED'"))), - 'funded': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage in ('WIP', 'COMPLETED')"))), + 'funded': str( + ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage in ('WIP', 'COMPLETED')"))), # should have a refund_address 'refunding': str(ex(sql_pc_p( ''' diff --git a/backend/grant/app.py b/backend/grant/app.py index f401c208..c833b582 100644 --- a/backend/grant/app.py +++ b/backend/grant/app.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- """The app module, containing the app factory function.""" -import sentry_sdk import logging import traceback + +import sentry_sdk from animal_case import animalify from flask import Flask, Response, jsonify, request, current_app, g from flask_cors import CORS @@ -10,7 +11,21 @@ from flask_security import SQLAlchemyUserDatastore from flask_sslify import SSLify from sentry_sdk.integrations.flask import FlaskIntegration from sentry_sdk.integrations.logging import LoggingIntegration -from grant import commands, proposal, user, comment, milestone, admin, email, blockchain, task, rfp, e2e, home +from grant import ( + commands, + proposal, + user, + ccr, + comment, + milestone, + admin, + email, + blockchain, + task, + rfp, + e2e, + home +) from grant.extensions import bcrypt, migrate, db, ma, security, limiter from grant.settings import SENTRY_RELEASE, ENV, E2E_TESTING, DEBUG, CORS_DOMAINS from grant.utils.auth import AuthException, handle_auth_error, get_authed_user @@ -129,6 +144,7 @@ def register_extensions(app): def register_blueprints(app): """Register Flask blueprints.""" + app.register_blueprint(ccr.views.blueprint) app.register_blueprint(comment.views.blueprint) app.register_blueprint(proposal.views.blueprint) app.register_blueprint(user.views.blueprint) @@ -165,4 +181,5 @@ def register_commands(app): app.cli.add_command(proposal.commands.create_proposals) app.cli.add_command(proposal.commands.retire_v1_proposals) app.cli.add_command(user.commands.set_admin) + app.cli.add_command(user.commands.mangle_users) app.cli.add_command(task.commands.create_task) diff --git a/backend/grant/ccr/__init__.py b/backend/grant/ccr/__init__.py new file mode 100644 index 00000000..3b1476bb --- /dev/null +++ b/backend/grant/ccr/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import views diff --git a/backend/grant/ccr/models.py b/backend/grant/ccr/models.py new file mode 100644 index 00000000..4b982d39 --- /dev/null +++ b/backend/grant/ccr/models.py @@ -0,0 +1,230 @@ +from datetime import datetime, timedelta +from decimal import Decimal + +from sqlalchemy import or_ +from sqlalchemy.ext.hybrid import hybrid_property + +from grant.email.send import send_email +from grant.extensions import ma, db +from grant.utils.enums import CCRStatus +from grant.utils.exceptions import ValidationException +from grant.utils.misc import make_admin_url, gen_random_id, dt_to_unix + + +def default_content(): + return """# Overview + +What you think should be accomplished + + +# Approach + +How you expect a proposing team to accomplish your request + + +# Deliverable + +The end result of a proposal the fulfills this request +""" + + +class CCR(db.Model): + __tablename__ = "ccr" + + id = db.Column(db.Integer(), primary_key=True) + date_created = db.Column(db.DateTime) + + title = db.Column(db.String(255), nullable=True) + brief = db.Column(db.String(255), nullable=True) + content = db.Column(db.Text, nullable=True) + status = db.Column(db.String(255), nullable=False) + _target = db.Column("target", db.String(255), nullable=True) + reject_reason = db.Column(db.String()) + + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + author = db.relationship("User", back_populates="ccrs") + + rfp_id = db.Column(db.Integer, db.ForeignKey("rfp.id"), nullable=True) + rfp = db.relationship("RFP", back_populates="ccr") + + @staticmethod + def get_by_user(user, statuses=[CCRStatus.LIVE]): + status_filter = or_(CCR.status == v for v in statuses) + return CCR.query \ + .filter(CCR.user_id == user.id) \ + .filter(status_filter) \ + .all() + + @staticmethod + def create(**kwargs): + ccr = CCR( + **kwargs + ) + db.session.add(ccr) + db.session.flush() + return ccr + + @hybrid_property + def target(self): + return self._target + + @target.setter + def target(self, target: str): + if target and Decimal(target) > 0: + self._target = target + else: + self._target = None + + def __init__( + self, + user_id: int, + title: str = '', + brief: str = '', + content: str = default_content(), + target: str = '0', + status: str = CCRStatus.DRAFT, + ): + assert CCRStatus.includes(status) + self.id = gen_random_id(CCR) + self.date_created = datetime.now() + self.title = title[:255] + self.brief = brief[:255] + self.content = content + self.target = target + self.status = status + self.user_id = user_id + + def update( + self, + title: str = '', + brief: str = '', + content: str = '', + target: str = '0', + ): + self.title = title[:255] + self.brief = brief[:255] + self.content = content[:300000] + self._target = target[:255] if target != '' and target else '0' + + # state: status (DRAFT || REJECTED) -> (PENDING || STAKING) + def submit_for_approval(self): + self.validate_publishable() + allowed_statuses = [CCRStatus.DRAFT, CCRStatus.REJECTED] + # specific validation + if self.status not in allowed_statuses: + raise ValidationException(f"CCR status must be draft or rejected to submit for approval") + self.set_pending() + + def send_admin_email(self, type: str): + from grant.user.models import User + admins = User.get_admins() + for a in admins: + send_email(a.email_address, type, { + 'user': a, + 'ccr': self, + 'ccr_url': make_admin_url(f'/ccrs/{self.id}'), + }) + + # state: status DRAFT -> PENDING + def set_pending(self): + self.send_admin_email('admin_approval_ccr') + self.status = CCRStatus.PENDING + db.session.add(self) + db.session.flush() + + def validate_publishable(self): + # Require certain fields + required_fields = ['title', 'content', 'brief', 'target'] + for field in required_fields: + if not hasattr(self, field): + raise ValidationException("Proposal must have a {}".format(field)) + + # Stricter limits on certain fields + if len(self.title) > 60: + raise ValidationException("Proposal title cannot be longer than 60 characters") + if len(self.brief) > 140: + raise ValidationException("Brief cannot be longer than 140 characters") + if len(self.content) > 250000: + raise ValidationException("Content cannot be longer than 250,000 characters") + + # state: status PENDING -> (LIVE || REJECTED) + def approve_pending(self, is_approve, reject_reason=None): + from grant.rfp.models import RFP + self.validate_publishable() + # specific validation + if not self.status == CCRStatus.PENDING: + raise ValidationException(f"CCR must be pending to approve or reject") + + if is_approve: + self.status = CCRStatus.LIVE + rfp = RFP( + title=self.title, + brief=self.brief, + content=self.content, + bounty=self._target, + date_closes=datetime.now() + timedelta(days=90), + ) + db.session.add(self) + db.session.add(rfp) + db.session.flush() + self.rfp_id = rfp.id + db.session.add(rfp) + db.session.flush() + + # for emails + db.session.commit() + + send_email(self.author.email_address, 'ccr_approved', { + 'user': self.author, + 'ccr': self, + 'admin_note': f'Congratulations! Your Request has been accepted. There may be a delay between acceptance and final posting as required by the Zcash Foundation.' + }) + return rfp.id + else: + if not reject_reason: + raise ValidationException("Please provide a reason for rejecting the ccr") + self.status = CCRStatus.REJECTED + self.reject_reason = reject_reason + # for emails + db.session.add(self) + db.session.commit() + send_email(self.author.email_address, 'ccr_rejected', { + 'user': self.author, + 'ccr': self, + 'admin_note': reject_reason + }) + return None + + +class CCRSchema(ma.Schema): + class Meta: + model = CCR + # Fields to expose + fields = ( + "author", + "id", + "title", + "brief", + "ccr_id", + "content", + "status", + "target", + "date_created", + "reject_reason", + "rfp" + ) + + rfp = ma.Nested("RFPSchema") + date_created = ma.Method("get_date_created") + author = ma.Nested("UserSchema") + ccr_id = ma.Method("get_ccr_id") + + def get_date_created(self, obj): + return dt_to_unix(obj.date_created) + + def get_ccr_id(self, obj): + return obj.id + + +ccr_schema = CCRSchema() +ccrs_schema = CCRSchema(many=True) diff --git a/backend/grant/ccr/views.py b/backend/grant/ccr/views.py new file mode 100644 index 00000000..4b6fa784 --- /dev/null +++ b/backend/grant/ccr/views.py @@ -0,0 +1,112 @@ +from flask import Blueprint, g +from marshmallow import fields +from sqlalchemy import or_ + +from grant.extensions import limiter +from grant.parser import body +from grant.utils.auth import ( + requires_auth, + requires_email_verified_auth, + get_authed_user +) +from grant.utils.auth import requires_ccr_owner_auth +from grant.utils.enums import CCRStatus +from grant.utils.exceptions import ValidationException +from .models import CCR, ccr_schema, ccrs_schema, db + +blueprint = Blueprint("ccr", __name__, url_prefix="/api/v1/ccrs") + + +@blueprint.route("/", methods=["GET"]) +def get_ccr(ccr_id): + ccr = CCR.query.filter_by(id=ccr_id).first() + if ccr: + if ccr.status != CCRStatus.LIVE: + if CCR.status == CCRStatus.DELETED: + return {"message": "CCR was deleted"}, 404 + authed_user = get_authed_user() + + if authed_user.id != ccr.author.id: + return {"message": "User cannot view this CCR"}, 404 + return ccr_schema.dump(ccr) + else: + return {"message": "No CCR matching id"}, 404 + + +@blueprint.route("/drafts", methods=["POST"]) +@limiter.limit("10/hour;3/minute") +@requires_email_verified_auth +def make_ccr_draft(): + user = g.current_user + ccr = CCR.create(status=CCRStatus.DRAFT, user_id=user.id) + db.session.commit() + return ccr_schema.dump(ccr), 201 + + +@blueprint.route("/drafts", methods=["GET"]) +@requires_auth +def get_ccr_drafts(): + ccrs = ( + CCR.query + .filter(or_( + CCR.status == CCRStatus.DRAFT, + CCR.status == CCRStatus.REJECTED, + )) + .order_by(CCR.date_created.desc()) + .all() + ) + return ccrs_schema.dump(ccrs), 200 + + +@blueprint.route("/", methods=["DELETE"]) +@requires_ccr_owner_auth +def delete_ccr(ccr_id): + deleteable_statuses = [ + CCRStatus.DRAFT, + CCRStatus.PENDING, + CCRStatus.APPROVED, + CCRStatus.REJECTED, + ] + status = g.current_ccr.status + if status not in deleteable_statuses: + return {"message": "Cannot delete CCRs with %s status" % status}, 400 + db.session.delete(g.current_ccr) + db.session.commit() + return {"message": "ok"}, 202 + + +@blueprint.route("/", methods=["PUT"]) +@requires_ccr_owner_auth +@body({ + "title": fields.Str(required=True), + "brief": fields.Str(required=True), + "content": fields.Str(required=True), + "target": fields.Str(required=True, allow_none=True), +}) +def update_ccr(ccr_id, **kwargs): + try: + if g.current_ccr.status not in [CCRStatus.DRAFT, + CCRStatus.REJECTED]: + raise ValidationException( + f"CCR with status: {g.current_ccr.status} are not authorized for updates" + ) + g.current_ccr.update(**kwargs) + except ValidationException as e: + return {"message": "{}".format(str(e))}, 400 + db.session.add(g.current_ccr) + + # Commit + db.session.commit() + return ccr_schema.dump(g.current_ccr), 200 + + +@blueprint.route("//submit_for_approval", methods=["PUT"]) +@requires_ccr_owner_auth +def submit_for_approval_ccr(ccr_id): + try: + g.current_ccr.submit_for_approval() + except ValidationException as e: + return {"message": "{}".format(str(e))}, 400 + db.session.add(g.current_ccr) + db.session.commit() + return ccr_schema.dump(g.current_ccr), 200 diff --git a/backend/grant/email/send.py b/backend/grant/email/send.py index 5e7b6f05..3d1d7fd3 100644 --- a/backend/grant/email/send.py +++ b/backend/grant/email/send.py @@ -1,14 +1,15 @@ -from .subscription_settings import EmailSubscription, is_subscribed -from sendgrid.helpers.mail import Email, Mail, Content -from python_http_client import HTTPError -from grant.utils.misc import make_url -from sentry_sdk import capture_exception -from grant.settings import SENDGRID_API_KEY, SENDGRID_DEFAULT_FROM, SENDGRID_DEFAULT_FROMNAME, UI -from grant.settings import SENDGRID_API_KEY, SENDGRID_DEFAULT_FROM, UI, E2E_TESTING -import sendgrid from threading import Thread -from flask import render_template, Markup, current_app, g +import sendgrid +from flask import render_template, Markup, current_app, g +from python_http_client import HTTPError +from sendgrid.helpers.mail import Email, Mail, Content +from sentry_sdk import capture_exception + +from grant.settings import SENDGRID_API_KEY, SENDGRID_DEFAULT_FROM, UI, E2E_TESTING +from grant.settings import SENDGRID_DEFAULT_FROMNAME +from grant.utils.misc import make_url +from .subscription_settings import EmailSubscription, is_subscribed default_template_args = { 'home_url': make_url('/'), @@ -68,13 +69,29 @@ def change_password_info(email_args): def proposal_approved(email_args): return { - 'subject': 'Your proposal has been approved!', - 'title': 'Your proposal has been approved', - 'preview': 'Start raising funds for {} now'.format(email_args['proposal'].title), + 'subject': 'Your proposal has been reviewed', + 'title': 'Your proposal has been reviewed', + 'preview': '{} is now live on ZF Grants.'.format(email_args['proposal'].title), 'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL } +def ccr_approved(email_args): + return { + 'subject': 'Your request has been approved!', + 'title': 'Your request has been approved', + 'preview': '{} will soon be live on ZF Grants!'.format(email_args['ccr'].title), + } + + +def ccr_rejected(email_args): + return { + 'subject': 'Your request has changes requested', + 'title': 'Your request has changes requested', + 'preview': '{} has changes requested'.format(email_args['ccr'].title), + } + + def proposal_rejected(email_args): return { 'subject': 'Your proposal has changes requested', @@ -300,6 +317,15 @@ def admin_approval(email_args): } +def admin_approval_ccr(email_args): + return { + 'subject': f'Review needed for {email_args["ccr"].title}', + 'title': f'CCR Review', + 'preview': f'{email_args["ccr"].title} needs review, as an admin you can help.', + 'subscription': EmailSubscription.ADMIN_APPROVAL_CCR, + } + + def admin_arbiter(email_args): return { 'subject': f'Arbiter needed for {email_args["proposal"].title}', @@ -346,6 +372,8 @@ get_info_lookup = { 'change_email': change_email_info, 'change_email_old': change_email_old_info, 'change_password': change_password_info, + 'ccr_rejected': ccr_rejected, + 'ccr_approved': ccr_approved, 'proposal_approved': proposal_approved, 'proposal_rejected': proposal_rejected, 'proposal_contribution': proposal_contribution, @@ -367,6 +395,7 @@ get_info_lookup = { 'milestone_accept': milestone_accept, 'milestone_paid': milestone_paid, 'admin_approval': admin_approval, + 'admin_approval_ccr': admin_approval_ccr, 'admin_arbiter': admin_arbiter, 'admin_payout': admin_payout, 'followed_proposal_milestone': followed_proposal_milestone, diff --git a/backend/grant/email/subscription_settings.py b/backend/grant/email/subscription_settings.py index a1b2f90a..745fcfd3 100644 --- a/backend/grant/email/subscription_settings.py +++ b/backend/grant/email/subscription_settings.py @@ -69,6 +69,10 @@ class EmailSubscription(Enum): 'bit': 15, 'key': 'followed_proposal' } + ADMIN_APPROVAL_CCR = { + 'bit': 16, + 'key': 'admin_approval_ccr' + } def is_email_sub_key(k: str): diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 47d8482c..567002db 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -228,6 +228,28 @@ class ProposalArbiter(db.Model): raise ValidationException('User is not arbiter') +def default_proposal_content(): + return """# Overview + +Help us understand the goal(s) of the proposal at a high level. + + +# Approach + +The plan for accomplishing the goal(s) laid out in the overview. + + +# Team + +Who you are, and why you're credible to execute on the goals of the proposal. + + +# Deliverable + +The end result of your efforts as related to this proposal. +""" + + class Proposal(db.Model): __tablename__ = "proposal" @@ -241,7 +263,7 @@ class Proposal(db.Model): title = db.Column(db.String(255), nullable=False) brief = db.Column(db.String(255), nullable=False) stage = db.Column(db.String(255), nullable=False) - content = db.Column(db.Text, nullable=False) + content = db.Column(db.Text, nullable=False, default=default_proposal_content()) category = db.Column(db.String(255), nullable=True) date_approved = db.Column(db.DateTime) date_published = db.Column(db.DateTime) @@ -290,7 +312,7 @@ class Proposal(db.Model): status: str = ProposalStatus.DRAFT, title: str = '', brief: str = '', - content: str = '', + content: str = default_proposal_content(), stage: str = ProposalStage.PREVIEW, target: str = '0', payout_address: str = '', @@ -521,7 +543,7 @@ class Proposal(db.Model): 'proposal_url': make_admin_url(f'/proposals/{self.id}'), }) - # state: status (DRAFT || REJECTED) -> (PENDING || STAKING) + # state: status (DRAFT || REJECTED) -> (PENDING) def submit_for_approval(self): self.validate_publishable() self.validate_milestone_days() @@ -529,11 +551,7 @@ class Proposal(db.Model): # specific validation if self.status not in allowed_statuses: raise ValidationException(f"Proposal status must be draft or rejected to submit for approval") - # set to PENDING if staked, else STAKING - if self.is_staked: - self.status = ProposalStatus.PENDING - else: - self.status = ProposalStatus.STAKING + self.set_pending() def set_pending_when_ready(self): if self.status == ProposalStatus.STAKING and self.is_staked: @@ -541,10 +559,6 @@ class Proposal(db.Model): # state: status STAKING -> PENDING def set_pending(self): - if self.status != ProposalStatus.STAKING: - raise ValidationException(f"Proposal status must be staking in order to be set to pending") - if not self.is_staked: - raise ValidationException(f"Proposal is not fully staked, cannot set to pending") self.send_admin_email('admin_approval') self.status = ProposalStatus.PENDING db.session.add(self) @@ -566,16 +580,23 @@ class Proposal(db.Model): self.date_published = datetime.datetime.now() self.stage = ProposalStage.WIP - with_or_out = 'without' if with_funding: self.fully_fund_contibution_bounty() - with_or_out = 'with' for t in self.team: + admin_note = '' + if with_funding: + admin_note = 'Congratulations! Your proposal has been accepted with funding from the Zcash Foundation.' + else: + admin_note = ''' + We've chosen to list your proposal on ZF Grants, but we won't be funding your proposal at this time. + Your proposal can still receive funding from the community in the form of tips if you have set a tip address for your proposal. + If you have not yet done so, you can do this from the actions dropdown at your proposal. + ''' send_email(t.email_address, 'proposal_approved', { 'user': t, 'proposal': self, 'proposal_url': make_url(f'/proposals/{self.id}'), - 'admin_note': f'Congratulations! Your proposal has been accepted {with_or_out} funding.' + 'admin_note': admin_note }) else: if not reject_reason: diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 03b7fbe1..763baf9f 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -321,17 +321,6 @@ def submit_for_approval_proposal(proposal_id): return proposal_schema.dump(g.current_proposal), 200 -@blueprint.route("//stake", methods=["GET"]) -@requires_team_member_auth -def get_proposal_stake(proposal_id): - if g.current_proposal.status != ProposalStatus.STAKING: - return {"message": "ok"}, 400 - contribution = g.current_proposal.get_staking_contribution(g.current_user.id) - if contribution: - return proposal_contribution_schema.dump(contribution) - return {"message": "ok"}, 404 - - @blueprint.route("//publish", methods=["PUT"]) @requires_team_member_auth def publish_proposal(proposal_id): diff --git a/backend/grant/rfp/models.py b/backend/grant/rfp/models.py index 0db23d69..03eba413 100644 --- a/backend/grant/rfp/models.py +++ b/backend/grant/rfp/models.py @@ -34,6 +34,8 @@ class RFP(db.Model): date_closed = db.Column(db.DateTime, nullable=True) version = db.Column(db.String(255), nullable=True) + ccr = db.relationship("CCR", uselist=False, back_populates="rfp") + # Relationships proposals = db.relationship( "Proposal", @@ -57,7 +59,6 @@ class RFP(db.Model): .correlate_except(rfp_liker) ) - @hybrid_property def bounty(self): return self._bounty @@ -134,9 +135,11 @@ class RFPSchema(ma.Schema): "accepted_proposals", "authed_liked", "likes_count", - "is_version_two" + "is_version_two", + "ccr" ) + ccr = ma.Nested("CCRSchema", exclude=["rfp"]) status = ma.Method("get_status") date_closes = ma.Method("get_date_closes") date_opened = ma.Method("get_date_opened") @@ -184,9 +187,11 @@ class AdminRFPSchema(ma.Schema): "date_opened", "date_closed", "proposals", - "is_version_two" + "is_version_two", + "ccr" ) + ccr = ma.Nested("CCRSchema", exclude=["rfp"]) status = ma.Method("get_status") date_created = ma.Method("get_date_created") date_closes = ma.Method("get_date_closes") diff --git a/backend/grant/task/jobs.py b/backend/grant/task/jobs.py index b5ce9c11..f317d554 100644 --- a/backend/grant/task/jobs.py +++ b/backend/grant/task/jobs.py @@ -151,15 +151,19 @@ class PruneDraft: @staticmethod def process_task(task): - from grant.proposal.models import Proposal + from grant.proposal.models import Proposal, default_proposal_content proposal = Proposal.query.filter_by(id=task.blob["proposal_id"]).first() # If it was deleted or moved out of a draft, noop out if not proposal or proposal.status != ProposalStatus.DRAFT: return - # If any of the proposal fields are filled, noop out - if proposal.title or proposal.brief or proposal.content or proposal.category or proposal.target != "0": + # If proposal content deviates from the default, noop out + if proposal.content != default_proposal_content(): + return + + # If any of the remaining proposal fields are filled, noop out + if proposal.title or proposal.brief or proposal.category or proposal.target != "0": return if proposal.payout_address or proposal.milestones: diff --git a/backend/grant/templates/emails/admin_approval_ccr.html b/backend/grant/templates/emails/admin_approval_ccr.html new file mode 100644 index 00000000..45ab8f58 --- /dev/null +++ b/backend/grant/templates/emails/admin_approval_ccr.html @@ -0,0 +1,32 @@ +

+ + {{ args.ccr.title }} + is awaiting approval. As an admin you can help out by reviewing it. +

+ + + + + +
+ + + + +
+ + Review Request + +
+
diff --git a/backend/grant/templates/emails/admin_approval_ccr.txt b/backend/grant/templates/emails/admin_approval_ccr.txt new file mode 100644 index 00000000..16289bb2 --- /dev/null +++ b/backend/grant/templates/emails/admin_approval_ccr.txt @@ -0,0 +1,3 @@ +{{ args.ccr.title }} is awaiting approval. As an admin you can help out by reviewing it. + +Visit the request and review: {{ args.ccr_url }} diff --git a/backend/grant/templates/emails/ccr_approved.html b/backend/grant/templates/emails/ccr_approved.html new file mode 100644 index 00000000..42a942cc --- /dev/null +++ b/backend/grant/templates/emails/ccr_approved.html @@ -0,0 +1,12 @@ +

+ Congratulations on your approval! We look forward to seeing proposals that are generated as a result of your request. +

+ +{% if args.admin_note %} +

+ A note from the admin team was attached to your approval: +

+

+ “{{ args.admin_note }}” +

+{% endif %} \ No newline at end of file diff --git a/backend/grant/templates/emails/ccr_approved.txt b/backend/grant/templates/emails/ccr_approved.txt new file mode 100644 index 00000000..5dc43e75 --- /dev/null +++ b/backend/grant/templates/emails/ccr_approved.txt @@ -0,0 +1,9 @@ +Congratulations on your approval! We look forward to seeing proposals that are generated as a result of your request. + +{% if args.admin_note %} +A note from the admin team was attached to your approval: + +> {{ args.admin_note }} +{% endif %} + +{{ args.proposal_url }} \ No newline at end of file diff --git a/backend/grant/templates/emails/ccr_rejected.html b/backend/grant/templates/emails/ccr_rejected.html new file mode 100644 index 00000000..9d5f0db3 --- /dev/null +++ b/backend/grant/templates/emails/ccr_rejected.html @@ -0,0 +1,19 @@ +

+ Your request has changes requested. You're free to modify it + and try submitting again. +

+ +{% if args.admin_note %} +

+ A note from the admin team was attached to your rejection: +

+

+ “{{ args.admin_note }}” +

+{% endif %} + +

+ Please note that repeated submissions without significant changes or with + content that doesn't match the platform guidelines may result in a removal + of your submission privileges. +

\ No newline at end of file diff --git a/backend/grant/templates/emails/ccr_rejected.txt b/backend/grant/templates/emails/ccr_rejected.txt new file mode 100644 index 00000000..6a542201 --- /dev/null +++ b/backend/grant/templates/emails/ccr_rejected.txt @@ -0,0 +1,12 @@ +Your request has changes requested. You're free to modify it +and try submitting again. + +{% if args.admin_note %} +A note from the admin team was attached to your rejection: + +> {{ args.admin_note }} +{% endif %} + +Please note that repeated submissions without significant changes or with +content that doesn't match the platform guidelines may result in a removal +of your submission privileges. \ No newline at end of file diff --git a/backend/grant/templates/emails/proposal_approved.html b/backend/grant/templates/emails/proposal_approved.html index df35f3f8..84f07632 100644 --- a/backend/grant/templates/emails/proposal_approved.html +++ b/backend/grant/templates/emails/proposal_approved.html @@ -1,7 +1,5 @@

- Congratulations on your approval! We look forward to seeing the support your - proposal receives. To get your campaign started, click below and follow the - instructions to publish your proposal. + Your proposal has been reviewed by the Zcash Foundation and is now listed on ZF Grants!

{% if args.admin_note %} @@ -13,22 +11,3 @@

{% endif %} - - - - -
- - - - -
- - Publish your proposal - -
-
\ No newline at end of file diff --git a/backend/grant/templates/emails/proposal_approved.txt b/backend/grant/templates/emails/proposal_approved.txt index f22daa7a..7080ed43 100644 --- a/backend/grant/templates/emails/proposal_approved.txt +++ b/backend/grant/templates/emails/proposal_approved.txt @@ -1,6 +1,5 @@ -Congratulations on your approval! We look forward to seeing the support your -proposal receives. To start the fundraising (and the clock) go to the URL -below and publish your proposal. +Your proposal has been reviewed by the Zcash Foundation and is now listed on ZF Grants! + {% if args.admin_note %} A note from the admin team was attached to your approval: diff --git a/backend/grant/templates/emails/proposal_canceled.html b/backend/grant/templates/emails/proposal_canceled.html index 18d7fe7e..14dd4670 100644 --- a/backend/grant/templates/emails/proposal_canceled.html +++ b/backend/grant/templates/emails/proposal_canceled.html @@ -1,7 +1,6 @@

This notice is to inform you that your proposal {{ args.proposal.title }} - has been canceled. We've let your contributors know, and they should be expecting refunds - shortly. + has been canceled.

diff --git a/backend/grant/templates/emails/proposal_canceled.txt b/backend/grant/templates/emails/proposal_canceled.txt index 30fe36be..c3aa9a3f 100644 --- a/backend/grant/templates/emails/proposal_canceled.txt +++ b/backend/grant/templates/emails/proposal_canceled.txt @@ -1,6 +1,5 @@ This notice is to inform you that your proposal "{{ args.proposal.title }}" -has been canceled. We've let your contributors know, and they should be expecting refunds -shortly. +has been canceled. If you have any further questions, please contact support for more information: {{ args.support_url }} \ No newline at end of file diff --git a/backend/grant/user/commands.py b/backend/grant/user/commands.py index 52ccf178..6472919b 100644 --- a/backend/grant/user/commands.py +++ b/backend/grant/user/commands.py @@ -37,6 +37,7 @@ def set_admin(identity): if user: user.set_admin(True) + user.email_verification.has_verified = True db.session.add(user) db.session.commit() click.echo(f'Successfully set {user.display_name} (uid {user.id}) to admin') diff --git a/backend/grant/user/models.py b/backend/grant/user/models.py index e847a840..572edcf2 100644 --- a/backend/grant/user/models.py +++ b/backend/grant/user/models.py @@ -3,6 +3,7 @@ from flask_security.core import current_user from flask_security.utils import hash_password, verify_and_update_password, login_user from sqlalchemy.ext.hybrid import hybrid_property from grant.comment.models import Comment +from grant.ccr.models import CCR from grant.email.models import EmailVerification, EmailRecovery from grant.email.send import send_email from grant.email.subscription_settings import ( @@ -125,6 +126,7 @@ class User(db.Model, UserMixin): # relations social_medias = db.relationship(SocialMedia, backref="user", lazy=True, cascade="all, delete-orphan") comments = db.relationship(Comment, backref="user", lazy=True) + ccrs = db.relationship(CCR, back_populates="author", lazy=True, cascade="all, delete-orphan") avatar = db.relationship(Avatar, uselist=False, back_populates="user", cascade="all, delete-orphan") settings = db.relationship(UserSettings, uselist=False, back_populates="user", lazy=True, cascade="all, delete-orphan") @@ -148,7 +150,6 @@ class User(db.Model, UserMixin): "RFP", secondary="rfp_liker", back_populates="likes" ) - def __init__( self, email_address, diff --git a/backend/grant/user/views.py b/backend/grant/user/views.py index 492f2698..dafb83d3 100644 --- a/backend/grant/user/views.py +++ b/backend/grant/user/views.py @@ -8,17 +8,18 @@ from webargs import validate import grant.utils.auth as auth from grant.comment.models import Comment, user_comments_schema from grant.email.models import EmailRecovery +from grant.ccr.models import CCR, ccrs_schema from grant.extensions import limiter from grant.parser import query, body from grant.proposal.models import ( Proposal, ProposalTeamInvite, invites_with_proposal_schema, - ProposalContribution, user_proposal_contributions_schema, user_proposals_schema, user_proposal_arbiters_schema ) +from grant.proposal.models import ProposalContribution from grant.utils.enums import ProposalStatus, ContributionStatus from grant.utils.exceptions import ValidationException from grant.utils.requests import validate_blockchain_get @@ -50,14 +51,20 @@ def get_me(): "withComments": fields.Bool(required=False, missing=None), "withFunded": fields.Bool(required=False, missing=None), "withPending": fields.Bool(required=False, missing=None), - "withArbitrated": fields.Bool(required=False, missing=None) + "withArbitrated": fields.Bool(required=False, missing=None), + "withRequests": fields.Bool(required=False, missing=None) + }) -def get_user(user_id, with_proposals, with_comments, with_funded, with_pending, with_arbitrated): +def get_user(user_id, with_proposals, with_comments, with_funded, with_pending, with_arbitrated, with_requests): user = User.get_by_id(user_id) if user: result = user_schema.dump(user) authed_user = auth.get_authed_user() is_self = authed_user and authed_user.id == user.id + if with_requests: + requests = CCR.get_by_user(user) + requests_dump = ccrs_schema.dump(requests) + result["requests"] = requests_dump if with_proposals: proposals = Proposal.get_by_user(user) proposals_dump = user_proposals_schema.dump(proposals) @@ -75,14 +82,22 @@ 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 is_self: - pending = Proposal.get_by_user(user, [ + pending_proposals = Proposal.get_by_user(user, [ ProposalStatus.STAKING, ProposalStatus.PENDING, ProposalStatus.APPROVED, ProposalStatus.REJECTED, ]) - pending_dump = user_proposals_schema.dump(pending) - result["pendingProposals"] = pending_dump + pending_proposals_dump = user_proposals_schema.dump(pending_proposals) + result["pendingProposals"] = pending_proposals_dump + pending_ccrs = CCR.get_by_user(user, [ + ProposalStatus.STAKING, + ProposalStatus.PENDING, + ProposalStatus.APPROVED, + ProposalStatus.REJECTED, + ]) + pending_ccrs_dump = ccrs_schema.dump(pending_ccrs) + result["pendingRequests"] = pending_ccrs_dump if with_arbitrated and is_self: result["arbitrated"] = user_proposal_arbiters_schema.dump(user.arbiter_proposals) diff --git a/backend/grant/utils/auth.py b/backend/grant/utils/auth.py index 96252834..7ca53b8d 100644 --- a/backend/grant/utils/auth.py +++ b/backend/grant/utils/auth.py @@ -1,10 +1,11 @@ -from functools import wraps from datetime import datetime, timedelta +from functools import wraps import sentry_sdk from flask import request, g, jsonify, session, current_app from flask_security.core import current_user from flask_security.utils import logout_user + from grant.settings import BLOCKCHAIN_API_SECRET @@ -26,7 +27,7 @@ def throw_on_banned(user): raise AuthException("You are banned") -def is_auth_fresh(minutes: int=20): +def is_auth_fresh(minutes: int = 20): if 'last_login_time' in session: last = session['last_login_time'] now = datetime.now() @@ -135,6 +136,28 @@ def requires_team_member_auth(f): return requires_email_verified_auth(decorated) +def requires_ccr_owner_auth(f): + @wraps(f) + def decorated(*args, **kwargs): + from grant.ccr.models import CCR + + ccr_id = kwargs["ccr_id"] + if not ccr_id: + return jsonify(message="Decorator requires_ccr_owner_auth requires path variable "), 500 + + ccr = CCR.query.filter_by(id=ccr_id).first() + if not ccr: + return jsonify(message="No CCR exists with id {}".format(ccr_id)), 404 + + if g.current_user.id != ccr.author.id: + return jsonify(message="You are not authorized to modify this CCR"), 403 + + g.current_ccr = ccr + return f(*args, **kwargs) + + return requires_email_verified_auth(decorated) + + def requires_arbiter_auth(f): @wraps(f) def decorated(*args, **kwargs): diff --git a/backend/grant/utils/enums.py b/backend/grant/utils/enums.py index baf010d8..71a380ea 100644 --- a/backend/grant/utils/enums.py +++ b/backend/grant/utils/enums.py @@ -11,10 +11,22 @@ class CustomEnum(): not attr.startswith('__')] -class ProposalStatusEnum(CustomEnum): +class CCRStatusEnum(CustomEnum): DRAFT = 'DRAFT' PENDING = 'PENDING' + APPROVED = 'APPROVED' + REJECTED = 'REJECTED' + LIVE = 'LIVE' + DELETED = 'DELETED' + + +CCRStatus = CCRStatusEnum() + + +class ProposalStatusEnum(CustomEnum): + DRAFT = 'DRAFT' STAKING = 'STAKING' + PENDING = 'PENDING' APPROVED = 'APPROVED' REJECTED = 'REJECTED' LIVE = 'LIVE' diff --git a/backend/grant/utils/pagination.py b/backend/grant/utils/pagination.py index 1860f7da..d29e1c5c 100644 --- a/backend/grant/utils/pagination.py +++ b/backend/grant/utils/pagination.py @@ -1,12 +1,14 @@ import abc -from sqlalchemy import or_, and_ +from sqlalchemy import or_ + +from grant.ccr.models import CCR from grant.comment.models import Comment, comments_schema -from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema -from grant.comment.models import Comment, comments_schema -from grant.user.models import User, UserSettings, users_schema from grant.milestone.models import Milestone -from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, MilestoneStage +from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema +from grant.user.models import User, UserSettings, users_schema +from .enums import CCRStatus, ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, \ + MilestoneStage def extract_filters(sw, strings): @@ -39,13 +41,13 @@ class Pagination(abc.ABC): # consider moving these args into __init__ and attaching to self @abc.abstractmethod def paginate( - self, - schema: ma.Schema, - query: db.Query, - page: int, - filters: list, - search: str, - sort: str, + self, + schema: ma.Schema, + query: db.Query, + page: int, + filters: list, + search: str, + sort: str, ): pass @@ -68,13 +70,13 @@ class ProposalPagination(Pagination): } def paginate( - self, - schema: ma.Schema, - query: db.Query=None, - page: int=1, - filters: list=None, - search: str=None, - sort: str='PUBLISHED:DESC', + self, + schema: ma.Schema, + query: db.Query = None, + page: int = 1, + filters: list = None, + search: str = None, + sort: str = 'PUBLISHED:DESC', ): query = query or Proposal.query sort = sort or 'PUBLISHED:DESC' @@ -142,13 +144,13 @@ class ContributionPagination(Pagination): } def paginate( - self, - schema: ma.Schema=proposal_contributions_schema, - query: db.Query=None, - page: int=1, - filters: list=None, - search: str=None, - sort: str='PUBLISHED:DESC', + self, + schema: ma.Schema = proposal_contributions_schema, + query: db.Query = None, + page: int = 1, + filters: list = None, + search: str = None, + sort: str = 'PUBLISHED:DESC', ): query = query or ProposalContribution.query sort = sort or 'CREATED:DESC' @@ -167,9 +169,9 @@ class ContributionPagination(Pagination): .filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \ .join(Proposal) \ .filter(or_( - Proposal.stage == ProposalStage.FAILED, - Proposal.stage == ProposalStage.CANCELED, - )) \ + Proposal.stage == ProposalStage.FAILED, + Proposal.stage == ProposalStage.CANCELED, + )) \ .join(ProposalContribution.user) \ .join(UserSettings) \ .filter(UserSettings.refund_address != None) @@ -179,9 +181,9 @@ class ContributionPagination(Pagination): .filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \ .join(Proposal) \ .filter(or_( - Proposal.stage == ProposalStage.FAILED, - Proposal.stage == ProposalStage.CANCELED, - )) \ + Proposal.stage == ProposalStage.FAILED, + Proposal.stage == ProposalStage.CANCELED, + )) \ .join(ProposalContribution.user, isouter=True) \ .join(UserSettings, isouter=True) \ .filter(UserSettings.refund_address == None) @@ -222,13 +224,13 @@ class UserPagination(Pagination): } def paginate( - self, - schema: ma.Schema=users_schema, - query: db.Query=None, - page: int=1, - filters: list=None, - search: str=None, - sort: str='EMAIL:DESC', + self, + schema: ma.Schema = users_schema, + query: db.Query = None, + page: int = 1, + filters: list = None, + search: str = None, + sort: str = 'EMAIL:DESC', ): query = query or Proposal.query sort = sort or 'EMAIL:DESC' @@ -278,13 +280,13 @@ class CommentPagination(Pagination): } def paginate( - self, - schema: ma.Schema=comments_schema, - query: db.Query=None, - page: int=1, - filters: list=None, - search: str=None, - sort: str='CREATED:DESC', + self, + schema: ma.Schema = comments_schema, + query: db.Query = None, + page: int = 1, + filters: list = None, + search: str = None, + sort: str = 'CREATED:DESC', ): query = query or Comment.query sort = sort or 'CREATED:DESC' @@ -320,7 +322,58 @@ class CommentPagination(Pagination): } +class CCRPagination(Pagination): + def __init__(self): + self.FILTERS = [f'STATUS_{s}' for s in CCRStatus.list()] + self.PAGE_SIZE = 9 + self.SORT_MAP = { + 'CREATED:DESC': CCR.date_created.desc(), + 'CREATED:ASC': CCR.date_created + } + + def paginate( + self, + schema: ma.Schema, + query: db.Query = None, + page: int = 1, + filters: list = None, + search: str = None, + sort: str = 'PUBLISHED:DESC', + ): + query = query or CCR.query + sort = sort or 'PUBLISHED:DESC' + + # FILTER + if filters: + self.validate_filters(filters) + status_filters = extract_filters('STATUS_', filters) + + if status_filters: + query = query.filter(CCR.status.in_(status_filters)) + + # SORT (see self.SORT_MAP) + if sort: + self.validate_sort(sort) + query = query.order_by(self.SORT_MAP[sort]) + + # SEARCH + if search: + query = query.filter(CCR.title.ilike(f'%{search}%')) + + res = query.paginate(page, self.PAGE_SIZE, False) + return { + 'page': res.page, + 'total': res.total, + 'page_size': self.PAGE_SIZE, + 'items': schema.dump(res.items), + 'filters': filters, + 'search': search, + 'sort': sort + } + + # expose pagination methods here +ccr = CCRPagination().paginate proposal = ProposalPagination().paginate contribution = ContributionPagination().paginate comment = CommentPagination().paginate diff --git a/backend/migrations/versions/2721189b0c8f_.py b/backend/migrations/versions/2721189b0c8f_.py new file mode 100644 index 00000000..ee7992a2 --- /dev/null +++ b/backend/migrations/versions/2721189b0c8f_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: 2721189b0c8f +Revises: 1e1460456ce4 +Create Date: 2019-11-27 19:59:20.246227 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2721189b0c8f' +down_revision = '1e1460456ce4' +branch_labels = None +depends_on = None + + +def upgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.create_table('ccr', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('date_created', sa.DateTime(), nullable=True), + sa.Column('title', sa.String(length=255), nullable=True), + sa.Column('brief', sa.String(length=255), nullable=True), + sa.Column('content', sa.Text(), nullable=True), + sa.Column('status', sa.String(length=255), nullable=False), + sa.Column('target', sa.String(length=255), nullable=True), + sa.Column('reject_reason', sa.String(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('rfp_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['rfp_id'], ['rfp.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.drop_table('ccr') + # ### end Alembic commands ### diff --git a/backend/tests/ccr/__init__.py b/backend/tests/ccr/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/ccr/test_ccr_api.py b/backend/tests/ccr/test_ccr_api.py new file mode 100644 index 00000000..b95a45a1 --- /dev/null +++ b/backend/tests/ccr/test_ccr_api.py @@ -0,0 +1,40 @@ +import json + +from grant.ccr.models import CCR +from ..config import BaseCCRCreatorConfig +from ..test_data import test_ccr + + +class TestCCRApi(BaseCCRCreatorConfig): + + def test_create_new_draft(self): + self.login_default_user() + resp = self.app.post( + "/api/v1/ccrs/drafts", + ) + self.assertStatus(resp, 201) + + ccr_db = CCR.query.filter_by(id=resp.json['ccrId']) + self.assertIsNotNone(ccr_db) + + def test_no_auth_create_new_draft(self): + resp = self.app.post( + "/api/v1/ccrs/drafts" + ) + self.assert401(resp) + + def test_update_CCR_draft(self): + new_title = "Updated!" + new_ccr = test_ccr.copy() + new_ccr["title"] = new_title + + self.login_default_user() + resp = self.app.put( + "/api/v1/ccrs/{}".format(self.ccr.id), + data=json.dumps(new_ccr), + content_type='application/json' + ) + print(resp) + self.assert200(resp) + self.assertEqual(resp.json["title"], new_title) + self.assertEqual(self.ccr.title, new_title) diff --git a/backend/tests/config.py b/backend/tests/config.py index bd8c8b5a..83f86cb7 100644 --- a/backend/tests/config.py +++ b/backend/tests/config.py @@ -6,6 +6,7 @@ from flask_testing import TestCase from mock import patch from grant.app import create_app +from grant.ccr.models import CCR from grant.extensions import limiter from grant.milestone.models import Milestone from grant.proposal.models import Proposal @@ -13,7 +14,7 @@ from grant.settings import PROPOSAL_STAKING_AMOUNT 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, mock_blockchain_api_requests +from .test_data import test_user, test_other_user, test_proposal, mock_blockchain_api_requests, test_ccr class BaseTestConfig(TestCase): @@ -184,3 +185,23 @@ class BaseProposalCreatorConfig(BaseUserConfig): db.session.add(contribution) db.session.flush() self.proposal.set_pending_when_ready() + + +class BaseCCRCreatorConfig(BaseUserConfig): + def setUp(self): + super().setUp() + self._ccr = CCR.create( + status=ProposalStatus.DRAFT, + title=test_ccr["title"], + content=test_ccr["content"], + brief=test_ccr["brief"], + target=test_ccr["target"], + user_id=self.user.id + ) + self._ccr_id = self._ccr.id + db.session.commit() + + # always return fresh (avoid detached instance issues) + @property + def ccr(self): + return CCR.query.filter_by(id=self._ccr_id).first() diff --git a/backend/tests/proposal/test_api.py b/backend/tests/proposal/test_api.py index 8d9b8937..5a08a2d5 100644 --- a/backend/tests/proposal/test_api.py +++ b/backend/tests/proposal/test_api.py @@ -126,7 +126,7 @@ class TestProposalAPI(BaseProposalCreatorConfig): self.login_default_user() resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id)) self.assert200(resp) - self.assertEqual(resp.json['status'], ProposalStatus.STAKING) + self.assertEqual(resp.json['status'], ProposalStatus.PENDING) @patch('requests.get', side_effect=mock_blockchain_api_requests) def test_no_auth_proposal_draft_submit_for_approval(self, mock_get): @@ -152,60 +152,6 @@ class TestProposalAPI(BaseProposalCreatorConfig): resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id)) self.assert400(resp) - # /stake - @patch('requests.get', side_effect=mock_blockchain_api_requests) - def test_proposal_stake(self, mock_get): - self.login_default_user() - self.proposal.status = ProposalStatus.STAKING - resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}/stake") - print(resp) - self.assert200(resp) - self.assertEquals(resp.json['amount'], str(PROPOSAL_STAKING_AMOUNT.normalize())) - - @patch('requests.get', side_effect=mock_blockchain_api_requests) - def test_proposal_stake_no_auth(self, mock_get): - self.proposal.status = ProposalStatus.STAKING - resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}/stake") - print(resp) - self.assert401(resp) - - @patch('requests.get', side_effect=mock_blockchain_api_requests) - def test_proposal_stake_bad_status(self, mock_get): - self.login_default_user() - self.proposal.status = ProposalStatus.PENDING # should be staking - resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}/stake") - print(resp) - self.assert400(resp) - - @patch('requests.get', side_effect=mock_blockchain_api_requests) - def test_proposal_stake_funded(self, mock_get): - self.login_default_user() - # fake stake contribution with confirmation - self.stake_proposal() - resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}/stake") - print(resp) - self.assert400(resp) - - # /publish - @patch('requests.get', side_effect=mock_blockchain_api_requests) - def test_publish_proposal_approved(self, mock_get): - self.login_default_user() - # proposal needs to be APPROVED - self.proposal.status = ProposalStatus.APPROVED - resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id)) - self.assert200(resp) - - @patch('requests.get', side_effect=mock_blockchain_api_requests) - def test_no_auth_publish_proposal(self, mock_get): - resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id)) - self.assert401(resp) - - @patch('requests.get', side_effect=mock_blockchain_api_requests) - def test_invalid_proposal_publish_proposal(self, mock_get): - self.login_default_user() - resp = self.app.put("/api/v1/proposals/12345/publish") - self.assert404(resp) - @patch('requests.get', side_effect=mock_blockchain_api_requests) def test_invalid_status_proposal_publish_proposal(self, mock_get): self.login_default_user() @@ -223,19 +169,18 @@ class TestProposalAPI(BaseProposalCreatorConfig): # / def test_get_proposals(self): - self.test_publish_proposal_approved() + self.proposal.status = ProposalStatus.LIVE resp = self.app.get("/api/v1/proposals/") self.assert200(resp) def test_get_proposals_does_not_include_team_member_email_addresses(self): - self.test_publish_proposal_approved() + self.proposal.status = ProposalStatus.LIVE resp = self.app.get("/api/v1/proposals/") self.assert200(resp) for each_proposal in resp.json['items']: for team_member in each_proposal["team"]: self.assertIsNone(team_member.get('email_address')) - def test_follow_proposal(self): # not logged in resp = self.app.put( diff --git a/backend/tests/test_data.py b/backend/tests/test_data.py index 80b77e90..a23e50fa 100644 --- a/backend/tests/test_data.py +++ b/backend/tests/test_data.py @@ -49,6 +49,14 @@ test_proposal = { "deadlineDuration": 100 } +test_ccr = { + "user_id": test_user, + "content": "## My Proposal", + "title": "Give Me Money", + "brief": "$$$", + "target": "123.456", +} + test_comment = { "comment": "Test comment" } diff --git a/frontend/.env.example b/frontend/.env.example index f7532867..9de348c7 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -26,4 +26,4 @@ DISABLE_SSL=true # TESTNET=true # Maximum amount for a proposal target, keep in sync with backend .env -PROPOSAL_TARGET_MAX=500000 +PROPOSAL_TARGET_MAX=999999 diff --git a/frontend/client/Routes.tsx b/frontend/client/Routes.tsx index c39d2d36..385c8c92 100644 --- a/frontend/client/Routes.tsx +++ b/frontend/client/Routes.tsx @@ -20,9 +20,12 @@ import 'styles/style.less'; const opts = { fallback: }; const Home = loadable(() => import('pages/index'), opts); const Create = loadable(() => import('pages/create'), opts); +const CreateRequest = loadable(() => import('pages/create-request'), opts); +const RequestEdit = loadable(() => import('pages/request-edit'), opts); const ProposalEdit = loadable(() => import('pages/proposal-edit'), opts); const Proposals = loadable(() => import('pages/proposals'), opts); const Proposal = loadable(() => import('pages/proposal'), opts); +const Ccr = loadable(() => import('pages/ccr'), opts); const Auth = loadable(() => import('pages/auth')); const SignOut = loadable(() => import('pages/sign-out'), opts); const Profile = loadable(() => import('pages/profile'), opts); @@ -63,6 +66,43 @@ const routeConfigs: RouteConfig[] = [ isFullScreen: true, }, }, + { + // Create request + route: { + path: '/create-request', + component: CreateRequest, + }, + template: { + title: 'Create a Request', + }, + onlyLoggedIn: true, + }, + { + // Request edit page + route: { + path: '/ccrs/:id/edit', + component: RequestEdit, + }, + template: { + title: 'Edit Request', + isFullScreen: true, + hideFooter: true, + }, + onlyLoggedIn: true, + }, + { + // Request view page + route: { + path: '/ccrs/:id', + component: Ccr, + }, + template: { + title: 'View Request', + isFullScreen: true, + hideFooter: true, + }, + onlyLoggedIn: true, + }, { // Create proposal route: { diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index bceb56c1..0fd5ae2c 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -14,6 +14,7 @@ import { ProposalPageParams, PageParams, UserSettings, + CCR, } from 'types'; import { formatUserForPost, @@ -23,6 +24,7 @@ import { formatProposalPageParamsForGet, formatProposalPageFromGet, } from 'utils/api'; +import { CCRDraft } from 'types/ccr'; export function getProposals(page?: ProposalPageParams): Promise<{ data: ProposalPage }> { let serverParams; @@ -88,6 +90,7 @@ export function getUser(address: string): Promise<{ data: User }> { return axios .get(`/api/v1/users/${address}`, { params: { + withRequests: true, withProposals: true, withComments: true, withFunded: true, @@ -201,16 +204,6 @@ export function verifySocial(service: SOCIAL_SERVICE, code: string): Promise { - const res = await axios.get(process.env.CROWD_FUND_FACTORY_URL as string); - return res.data; -} - -export async function fetchCrowdFundJSON(): Promise { - const res = await axios.get(process.env.CROWD_FUND_URL as string); - return res.data; -} - interface ProposalTipJarArgs { address?: string; viewKey?: string; @@ -225,7 +218,6 @@ export function updateProposalTipJarSettings( }); } - export function postProposalUpdate( proposalId: number, title: string, @@ -379,12 +371,6 @@ export function getProposalContribution( return axios.get(`/api/v1/proposals/${proposalId}/contributions/${contributionId}`); } -export function getProposalStakingContribution( - proposalId: number, -): Promise<{ data: ContributionWithAddressesAndUser }> { - return axios.get(`/api/v1/proposals/${proposalId}/stake`); -} - export function getRFPs(): Promise<{ data: RFP[] }> { return axios.get('/api/v1/rfps/').then(res => { res.data = res.data.map(formatRFPFromGet); @@ -417,3 +403,34 @@ export function getHomeLatest(): Promise<{ return res; }); } + +// CCRs +export function getCCRDrafts(): Promise<{ data: CCRDraft[] }> { + return axios.get('/api/v1/ccrs/drafts'); +} + +export function postCCRDraft(): Promise<{ data: CCRDraft }> { + return axios.post('/api/v1/ccrs/drafts'); +} + +export function deleteCCR(ccrId: number): Promise { + return axios.delete(`/api/v1/ccrs/${ccrId}`); +} + +export function putCCR(ccr: CCRDraft): Promise<{ data: CCRDraft }> { + // Exclude some keys + const { ccrId, author, dateCreated, status, ...rest } = ccr; + return axios.put(`/api/v1/ccrs/${ccrId}`, rest); +} + +export function getCCR(ccrId: number | string): Promise<{ data: CCR }> { + return axios.get(`/api/v1/ccrs/${ccrId}`).then(res => { + return res; + }); +} + +export async function putCCRSubmitForApproval(ccr: CCRDraft): Promise<{ data: CCR }> { + return axios.put(`/api/v1/ccrs/${ccr.ccrId}/submit_for_approval`).then(res => { + return res; + }); +} diff --git a/frontend/client/components/CCRDraftList/index.tsx b/frontend/client/components/CCRDraftList/index.tsx new file mode 100644 index 00000000..77e95a7c --- /dev/null +++ b/frontend/client/components/CCRDraftList/index.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Link } from 'react-router-dom'; +import { Button, Divider, List, message, Popconfirm, Spin } from 'antd'; +import Placeholder from 'components/Placeholder'; +import { getIsVerified } from 'modules/auth/selectors'; +import Loader from 'components/Loader'; +import { CCRDraft, CCRSTATUS } from 'types'; +import { + createCCRDraft, + deleteCCRDraft, + fetchAndCreateCCRDrafts, +} from 'modules/ccr/actions'; +import { AppState } from 'store/reducers'; +import './style.less'; + +interface StateProps { + drafts: AppState['ccr']['drafts']; + isFetchingDrafts: AppState['ccr']['isFetchingDrafts']; + fetchDraftsError: AppState['ccr']['fetchDraftsError']; + isCreatingDraft: AppState['ccr']['isCreatingDraft']; + createDraftError: AppState['ccr']['createDraftError']; + isDeletingDraft: AppState['ccr']['isDeletingDraft']; + deleteDraftError: AppState['ccr']['deleteDraftError']; + isVerified: ReturnType; +} + +interface DispatchProps { + createCCRDraft: typeof createCCRDraft; + deleteCCRDraft: typeof deleteCCRDraft; + fetchAndCreateCCRDrafts: typeof fetchAndCreateCCRDrafts; +} + +interface OwnProps { + createIfNone?: boolean; +} + +type Props = StateProps & DispatchProps & OwnProps; + +interface State { + deletingId: number | null; +} + +class CCRDraftList extends React.Component { + state: State = { + deletingId: null, + }; + + componentDidMount() { + this.props.fetchAndCreateCCRDrafts(); + } + + componentDidUpdate(prevProps: Props) { + const { isDeletingDraft, deleteDraftError, createDraftError } = this.props; + if (prevProps.isDeletingDraft && !isDeletingDraft) { + this.setState({ deletingId: null }); + } + if (deleteDraftError && prevProps.deleteDraftError !== deleteDraftError) { + message.error(deleteDraftError, 3); + } + if (createDraftError && prevProps.createDraftError !== createDraftError) { + message.error(createDraftError, 3); + } + } + + render() { + const { drafts, isCreatingDraft, isFetchingDrafts, isVerified } = this.props; + const { deletingId } = this.state; + + if (!isVerified) { + return ( +

+ +
+ ); + } + + if (!drafts || isCreatingDraft) { + return ; + } + + let draftsEl; + if (drafts.length) { + draftsEl = ( + { + const actions = [ + + Edit + , + this.deleteDraft(d.ccrId)} + > + Delete + , + ]; + return ( + + + + {d.title || Untitled Request} + {d.status === CCRSTATUS.REJECTED && (rejected)} + + } + description={d.brief || No description} + /> + + + ); + }} + /> + ); + } else { + draftsEl = ( + + ); + } + + return ( +
+

Your Request Drafts

+ {draftsEl} + or + +
+ ); + } + + private createDraft = () => { + this.props.createCCRDraft(); + }; + + private deleteDraft = (ccrId: number) => { + this.props.deleteCCRDraft(ccrId); + this.setState({ deletingId: ccrId }); + }; +} + +export default connect( + state => ({ + drafts: state.ccr.drafts, + isFetchingDrafts: state.ccr.isFetchingDrafts, + fetchDraftsError: state.ccr.fetchDraftsError, + isCreatingDraft: state.ccr.isCreatingDraft, + createDraftError: state.ccr.createDraftError, + isDeletingDraft: state.ccr.isDeletingDraft, + deleteDraftError: state.ccr.deleteDraftError, + isVerified: getIsVerified(state), + }), + { + createCCRDraft, + deleteCCRDraft, + fetchAndCreateCCRDrafts, + }, +)(CCRDraftList); diff --git a/frontend/client/components/CCRDraftList/style.less b/frontend/client/components/CCRDraftList/style.less new file mode 100644 index 00000000..20a308d6 --- /dev/null +++ b/frontend/client/components/CCRDraftList/style.less @@ -0,0 +1,26 @@ +.CreateRequestDraftList { + max-width: 560px; + margin: 0 auto; + + &-title { + font-size: 1.6rem; + text-align: center; + margin-bottom: 1rem; + } + + &-create { + display: block; + max-width: 280px; + height: 3.2rem; + margin: 0 auto; + } + + .ant-divider { + margin-top: 1rem; + margin-bottom: 2rem; + } + + .ant-alert { + margin-bottom: 1rem; + } +} \ No newline at end of file diff --git a/frontend/client/components/CCRFlow/Basics.tsx b/frontend/client/components/CCRFlow/Basics.tsx new file mode 100644 index 00000000..89173f4b --- /dev/null +++ b/frontend/client/components/CCRFlow/Basics.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { Form, Input } from 'antd'; +import { CCRDraft } from 'types'; +import { getCCRErrors } from 'modules/ccr/utils'; + +interface OwnProps { + ccrId: number; + initialState?: Partial; + + updateForm(form: Partial): void; +} + +type Props = OwnProps; + +interface State extends Partial { + title: string; + brief: string; + target: string; +} + +class CCRFlowBasics extends React.Component { + constructor(props: Props) { + super(props); + this.state = { + title: '', + brief: '', + target: '', + ...(props.initialState || {}), + }; + } + + render() { + const { title, brief, target } = this.state; + const errors = getCCRErrors(this.state, true); + + // Don't show target error at zero since it defaults to that + // Error just shows up at the end to prevent submission + if (target === '0') { + errors.target = undefined; + } + + return ( +
+ + + + + + + + + + + +
+ ); + } + + private handleInputChange = ( + event: React.ChangeEvent, + ) => { + const { value, name } = event.currentTarget; + this.setState({ [name]: value } as any, () => { + this.props.updateForm(this.state); + }); + }; +} + +export default CCRFlowBasics; diff --git a/frontend/client/components/CCRFlow/CCRExplainer.less b/frontend/client/components/CCRFlow/CCRExplainer.less new file mode 100644 index 00000000..8126dafc --- /dev/null +++ b/frontend/client/components/CCRFlow/CCRExplainer.less @@ -0,0 +1,86 @@ +@import '~styles/variables.less'; + +@small-query: ~'(max-width: 640px)'; + +.CCRExplainer { + display: flex; + flex-direction: column; + + &-header { + margin: 3rem auto 5rem; + + &-title { + font-size: 2rem; + text-align: center; + + } + + &-subtitle { + font-size: 1.4rem; + margin-bottom: 0; + opacity: 0.7; + text-align: center; + + @media @small-query { + font-size: 1.8rem; + } + } + } + + &-create { + display: block; + width: 280px; + margin-top: 0.5rem; + font-size: 1.5rem; + height: 4.2rem; + } + + &-actions { + margin: 6rem auto; + justify-content: center; + display: flex; + flex-direction: column; + } + + &-items { + max-width: 1200px; + padding: 0 2rem; + margin: 0 auto; + display: flex; + + @media @small-query { + flex-direction: column; + } + + &-item { + display: flex; + justify-content: center; + align-items: center; + margin: 0 2rem; + flex-direction: column; + + @media @small-query { + margin-bottom: 5rem; + } + + &-text { + font-size: 1.1rem; + text-align: center; + margin-top: 1rem; + + @media @small-query { + font-size: 1.5rem; + } + } + + &-icon { + flex-shrink: 0; + width: 8rem; + + @media @small-query { + width: 12rem; + } + } + } + } +} \ No newline at end of file diff --git a/frontend/client/components/CCRFlow/CCRExplainer.tsx b/frontend/client/components/CCRFlow/CCRExplainer.tsx new file mode 100644 index 00000000..338ec02e --- /dev/null +++ b/frontend/client/components/CCRFlow/CCRExplainer.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { withNamespaces, WithNamespaces } from 'react-i18next'; +import SubmitIcon from 'static/images/guide-submit.svg'; +import ReviewIcon from 'static/images/guide-review.svg'; +import './CCRExplainer.less'; +import * as ls from 'local-storage'; +import { Button, Checkbox, Icon } from 'antd'; + +interface CreateProps { + startSteps: () => void; +} + +type Props = WithNamespaces & CreateProps; + +const CCRExplainer: React.SFC = ({ startSteps }) => { + const items = [ + { + text: + 'Anyone can create a request for improvements to the Zcash ecosystem. Approved requests are posted publicly to garner interest for proposals.', + icon: , + }, + { + text: + "The request is reviewed by the Zcash Foundation. \nYou'll be notified should the Zcash Foundation choose to make your request public.", + icon: , + }, + ]; + + return ( +
+
+

Creating a Request

+
+ We can't wait to get your request! Before starting, here's what you should + know... +
+
+
+ {items.map((item, idx) => ( +
+
{item.icon}
+
{item.text}
+
+ ))} +
+
+ ls.set('noExplainCCR', ev.target.checked)}> + Don't show this again + + +
+
+ ); +}; + +export default withNamespaces()(CCRExplainer); diff --git a/frontend/client/components/CCRFlow/CCRFinal.less b/frontend/client/components/CCRFlow/CCRFinal.less new file mode 100644 index 00000000..281aa156 --- /dev/null +++ b/frontend/client/components/CCRFlow/CCRFinal.less @@ -0,0 +1,41 @@ +@import '~styles/variables.less'; + +.CCRFinal { + max-width: 550px; + padding: 1rem; + margin: 3rem auto; + + &-message { + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 2rem; + + .anticon { + margin-right: 1rem; + font-size: 3.2rem; + } + &.is-error .anticon { + color: @error-color; + } + &.is-success .anticon { + color: @success-color; + } + + &-text { + font-size: 1rem; + text-align: left; + } + } + + &-contribute { + border: 1px solid rgba(0, 0, 0, 0.05); + padding: 1.5rem; + } + + &-staked { + margin-top: 1rem; + font-size: 1.1rem; + text-align: center; + } +} diff --git a/frontend/client/components/CCRFlow/CCRFinal.tsx b/frontend/client/components/CCRFlow/CCRFinal.tsx new file mode 100644 index 00000000..816d8bea --- /dev/null +++ b/frontend/client/components/CCRFlow/CCRFinal.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Icon } from 'antd'; +import { Link } from 'react-router-dom'; +import Loader from 'components/Loader'; +import { ccrActions } from 'modules/ccr'; +import { AppState } from 'store/reducers'; +import './CCRFinal.less'; + +interface OwnProps { + goBack(): void; +} + +interface StateProps { + form: AppState['ccr']['form']; + submittedCCR: AppState['ccr']['submittedCCR']; + submitError: AppState['ccr']['submitError']; +} + +interface DispatchProps { + submitCCR: typeof ccrActions['submitCCR']; +} + +type Props = OwnProps & StateProps & DispatchProps; + +class CCRFinal extends React.Component { + componentDidMount() { + this.submit(); + } + + render() { + const { submittedCCR, submitError, goBack } = this.props; + const ready = submittedCCR; + + let content; + if (submitError) { + content = ( +
+ +
+

+ Something went wrong during creation +

+
{submitError}
+ Click here to go back to the form and try again. +
+
+ ); + } else if (ready) { + content = ( + <> +
+ + +
+ Your request has been submitted! Check your{' '} + profile's pending tab to check its + status. +
+
+ + ); + } else { + content = ; + } + + return
{content}
; + } + + private submit = () => { + if (this.props.form) { + this.props.submitCCR(this.props.form); + } + }; +} + +export default connect( + (state: AppState) => ({ + form: state.ccr.form, + submittedCCR: state.ccr.submittedCCR, + submitError: state.ccr.submitError, + }), + { + submitCCR: ccrActions.submitCCR, + }, +)(CCRFinal); diff --git a/frontend/client/components/CCRFlow/CCRPreview.less b/frontend/client/components/CCRFlow/CCRPreview.less new file mode 100644 index 00000000..cd6c719d --- /dev/null +++ b/frontend/client/components/CCRFlow/CCRPreview.less @@ -0,0 +1,28 @@ +@import '~styles/variables.less'; + +.CCRPreview { + &-preview { + // simulate non-fullscreen template margins + margin: @template-space-top @template-space-sides; + padding-bottom: 8rem; + } + + &-banner { + width: 100vw; + position: relative; + left: 50%; + right: 50%; + margin: 0 -50vw 1rem; + text-align: center; + + .ant-alert { + padding: 1rem; + } + } + + &-loader { + flex: 1; + position: relative; + height: 14rem; + } +} diff --git a/frontend/client/components/CCRFlow/CCRPreview.tsx b/frontend/client/components/CCRFlow/CCRPreview.tsx new file mode 100644 index 00000000..bd92ec85 --- /dev/null +++ b/frontend/client/components/CCRFlow/CCRPreview.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import { Alert } from 'antd'; +import { connect } from 'react-redux'; +import { Link } from 'react-router-dom'; +import Loader from 'components/Loader'; +import { RFPDetail } from 'components/RFP'; +import { AppState } from 'store/reducers'; +import { makeRfpPreviewFromCcrDraft } from 'modules/create/utils'; +import { CCRDraft, CCR, CCRSTATUS } from 'types'; +import { getCCR } from 'api/api'; +import './CCRPreview.less'; + +interface StateProps { + form: CCRDraft; +} + +interface OwnProps { + id?: number; +} + +type Props = StateProps & OwnProps; + +interface State { + loading: boolean; + ccr?: CCR; + error?: string; +} + +class CCRFlowPreview extends React.Component { + state: State = { + loading: false, + }; + + async componentWillMount() { + const { id } = this.props; + + if (id) { + this.setState({ loading: true }); + try { + const { data } = await getCCR(id); + this.setState({ ccr: data }); + } catch (e) { + this.setState({ error: e.message || e.toString()}) + } + this.setState({ loading: false }); + } + } + + render() { + const { ccr, loading, error } = this.state; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +
+ ); + } + + const { form } = this.props; + const previewData = ccr ? ccr : form; + const rfp = makeRfpPreviewFromCcrDraft(previewData); + + // BANNER + const statusBanner = { + [CCRSTATUS.DRAFT]: { + blurb: <>This is a preview of your request. It has not yet been published., + type: 'warning', + }, + [CCRSTATUS.PENDING]: { + blurb: ( + <>Your request is being reviewed. You will get an email when it is complete. + ), + type: 'warning', + }, + [CCRSTATUS.APPROVED]: { + blurb: ( + <> + Your request has been approved! It will be made live to the community sometime + soon. + + ), + type: 'success', + }, + [CCRSTATUS.REJECTED]: { + blurb: ( + <> + Your request has changes requested. Visit your profile's pending tab for more + information. + + ), + type: 'error', + }, + [CCRSTATUS.LIVE]: { + blurb: ( + <> + Your request has been approved and is live! You can find it on the{' '} + requests page. + + ), + type: 'success', + }, + } as any; + + const banner = statusBanner[previewData.status]; + + return ( +
+ {banner && ( +
+ +
+ )} + +
+ null) as any} + /> +
+
+ ); + } +} + +export default connect(state => ({ + form: state.ccr.form as CCRDraft, +}))(CCRFlowPreview); diff --git a/frontend/client/components/CCRFlow/CCRReview.less b/frontend/client/components/CCRFlow/CCRReview.less new file mode 100644 index 00000000..60de2eb1 --- /dev/null +++ b/frontend/client/components/CCRFlow/CCRReview.less @@ -0,0 +1,60 @@ +@import '~styles/variables.less'; + +.CCRReview { + &-section { + max-width: 980px; + margin: 0 auto; + } +} + +.CCRReviewField { + display: flex; + flex-direction: row; + + &-label { + width: 220px; + padding: 0 1.5rem 1rem 0; + font-size: 1.3rem; + opacity: 0.7; + text-align: right; + + &-error { + color: @error-color; + opacity: 0.8; + font-size: 0.8rem; + } + } + + &-content { + flex: 1; + font-size: 1.3rem; + padding: 0 0 1rem 1.5rem; + border-left: 1px solid #ddd; + word-break: break-word; + + code { + font-size: 1rem; + } + + &-empty { + font-size: 1.3rem; + opacity: 0.3; + letter-spacing: 0.1rem; + } + + &-edit { + margin-bottom: 5rem; + padding: 0.5rem 1rem; + font-size: 1rem; + border: 1px solid @primary-color; + color: @primary-color; + opacity: 0.8; + border-radius: 2px; + cursor: pointer; + + &:hover { + opacity: 1; + } + } + } +} diff --git a/frontend/client/components/CCRFlow/CCRReview.tsx b/frontend/client/components/CCRFlow/CCRReview.tsx new file mode 100644 index 00000000..959cadb3 --- /dev/null +++ b/frontend/client/components/CCRFlow/CCRReview.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { FIELD_NAME_MAP, getCCRErrors, KeyOfForm } from 'modules/ccr/utils'; +import Markdown from 'components/Markdown'; +import { AppState } from 'store/reducers'; +import { CCR_STEP } from './index'; +import { CCRDraft } from 'types'; +import { formatUsd } from 'utils/formatters' +import './CCRReview.less'; + +interface OwnProps { + setStep(step: CCR_STEP): void; +} + +interface StateProps { + form: CCRDraft; +} + +type Props = OwnProps & StateProps; + +interface Field { + key: KeyOfForm; + content: React.ReactNode; + error: string | Falsy; + isHide?: boolean; +} + +interface Section { + step: CCR_STEP; + name: string; + fields: Field[]; +} + +class CCRReview extends React.Component { + render() { + const { form } = this.props; + const errors = getCCRErrors(this.props.form); + const sections: Section[] = [ + { + step: CCR_STEP.BASICS, + name: 'Basics', + fields: [ + { + key: 'title', + content:

{form.title}

, + error: errors.title, + }, + { + key: 'brief', + content: form.brief, + error: errors.brief, + }, + { + key: 'target', + content:
{formatUsd(form.target)}
, + error: errors.target, + }, + ], + }, + + { + step: CCR_STEP.DETAILS, + name: 'Details', + fields: [ + { + key: 'content', + content: , + error: errors.content, + }, + ], + }, + ]; + + return ( +
+ {sections.map(s => ( +
+ {s.fields.map( + f => + !f.isHide && ( +
+
+ {FIELD_NAME_MAP[f.key]} + {f.error && ( +
{f.error}
+ )} +
+
+ {this.isEmpty(form[f.key]) ? ( +
N/A
+ ) : ( + f.content + )} +
+
+ ), + )} +
+
+
+ +
+
+
+ ))} +
+ ); + } + + private setStep = (step: CCR_STEP) => { + this.props.setStep(step); + }; + + private isEmpty(value: any) { + if (typeof value === 'boolean') { + return false; // defined booleans are never empty + } + return !value || value.length === 0; + } +} + +export default connect(state => ({ + form: state.ccr.form as CCRDraft, +}))(CCRReview); diff --git a/frontend/client/components/CCRFlow/CCRSubmitWarningModal.less b/frontend/client/components/CCRFlow/CCRSubmitWarningModal.less new file mode 100644 index 00000000..0663eb01 --- /dev/null +++ b/frontend/client/components/CCRFlow/CCRSubmitWarningModal.less @@ -0,0 +1,13 @@ +.CCRSubmitWarningModal { + .ant-alert { + margin-bottom: 1rem; + + ul { + padding-top: 0.25rem; + } + + p:last-child { + margin-bottom: 0; + } + } +} diff --git a/frontend/client/components/CCRFlow/CCRSubmitWarningModal.tsx b/frontend/client/components/CCRFlow/CCRSubmitWarningModal.tsx new file mode 100644 index 00000000..aac20b0a --- /dev/null +++ b/frontend/client/components/CCRFlow/CCRSubmitWarningModal.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Modal } from 'antd'; +import './CCRSubmitWarningModal.less'; + +interface Props { + isVisible: boolean; + handleClose(): void; + handleSubmit(): void; +} + +export default class CCRSubmitWarningModal extends React.Component { + render() { + const { isVisible, handleClose, handleSubmit } = this.props; + + return ( + Confirm submission} + visible={isVisible} + okText={'Submit'} + cancelText="Never mind" + onOk={handleSubmit} + onCancel={handleClose} + > +
+

+ Are you sure you're ready to submit your request for approval? Once you’ve + done so, you won't be able to edit it. +

+
+
+ ); + } +} diff --git a/frontend/client/components/CCRFlow/Details.tsx b/frontend/client/components/CCRFlow/Details.tsx new file mode 100644 index 00000000..2234753a --- /dev/null +++ b/frontend/client/components/CCRFlow/Details.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Form, Alert } from 'antd'; +import MarkdownEditor from 'components/MarkdownEditor'; +import { CCRDraft } from 'types'; +import { getCCRErrors } from 'modules/ccr/utils'; + +interface State { + content: string; +} + +interface Props { + initialState?: Partial; + updateForm(form: Partial): void; +} + +export default class CreateFlowTeam extends React.Component { + constructor(props: Props) { + super(props); + this.state = { + content: '', + ...(props.initialState || {}), + }; + } + + render() { + const errors = getCCRErrors(this.state, true); + + return ( +
+ + {errors.content && } + + ); + } + + private handleChange = (markdown: string) => { + if (markdown !== this.state.content) { + this.setState({ content: markdown }, () => { + this.props.updateForm(this.state); + }); + } + }; +} diff --git a/frontend/client/components/CCRFlow/index.less b/frontend/client/components/CCRFlow/index.less new file mode 100644 index 00000000..0b772d50 --- /dev/null +++ b/frontend/client/components/CCRFlow/index.less @@ -0,0 +1,133 @@ +@import '~styles/variables.less'; + +@keyframes draft-notification-popup { + from { + opacity: 0; + transform: translateY(0.5rem); + } + to { + opacity: 0.3; + transform: translateY(0); + } +} + +.CCRFlow { + padding: 2.5rem 2rem 8rem; + + &-header { + max-width: 860px; + padding: 0 1rem; + margin: 1rem auto 3rem; + + &-title { + font-size: 2rem; + margin: 3rem auto 0.5rem; + text-align: center; + } + + &-subtitle { + font-size: 1.4rem; + margin-bottom: 0; + opacity: 0.7; + text-align: center; + } + } + + &-content { + padding-bottom: 2rem; + } + + &-footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: center; + align-items: center; + height: 7rem; + padding: 0 1rem; + background: #fff; + border-top: 1px solid #eee; + z-index: 1000; + + &-help { + font-size: 1rem; + margin-right: 1rem; + max-width: 380px; + text-align: right; + } + + &-button { + display: block; + height: 4rem; + line-height: 4rem; + width: 100%; + max-width: 12rem; + padding: 0; + margin: 0 0.5rem; + font-size: 1.4rem; + border: 1px solid #999; + color: #777; + background: transparent; + border-radius: 4px; + cursor: pointer; + transition-property: background, color, border-color, opacity; + transition-duration: 100ms; + transition-timing-function: ease; + + &.is-primary { + background: @primary-color; + color: #fff; + border: none; + } + + &:hover { + opacity: 0.8; + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } + + .anticon { + font-size: 1.2rem; + margin-left: 0.2rem; + } + } + + &-example { + position: absolute; + bottom: 10px; + right: 10px; + opacity: 0.08; + font-size: 1rem; + + &:hover { + opacity: 0.5; + } + } + } + + &-draftNotification { + position: fixed; + bottom: 8rem; + right: 1rem; + text-align: right; + font-size: 0.8rem; + opacity: 0.3; + animation: draft-notification-popup 120ms ease 1; + + &.is-error { + color: @error-color; + } + } + + &-loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } +} diff --git a/frontend/client/components/CCRFlow/index.tsx b/frontend/client/components/CCRFlow/index.tsx new file mode 100644 index 00000000..3e4fe112 --- /dev/null +++ b/frontend/client/components/CCRFlow/index.tsx @@ -0,0 +1,320 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { compose } from 'recompose'; +import { Steps, Icon } from 'antd'; +import qs from 'query-string'; +import { withRouter, RouteComponentProps } from 'react-router'; +import { History } from 'history'; +import { debounce } from 'underscore'; +import Basics from './Basics'; +import Details from './Details'; +import Review from './CCRReview'; +import Preview from './CCRPreview'; +import Final from './CCRFinal'; +import CCRSubmitWarningModal from './CCRSubmitWarningModal'; +import { ccrActions } from 'modules/ccr'; +import { CCRDraft } from 'types'; +import { getCCRErrors } from 'modules/ccr/utils'; + +import { AppState } from 'store/reducers'; + +import './index.less'; +import ls from 'local-storage'; +import Explainer from './CCRExplainer'; + +export enum CCR_STEP { + BASICS = 'BASICS', + DETAILS = 'DETAILS', + REVIEW = 'REVIEW', +} + +const STEP_ORDER = [CCR_STEP.BASICS, CCR_STEP.DETAILS, CCR_STEP.REVIEW]; + +interface StepInfo { + short: string; + title: React.ReactNode; + subtitle: React.ReactNode; + help: React.ReactNode; + component: any; +} + +interface LSExplainer { + noExplainCCR: boolean; +} + +const STEP_INFO: { [key in CCR_STEP]: StepInfo } = { + [CCR_STEP.BASICS]: { + short: 'Basics', + title: 'Let’s start with the basics', + subtitle: 'Don’t worry, you can come back and change things before publishing', + help: + 'You don’t have to fill out everything at once right now, you can come back later.', + component: Basics, + }, + [CCR_STEP.DETAILS]: { + short: 'Details', + title: 'Dive into the details', + subtitle: 'Here’s your chance to lay out the full request, in all its glory', + help: `Make sure people know what you’re requesting, why it's needed, how they can accomplish it`, + component: Details, + }, + [CCR_STEP.REVIEW]: { + short: 'Review', + title: 'Review your request', + subtitle: 'Feel free to edit any field that doesn’t look right', + help: 'You’ll get a chance to preview your request next before you publish it', + component: Review, + }, +}; + +interface StateProps { + form: AppState['ccr']['form']; + isSavingDraft: AppState['ccr']['isSavingDraft']; + hasSavedDraft: AppState['ccr']['hasSavedDraft']; + saveDraftError: AppState['ccr']['saveDraftError']; +} + +interface DispatchProps { + updateCCRForm: typeof ccrActions['updateCCRForm']; +} + +type Props = StateProps & DispatchProps & RouteComponentProps; + +interface State { + step: CCR_STEP; + isPreviewing: boolean; + isShowingSubmitWarning: boolean; + isSubmitting: boolean; + isExample: boolean; + isExplaining: boolean; +} + +class CCRFlow extends React.Component { + private historyUnlisten: () => void; + private debouncedUpdateForm: (form: Partial) => void; + + constructor(props: Props) { + super(props); + const searchValues = qs.parse(props.location.search); + const queryStep = searchValues.step ? searchValues.step.toUpperCase() : null; + const step = + queryStep && CCR_STEP[queryStep] + ? (CCR_STEP[queryStep] as CCR_STEP) + : CCR_STEP.BASICS; + const noExplain = !!ls('noExplainCCR'); + + this.state = { + step, + isPreviewing: false, + isSubmitting: false, + isExample: false, + isShowingSubmitWarning: false, + isExplaining: !noExplain, + }; + this.debouncedUpdateForm = debounce(this.updateForm, 800); + this.historyUnlisten = this.props.history.listen(this.handlePop); + } + + componentWillUnmount() { + if (this.historyUnlisten) { + this.historyUnlisten(); + } + } + + render() { + const { isSavingDraft, saveDraftError } = this.props; + const { + step, + isPreviewing, + isSubmitting, + isShowingSubmitWarning, + isExplaining, + } = this.state; + + const info = STEP_INFO[step]; + const currentIndex = STEP_ORDER.indexOf(step); + const isLastStep = STEP_ORDER.indexOf(step) === STEP_ORDER.length - 1; + const StepComponent = info.component; + + let content; + let showFooter = true; + if (isSubmitting) { + content = ; + showFooter = false; + } else if (isPreviewing) { + content = ; + } else if (isExplaining) { + content = ; + showFooter = false; + } else { + // Antd definitions are missing `onClick` for step, even though it works. + const Step = Steps.Step as any; + content = ( +
+
+ + {STEP_ORDER.slice(0, 3).map(s => ( + this.setStep(s)} + style={{ cursor: 'pointer' }} + /> + ))} + +

{info.title}

+
{info.subtitle}
+
+
+ +
+
+ ); + } + + return ( +
+ {content} + {showFooter && ( +
+ {isLastStep ? ( + <> + + + + ) : ( + <> +
{info.help}
+ + + )} +
+ )} + {isSavingDraft ? ( +
Saving draft...
+ ) : ( + saveDraftError && ( +
+ Failed to save draft! +
+ {saveDraftError} +
+ ) + )} + +
+ ); + } + + private updateForm = (form: Partial) => { + this.props.updateCCRForm(form); + }; + + private setStep = (step: CCR_STEP, skipHistory?: boolean) => { + this.setState({ step }); + if (!skipHistory) { + const { history, location } = this.props; + history.push(`${location.pathname}?step=${step.toLowerCase()}`); + } + }; + + private nextStep = () => { + const idx = STEP_ORDER.indexOf(this.state.step); + if (idx !== STEP_ORDER.length - 1) { + this.setStep(STEP_ORDER[idx + 1]); + } + }; + + private togglePreview = () => { + this.setState({ isPreviewing: !this.state.isPreviewing }); + }; + + private startSubmit = () => { + this.setState({ + isSubmitting: true, + isShowingSubmitWarning: false, + }); + }; + + private checkFormErrors = () => { + if (!this.props.form) { + return true; + } + const errors = getCCRErrors(this.props.form); + return !!Object.keys(errors).length; + }; + + private handlePop: History.LocationListener = (location, action) => { + if (action === 'POP') { + this.setState({ isPreviewing: false }); + const searchValues = qs.parse(location.search); + const urlStep = searchValues.step && searchValues.step.toUpperCase(); + if (urlStep && CCR_STEP[urlStep]) { + this.setStep(urlStep as CCR_STEP, true); + } else { + this.setStep(CCR_STEP.BASICS, true); + } + } + }; + + private openPublishWarning = () => { + this.setState({ isShowingSubmitWarning: true }); + }; + + private closePublishWarning = () => { + this.setState({ isShowingSubmitWarning: false }); + }; + + private cancelSubmit = () => { + this.setState({ isSubmitting: false }); + }; + + private startSteps = () => { + this.setState({ step: CCR_STEP.BASICS, isExplaining: false }); + }; +} + +const withConnect = connect( + (state: AppState) => ({ + form: state.ccr.form, + isSavingDraft: state.ccr.isSavingDraft, + hasSavedDraft: state.ccr.hasSavedDraft, + saveDraftError: state.ccr.saveDraftError, + }), + { + updateCCRForm: ccrActions.updateCCRForm, + }, +); + +export default compose( + withRouter, + withConnect, +)(CCRFlow); diff --git a/frontend/client/components/Card/index.tsx b/frontend/client/components/Card/index.tsx index c98ebb5e..087f6678 100644 --- a/frontend/client/components/Card/index.tsx +++ b/frontend/client/components/Card/index.tsx @@ -4,7 +4,7 @@ import classnames from 'classnames'; import './index.less'; import { Link } from 'react-router-dom'; import { Proposal } from 'types'; -import Like from 'components/Like' +import Like from 'components/Like'; interface CardInfoProps { proposal: Proposal; @@ -13,14 +13,10 @@ interface CardInfoProps { export const CardInfo: React.SFC = ({ proposal, time }) => (
-
- -
-
- {moment(time).fromNow()} +
+
+
{moment(time).fromNow()}
); @@ -43,7 +39,7 @@ export class Card extends React.Component { {children}
- ) + ); } } diff --git a/frontend/client/components/CopyInput.tsx b/frontend/client/components/CopyInput.tsx index a22303b1..3611b06d 100644 --- a/frontend/client/components/CopyInput.tsx +++ b/frontend/client/components/CopyInput.tsx @@ -3,7 +3,7 @@ import { Button, Form, Input, message } from 'antd'; import classnames from 'classnames'; import CopyToClipboard from 'react-copy-to-clipboard'; -import './ContributionModal/PaymentInfo.less' +import './ContributionModal/PaymentInfo.less'; interface CopyInputProps { label: string; diff --git a/frontend/client/components/CreateFlow/Details.tsx b/frontend/client/components/CreateFlow/Details.tsx index 070e504b..097f876a 100644 --- a/frontend/client/components/CreateFlow/Details.tsx +++ b/frontend/client/components/CreateFlow/Details.tsx @@ -30,7 +30,7 @@ export default class CreateFlowTeam extends React.Component { {errors.content && } diff --git a/frontend/client/components/CreateFlow/Explainer.less b/frontend/client/components/CreateFlow/Explainer.less index 7f27bd17..31dbef27 100644 --- a/frontend/client/components/CreateFlow/Explainer.less +++ b/frontend/client/components/CreateFlow/Explainer.less @@ -31,11 +31,12 @@ display: block; width: 280px; margin-top: 0.5rem; - height: 3.2rem; + font-size: 1.5rem; + height: 4.2rem; } &-actions { - margin: 4rem auto; + margin: 6rem auto; justify-content: center; display: flex; flex-direction: column; diff --git a/frontend/client/components/CreateFlow/Explainer.tsx b/frontend/client/components/CreateFlow/Explainer.tsx index 9de73250..b793c714 100644 --- a/frontend/client/components/CreateFlow/Explainer.tsx +++ b/frontend/client/components/CreateFlow/Explainer.tsx @@ -32,9 +32,10 @@ const Explainer: React.SFC = ({ t, startSteps }) => { return (
-

{t('home.guide.title')}

+

Creating a Proposal

- You're almost ready to create a proposal. + We can't wait to get your request! Before starting, here's what you should + know...
diff --git a/frontend/client/components/CreateFlow/Final.tsx b/frontend/client/components/CreateFlow/Final.tsx index 9d5ad601..fb09182c 100644 --- a/frontend/client/components/CreateFlow/Final.tsx +++ b/frontend/client/components/CreateFlow/Final.tsx @@ -2,14 +2,10 @@ import React from 'react'; import { connect } from 'react-redux'; import { Icon } from 'antd'; import { Link } from 'react-router-dom'; -import Result from 'ant-design-pro/lib/Result'; import Loader from 'components/Loader'; import { createActions } from 'modules/create'; import { AppState } from 'store/reducers'; -import { getProposalStakingContribution } from 'api/api'; import './Final.less'; -import PaymentInfo from 'components/ContributionModal/PaymentInfo'; -import { ContributionWithAddresses } from 'types'; interface OwnProps { goBack(): void; @@ -27,34 +23,15 @@ interface DispatchProps { type Props = OwnProps & StateProps & DispatchProps; -const STATE = { - contribution: null as null | ContributionWithAddresses, - contributionError: null as null | Error, -}; - -type State = typeof STATE; - -class CreateFinal extends React.Component { - state = STATE; +class CreateFinal extends React.Component { componentDidMount() { this.submit(); } - componentDidUpdate(prev: Props) { - const { submittedProposal } = this.props; - if (!prev.submittedProposal && submittedProposal) { - if (!submittedProposal.isStaked) { - this.getStakingContribution(); - } - } - } - render() { const { submittedProposal, submitError, goBack } = this.props; - const { contribution, contributionError } = this.state; - const ready = submittedProposal && (submittedProposal.isStaked || contribution); - const staked = submittedProposal && submittedProposal.isStaked; + const ready = submittedProposal; let content; if (submitError) { @@ -75,67 +52,14 @@ class CreateFinal extends React.Component { <>
- {staked && ( -
- Your proposal has been staked and submitted! Check your{' '} - profile's pending proposals tab{' '} - to check its status. -
- )} - {!staked && ( -
- Your proposal has been submitted! Please send the staking contribution of{' '} - {contribution && contribution.amount} ZEC using the instructions - below. -
- )} +
+ Your proposal has been submitted! Check your{' '} + profile's pending tab to check its + status. +
- {!staked && ( - <> -
- -

- If you cannot send the payment now, you may bring up these - instructions again by visiting your{' '} - profile's funded tab. -

-

- Once your payment has been sent and processed with 6 - confirmations, you will receive an email. Visit your{' '} - - profile's pending proposals tab - {' '} - at any time to check its status. -

- - } - contribution={contribution} - /> -
-

- I'm finished, take me to{' '} - my pending proposals! -

- - )} ); - } else if (contributionError) { - content = ( - - We were unable to get your staking contribution started. You can finish - staking from your profile, please try - again from there soon. - - } - /> - ); } else { content = ; } @@ -148,18 +72,6 @@ class CreateFinal extends React.Component { this.props.submitProposal(this.props.form); } }; - - private getStakingContribution = async () => { - const { submittedProposal } = this.props; - if (submittedProposal) { - try { - const res = await getProposalStakingContribution(submittedProposal.proposalId); - this.setState({ contribution: res.data }); - } catch (err) { - this.setState({ contributionError: err }); - } - } - }; } export default connect( diff --git a/frontend/client/components/CreateFlow/Milestones.tsx b/frontend/client/components/CreateFlow/Milestones.tsx index 08a1d8ed..87cdbf89 100644 --- a/frontend/client/components/CreateFlow/Milestones.tsx +++ b/frontend/client/components/CreateFlow/Milestones.tsx @@ -144,6 +144,12 @@ const MilestoneFields = ({ maxLength={255} />
+ {index > 0 && ( +
+ (Note: This number represents the number of days past the previous milestone day + estimate) +
+ )}
Payout Immediately - +
)} diff --git a/frontend/client/components/CreateFlow/Review.tsx b/frontend/client/components/CreateFlow/Review.tsx index d719026f..669c5684 100644 --- a/frontend/client/components/CreateFlow/Review.tsx +++ b/frontend/client/components/CreateFlow/Review.tsx @@ -124,8 +124,8 @@ class CreateReview extends React.Component { return (
- {sections.map(s => ( -
+ {sections.map((s, i) => ( +
{s.fields.map( f => !f.isHide && ( diff --git a/frontend/client/components/CreateFlow/SubmitWarningModal.tsx b/frontend/client/components/CreateFlow/SubmitWarningModal.tsx index f2f077fa..84eb4f46 100644 --- a/frontend/client/components/CreateFlow/SubmitWarningModal.tsx +++ b/frontend/client/components/CreateFlow/SubmitWarningModal.tsx @@ -16,13 +16,11 @@ export default class SubmitWarningModal extends React.Component { const { proposal, isVisible, handleClose, handleSubmit } = this.props; const warnings = proposal ? getCreateWarnings(proposal) : []; - const staked = proposal && proposal.isStaked; - return ( Confirm submission} visible={isVisible} - okText={staked ? 'Submit' : `I'm ready to stake`} + okText={'Submit'} cancelText="Never mind" onOk={handleSubmit} onCancel={handleClose} @@ -45,20 +43,10 @@ export default class SubmitWarningModal extends React.Component { } /> )} - {staked && ( -

- Are you sure you're ready to submit your proposal for approval? Once you’ve - done so, you won't be able to edit it. -

- )} - {!staked && ( -

- Are you sure you're ready to submit your proposal? You will be asked to send - a staking contribution of {process.env.PROPOSAL_STAKING_AMOUNT} ZEC. - Once confirmed, the proposal will be submitted for approval by site - administrators. -

- )} +

+ Are you sure you're ready to submit your proposal for approval? Once you’ve + done so, you won't be able to edit it. +

); diff --git a/frontend/client/components/CreateFlow/index.less b/frontend/client/components/CreateFlow/index.less index 9d28fa23..dab3d79a 100644 --- a/frontend/client/components/CreateFlow/index.less +++ b/frontend/client/components/CreateFlow/index.less @@ -15,7 +15,7 @@ padding: 2.5rem 2rem 8rem; &-header { - max-width: 860px; + max-width: 1200px; padding: 0 1rem; margin: 1rem auto 3rem; diff --git a/frontend/client/components/CreateFlow/index.tsx b/frontend/client/components/CreateFlow/index.tsx index 0e852cb5..5d9e61ea 100644 --- a/frontend/client/components/CreateFlow/index.tsx +++ b/frontend/client/components/CreateFlow/index.tsx @@ -248,7 +248,8 @@ class CreateFlow extends React.Component { key="next" onClick={this.nextStep} > - {isSecondToLastStep ? 'Review' : 'Continue' } + {isSecondToLastStep ? 'Review' : 'Continue'}{' '} + )} diff --git a/frontend/client/components/DraftList/index.tsx b/frontend/client/components/DraftList/index.tsx index 8c55a0d5..e5cc38e6 100644 --- a/frontend/client/components/DraftList/index.tsx +++ b/frontend/client/components/DraftList/index.tsx @@ -135,7 +135,7 @@ class DraftList extends React.Component { - {d.title || Untitled proposal} + {d.title || Untitled Proposal} {d.status === STATUS.REJECTED && (changes requested)} } @@ -158,7 +158,7 @@ class DraftList extends React.Component { return (
-

Your drafts

+

Your Proposal Drafts

{draftsEl} or
@@ -54,9 +79,30 @@ export default class Header extends React.Component { -
- -
+ {!hasCheckedUser && (ccrDrafts === null || proposalDrafts === null) ? null : ( +
+
+ + {Array.isArray(proposalDrafts) && proposalDrafts.length > 0 ? ( + + ) : ( + + )} + +
+
+ + {Array.isArray(ccrDrafts) && ccrDrafts.length > 0 ? ( + + ) : ( + + )} + +
+ + +
+ )} @@ -73,3 +119,20 @@ export default class Header extends React.Component { private openDrawer = () => this.setState({ isDrawerOpen: true }); private closeDrawer = () => this.setState({ isDrawerOpen: false }); } + +const withConnect = connect( + (state: AppState) => ({ + hasCheckedUser: state.auth.hasCheckedUser, + ccrDrafts: state.ccr.drafts, + proposalDrafts: state.create.drafts, + }), + { + fetchCCRDrafts: ccrActions.fetchCCRDrafts, + fetchDrafts: createActions.fetchDrafts, + }, +); + +export default compose( + withRouter, + withConnect, +)(Header); diff --git a/frontend/client/components/Header/style.less b/frontend/client/components/Header/style.less index 9b803586..68cd3351 100644 --- a/frontend/client/components/Header/style.less +++ b/frontend/client/components/Header/style.less @@ -4,6 +4,13 @@ @link-padding: 0.7rem; @small-query: ~'(max-width: 820px)'; @big-query: ~'(min-width: 821px)'; +@big: ~'(max-width: 1040px)'; + +.is-desktop { + @media @big { + display: none; + } +} .Header { top: 0; @@ -67,6 +74,8 @@ &-links { display: flex; + align-items: center; + justify-content: center; transition: transform @header-transition ease; .is-transparent & { @@ -95,6 +104,11 @@ } } + &-button { + padding: 0 @link-padding / 2; + } + + &-link { display: block; background: none; diff --git a/frontend/client/components/Home/Intro.less b/frontend/client/components/Home/Intro.less index 42505675..51661f0a 100644 --- a/frontend/client/components/Home/Intro.less +++ b/frontend/client/components/Home/Intro.less @@ -1,4 +1,5 @@ @import '~styles/variables.less'; +@min-tablet-query: ~'(min-width: 920px)'; .HomeIntro { position: relative; @@ -7,7 +8,7 @@ align-items: center; max-width: 1440px; padding: 0 4rem; - margin: 0 auto 4rem; + margin: 4rem auto; overflow: hidden; @media @thin-query { @@ -20,7 +21,6 @@ &-content { - &-title { margin-bottom: 2rem; font-size: 2.6rem; @@ -35,27 +35,78 @@ &-buttons { display: flex; align-items: center; + justify-content: start; - &-main { + @media @tablet-query { + margin-left: 0; + } + + @media @mobile-query { + flex-direction: column; + width: 100%; + } + + &-button { display: flex; align-items: center; justify-content: center; - height: 3.6rem; - padding: 0 3rem; - margin-right: 0.75rem; - font-size: 1.2rem; - background: @primary-color; - color: #FFF; + height: 4.2rem; + width: 16rem; + padding: 0; + margin: 0 10px; + border: 2px solid rgba(@text-color, 0.7); + color: rgba(@text-color, 0.7); + text-align: center; + font-size: 1.4rem; border-radius: 4px; + background: #fff; + transition: transform 200ms ease, box-shadow 200ms ease; - &:hover { - color: #FFF; - opacity: 0.9; + &:hover, + &:focus { + transform: translateY(-2px); + border-color: rgba(@text-color, 0.9); + color: rgba(@text-color, 0.9); } - } - &-learn { - font-size: 1rem; + &:active { + transform: translateY(0px); + border-color: rgba(@text-color, 1); + color: rgba(@text-color, 1); + } + + @media @tablet-query { + width: 100%; + height: 5rem; + font-size: 1.8rem; + max-width: 320px; + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0; + } + } + + &.is-primary { + border-color: rgba(@primary-color, 0.7); + color: rgba(@primary-color, 0.7); + + @media @min-tablet-query { + margin-left: 0; + } + + &hover, + &:focus { + color: @primary-color; + border-color: rgba(@primary-color, 0.9); + color: rgba(@primary-color, 0.9); + } + + &:active { + border-color: rgba(@primary-color, 1); + color: rgba(@primary-color, 1); + } + } } } diff --git a/frontend/client/components/Home/Intro.tsx b/frontend/client/components/Home/Intro.tsx index e804acf1..4aa48186 100644 --- a/frontend/client/components/Home/Intro.tsx +++ b/frontend/client/components/Home/Intro.tsx @@ -19,17 +19,20 @@ const HomeIntro: React.SFC = ({ t, authUser }) => (

{t('home.intro.subtitle')}

{authUser ? ( - + {t('home.intro.browse')} ) : ( - + {t('home.intro.signup')} )} - - {t('home.intro.learn')} - + + {t('home.intro.ccr')} +
{ try { await likeProposal(proposalId, !authedLiked); await fetchProposal(proposalId); - message.success(<>Proposal {authedLiked ? 'unliked' : 'liked'}); } catch (error) { // tslint:disable:no-console console.error('Like.handleProposalLike - unable to change like state', error); diff --git a/frontend/client/components/Profile/ProfileCCR.less b/frontend/client/components/Profile/ProfileCCR.less new file mode 100644 index 00000000..6ef99610 --- /dev/null +++ b/frontend/client/components/Profile/ProfileCCR.less @@ -0,0 +1,60 @@ +@small-query: ~'(max-width: 640px)'; + +.ProfileCCR { + display: flex; + padding-bottom: 1.2rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + margin-bottom: 1rem; + + &:last-child { + border-bottom: none; + padding-bottom: none; + } + + @media @small-query { + flex-direction: column; + padding-bottom: 0.6rem; + } + + &-title { + font-size: 1.2rem; + font-weight: 600; + color: inherit; + display: block; + margin-bottom: 0.5rem; + } + + &-block { + flex: 1 0 0%; + + &:last-child { + margin-left: 1.2rem; + flex: 0 0 0%; + min-width: 15rem; + + @media @small-query { + margin-left: 0; + margin-top: 0.6rem; + } + } + + &-team { + @media @small-query { + display: flex; + flex-flow: wrap; + } + + & .UserRow { + margin-right: 1rem; + } + } + } + + &-raised { + margin-top: 0.6rem; + + & small { + opacity: 0.6; + } + } +} diff --git a/frontend/client/components/Profile/ProfileCCR.tsx b/frontend/client/components/Profile/ProfileCCR.tsx new file mode 100644 index 00000000..535e3917 --- /dev/null +++ b/frontend/client/components/Profile/ProfileCCR.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { UserCCR } from 'types'; +import UserRow from 'components/UserRow'; +import './ProfileCCR.less'; + +interface OwnProps { + ccr: UserCCR; +} + +export default class ProfileCCR extends React.Component { + render() { + const { title, brief, ccrId, author } = this.props.ccr; + return ( +
+
+ + {title} + +
{brief}
+
+
+

Author

+
+ +
+
+
+ ); + } +} diff --git a/frontend/client/components/Profile/ProfilePending.tsx b/frontend/client/components/Profile/ProfilePending.tsx index 3e204906..f453d4ae 100644 --- a/frontend/client/components/Profile/ProfilePending.tsx +++ b/frontend/client/components/Profile/ProfilePending.tsx @@ -1,17 +1,14 @@ import React, { ReactNode } from 'react'; import { Link } from 'react-router-dom'; import { Button, Popconfirm, message, Tag } from 'antd'; -import { UserProposal, STATUS, ContributionWithAddressesAndUser } from 'types'; -import ContributionModal from 'components/ContributionModal'; -import { getProposalStakingContribution } from 'api/api'; -import { deletePendingProposal, publishPendingProposal } from 'modules/users/actions'; +import { UserProposal, STATUS } from 'types'; +import { deletePendingProposal } from 'modules/users/actions'; import { connect } from 'react-redux'; import { AppState } from 'store/reducers'; import './ProfilePending.less'; interface OwnProps { proposal: UserProposal; - onPublish(id: UserProposal['proposalId']): void; } interface StateProps { @@ -20,7 +17,6 @@ interface StateProps { interface DispatchProps { deletePendingProposal: typeof deletePendingProposal; - publishPendingProposal: typeof publishPendingProposal; } type Props = OwnProps & StateProps & DispatchProps; @@ -28,21 +24,17 @@ type Props = OwnProps & StateProps & DispatchProps; interface State { isDeleting: boolean; isPublishing: boolean; - isLoadingStake: boolean; - stakeContribution: ContributionWithAddressesAndUser | null; } class ProfilePending extends React.Component { state: State = { isDeleting: false, isPublishing: false, - isLoadingStake: false, - stakeContribution: null, }; render() { const { status, title, proposalId, rejectReason } = this.props.proposal; - const { isDeleting, isPublishing, isLoadingStake, stakeContribution } = this.state; + const { isDeleting, isPublishing } = this.state; const isDisableActions = isDeleting || isPublishing; @@ -68,7 +60,7 @@ class ProfilePending extends React.Component { tag: 'Staking', blurb: (
- Awaiting staking contribution, you will recieve an email when staking has been + Awaiting staking contribution, you will receive an email when staking has been confirmed. If you staked this proposal you may check its status under the "funded" tab.
@@ -89,23 +81,13 @@ class ProfilePending extends React.Component {
- {title} {st[status].tag} + {title} {st[status].tag} Proposal
{st[status].blurb}
- {STATUS.APPROVED === status && ( - - )} {STATUS.REJECTED === status && ( )} - {STATUS.STAKING === status && ( - - )} {
- - {STATUS.STAKING && ( - - For your proposal to be considered, please send a staking contribution of{' '} - {stakeContribution && stakeContribution.amount} ZEC using the - instructions below. Once your payment has been sent and received 6 - confirmations, you will receive an email. -

- } - /> - )}
); } - private handlePublish = async () => { - const { - user, - proposal: { proposalId }, - onPublish, - } = this.props; - if (!user) return; - this.setState({ isPublishing: true }); - try { - await this.props.publishPendingProposal(user.userid, proposalId); - onPublish(proposalId); - } catch (e) { - message.error(e.message || e.toString()); - this.setState({ isPublishing: false }); - } - }; - private handleDelete = async () => { const { user, @@ -185,26 +125,6 @@ class ProfilePending extends React.Component { this.setState({ isDeleting: false }); } }; - - private openStakingModal = async () => { - try { - this.setState({ isLoadingStake: true }); - const res = await getProposalStakingContribution(this.props.proposal.proposalId); - this.setState({ stakeContribution: res.data }, () => { - this.setState({ isLoadingStake: false }); - }); - } catch (err) { - console.error(err); - message.error('Failed to get staking contribution, try again later', 3); - this.setState({ isLoadingStake: false }); - } - }; - - private closeStakingModal = () => - this.setState({ - isLoadingStake: false, - stakeContribution: null, - }); } export default connect( @@ -213,6 +133,5 @@ export default connect( }), { deletePendingProposal, - publishPendingProposal, }, )(ProfilePending); diff --git a/frontend/client/components/Profile/ProfilePendingCCR.tsx b/frontend/client/components/Profile/ProfilePendingCCR.tsx new file mode 100644 index 00000000..1a6198a7 --- /dev/null +++ b/frontend/client/components/Profile/ProfilePendingCCR.tsx @@ -0,0 +1,119 @@ +import React, { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import { Button, Popconfirm, message, Tag } from 'antd'; +import { CCRSTATUS, STATUS, UserCCR } from 'types'; +import { deletePendingRequest } from 'modules/users/actions'; +import { connect } from 'react-redux'; +import { AppState } from 'store/reducers'; +import './ProfilePending.less'; + +interface OwnProps { + ccr: UserCCR; +} + +interface StateProps { + user: AppState['auth']['user']; +} + +interface DispatchProps { + deletePendingRequest: typeof deletePendingRequest; +} + +type Props = OwnProps & StateProps & DispatchProps; + +interface State { + isDeleting: boolean; +} + +class ProfilePendingCCR extends React.Component { + state: State = { + isDeleting: false, + }; + + render() { + const { status, title, ccrId, rejectReason } = this.props.ccr; + const { isDeleting } = this.state; + + const isDisableActions = isDeleting; + + const st = { + [STATUS.REJECTED]: { + color: 'red', + tag: 'Rejected', + blurb: ( + <> +
This request was rejected for the following reason:
+ {rejectReason} +
You may edit this request and re-submit it for approval.
+ + ), + }, + [STATUS.PENDING]: { + color: 'purple', + tag: 'Pending', + blurb: ( +
+ You will receive an email when this request has completed the review process. +
+ ), + }, + } as { [key in STATUS]: { color: string; tag: string; blurb: ReactNode } }; + + return ( +
+
+ + {title} {st[status].tag} Request + +
+ {st[status].blurb} +
+
+
+ {CCRSTATUS.REJECTED === status && ( + + + + )} + + this.handleDelete()} + > + + +
+
+ ); + } + + private handleDelete = async () => { + const { + user, + ccr: { ccrId }, + } = this.props; + if (!user) return; + this.setState({ isDeleting: true }); + try { + await this.props.deletePendingRequest(user.userid, ccrId); + message.success('Request deleted.'); + } catch (e) { + message.error(e.message || e.toString()); + } + this.setState({ isDeleting: false }); + }; +} + +export default connect( + state => ({ + user: state.auth.user, + }), + { + deletePendingRequest, + }, +)(ProfilePendingCCR); diff --git a/frontend/client/components/Profile/ProfilePendingList.tsx b/frontend/client/components/Profile/ProfilePendingList.tsx index ca26ab47..31d0f114 100644 --- a/frontend/client/components/Profile/ProfilePendingList.tsx +++ b/frontend/client/components/Profile/ProfilePendingList.tsx @@ -1,54 +1,29 @@ import React from 'react'; -import { Link } from 'react-router-dom'; -import { Modal } from 'antd'; -import { UserProposal } from 'types'; +import { UserProposal, UserCCR } from 'types'; import ProfilePending from './ProfilePending'; +import ProfilePendingCCR from './ProfilePendingCCR'; interface OwnProps { proposals: UserProposal[]; + requests: UserCCR[]; } type Props = OwnProps; -const STATE = { - publishedId: null as null | UserProposal['proposalId'], -}; - -type State = typeof STATE; - -class ProfilePendingList extends React.Component { - state = STATE; +class ProfilePendingList extends React.Component { render() { - const { proposals } = this.props; - const { publishedId } = this.state; + const { proposals, requests } = this.props; return ( <> {proposals.map(p => ( - + + ))} + {requests.map(r => ( + ))} - - this.setState({ publishedId: null })} - > -
- Your proposal is live!{' '} - Click here to check it out. -
-
); } - - private handlePublish = (publishedId: UserProposal['proposalId']) => { - this.setState({ publishedId }); - }; } export default ProfilePendingList; diff --git a/frontend/client/components/Profile/index.tsx b/frontend/client/components/Profile/index.tsx index 62b8e00e..7050d7ec 100644 --- a/frontend/client/components/Profile/index.tsx +++ b/frontend/client/components/Profile/index.tsx @@ -19,6 +19,7 @@ import ProfileProposal from './ProfileProposal'; import ProfileContribution from './ProfileContribution'; import ProfileComment from './ProfileComment'; import ProfileInvite from './ProfileInvite'; +import ProfileCCR from './ProfileCCR'; import Placeholder from 'components/Placeholder'; import Loader from 'components/Loader'; import ExceptionPage from 'components/ExceptionPage'; @@ -91,6 +92,8 @@ class Profile extends React.Component { const { proposals, pendingProposals, + pendingRequests, + requests, contributions, comments, invites, @@ -98,8 +101,10 @@ class Profile extends React.Component { } = user; const isLoading = user.isFetching; - const nonePending = pendingProposals.length === 0; - const noneCreated = proposals.length === 0; + const noProposalsPending = pendingProposals.length === 0; + const noProposalsCreated = proposals.length === 0; + const noRequestsPending = pendingRequests.length === 0; + const noRequestsCreated = requests.length === 0; const noneFunded = contributions.length === 0; const noneCommented = comments.length === 0; const noneArbitrated = arbitrated.length === 0; @@ -108,8 +113,8 @@ class Profile extends React.Component { return (
@@ -128,33 +133,47 @@ class Profile extends React.Component { {isAuthedUser && (
- {nonePending && ( - - )} - + {noProposalsPending && + noRequestsPending && ( + + )} +
)} - +
- {noneCreated && ( - - )} + {noProposalsCreated && + noRequestsCreated && ( + + )} {proposals.map(p => ( ))} + {requests.map(c => ( + + ))}
diff --git a/frontend/client/components/Proposal/TippingBlock/index.tsx b/frontend/client/components/Proposal/TippingBlock/index.tsx index 94993238..01fc526c 100644 --- a/frontend/client/components/Proposal/TippingBlock/index.tsx +++ b/frontend/client/components/Proposal/TippingBlock/index.tsx @@ -24,7 +24,7 @@ const TippingBlock: React.SFC = ({ proposal }) => { ???   diff --git a/frontend/client/components/Proposal/index.tsx b/frontend/client/components/Proposal/index.tsx index 982bf6c1..7627468e 100644 --- a/frontend/client/components/Proposal/index.tsx +++ b/frontend/client/components/Proposal/index.tsx @@ -14,7 +14,7 @@ import { AlertProps } from 'antd/lib/alert'; import ExceptionPage from 'components/ExceptionPage'; import HeaderDetails from 'components/HeaderDetails'; import CampaignBlock from './CampaignBlock'; -import TippingBlock from './TippingBlock' +import TippingBlock from './TippingBlock'; import TeamBlock from './TeamBlock'; import RFPBlock from './RFPBlock'; import Milestones from './Milestones'; @@ -28,7 +28,7 @@ import { withRouter } from 'react-router'; import SocialShare from 'components/SocialShare'; import Follow from 'components/Follow'; import Like from 'components/Like'; -import { TipJarProposalSettingsModal } from 'components/TipJar' +import { TipJarProposalSettingsModal } from 'components/TipJar'; import './index.less'; interface OwnProps { @@ -63,7 +63,7 @@ export class ProposalDetail extends React.Component { isBodyOverflowing: false, isUpdateOpen: false, isCancelOpen: false, - isTipJarOpen: false + isTipJarOpen: false, }; bodyEl: HTMLElement | null = null; @@ -94,7 +94,13 @@ export class ProposalDetail extends React.Component { render() { const { user, detail: proposal, isPreview, detailError } = this.props; - const { isBodyExpanded, isBodyOverflowing, isCancelOpen, isUpdateOpen, isTipJarOpen } = this.state; + const { + isBodyExpanded, + isBodyOverflowing, + isCancelOpen, + isUpdateOpen, + isTipJarOpen, + } = this.state; const showExpand = !isBodyExpanded && isBodyOverflowing; const wrongProposal = proposal && proposal.proposalId !== this.props.proposalId; @@ -246,8 +252,8 @@ export class ProposalDetail extends React.Component {
- + {proposal.rfp && }
@@ -266,9 +272,11 @@ export class ProposalDetail extends React.Component { - - - + {!proposal.isVersionTwo && ( + + + + )}
@@ -284,7 +292,7 @@ export class ProposalDetail extends React.Component { isVisible={isCancelOpen} handleClose={this.closeCancelModal} /> - { } }; - private openTipJarModal = () => this.setState({ isTipJarOpen: true }); private closeTipJarModal = () => this.setState({ isTipJarOpen: false }); diff --git a/frontend/client/components/Proposals/Filters/index.tsx b/frontend/client/components/Proposals/Filters/index.tsx index 5362ff9a..2e127fee 100644 --- a/frontend/client/components/Proposals/Filters/index.tsx +++ b/frontend/client/components/Proposals/Filters/index.tsx @@ -2,12 +2,7 @@ import React from 'react'; import { Select, Radio, Card } from 'antd'; import { RadioChangeEvent } from 'antd/lib/radio'; import { SelectValue } from 'antd/lib/select'; -import { - PROPOSAL_SORT, - SORT_LABELS, - PROPOSAL_STAGE, - STAGE_UI, -} from 'api/constants'; +import { PROPOSAL_SORT, SORT_LABELS, PROPOSAL_STAGE, STAGE_UI } from 'api/constants'; import { typedKeys } from 'utils/ts'; import { ProposalPage } from 'types'; @@ -55,7 +50,7 @@ export default class ProposalFilters extends React.Component { PROPOSAL_STAGE.PREVIEW, PROPOSAL_STAGE.FAILED, PROPOSAL_STAGE.CANCELED, - PROPOSAL_STAGE.FUNDING_REQUIRED + PROPOSAL_STAGE.FUNDING_REQUIRED, ].includes(s as PROPOSAL_STAGE), ) // skip a few .map(s => ( diff --git a/frontend/client/components/Proposals/ProposalCard/index.tsx b/frontend/client/components/Proposals/ProposalCard/index.tsx index 58a0592d..edd9d879 100644 --- a/frontend/client/components/Proposals/ProposalCard/index.tsx +++ b/frontend/client/components/Proposals/ProposalCard/index.tsx @@ -1,12 +1,12 @@ import React from 'react'; import { Redirect } from 'react-router-dom'; import classnames from 'classnames'; -import { Progress } from 'antd' +import { Progress } from 'antd'; import { Proposal } from 'types'; import Card from 'components/Card'; import UserAvatar from 'components/UserAvatar'; import UnitDisplay from 'components/UnitDisplay'; -import { formatUsd } from 'utils/formatters' +import { formatUsd } from 'utils/formatters'; import './style.less'; export class ProposalCard extends React.Component { @@ -26,7 +26,7 @@ export class ProposalCard extends React.Component { contributionMatching, isVersionTwo, funded, - percentFunded + percentFunded, } = this.props; return ( diff --git a/frontend/client/components/Proposals/ProposalCard/style.less b/frontend/client/components/Proposals/ProposalCard/style.less index 53ff436a..56b12fbd 100644 --- a/frontend/client/components/Proposals/ProposalCard/style.less +++ b/frontend/client/components/Proposals/ProposalCard/style.less @@ -86,8 +86,6 @@ } &-funding { - display: flex; - justify-content: center; line-height: 2.5rem; &-raised { diff --git a/frontend/client/components/Proposals/index.tsx b/frontend/client/components/Proposals/index.tsx index 5d2c9abb..94d027ca 100644 --- a/frontend/client/components/Proposals/index.tsx +++ b/frontend/client/components/Proposals/index.tsx @@ -61,20 +61,6 @@ class Proposals extends React.Component { ); return (
-
-
- -
-
-

Zcash Foundation Proposals

-

- The Zcash Foundation accepts proposals from community members to improve the - Zcash ecosystem. Proposals are either accepted with or without funding, - should they be approved by the Zcash Foundation. -

-
-
-
{isFiltersDrawered ? ( { ) : ( -
{filtersComponent}
+
+
+ + +
+ {filtersComponent} +
)}
-
- - +
+
+ +
+
+

Zcash Foundation Proposals

+

+ The Zcash Foundation accepts proposals from community members to improve + the Zcash ecosystem. Proposals are either funded by the Zcash Foundation + directly, or are opened for community donations should they be approved + by the Zcash Foundation." +

+
+ { +export class RFPDetail extends React.Component { componentDidMount() { this.props.fetchRfp(this.props.rfpId); } @@ -134,6 +134,14 @@ class RFPDetail extends React.Component { Proposal submissions end {moment(rfp.dateCloses * 1000).format('LL')} )} + {rfp.ccr && ( +
  • + Submitted by{' '} + + {rfp.ccr.author.displayName} + +
  • + )}
    diff --git a/frontend/client/components/RFPs/RFPItem.tsx b/frontend/client/components/RFPs/RFPItem.tsx index 02c305af..a32ef19c 100644 --- a/frontend/client/components/RFPs/RFPItem.tsx +++ b/frontend/client/components/RFPs/RFPItem.tsx @@ -27,6 +27,7 @@ export default class RFPItem extends React.Component { bounty, matching, isVersionTwo, + ccr, } = rfp; const closeDate = dateCloses || dateClosed; @@ -54,6 +55,13 @@ export default class RFPItem extends React.Component { , ); } + if (ccr) { + tags.push( + + Community Created Request + , + ); + } } return ( @@ -75,6 +83,11 @@ export default class RFPItem extends React.Component {
    {acceptedProposals.length} proposals approved
    + {ccr && ( +
    + Submitted by {ccr.author.displayName} +
    + )}
    ); diff --git a/frontend/client/components/RFPs/index.tsx b/frontend/client/components/RFPs/index.tsx index d8cf3fb2..4bb7c65b 100644 --- a/frontend/client/components/RFPs/index.tsx +++ b/frontend/client/components/RFPs/index.tsx @@ -75,9 +75,9 @@ class RFPs extends React.Component {

    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. + high-priority needs in the Zcash ecosystem. In addition to funding from the + Zcash Foundation, accepted proposals may receive supplemental donations from + the community when they have set a "tip address" for their proposal.

    diff --git a/frontend/client/components/Settings/Account/RefundAddress.tsx b/frontend/client/components/Settings/Account/RefundAddress.tsx index 7a561997..070ca895 100644 --- a/frontend/client/components/Settings/Account/RefundAddress.tsx +++ b/frontend/client/components/Settings/Account/RefundAddress.tsx @@ -13,14 +13,12 @@ interface Props { } interface State { - isSaving: boolean - refundAddress: string | null - refundAddressSet: string | null + isSaving: boolean; + refundAddress: string | null; + refundAddressSet: string | null; } - export default class RefundAddress extends React.Component { - static getDerivedStateFromProps(nextProps: Props, prevState: State) { const { userSettings } = nextProps; const { refundAddress, refundAddressSet } = prevState; @@ -42,11 +40,11 @@ export default class RefundAddress extends React.Component { return ret; } - state: State = { + state: State = { isSaving: false, refundAddress: null, - refundAddressSet: null - }; + refundAddressSet: null, + }; render() { const { isSaving, refundAddress, refundAddressSet } = this.state; diff --git a/frontend/client/components/Settings/Account/TipJarAddress.tsx b/frontend/client/components/Settings/Account/TipJarAddress.tsx index 6a250534..cb4a4193 100644 --- a/frontend/client/components/Settings/Account/TipJarAddress.tsx +++ b/frontend/client/components/Settings/Account/TipJarAddress.tsx @@ -50,7 +50,7 @@ export default class TipJarAddress extends React.Component { const { isSaving, tipJarAddress, tipJarAddressSet } = this.state; const { isFetching, errorFetching, userSettings } = this.props; const addressChanged = tipJarAddress !== tipJarAddressSet; - const hasViewKeySet = userSettings && userSettings.tipJarViewKey + const hasViewKeySet = userSettings && userSettings.tipJarViewKey; let addressIsValid; let status: 'validating' | 'error' | undefined; @@ -69,7 +69,7 @@ export default class TipJarAddress extends React.Component { if (tipJarAddress === '' && hasViewKeySet) { status = 'error'; - help = 'You must unset your view key before unsetting your address' + help = 'You must unset your view key before unsetting your address'; } return ( diff --git a/frontend/client/components/Settings/Account/index.tsx b/frontend/client/components/Settings/Account/index.tsx index 90a3818b..2ba54322 100644 --- a/frontend/client/components/Settings/Account/index.tsx +++ b/frontend/client/components/Settings/Account/index.tsx @@ -18,7 +18,7 @@ interface State { userSettings: UserSettings | undefined; isFetching: boolean; errorFetching: boolean; -}; +} const STATE: State = { userSettings: undefined, diff --git a/frontend/client/components/Template/index.tsx b/frontend/client/components/Template/index.tsx index fb2a7493..defb614a 100644 --- a/frontend/client/components/Template/index.tsx +++ b/frontend/client/components/Template/index.tsx @@ -36,6 +36,8 @@ export default class Template extends React.PureComponent { )}
    + {/* + // @ts-ignore */}
    {content}
    diff --git a/frontend/client/components/TipJar/index.tsx b/frontend/client/components/TipJar/index.tsx index 9b7d14cf..8980f31f 100644 --- a/frontend/client/components/TipJar/index.tsx +++ b/frontend/client/components/TipJar/index.tsx @@ -1,3 +1,3 @@ -export * from './TipJarBlock' -export * from './TipJarModal' -export * from './TipJarProposalSettingsModal' +export * from './TipJarBlock'; +export * from './TipJarModal'; +export * from './TipJarProposalSettingsModal'; diff --git a/frontend/client/modules/ccr/actions.ts b/frontend/client/modules/ccr/actions.ts new file mode 100644 index 00000000..cf5c7435 --- /dev/null +++ b/frontend/client/modules/ccr/actions.ts @@ -0,0 +1,68 @@ +import { Dispatch } from 'redux'; +import types from './types'; +import { CCRDraft } from 'types/ccr'; +import { putCCR, putCCRSubmitForApproval } from 'api/api'; + +export function initializeForm(ccrId: number) { + return { + type: types.INITIALIZE_CCR_FORM_PENDING, + payload: ccrId, + }; +} + +export function updateCCRForm(form: Partial) { + return (dispatch: Dispatch) => { + dispatch({ + type: types.UPDATE_CCR_FORM, + payload: form, + }); + dispatch(saveCCRDraft()); + }; +} + +export function saveCCRDraft() { + return { type: types.SAVE_CCR_DRAFT_PENDING }; +} + +export function fetchCCRDrafts() { + return { type: types.FETCH_CCR_DRAFTS_PENDING }; +} + +export function createCCRDraft() { + return { + type: types.CREATE_CCR_DRAFT_PENDING, + }; +} + +export function fetchAndCreateCCRDrafts() { + return { + type: types.FETCH_AND_CREATE_CCR_DRAFTS, + }; +} + +export function deleteCCRDraft(ccrId: number) { + return { + type: types.DELETE_CCR_DRAFT_PENDING, + payload: ccrId, + }; +} + +export function submitCCR(form: CCRDraft) { + return async (dispatch: Dispatch) => { + dispatch({ type: types.SUBMIT_CCR_PENDING }); + try { + await putCCR(form); + const res = await putCCRSubmitForApproval(form); + dispatch({ + type: types.SUBMIT_CCR_FULFILLED, + payload: res.data, + }); + } catch (err) { + dispatch({ + type: types.SUBMIT_CCR_REJECTED, + payload: err.message || err.toString(), + error: true, + }); + } + }; +} diff --git a/frontend/client/modules/ccr/index.ts b/frontend/client/modules/ccr/index.ts new file mode 100644 index 00000000..2446dd72 --- /dev/null +++ b/frontend/client/modules/ccr/index.ts @@ -0,0 +1,8 @@ +import reducers, { CCRState, INITIAL_STATE } from './reducers'; +import * as ccrActions from './actions'; +import * as ccrTypes from './types'; +import ccrSagas from './sagas'; + +export { ccrActions, ccrTypes, ccrSagas, CCRState, INITIAL_STATE }; + +export default reducers; diff --git a/frontend/client/modules/ccr/reducers.ts b/frontend/client/modules/ccr/reducers.ts new file mode 100644 index 00000000..82b298dd --- /dev/null +++ b/frontend/client/modules/ccr/reducers.ts @@ -0,0 +1,195 @@ +import types from './types'; +import { CCRDraft, CCR } from 'types'; + +export interface CCRState { + drafts: CCRDraft[] | null; + form: CCRDraft | null; + + isInitializingForm: boolean; + initializeFormError: string | null; + + isSavingDraft: boolean; + hasSavedDraft: boolean; + saveDraftError: string | null; + + isFetchingDrafts: boolean; + fetchDraftsError: string | null; + + isCreatingDraft: boolean; + createDraftError: string | null; + + isDeletingDraft: boolean; + deleteDraftError: string | null; + + submittedCCR: CCR | null; + isSubmitting: boolean; + submitError: string | null; + + publishedCCR: CCR | null; + isPublishing: boolean; + publishError: string | null; +} + +export const INITIAL_STATE: CCRState = { + drafts: null, + form: null, + + isInitializingForm: false, + initializeFormError: null, + + isSavingDraft: false, + hasSavedDraft: true, + saveDraftError: null, + + isFetchingDrafts: false, + fetchDraftsError: null, + + isCreatingDraft: false, + createDraftError: null, + + isDeletingDraft: false, + deleteDraftError: null, + + submittedCCR: null, + isSubmitting: false, + submitError: null, + + publishedCCR: null, + isPublishing: false, + publishError: null, +}; + +export default function createReducer( + state: CCRState = INITIAL_STATE, + action: any, +): CCRState { + switch (action.type) { + case types.UPDATE_CCR_FORM: + return { + ...state, + form: { + ...state.form, + ...action.payload, + }, + hasSavedDraft: false, + }; + + case types.INITIALIZE_CCR_FORM_PENDING: + return { + ...state, + form: null, + isInitializingForm: true, + initializeFormError: null, + }; + case types.INITIALIZE_CCR_FORM_FULFILLED: + return { + ...state, + form: { ...action.payload }, + isInitializingForm: false, + }; + case types.INITIALIZE_CCR_FORM_REJECTED: + return { + ...state, + isInitializingForm: false, + initializeFormError: action.payload, + }; + + case types.SAVE_CCR_DRAFT_PENDING: + return { + ...state, + isSavingDraft: true, + }; + case types.SAVE_CCR_DRAFT_FULFILLED: + return { + ...state, + isSavingDraft: false, + hasSavedDraft: true, + // Only clear error once save was a success + saveDraftError: null, + }; + case types.SAVE_CCR_DRAFT_REJECTED: + return { + ...state, + isSavingDraft: false, + hasSavedDraft: false, + saveDraftError: action.payload, + }; + + case types.FETCH_CCR_DRAFTS_PENDING: + return { + ...state, + isFetchingDrafts: true, + fetchDraftsError: null, + }; + case types.FETCH_CCR_DRAFTS_FULFILLED: + return { + ...state, + isFetchingDrafts: false, + drafts: action.payload, + }; + case types.FETCH_CCR_DRAFTS_REJECTED: + return { + ...state, + isFetchingDrafts: false, + fetchDraftsError: action.payload, + }; + + case types.CREATE_CCR_DRAFT_PENDING: + return { + ...state, + isCreatingDraft: true, + createDraftError: null, + }; + case types.CREATE_CCR_DRAFT_FULFILLED: + return { + ...state, + drafts: [...(state.drafts || []), action.payload], + isCreatingDraft: false, + }; + case types.CREATE_CCR_DRAFT_REJECTED: + return { + ...state, + createDraftError: action.payload, + isCreatingDraft: false, + }; + + case types.DELETE_CCR_DRAFT_PENDING: + return { + ...state, + isDeletingDraft: true, + deleteDraftError: null, + }; + case types.DELETE_CCR_DRAFT_FULFILLED: + return { + ...state, + isDeletingDraft: false, + }; + case types.DELETE_CCR_DRAFT_REJECTED: + return { + ...state, + isDeletingDraft: false, + deleteDraftError: action.payload, + }; + + case types.SUBMIT_CCR_PENDING: + return { + ...state, + submittedCCR: null, + isSubmitting: true, + submitError: null, + }; + case types.SUBMIT_CCR_FULFILLED: + return { + ...state, + submittedCCR: action.payload, + isSubmitting: false, + }; + case types.SUBMIT_CCR_REJECTED: + return { + ...state, + submitError: action.payload, + isSubmitting: false, + }; + } + return state; +} diff --git a/frontend/client/modules/ccr/sagas.ts b/frontend/client/modules/ccr/sagas.ts new file mode 100644 index 00000000..05ffe7a4 --- /dev/null +++ b/frontend/client/modules/ccr/sagas.ts @@ -0,0 +1,144 @@ +import { SagaIterator } from 'redux-saga'; +import { takeEvery, takeLatest, put, take, call, select } from 'redux-saga/effects'; +import { replace } from 'connected-react-router'; +import { + postCCRDraft, + getCCRDrafts, + putCCR, + deleteCCR as RDeleteCCRDraft, + getCCR, +} from 'api/api'; +import { getDrafts, getDraftById, getFormState } from './selectors'; +import { + createCCRDraft, + fetchCCRDrafts, + initializeForm, + deleteCCRDraft, +} from './actions'; +import types from './types'; + +export function* handleCreateDraft(): SagaIterator { + try { + const res: Yielded = yield call(postCCRDraft); + yield put({ + type: types.CREATE_CCR_DRAFT_FULFILLED, + payload: res.data, + }); + yield put(replace(`/ccrs/${res.data.ccrId}/edit`)); + } catch (err) { + yield put({ + type: types.CREATE_CCR_DRAFT_REJECTED, + payload: err.message || err.toString(), + error: true, + }); + } +} + +export function* handleFetchDrafts(): SagaIterator { + try { + const res: Yielded = yield call(getCCRDrafts); + yield put({ + type: types.FETCH_CCR_DRAFTS_FULFILLED, + payload: res.data, + }); + } catch (err) { + yield put({ + type: types.FETCH_CCR_DRAFTS_REJECTED, + payload: err.message || err.toString(), + error: true, + }); + } +} + +export function* handleSaveDraft(): SagaIterator { + try { + const draft: Yielded = yield select(getFormState); + if (!draft) { + throw new Error('No form state to save draft'); + } + yield call(putCCR, draft); + yield put({ type: types.SAVE_CCR_DRAFT_FULFILLED }); + } catch (err) { + yield put({ + type: types.SAVE_CCR_DRAFT_REJECTED, + payload: err.message || err.toString(), + error: true, + }); + } +} + +export function* handleFetchAndCreateDrafts(): SagaIterator { + yield put(fetchCCRDrafts()); + yield take([types.FETCH_CCR_DRAFTS_FULFILLED, types.FETCH_CCR_DRAFTS_PENDING]); + const drafts: Yielded = yield select(getDrafts); + + // Back out if draft fetch failed and we don't have drafts + if (!drafts) { + console.warn('Fetch of drafts failed, not creating new draft'); + return; + } + + if (drafts.length === 0) { + yield put(createCCRDraft()); + } +} + +export function* handleDeleteDraft( + action: ReturnType, +): SagaIterator { + try { + yield call(RDeleteCCRDraft, action.payload); + put({ type: types.DELETE_CCR_DRAFT_FULFILLED }); + } catch (err) { + yield put({ + type: types.DELETE_CCR_DRAFT_REJECTED, + payload: err.message || err.toString(), + error: true, + }); + return; + } + yield call(handleFetchDrafts); +} + +export function* handleInitializeForm( + action: ReturnType, +): SagaIterator { + try { + const ccrId = action.payload; + let draft: Yielded = yield select(getDraftById, ccrId); + if (!draft) { + yield call(handleFetchDrafts); + draft = yield select(getDraftById, ccrId); + if (!draft) { + // If it's a real ccr, just not in draft form, redirect to it + try { + yield call(getCCR, ccrId); + yield put({ type: types.INITIALIZE_CCR_FORM_REJECTED }); + yield put(replace(`/ccrs/${action.payload}`)); + return; + } catch (err) { + throw new Error('CCR not found'); + } + } + } + yield put({ + type: types.INITIALIZE_CCR_FORM_FULFILLED, + payload: draft, + }); + } catch (err) { + yield put({ + type: types.INITIALIZE_CCR_FORM_REJECTED, + payload: err.message || err.toString(), + error: true, + }); + } +} + +export default function* ccrSagas(): SagaIterator { + yield takeEvery(types.CREATE_CCR_DRAFT_PENDING, handleCreateDraft); + yield takeLatest(types.FETCH_CCR_DRAFTS_PENDING, handleFetchDrafts); + yield takeLatest(types.SAVE_CCR_DRAFT_PENDING, handleSaveDraft); + yield takeEvery(types.DELETE_CCR_DRAFT_PENDING, handleDeleteDraft); + yield takeEvery(types.INITIALIZE_CCR_FORM_PENDING, handleInitializeForm); + yield takeEvery(types.FETCH_AND_CREATE_CCR_DRAFTS, handleFetchAndCreateDrafts); +} diff --git a/frontend/client/modules/ccr/selectors.ts b/frontend/client/modules/ccr/selectors.ts new file mode 100644 index 00000000..7de00239 --- /dev/null +++ b/frontend/client/modules/ccr/selectors.ts @@ -0,0 +1,11 @@ +import { AppState as S } from 'store/reducers'; + +export const getDrafts = (s: S) => s.ccr.drafts; +export const getDraftsFetchError = (s: S) => s.ccr.fetchDraftsError; + +export const getDraftById = (s: S, id: number) => { + const drafts = getDrafts(s) || []; + return drafts.find(d => d.ccrId === id); +}; + +export const getFormState = (s: S) => s.ccr.form; diff --git a/frontend/client/modules/ccr/types.ts b/frontend/client/modules/ccr/types.ts new file mode 100644 index 00000000..65fa2f55 --- /dev/null +++ b/frontend/client/modules/ccr/types.ts @@ -0,0 +1,36 @@ +enum CCRTypes { + UPDATE_CCR_FORM = 'UPDATE_CCR_FORM', + + INITIALIZE_CCR_FORM = 'INITIALIZE_CCR_FORM', + INITIALIZE_CCR_FORM_PENDING = 'INITIALIZE_CCR_FORM_PENDING', + INITIALIZE_CCR_FORM_FULFILLED = 'INITIALIZE_CCR_FORM_FULFILLED', + INITIALIZE_CCR_FORM_REJECTED = 'INITIALIZE_CCR_FORM_REJECTED', + + SAVE_CCR_DRAFT = 'SAVE_CCR_DRAFT', + SAVE_CCR_DRAFT_PENDING = 'SAVE_CCR_DRAFT_PENDING', + SAVE_CCR_DRAFT_FULFILLED = 'SAVE_CCR_DRAFT_FULFILLED', + SAVE_CCR_DRAFT_REJECTED = 'SAVE_CCR_DRAFT_REJECTED', + + FETCH_CCR_DRAFTS = 'FETCH_CCR_DRAFTS', + FETCH_CCR_DRAFTS_PENDING = 'FETCH_CCR_DRAFTS_PENDING', + FETCH_CCR_DRAFTS_FULFILLED = 'FETCH_CCR_DRAFTS_FULFILLED', + FETCH_CCR_DRAFTS_REJECTED = 'FETCH_CCR_DRAFTS_REJECTED', + + CREATE_CCR_DRAFT = 'CREATE_CCR_DRAFT', + CREATE_CCR_DRAFT_PENDING = 'CREATE_CCR_DRAFT_PENDING', + CREATE_CCR_DRAFT_FULFILLED = 'CREATE_CCR_DRAFT_FULFILLED', + CREATE_CCR_DRAFT_REJECTED = 'CREATE_CCR_DRAFT_REJECTED', + + FETCH_AND_CREATE_CCR_DRAFTS = 'FETCH_AND_CREATE_CCR_DRAFTS', + + DELETE_CCR_DRAFT = 'DELETE_CCR_DRAFT', + DELETE_CCR_DRAFT_PENDING = 'DELETE_CCR_DRAFT_PENDING', + DELETE_CCR_DRAFT_FULFILLED = 'DELETE_CCR_DRAFT_FULFILLED', + DELETE_CCR_DRAFT_REJECTED = 'DELETE_CCR_DRAFT_REJECTED', + + SUBMIT_CCR = 'SUBMIT_CCR', + SUBMIT_CCR_PENDING = 'SUBMIT_CCR_PENDING', + SUBMIT_CCR_FULFILLED = 'SUBMIT_CCR_FULFILLED', + SUBMIT_CCR_REJECTED = 'SUBMIT_CCR_REJECTED', +} +export default CCRTypes; diff --git a/frontend/client/modules/ccr/utils.ts b/frontend/client/modules/ccr/utils.ts new file mode 100644 index 00000000..9d50a92d --- /dev/null +++ b/frontend/client/modules/ccr/utils.ts @@ -0,0 +1,64 @@ +import { CCRDraft } from 'types'; +import { getAmountErrorUsd, getAmountErrorUsdFromString } from 'utils/validators'; + +interface CCRFormErrors { + title?: string; + brief?: string; + target?: string; + content?: string; +} + +export type KeyOfForm = keyof CCRFormErrors; +export const FIELD_NAME_MAP: { [key in KeyOfForm]: string } = { + title: 'Title', + brief: 'Brief', + target: 'Target amount', + content: 'Details', +}; + +const requiredFields = ['title', 'brief', 'target', 'content']; + +export function getCCRErrors( + form: Partial, + skipRequired?: boolean, +): CCRFormErrors { + const errors: CCRFormErrors = {}; + const { title, content, brief, target } = form; + + // Required fields with no extra validation + if (!skipRequired) { + for (const key of requiredFields) { + if (!form[key as KeyOfForm]) { + errors[key as KeyOfForm] = `${FIELD_NAME_MAP[key as KeyOfForm]} is required`; + } + } + } + + // Title + if (title && title.length > 60) { + errors.title = 'Title can only be 60 characters maximum'; + } + + // Brief + if (brief && brief.length > 140) { + errors.brief = 'Brief can only be 140 characters maximum'; + } + + // Content limit for our database's sake + if (content && content.length > 250000) { + errors.content = 'Details can only be 250,000 characters maximum'; + } + + // Amount to raise + const targetFloat = target ? parseFloat(target) : 0; + if (target && !Number.isNaN(targetFloat)) { + const limit = parseFloat(process.env.PROPOSAL_TARGET_MAX as string); + const targetErr = + getAmountErrorUsd(targetFloat, limit) || getAmountErrorUsdFromString(target); + if (targetErr) { + errors.target = targetErr; + } + } + + return errors; +} diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index c83c35ce..7ef8b394 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -1,5 +1,12 @@ -import { ProposalDraft, STATUS, MILESTONE_STAGE, PROPOSAL_ARBITER_STATUS } from 'types'; -import { User } from 'types'; +import { + ProposalDraft, + STATUS, + MILESTONE_STAGE, + PROPOSAL_ARBITER_STATUS, + CCRDraft, + RFP, +} from 'types'; +import { User, CCR } from 'types'; import { getAmountErrorUsd, getAmountErrorUsdFromString, @@ -7,8 +14,8 @@ import { isValidTAddress, isValidSproutAddress, } from 'utils/validators'; -import { Zat, toZat } from 'utils/units'; -import { PROPOSAL_STAGE } from 'api/constants'; +import { toUsd } from 'utils/units'; +import { PROPOSAL_STAGE, RFP_STATUS } from 'api/constants'; import { ProposalDetail, PROPOSAL_DETAIL_INITIAL_STATE, @@ -232,10 +239,10 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta dateCreated: Date.now() / 1000, datePublished: Date.now() / 1000, dateApproved: Date.now() / 1000, - target: toZat(draft.target), - funded: Zat('0'), + target: toUsd(draft.target), + funded: toUsd('0'), contributionMatching: 0, - contributionBounty: Zat('0'), + contributionBounty: toUsd('0'), percentFunded: 0, stage: PROPOSAL_STAGE.PREVIEW, isStaked: true, @@ -255,7 +262,7 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta index: idx, title: m.title, content: m.content, - amount: toZat(target * (parseInt(m.payoutPercent, 10) / 100)), + amount: (target * (parseInt(m.payoutPercent, 10) / 100)).toFixed(2), daysEstimated: m.daysEstimated, immediatePayout: m.immediatePayout, payoutPercent: m.payoutPercent.toString(), @@ -264,3 +271,28 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta ...PROPOSAL_DETAIL_INITIAL_STATE, }; } + +export function makeRfpPreviewFromCcrDraft(draft: CCRDraft): RFP { + const ccr: CCR = { + ...draft, + }; + const now = new Date().getTime(); + const { brief, content, title } = draft; + + return { + id: 0, + urlId: '', + status: RFP_STATUS.LIVE, + acceptedProposals: [], + bounty: draft.target ? toUsd(draft.target) : null, + matching: false, + dateOpened: now / 1000, + authedLiked: false, + likesCount: 0, + isVersionTwo: true, + ccr, + brief, + content, + title, + }; +} diff --git a/frontend/client/modules/proposals/actions.ts b/frontend/client/modules/proposals/actions.ts index 87c924b8..d73682d0 100644 --- a/frontend/client/modules/proposals/actions.ts +++ b/frontend/client/modules/proposals/actions.ts @@ -120,15 +120,13 @@ export function fetchProposal(proposalId: Proposal['proposalId']) { } export type TUpdateProposal = typeof updateProposal; -export function updateProposal( - proposal: Proposal -) { +export function updateProposal(proposal: Proposal) { return (dispatch: Dispatch, getState: GetState) => { dispatch({ type: types.PROPOSAL_DATA_FULFILLED, payload: addProposalUserRoles(proposal, getState()), }); - } + }; } export function fetchProposalComments(id?: number) { diff --git a/frontend/client/modules/users/actions.ts b/frontend/client/modules/users/actions.ts index 6a8c0133..1c762ca4 100644 --- a/frontend/client/modules/users/actions.ts +++ b/frontend/client/modules/users/actions.ts @@ -8,6 +8,7 @@ import { deleteProposalContribution, deleteProposalDraft, putProposalPublish, + deleteCCR, } from 'api/api'; import { Dispatch } from 'redux'; import { cleanClone } from 'utils/helpers'; @@ -93,7 +94,10 @@ export function respondToInvite( }; } -export function deleteContribution(userId: string | number, contributionId: string | number) { +export function deleteContribution( + userId: string | number, + contributionId: string | number, +) { // Fire and forget deleteProposalContribution(contributionId); return { @@ -126,3 +130,12 @@ export function publishPendingProposal(userId: number, proposalId: number) { }); }; } + +export function deletePendingRequest(userId: number, requestId: number) { + return async (dispatch: Dispatch) => { + await dispatch({ + type: types.USER_DELETE_REQUEST, + payload: deleteCCR(requestId).then(_ => ({ userId, requestId })), + }); + }; +} diff --git a/frontend/client/modules/users/reducers.ts b/frontend/client/modules/users/reducers.ts index 3dd319f4..8fe5b0eb 100644 --- a/frontend/client/modules/users/reducers.ts +++ b/frontend/client/modules/users/reducers.ts @@ -6,6 +6,7 @@ import { UserContribution, TeamInviteWithProposal, UserProposalArbiter, + UserCCR, } from 'types'; import types from './types'; @@ -21,8 +22,10 @@ export interface UserState extends User { isUpdating: boolean; updateError: string | null; pendingProposals: UserProposal[]; + pendingRequests: UserCCR[]; arbitrated: UserProposalArbiter[]; proposals: UserProposal[]; + requests: UserCCR[]; contributions: UserContribution[]; comments: UserComment[]; isFetchingInvites: boolean; @@ -53,8 +56,10 @@ export const INITIAL_USER_STATE: UserState = { isUpdating: false, updateError: null, pendingProposals: [], + pendingRequests: [], arbitrated: [], proposals: [], + requests: [], contributions: [], comments: [], isFetchingInvites: false, @@ -111,24 +116,6 @@ export default (state = INITIAL_STATE, action: any) => { updateError: errorMessage, }); // invites - case types.FETCH_USER_INVITES_PENDING: - return updateUserState(state, userFetchId, { - isFetchingInvites: true, - fetchErrorInvites: null, - }); - case types.FETCH_USER_INVITES_FULFILLED: - return updateUserState(state, userFetchId, { - isFetchingInvites: false, - hasFetchedInvites: true, - invites, - }); - case types.FETCH_USER_INVITES_REJECTED: - return updateUserState(state, userFetchId, { - isFetchingInvites: false, - hasFetchedInvites: true, - fetchErrorInvites: errorMessage, - }); - // invites case types.FETCH_USER_INVITES_PENDING: return updateUserState(state, userFetchId, { isFetchingInvites: true, diff --git a/frontend/client/modules/users/types.ts b/frontend/client/modules/users/types.ts index 4e6858c8..e633bd9a 100644 --- a/frontend/client/modules/users/types.ts +++ b/frontend/client/modules/users/types.ts @@ -26,6 +26,12 @@ enum UsersActions { USER_PUBLISH_PROPOSAL = 'USER_PUBLISH_PROPOSAL', USER_PUBLISH_PROPOSAL_FULFILLED = 'USER_PUBLISH_PROPOSAL_FULFILLED', + + USER_DELETE_REQUEST = 'USER_DELETE_REQUEST', + USER_DELETE_REQUEST_FULFILLED = 'USER_DELETE_REQUEST_FULFILLED', + + USER_PUBLISH_REQUEST = 'USER_PUBLISH_REQUEST', + USER_PUBLISH_REQUEST_FULFILLED = 'USER_PUBLISH_REQUEST_FULFILLED', } export default UsersActions; diff --git a/frontend/client/pages/ccr.tsx b/frontend/client/pages/ccr.tsx new file mode 100644 index 00000000..fc8d6b6b --- /dev/null +++ b/frontend/client/pages/ccr.tsx @@ -0,0 +1,16 @@ +import React, { Component } from 'react'; +import CcrPreview from 'components/CCRFlow/CCRPreview' +import { extractIdFromSlug } from 'utils/api'; + +import { withRouter, RouteComponentProps } from 'react-router'; + +type RouteProps = RouteComponentProps; + +class CcrPage extends Component { + render() { + const ccrId = extractIdFromSlug(this.props.match.params.id); + return ; + } +} + +export default withRouter(CcrPage); diff --git a/frontend/client/pages/create-request.tsx b/frontend/client/pages/create-request.tsx new file mode 100644 index 00000000..fd61306a --- /dev/null +++ b/frontend/client/pages/create-request.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { withRouter, RouteComponentProps } from 'react-router'; +import CreateRequestDraftList from 'components/CCRDraftList'; + +type Props = RouteComponentProps<{}>; + +class CreateRequestPage extends React.Component { + render() { + return ( + <> + + + + ); + } +} + +export default withRouter(CreateRequestPage); diff --git a/frontend/client/pages/email-verify.tsx b/frontend/client/pages/email-verify.tsx index bd8170fe..f9d41cb8 100644 --- a/frontend/client/pages/email-verify.tsx +++ b/frontend/client/pages/email-verify.tsx @@ -28,7 +28,7 @@ class VerifyEmail extends React.Component { this.setState({ hasVerified: true, isVerifying: false, - }) + }); }) .catch(err => { this.setState({ @@ -41,7 +41,7 @@ class VerifyEmail extends React.Component { error: ` Missing code parameter from email. Make sure you copied the full link. - ` + `, }); } } @@ -63,7 +63,7 @@ class VerifyEmail extends React.Component {
    ); - + if (hasVerified) { return ( { } } -export default withRouter(VerifyEmail); \ No newline at end of file +export default withRouter(VerifyEmail); diff --git a/frontend/client/pages/request-edit.tsx b/frontend/client/pages/request-edit.tsx new file mode 100644 index 00000000..73e6a567 --- /dev/null +++ b/frontend/client/pages/request-edit.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { withRouter, RouteComponentProps } from 'react-router'; +import CCRFlow from 'components/CCRFlow'; +import { initializeForm } from 'modules/ccr/actions'; +import { AppState } from 'store/reducers'; +import Loader from 'components/Loader'; + +interface StateProps { + form: AppState['ccr']['form']; + isInitializingForm: AppState['ccr']['isInitializingForm']; + initializeFormError: AppState['ccr']['initializeFormError']; +} + +interface DispatchProps { + initializeForm: typeof initializeForm; +} + +type Props = StateProps & DispatchProps & RouteComponentProps<{ id: string }>; + +class RequestEdit extends React.Component { + componentWillMount() { + const proposalId = parseInt(this.props.match.params.id, 10); + this.props.initializeForm(proposalId); + } + + render() { + const { form, initializeFormError } = this.props; + if (form) { + return ; + } else if (initializeFormError) { + return

    {initializeFormError}

    ; + } else { + return ; + } + } +} + +const ConnectedRequestEdit = connect( + state => ({ + form: state.ccr.form, + isInitializingForm: state.ccr.isInitializingForm, + initializeFormError: state.ccr.initializeFormError, + }), + { initializeForm }, +)(RequestEdit); + +export default withRouter(ConnectedRequestEdit); diff --git a/frontend/client/static/locales/en/common.json b/frontend/client/static/locales/en/common.json index b5fad0cc..ae905fbf 100644 --- a/frontend/client/static/locales/en/common.json +++ b/frontend/client/static/locales/en/common.json @@ -8,8 +8,9 @@ "intro": { "title": "Funding for Zcash ecosystem projects", - "subtitle": "Development, research, and community growth", + "subtitle": "Development, research, and community growth.\nHelp us build a better Zcash by creating a proposal or requesting an improvement!", "signup": "Sign up now", + "ccr": "Create a request", "browse": "Browse proposals", "learn": "or learn more below" }, @@ -23,7 +24,7 @@ "requests": { "title": "Open Requests from the ZF", - "description": "The Zcash Foundation will periodically open up requests for proposals that have financial incentives attached to them in the form of fixed bounties, or pledges of contribution matching.\nProposals will be reviewed and chosen based on the ZF’s confidence in the team and their plan.\nTo be eligible for funding from the Zcash Foundation, teams must provide identifying information for legal reasons.\nIf none of the RFPs catch your eye, check out the list of promising ideas<\/a>!", + "description": "The Zcash Foundation will periodically open up requests for proposals that have financial incentives attached to them in the form of fixed bounties.\nProposals will be reviewed and chosen based on the ZF’s confidence in the team and their plan.\nTo be eligible for funding from the Zcash Foundation, teams must provide identifying information for legal reasons.\nIf none of the RFPs catch your eye, check out the list of promising ideas<\/a>!", "more": "See all requests", "emptyTitle": "No open requests at this time", "emptySubtitle": "But don’t let that stop you! Proposals can be submitted at any time." @@ -31,10 +32,10 @@ "guide": { "title": "How it works", - "submit": "Individuals and teams submit proposals against requests from the Zcash Foundation, or of their own ideas", - "review": "Their proposal is reviewed by the Zcash Foundation, and potentially rewarded a bounty or met with matching contributions", - "community": "The proposal is then opened up to the community to discuss, provide feedback, and help it reach its funding goal", - "complete": "Once successfully funded, the proposal creator(s) post updates with their progress and are paid out as they reach project milestones" + "submit": "Individuals and teams submit proposals against requests from the community or the Zcash Foundation, or submit one of their own ideas", + "review": "The proposal is reviewed by the Zcash Foundation, after which the proposal may be accepted with or without funding. In cases where the proposal is accepted without funding, the community may donate directly when the team has set a tip address.", + "community": "The proposal is then opened up to the community to discuss, provide feedback, and optionally donate to the team", + "complete": "The proposal creator(s) post updates with their progress, and if having received a bounty, are paid out as they reach project milestones" }, "actions": { diff --git a/frontend/client/store/reducers.tsx b/frontend/client/store/reducers.tsx index de10b389..76577b98 100644 --- a/frontend/client/store/reducers.tsx +++ b/frontend/client/store/reducers.tsx @@ -4,6 +4,7 @@ import proposal, { ProposalState, INITIAL_STATE as proposalInitialState, } from 'modules/proposals'; +import ccr, { CCRState, INITIAL_STATE as ccrInitialState } from 'modules/ccr'; import create, { CreateState, INITIAL_STATE as createInitialState } from 'modules/create'; import auth, { AuthState, INITIAL_STATE as authInitialState } from 'modules/auth'; import users, { UsersState, INITIAL_STATE as usersInitialState } from 'modules/users'; @@ -13,6 +14,7 @@ import history from './history'; export interface AppState { proposal: ProposalState; create: CreateState; + ccr: CCRState; users: UsersState; auth: AuthState; rfps: RFPState; @@ -25,9 +27,11 @@ export const combineInitialState: Omit = { users: usersInitialState, auth: authInitialState, rfps: rfpsInitialState, + ccr: ccrInitialState, }; export default combineReducers({ + ccr, proposal, create, users, diff --git a/frontend/client/store/sagas.ts b/frontend/client/store/sagas.ts index 27348b0f..60a6e28e 100644 --- a/frontend/client/store/sagas.ts +++ b/frontend/client/store/sagas.ts @@ -1,8 +1,10 @@ import { fork } from 'redux-saga/effects'; import { authSagas } from 'modules/auth'; import { createSagas } from 'modules/create'; +import { ccrSagas } from 'modules/ccr'; export default function* rootSaga() { yield fork(authSagas); yield fork(createSagas); + yield fork(ccrSagas); } diff --git a/frontend/client/utils/email.tsx b/frontend/client/utils/email.tsx index b245acf1..e061df06 100644 --- a/frontend/client/utils/email.tsx +++ b/frontend/client/utils/email.tsx @@ -89,6 +89,16 @@ export const EMAIL_SUBSCRIPTIONS: { [key in ESKey]: EmailSubscriptionInfo } = { category: EMAIL_SUBSCRIPTION_CATEGORY.ADMIN, value: false, }, + followedProposal: { + description: `proposals you're following`, + category: EMAIL_SUBSCRIPTION_CATEGORY.PROPOSAL, + value: false, + }, + adminApprovalCcr: { + description: 'CCR needs review', + category: EMAIL_SUBSCRIPTION_CATEGORY.ADMIN, + value: false, + }, }; export const EMAIL_SUBSCRIPTION_CATEGORIES: { diff --git a/frontend/types/ccr.ts b/frontend/types/ccr.ts new file mode 100644 index 00000000..6b9aab0d --- /dev/null +++ b/frontend/types/ccr.ts @@ -0,0 +1,38 @@ +import { User } from 'types/user'; +import { RFP } from 'types/rfp'; + +export enum CCRSTATUS { + DRAFT = 'DRAFT', + PENDING = 'PENDING', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', + LIVE = 'LIVE', + DELETED = 'DELETED', +} + +export interface CCRDraft { + author: User; + title: string; + brief: string; + ccrId: number; + status: CCRSTATUS; + target: string; + dateCreated: number; + content: string; +} + +export interface CCR extends CCRDraft { + rfp?: RFP; +} + +export interface UserCCR { + author: User; + ccrId: number; + status: CCRSTATUS; + title: string; + brief: string; + dateCreated: number; + dateApproved: number; + datePublished: number; + rejectReason: string; +} diff --git a/frontend/types/email.ts b/frontend/types/email.ts index db904f00..ab440eb1 100644 --- a/frontend/types/email.ts +++ b/frontend/types/email.ts @@ -4,6 +4,7 @@ export interface EmailSubscriptions { fundedProposalContribution: boolean; fundedProposalFunded: boolean; fundedProposalPayoutRequest: boolean; + followedProposal: boolean; fundedProposalUpdate: boolean; myCommentReply: boolean; myProposalApproval: boolean; @@ -15,6 +16,7 @@ export interface EmailSubscriptions { adminApproval: boolean; adminArbiter: boolean; adminPayout: boolean; + adminApprovalCcr: boolean; } export enum EMAIL_SUBSCRIPTION_CATEGORY { diff --git a/frontend/types/index.ts b/frontend/types/index.ts index 829b0b74..04cd6cc5 100644 --- a/frontend/types/index.ts +++ b/frontend/types/index.ts @@ -9,3 +9,4 @@ export * from './api'; export * from './email'; export * from './rfp'; export * from './pagination'; +export * from './ccr'; diff --git a/frontend/types/rfp.ts b/frontend/types/rfp.ts index d7ce6489..2b4e5d8f 100644 --- a/frontend/types/rfp.ts +++ b/frontend/types/rfp.ts @@ -1,5 +1,6 @@ import { Proposal } from './proposal'; import { RFP_STATUS } from 'api/constants'; +import { CCR } from 'types/ccr'; import { Zat, Usd } from 'utils/units'; export interface RFP { @@ -18,4 +19,5 @@ export interface RFP { authedLiked: boolean; likesCount: number; isVersionTwo: boolean; + ccr?: CCR; }