From 7ef5dea343f6f4bc54ed505d6fa79f1486faf81e Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Thu, 10 Oct 2019 11:11:18 -0400 Subject: [PATCH 01/54] add requests to drawer --- frontend/client/components/Header/Drawer.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/client/components/Header/Drawer.tsx b/frontend/client/components/Header/Drawer.tsx index 31114393..dd1cf470 100644 --- a/frontend/client/components/Header/Drawer.tsx +++ b/frontend/client/components/Header/Drawer.tsx @@ -84,6 +84,11 @@ class HeaderDrawer extends React.Component { Start a proposal + + + Browse requests + + ); From 54b0d58ffaba0dc54a053bc291199afb213063db Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Thu, 10 Oct 2019 20:12:38 -0400 Subject: [PATCH 02/54] Disallow Proposal Submissions to Expired RFPs (#25) * disallow rfp proposal submissions after close * Add closed tag to closed RFPs --- backend/grant/proposal/views.py | 7 +++- frontend/client/components/RFP/index.tsx | 46 +++++++++++++++--------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index eba7ed00..50c016fb 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -1,4 +1,5 @@ from decimal import Decimal +from datetime import datetime from flask import Blueprint, g, request, current_app from marshmallow import fields, validate @@ -25,7 +26,7 @@ from grant.utils.auth import ( internal_webhook ) from grant.utils.enums import Category -from grant.utils.enums import ProposalStatus, ProposalStage, ContributionStatus +from grant.utils.enums import ProposalStatus, ProposalStage, ContributionStatus, RFPStatus from grant.utils.exceptions import ValidationException from grant.utils.misc import is_email, make_url, from_zat, make_explore_url from .models import ( @@ -187,6 +188,10 @@ def make_proposal_draft(rfp_id): rfp = RFP.query.filter_by(id=rfp_id).first() if not rfp: return {"message": "The request this proposal was made for doesn’t exist"}, 400 + if datetime.now() > rfp.date_closes: + return {"message": "The request this proposal was made for has expired"}, 400 + if rfp.status == RFPStatus.CLOSED: + return {"message": "The request this proposal was made for has been closed"}, 400 proposal.category = rfp.category rfp.proposals.append(proposal) db.session.add(rfp) diff --git a/frontend/client/components/RFP/index.tsx b/frontend/client/components/RFP/index.tsx index 1278f825..fd81c57d 100644 --- a/frontend/client/components/RFP/index.tsx +++ b/frontend/client/components/RFP/index.tsx @@ -13,6 +13,7 @@ import Markdown from 'components/Markdown'; import ProposalCard from 'components/Proposals/ProposalCard'; import UnitDisplay from 'components/UnitDisplay'; import HeaderDetails from 'components/HeaderDetails'; +import { RFP_STATUS } from 'api/constants'; import './index.less'; interface OwnProps { @@ -48,6 +49,7 @@ class RFPDetail extends React.Component { } } + const isLive = rfp.status === RFP_STATUS.LIVE; const tags = []; if (rfp.matching) { @@ -66,6 +68,14 @@ class RFPDetail extends React.Component { ); } + if (!isLive) { + tags.push( + + Closed + + ); + } + return (
@@ -117,23 +127,25 @@ class RFPDetail extends React.Component {
)} -
- -
- Ready to take on this request?{' '} - - - -
-
-
+ {isLive && ( +
+ +
+ Ready to take on this request?{' '} + + + +
+
+
+ )} ); } From 746398c59b25a7e34513729a3933124d15d2ef22 Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Fri, 11 Oct 2019 15:51:10 -0400 Subject: [PATCH 03/54] Proposal Versioning (#21) * add proposal versioning * refactor backend to provide isVersionTwo * trigger ci * remove "version" --- admin/src/types.ts | 1 + backend/grant/proposal/models.py | 9 ++++++++- frontend/client/modules/create/utils.ts | 1 + frontend/stories/props.tsx | 1 + frontend/types/proposal.ts | 1 + 5 files changed, 12 insertions(+), 1 deletion(-) diff --git a/admin/src/types.ts b/admin/src/types.ts index 7129194d..97f7db4a 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -116,6 +116,7 @@ export interface Proposal { rfpOptIn: null | boolean; rfp?: RFP; arbiter: ProposalArbiter; + isVersionTwo: boolean; } export interface Comment { id: number; diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 2ca822de..01b26963 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -218,6 +218,7 @@ class Proposal(db.Model): id = db.Column(db.Integer(), primary_key=True) date_created = db.Column(db.DateTime) rfp_id = db.Column(db.Integer(), db.ForeignKey('rfp.id'), nullable=True) + version = db.Column(db.String(255), nullable=True) # Content info status = db.Column(db.String(255), nullable=False) @@ -272,6 +273,7 @@ class Proposal(db.Model): self.payout_address = payout_address self.deadline_duration = deadline_duration self.stage = stage + self.version = '2' @staticmethod def simple_validate(proposal): @@ -703,13 +705,15 @@ class ProposalSchema(ma.Schema): "invites", "rfp", "rfp_opt_in", - "arbiter" + "arbiter", + "is_version_two" ) date_created = ma.Method("get_date_created") date_approved = ma.Method("get_date_approved") date_published = ma.Method("get_date_published") proposal_id = ma.Method("get_proposal_id") + is_version_two = ma.Method("get_is_version_two") updates = ma.Nested("ProposalUpdateSchema", many=True) team = ma.Nested("UserSchema", many=True) @@ -731,6 +735,8 @@ class ProposalSchema(ma.Schema): def get_date_published(self, obj): return dt_to_unix(obj.date_published) if obj.date_published else None + def get_is_version_two(self, obj): + return True if obj.version == '2' else False proposal_schema = ProposalSchema() proposals_schema = ProposalSchema(many=True) @@ -748,6 +754,7 @@ user_fields = [ "date_published", "reject_reason", "team", + "is_version_two" ] user_proposal_schema = ProposalSchema(only=user_fields) user_proposals_schema = ProposalSchema(many=True, only=user_fields) diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index f10ee577..8cd3f5d9 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -252,6 +252,7 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta arbiter: { status: PROPOSAL_ARBITER_STATUS.ACCEPTED, }, + isVersionTwo: true, milestones: draft.milestones.map((m, idx) => ({ id: idx, index: idx, diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index 2f0fcf18..9d49c9f0 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -173,6 +173,7 @@ export function generateProposal({ socialMedias: [], }, }, + isVersionTwo: true, team: [ { userid: 123, diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts index 33548aa9..fdcb8222 100644 --- a/frontend/types/proposal.ts +++ b/frontend/types/proposal.ts @@ -62,6 +62,7 @@ export interface Proposal extends Omit { datePublished: number | null; dateApproved: number | null; arbiter: ProposalProposalArbiter; + isVersionTwo: boolean; isTeamMember?: boolean; // FE derived isArbiter?: boolean; // FE derived } From 701a2f95a9b62e7aa28a4289b17ee35b83819675 Mon Sep 17 00:00:00 2001 From: Daniel Ternyak Date: Fri, 11 Oct 2019 14:52:52 -0500 Subject: [PATCH 04/54] Proposal deadlines (#28) * add proposal versioning * remove deadlines * remove acceptedWithFunding * fix lint, remove commented code * refactor backend to provide isVersionTwo * refactor backend to provide isVersionTwo * Revert "refactor backend to provide isVersionTwo" This reverts commit e3b9bc661081e482326f83fa6aa517cf6bdebe6c. * trigger ci * remove "version" --- backend/grant/proposal/models.py | 2 +- backend/grant/proposal/views.py | 1 - .../client/components/CreateFlow/Payment.tsx | 40 +------------------ .../client/components/CreateFlow/Review.tsx | 7 ---- .../client/components/CreateFlow/example.ts | 1 - .../Proposal/CampaignBlock/index.tsx | 4 +- frontend/client/modules/create/utils.ts | 4 -- frontend/types/proposal.ts | 2 +- 8 files changed, 7 insertions(+), 54 deletions(-) diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 01b26963..44cf98f1 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -234,7 +234,7 @@ class Proposal(db.Model): # Payment info target = db.Column(db.String(255), nullable=False) payout_address = db.Column(db.String(255), nullable=False) - deadline_duration = db.Column(db.Integer(), nullable=False) + deadline_duration = db.Column(db.Integer(), nullable=True) contribution_matching = db.Column(db.Float(), nullable=False, default=0, server_default=db.text("0")) contribution_bounty = db.Column(db.String(255), nullable=False, default='0', server_default=db.text("'0'")) rfp_opt_in = db.Column(db.Boolean(), nullable=True) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 50c016fb..98e61354 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -228,7 +228,6 @@ def get_proposal_drafts(): "content": fields.Str(required=True), "target": fields.Str(required=True), "payoutAddress": fields.Str(required=True), - "deadlineDuration": fields.Int(required=True), "milestones": fields.List(fields.Dict(), required=True), "rfpOptIn": fields.Bool(required=False, missing=None), }) diff --git a/frontend/client/components/CreateFlow/Payment.tsx b/frontend/client/components/CreateFlow/Payment.tsx index 18bdfc3c..4e3a4e80 100644 --- a/frontend/client/components/CreateFlow/Payment.tsx +++ b/frontend/client/components/CreateFlow/Payment.tsx @@ -1,14 +1,11 @@ import React from 'react'; -import { Input, Form, Radio } from 'antd'; -import { RadioChangeEvent } from 'antd/lib/radio'; +import { Input, Form } from 'antd'; import { ProposalDraft } from 'types'; import { getCreateErrors } from 'modules/create/utils'; -import { ONE_DAY } from 'utils/time'; import { DONATION } from 'utils/constants'; interface State { payoutAddress: string; - deadlineDuration: number; } interface Props { @@ -21,13 +18,12 @@ export default class CreateFlowPayment extends React.Component { super(props); this.state = { payoutAddress: '', - deadlineDuration: ONE_DAY * 60, ...(props.initialState || {}), }; } render() { - const { payoutAddress, deadlineDuration } = this.state; + const { payoutAddress } = this.state; const errors = getCreateErrors(this.state, true); const payoutHelp = errors.payoutAddress || @@ -52,31 +48,6 @@ export default class CreateFlowPayment extends React.Component { onChange={this.handleInputChange} /> - - - - {deadlineDuration === 300 && ( - - 5 minutes - - )} - - 30 Days - - - 60 Days - - - 90 Days - - - ); } @@ -89,11 +60,4 @@ export default class CreateFlowPayment extends React.Component { this.props.updateForm(this.state); }); }; - - private handleRadioChange = (event: RadioChangeEvent) => { - const { value, name } = event.target; - this.setState({ [name as string]: value } as any, () => { - this.props.updateForm(this.state); - }); - }; } diff --git a/frontend/client/components/CreateFlow/Review.tsx b/frontend/client/components/CreateFlow/Review.tsx index defe4417..187e9028 100644 --- a/frontend/client/components/CreateFlow/Review.tsx +++ b/frontend/client/components/CreateFlow/Review.tsx @@ -118,13 +118,6 @@ class CreateReview extends React.Component { content: {form.payoutAddress}, error: errors.payoutAddress, }, - { - key: 'deadlineDuration', - content: `${Math.floor( - moment.duration((form.deadlineDuration || 0) * 1000).asDays(), - )} days`, - error: errors.deadlineDuration, - }, ], }, ]; diff --git a/frontend/client/components/CreateFlow/example.ts b/frontend/client/components/CreateFlow/example.ts index 5c9237a9..6168dee2 100644 --- a/frontend/client/components/CreateFlow/example.ts +++ b/frontend/client/components/CreateFlow/example.ts @@ -47,7 +47,6 @@ const createExampleProposal = (): Partial => { immediatePayout: false, }, ], - deadlineDuration: 300, }; }; diff --git a/frontend/client/components/Proposal/CampaignBlock/index.tsx b/frontend/client/components/Proposal/CampaignBlock/index.tsx index 12123d22..d509e66f 100644 --- a/frontend/client/components/Proposal/CampaignBlock/index.tsx +++ b/frontend/client/components/Proposal/CampaignBlock/index.tsx @@ -54,7 +54,9 @@ export class ProposalCampaignBlock extends React.Component { const { target, funded, percentFunded } = proposal; const datePublished = proposal.datePublished || Date.now() / 1000; const isRaiseGoalReached = funded.gte(target); - const deadline = (datePublished + proposal.deadlineDuration) * 1000; + const deadline = proposal.deadlineDuration + ? (datePublished + proposal.deadlineDuration) * 1000 + : 0; const isFrozen = proposal.stage === PROPOSAL_STAGE.FAILED || proposal.stage === PROPOSAL_STAGE.CANCELED; diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index 8cd3f5d9..48e3cdf6 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -30,7 +30,6 @@ interface CreateFormErrors { content?: string; payoutAddress?: string; milestones?: string[]; - deadlineDuration?: string; } export type KeyOfForm = keyof CreateFormErrors; @@ -44,7 +43,6 @@ export const FIELD_NAME_MAP: { [key in KeyOfForm]: string } = { content: 'Details', payoutAddress: 'Payout address', milestones: 'Milestones', - deadlineDuration: 'Funding deadline', }; const requiredFields = [ @@ -54,7 +52,6 @@ const requiredFields = [ 'target', 'content', 'payoutAddress', - 'deadlineDuration', ]; export function getCreateErrors( @@ -240,7 +237,6 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta dateCreated: Date.now() / 1000, datePublished: Date.now() / 1000, dateApproved: Date.now() / 1000, - deadlineDuration: 86400 * 60, target: toZat(draft.target), funded: Zat('0'), contributionMatching: 0, diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts index fdcb8222..49464d05 100644 --- a/frontend/types/proposal.ts +++ b/frontend/types/proposal.ts @@ -39,12 +39,12 @@ export interface ProposalDraft { stage: PROPOSAL_STAGE; target: string; payoutAddress: string; - deadlineDuration: number; milestones: CreateMilestone[]; team: User[]; invites: TeamInvite[]; status: STATUS; isStaked: boolean; + deadlineDuration?: number; rfp?: RFP; rfpOptIn?: boolean; } From fb6b9b5af7c153caf4b036b20eb56db75e3d9d24 Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Wed, 16 Oct 2019 23:43:20 -0400 Subject: [PATCH 05/54] Proposal Lifecycle & Crowdfunding (#23) * add proposal versioning * remove deadlines * update proposal lifecycle for admin * update proposal lifecycle for backend * update proposal lifecycle for frontend * fix tests * remove acceptedWithFunding * fix lint, remove commented code * remove commented code * refactor backend to provide isVersionTwo * refactor backend to provide isVersionTwo * Revert "refactor backend to provide isVersionTwo" This reverts commit e3b9bc661081e482326f83fa6aa517cf6bdebe6c. * use isVersionTwo in admin * add acceptedWithFunding * trigger ci * remove "version" * remove "version" * remove rejected from campaign block --- admin/src/components/ArbiterControl/index.tsx | 4 +- .../src/components/ProposalDetail/index.less | 5 +- admin/src/components/ProposalDetail/index.tsx | 164 +++++------------- admin/src/components/RFPForm/index.tsx | 12 -- admin/src/store.ts | 16 +- admin/src/types.ts | 1 + backend/grant/admin/views.py | 36 +--- backend/grant/proposal/commands.py | 2 +- backend/grant/proposal/models.py | 54 ++---- backend/grant/proposal/views.py | 3 - backend/grant/utils/enums.py | 1 - backend/tests/admin/test_admin_api.py | 54 +++--- .../client/components/CreateFlow/index.tsx | 4 +- .../Proposal/CampaignBlock/index.tsx | 163 ++++++++--------- frontend/client/components/RFP/index.tsx | 2 +- frontend/client/modules/create/utils.ts | 1 + frontend/stories/props.tsx | 3 +- frontend/types/proposal.ts | 1 + 18 files changed, 195 insertions(+), 331 deletions(-) diff --git a/admin/src/components/ArbiterControl/index.tsx b/admin/src/components/ArbiterControl/index.tsx index 4c85d617..fd71ae5f 100644 --- a/admin/src/components/ArbiterControl/index.tsx +++ b/admin/src/components/ArbiterControl/index.tsx @@ -30,10 +30,11 @@ class ArbiterControlNaked extends React.Component { }, 1000); render() { - const { arbiter } = this.props; + const { arbiter, isVersionTwo, acceptedWithFunding } = this.props; const { showSearch, searching } = this.state; const { results, search, error } = store.arbitersSearch; const showEmpty = !results.length && !searching; + const buttonDisabled = isVersionTwo && acceptedWithFunding === false const disp = { [PROPOSAL_ARBITER_STATUS.MISSING]: 'Nominate arbiter', @@ -51,6 +52,7 @@ class ArbiterControlNaked extends React.Component { type="primary" onClick={this.handleShowSearch} {...this.props.buttonProps} + disabled={buttonDisabled} > {disp[arbiter.status]} diff --git a/admin/src/components/ProposalDetail/index.less b/admin/src/components/ProposalDetail/index.less index b36ac488..17b9009e 100644 --- a/admin/src/components/ProposalDetail/index.less +++ b/admin/src/components/ProposalDetail/index.less @@ -27,8 +27,9 @@ .ant-collapse { margin-bottom: 16px; - button + button { - margin-left: 0.5rem; + button { + margin-right: 0.5rem; + margin-bottom: 0.25rem; } } diff --git a/admin/src/components/ProposalDetail/index.tsx b/admin/src/components/ProposalDetail/index.tsx index 977ad24b..747144bd 100644 --- a/admin/src/components/ProposalDetail/index.tsx +++ b/admin/src/components/ProposalDetail/index.tsx @@ -11,7 +11,6 @@ import { Collapse, Popconfirm, Input, - Switch, Tag, message, } from 'antd'; @@ -26,7 +25,6 @@ import { } from 'src/types'; import { Link } from 'react-router-dom'; import Back from 'components/Back'; -import Info from 'components/Info'; import Markdown from 'components/Markdown'; import ArbiterControl from 'components/ArbiterControl'; import { toZat, fromZat } from 'src/util/units'; @@ -65,6 +63,12 @@ class ProposalDetailNaked extends React.Component { return m.datePaid ? prev - parseFloat(m.payoutPercent) : prev; }, 100); + const { isVersionTwo } = p + const shouldShowArbiter = + !isVersionTwo || + (isVersionTwo && p.acceptedWithFunding === true); + const cancelButtonText = isVersionTwo ? 'Cancel' : 'Cancel & refund' + const renderCancelControl = () => { const disabled = this.getCancelAndRefundDisabled(); @@ -95,7 +99,7 @@ class ProposalDetailNaked extends React.Component { disabled={disabled} block > - Cancel & refund + { cancelButtonText } ); @@ -116,68 +120,6 @@ class ProposalDetailNaked extends React.Component { /> ); - const renderMatchingControl = () => ( -
- -
- Turn {p.contributionMatching ? 'off' : 'on'} contribution matching? -
- {p.status === PROPOSAL_STATUS.LIVE && ( -
- This is a LIVE proposal, this will alter the funding state of the - proposal! -
- )} - - } - okText="ok" - cancelText="cancel" - > - {' '} -
- - matching{' '} - - Contribution matching -
Funded amount will be multiplied by 2. -
Disabled after proposal is fully-funded. -
- } - /> - -
- ); - - const renderBountyControl = () => ( -
- -
- ); const renderApproved = () => p.status === PROPOSAL_STATUS.APPROVED && ( @@ -205,9 +147,17 @@ class ProposalDetailNaked extends React.Component { loading={store.proposalDetailApproving} icon="check" type="primary" - onClick={this.handleApprove} + onClick={() => this.handleApprove(true)} > - Approve + Approve With Funding + + - + */} )} - + {proposal.stage === PROPOSAL_STAGE.CANCELED ? ( + <> + + Proposal was canceled + + ) : ( + <> + + Proposal has been accepted + + )} + + )} + + {/* TODO: adapt below for tipjar? */} + {/* + /> */} ); } else { @@ -244,38 +279,6 @@ export class ProposalCampaignBlock extends React.Component { ); } - - private handleAmountChange = ( - event: React.ChangeEvent, - ) => { - const { value } = event.currentTarget; - if (!value) { - this.setState({ amountToRaise: '', amountError: null }); - return; - } - - const { target, funded } = this.props.proposal; - const remainingTarget = target.sub(funded); - const amount = parseFloat(value); - let amountError = null; - - if (Number.isNaN(amount)) { - // They're entering some garbage, they’ll work it out - } else { - const remainingTargetNum = parseFloat(fromZat(remainingTarget)); - amountError = getAmountError(amount, remainingTargetNum); - } - - this.setState({ amountToRaise: value, amountError }); - }; - - private handleChangePrivate = (ev: RadioChangeEvent) => { - const isPrivate = ev.target.value === 'isPrivate'; - this.setState({ isPrivate }); - }; - - private openContributionModal = () => this.setState({ isContributing: true }); - private closeContributionModal = () => this.setState({ isContributing: false }); } function mapStateToProps(state: AppState) { diff --git a/frontend/client/components/RFP/index.tsx b/frontend/client/components/RFP/index.tsx index fd81c57d..245baffa 100644 --- a/frontend/client/components/RFP/index.tsx +++ b/frontend/client/components/RFP/index.tsx @@ -72,7 +72,7 @@ class RFPDetail extends React.Component { tags.push( Closed - + , ); } diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index 48e3cdf6..b3c7db7b 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -248,6 +248,7 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta arbiter: { status: PROPOSAL_ARBITER_STATUS.ACCEPTED, }, + acceptedWithFunding: false, isVersionTwo: true, milestones: draft.milestones.map((m, idx) => ({ id: idx, diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index 9d49c9f0..17c0447a 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -173,7 +173,8 @@ export function generateProposal({ socialMedias: [], }, }, - isVersionTwo: true, + acceptedWithFunding: null, + isVersionTwo: false, team: [ { userid: 123, diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts index 49464d05..e491fa7a 100644 --- a/frontend/types/proposal.ts +++ b/frontend/types/proposal.ts @@ -62,6 +62,7 @@ export interface Proposal extends Omit { datePublished: number | null; dateApproved: number | null; arbiter: ProposalProposalArbiter; + acceptedWithFunding: boolean | null; isVersionTwo: boolean; isTeamMember?: boolean; // FE derived isArbiter?: boolean; // FE derived From 58eb8f245546c32e5d8861dce9dce65220a7e202 Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Thu, 17 Oct 2019 18:25:12 -0400 Subject: [PATCH 06/54] Make Accepted Proposals Live (#34) * make accepted proposals live * update tests --- backend/grant/proposal/models.py | 4 ++-- backend/tests/admin/test_admin_api.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index ab8eaf6e..a95c1d15 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -494,7 +494,7 @@ class Proposal(db.Model): db.session.add(self) db.session.flush() - # state: status PENDING -> (APPROVED || REJECTED) + # state: status PENDING -> (LIVE || REJECTED) def approve_pending(self, is_approve, with_funding, reject_reason=None): self.validate_publishable() # specific validation @@ -502,7 +502,7 @@ class Proposal(db.Model): raise ValidationException(f"Proposal must be pending to approve or reject") if is_approve: - self.status = ProposalStatus.APPROVED + self.status = ProposalStatus.LIVE self.date_approved = datetime.datetime.now() self.accepted_with_funding = with_funding with_or_out = 'without' diff --git a/backend/tests/admin/test_admin_api.py b/backend/tests/admin/test_admin_api.py index 718e7b28..c896b312 100644 --- a/backend/tests/admin/test_admin_api.py +++ b/backend/tests/admin/test_admin_api.py @@ -256,7 +256,7 @@ class TestAdminAPI(BaseProposalCreatorConfig): ) print(resp.json) self.assert200(resp) - self.assertEqual(resp.json["status"], ProposalStatus.APPROVED) + self.assertEqual(resp.json["status"], ProposalStatus.LIVE) self.assertEqual(resp.json["acceptedWithFunding"], True) self.assertEqual(resp.json["target"], resp.json["contributionBounty"]) @@ -274,7 +274,7 @@ class TestAdminAPI(BaseProposalCreatorConfig): ) print(resp.json) self.assert200(resp) - self.assertEqual(resp.json["status"], ProposalStatus.APPROVED) + self.assertEqual(resp.json["status"], ProposalStatus.LIVE) self.assertEqual(resp.json["acceptedWithFunding"], False) self.assertEqual(resp.json["contributionBounty"], "0") From 5799ffab1912e1ff941c29b0d61d9beea8139c50 Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Wed, 23 Oct 2019 17:34:10 -0400 Subject: [PATCH 07/54] Proposal Subscription (#31) * init proposal subscribe be and fe * add subscription email templates * wire up subscription emails * email subscribers on proposal milestone, update, cancel * disallow subscriptions if email not verified * update spelling, titles * disallow proposal subscribe if user is team member * hide subscribe if not signed in, is team member, canceled * port follow from grant-base * remove subscribed * convert subscribed to follower * backend - update tests * frontend - fix typings * finish follower port * update comment * fix email button display issues * remove loading on AuthButton to prevent two spinners --- admin/src/components/Emails/emails.ts | 11 +++ backend/grant/admin/example_emails.py | 9 +++ backend/grant/admin/views.py | 7 ++ backend/grant/email/send.py | 25 +++++- backend/grant/email/subscription_settings.py | 4 + backend/grant/proposal/models.py | 62 +++++++++++++-- backend/grant/proposal/views.py | 21 +++++ .../emails/followed_proposal_milestone.html | 31 ++++++++ .../emails/followed_proposal_milestone.txt | 3 + .../emails/followed_proposal_update.html | 29 +++++++ .../emails/followed_proposal_update.txt | 3 + backend/grant/user/models.py | 3 + backend/tests/proposal/test_api.py | 48 ++++++++++++ frontend/client/api/api.ts | 4 + frontend/client/components/AuthButton.tsx | 67 ++++++++++++++++ frontend/client/components/Follow/index.less | 24 ++++++ frontend/client/components/Follow/index.tsx | 76 +++++++++++++++++++ .../client/components/Proposal/index.less | 50 ++++++++---- frontend/client/components/Proposal/index.tsx | 40 +++++----- frontend/client/modules/create/utils.ts | 2 + frontend/stories/props.tsx | 2 + frontend/types/proposal.ts | 2 + 22 files changed, 484 insertions(+), 39 deletions(-) create mode 100644 backend/grant/templates/emails/followed_proposal_milestone.html create mode 100644 backend/grant/templates/emails/followed_proposal_milestone.txt create mode 100644 backend/grant/templates/emails/followed_proposal_update.html create mode 100644 backend/grant/templates/emails/followed_proposal_update.txt create mode 100644 frontend/client/components/AuthButton.tsx create mode 100644 frontend/client/components/Follow/index.less create mode 100644 frontend/client/components/Follow/index.tsx diff --git a/admin/src/components/Emails/emails.ts b/admin/src/components/Emails/emails.ts index 7312117e..f7df432b 100644 --- a/admin/src/components/Emails/emails.ts +++ b/admin/src/components/Emails/emails.ts @@ -145,4 +145,15 @@ export default [ title: 'Admin Payout', description: 'Sent when milestone payout has been approved', }, + { + id: 'followed_proposal_milestone', + title: 'Followed Proposal Milestone', + description: + 'Sent to followers of a proposal when one of its milestones has been approved', + }, + { + id: 'followed_proposal_update', + title: 'Followed Proposal Update', + description: 'Sent to followers of a proposal when it has a new update', + }, ] as Email[]; diff --git a/backend/grant/admin/example_emails.py b/backend/grant/admin/example_emails.py index b1455aff..a357c534 100644 --- a/backend/grant/admin/example_emails.py +++ b/backend/grant/admin/example_emails.py @@ -178,4 +178,13 @@ example_email_args = { 'proposal': proposal, 'proposal_url': 'https://grants-admin.zfnd.org/proposals/999', }, + 'followed_proposal_milestone': { + "proposal": proposal, + "milestone": milestone, + "proposal_url": "http://someproposal.com", + }, + 'followed_proposal_update': { + "proposal": proposal, + "proposal_url": "http://someproposal.com", + }, } diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index dc59dbe6..7642d9a9 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -415,6 +415,13 @@ def paid_milestone_payout_request(id, mid, tx_id): 'tx_explorer_url': make_explore_url(tx_id), 'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'), }) + + # email FOLLOWERS that milestone was accepted + proposal.send_follower_email( + "followed_proposal_milestone", + email_args={"milestone": ms}, + url_suffix="?tab=milestones", + ) return proposal_schema.dump(proposal), 200 return {"message": "No milestone matching id"}, 404 diff --git a/backend/grant/email/send.py b/backend/grant/email/send.py index bc89282b..cdf9830e 100644 --- a/backend/grant/email/send.py +++ b/backend/grant/email/send.py @@ -307,6 +307,27 @@ def admin_payout(email_args): } +def followed_proposal_milestone(email_args): + p = email_args["proposal"] + ms = email_args["milestone"] + return { + "subject": f"Milestone accepted for {p.title}", + "title": f"Milestone Accepted", + "preview": f"Followed proposal {p.title} has passed a milestone", + "subscription": EmailSubscription.FOLLOWED_PROPOSAL, + } + + +def followed_proposal_update(email_args): + p = email_args["proposal"] + return { + "subject": f"Proposal update for {p.title}", + "title": f"Proposal Update", + "preview": f"Followed proposal {p.title} has an update", + "subscription": EmailSubscription.FOLLOWED_PROPOSAL, + } + + get_info_lookup = { 'signup': signup_info, 'team_invite': team_invite_info, @@ -335,7 +356,9 @@ get_info_lookup = { 'milestone_paid': milestone_paid, 'admin_approval': admin_approval, 'admin_arbiter': admin_arbiter, - 'admin_payout': admin_payout + 'admin_payout': admin_payout, + 'followed_proposal_milestone': followed_proposal_milestone, + 'followed_proposal_update': followed_proposal_update } diff --git a/backend/grant/email/subscription_settings.py b/backend/grant/email/subscription_settings.py index 3a8f5482..a1b2f90a 100644 --- a/backend/grant/email/subscription_settings.py +++ b/backend/grant/email/subscription_settings.py @@ -65,6 +65,10 @@ class EmailSubscription(Enum): 'bit': 14, 'key': 'admin_payout' } + FOLLOWED_PROPOSAL = { + 'bit': 15, + 'key': 'followed_proposal' + } def is_email_sub_key(k: str): diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index a95c1d15..fd90c0f4 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -2,12 +2,11 @@ import datetime from decimal import Decimal, ROUND_DOWN from functools import reduce -from flask import current_app from marshmallow import post_dump -from sqlalchemy import func, or_ +from sqlalchemy import func, or_, select from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import column_property -from flask import current_app from grant.comment.models import Comment from grant.email.send import send_email from grant.extensions import ma, db @@ -32,6 +31,12 @@ proposal_team = db.Table( db.Column('proposal_id', db.Integer, db.ForeignKey('proposal.id')) ) +proposal_follower = db.Table( + "proposal_follower", + db.Model.metadata, + db.Column("user_id", db.Integer, db.ForeignKey("user.id")), + db.Column("proposal_id", db.Integer, db.ForeignKey("proposal.id")), +) class ProposalTeamInvite(db.Model): __tablename__ = "proposal_team_invite" @@ -250,6 +255,14 @@ class Proposal(db.Model): order_by="asc(Milestone.index)", lazy=True, cascade="all, delete-orphan") invites = db.relationship(ProposalTeamInvite, backref="proposal", lazy=True, cascade="all, delete-orphan") arbiter = db.relationship(ProposalArbiter, uselist=False, back_populates="proposal", cascade="all, delete-orphan") + followers = db.relationship( + "User", secondary=proposal_follower, back_populates="followed_proposals" + ) + followers_count = column_property( + select([func.count(proposal_follower.c.proposal_id)]) + .where(proposal_follower.c.proposal_id == id) + .correlate_except(proposal_follower) + ) def __init__( self, @@ -572,6 +585,26 @@ class Proposal(db.Model): 'account_settings_url': make_url('/profile/settings?tab=account') }) + def follow(self, user, is_follow): + if is_follow: + self.followers.append(user) + else: + self.followers.remove(user) + db.session.flush() + + def send_follower_email(self, type: str, email_args={}, url_suffix=""): + for u in self.followers: + send_email( + u.email_address, + type, + { + "user": u, + "proposal": self, + "proposal_url": make_url(f"/proposals/{self.id}{url_suffix}"), + **email_args, + }, + ) + @hybrid_property def contributed(self): contributions = ProposalContribution.query \ @@ -639,6 +672,22 @@ class Proposal(db.Model): d = {c.user.id: c.user for c in self.contributions if c.user and c.status == ContributionStatus.CONFIRMED} return d.values() + @hybrid_property + def authed_follows(self): + from grant.utils.auth import get_authed_user + + authed = get_authed_user() + if not authed: + return False + res = ( + db.session.query(proposal_follower) + .filter_by(user_id=authed.id, proposal_id=self.id) + .count() + ) + if res: + return True + return False + class ProposalSchema(ma.Schema): class Meta: @@ -674,7 +723,9 @@ class ProposalSchema(ma.Schema): "rfp_opt_in", "arbiter", "accepted_with_funding", - "is_version_two" + "is_version_two", + "authed_follows", + "followers_count" ) date_created = ma.Method("get_date_created") @@ -722,7 +773,8 @@ user_fields = [ "date_published", "reject_reason", "team", - "is_version_two" + "is_version_two", + "authed_follows" ] user_proposal_schema = ProposalSchema(only=user_fields) user_proposals_schema = ProposalSchema(many=True, only=user_fields) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 3aedf526..dd7a051e 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -371,6 +371,11 @@ def post_proposal_update(proposal_id, title, content): 'update_url': make_url(f'/proposals/{proposal_id}?tab=updates&update={update.id}'), }) + # Send email to all followers + g.current_proposal.send_follower_email( + "followed_proposal_update", url_suffix="?tab=updates" + ) + dumped_update = proposal_update_schema.dump(update) return dumped_update, 201 @@ -663,3 +668,19 @@ def reject_milestone_payout_request(proposal_id, milestone_id, reason): return proposal_schema.dump(g.current_proposal), 200 return {"message": "No milestone matching id"}, 404 + + +@blueprint.route("//follow", methods=["PUT"]) +@requires_auth +@body({"isFollow": fields.Bool(required=True)}) +def follow_proposal(proposal_id, is_follow): + user = g.current_user + # Make sure proposal exists + proposal = Proposal.query.filter_by(id=proposal_id).first() + if not proposal: + return {"message": "No proposal matching id"}, 404 + + proposal.follow(user, is_follow) + db.session.commit() + return {"message": "ok"}, 200 + diff --git a/backend/grant/templates/emails/followed_proposal_milestone.html b/backend/grant/templates/emails/followed_proposal_milestone.html new file mode 100644 index 00000000..8e0f4d3e --- /dev/null +++ b/backend/grant/templates/emails/followed_proposal_milestone.html @@ -0,0 +1,31 @@ +

+ Your followed proposal {{ args.proposal.title }} has had its + {{ args.milestone.title }} + milestone accepted! +

+ + + + + +
+ + + + +
+ + Check it out + +
+
diff --git a/backend/grant/templates/emails/followed_proposal_milestone.txt b/backend/grant/templates/emails/followed_proposal_milestone.txt new file mode 100644 index 00000000..56d13cb4 --- /dev/null +++ b/backend/grant/templates/emails/followed_proposal_milestone.txt @@ -0,0 +1,3 @@ +Your followed proposal {{ args.proposal.title }} has had its {{ args.milestone.title }} milestone accepted! + +Check it out: {{ args.proposal_url }} \ No newline at end of file diff --git a/backend/grant/templates/emails/followed_proposal_update.html b/backend/grant/templates/emails/followed_proposal_update.html new file mode 100644 index 00000000..f5d3f280 --- /dev/null +++ b/backend/grant/templates/emails/followed_proposal_update.html @@ -0,0 +1,29 @@ +

+ Your followed proposal {{ args.proposal.title }} has an update! +

+ + + + + +
+ + + + +
+ + Check it out + +
+
diff --git a/backend/grant/templates/emails/followed_proposal_update.txt b/backend/grant/templates/emails/followed_proposal_update.txt new file mode 100644 index 00000000..df11b955 --- /dev/null +++ b/backend/grant/templates/emails/followed_proposal_update.txt @@ -0,0 +1,3 @@ +Your followed proposal {{ args.proposal.title }} has an update! + +Check it out: {{ args.proposal_url }} \ No newline at end of file diff --git a/backend/grant/user/models.py b/backend/grant/user/models.py index de479a17..a2831a49 100644 --- a/backend/grant/user/models.py +++ b/backend/grant/user/models.py @@ -133,6 +133,9 @@ class User(db.Model, UserMixin): roles = db.relationship('Role', secondary='roles_users', backref=db.backref('users', lazy='dynamic')) arbiter_proposals = db.relationship("ProposalArbiter", lazy=True, back_populates="user") + followed_proposals = db.relationship( + "Proposal", secondary="proposal_follower", back_populates="followers" + ) def __init__( self, diff --git a/backend/tests/proposal/test_api.py b/backend/tests/proposal/test_api.py index 6f5b05a0..a645d9e9 100644 --- a/backend/tests/proposal/test_api.py +++ b/backend/tests/proposal/test_api.py @@ -234,3 +234,51 @@ class TestProposalAPI(BaseProposalCreatorConfig): 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( + f"/api/v1/proposals/{self.proposal.id}/follow", + data=json.dumps({"isFollow": True}), + content_type="application/json", + ) + self.assert401(resp) + + # logged in + self.login_default_user() + self.proposal.status = ProposalStatus.LIVE + + resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}") + self.assert200(resp) + self.assertEqual(resp.json["authedFollows"], False) + + # follow + resp = self.app.put( + f"/api/v1/proposals/{self.proposal.id}/follow", + data=json.dumps({"isFollow": True}), + content_type="application/json", + ) + self.assert200(resp) + + resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}") + self.assert200(resp) + self.assertEqual(resp.json["authedFollows"], True) + + self.assertEqual(self.proposal.followers[0].id, self.user.id) + self.assertEqual(self.user.followed_proposals[0].id, self.proposal.id) + + # un-follow + resp = self.app.put( + f"/api/v1/proposals/{self.proposal.id}/follow", + data=json.dumps({"isFollow": False}), + content_type="application/json", + ) + self.assert200(resp) + + resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}") + self.assert200(resp) + self.assertEqual(resp.json["authedFollows"], False) + + self.assertEqual(len(self.proposal.followers), 0) + self.assertEqual(len(self.user.followed_proposals), 0) \ No newline at end of file diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index 5f150056..b37d9809 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -42,6 +42,10 @@ export function getProposal(proposalId: number | string): Promise<{ data: Propos }); } +export function followProposal(proposalId: number, isFollow: boolean) { + return axios.put(`/api/v1/proposals/${proposalId}/follow`, { isFollow }); +} + export function getProposalComments(proposalId: number | string, params: PageParams) { return axios.get(`/api/v1/proposals/${proposalId}/comments`, { params }); } diff --git a/frontend/client/components/AuthButton.tsx b/frontend/client/components/AuthButton.tsx new file mode 100644 index 00000000..1cdcbb95 --- /dev/null +++ b/frontend/client/components/AuthButton.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Redirect, RouteProps } from 'react-router'; +import { Button } from 'antd'; +import { AppState } from 'store/reducers'; +import { authActions } from 'modules/auth'; +import { NativeButtonProps } from 'antd/lib/button/button'; +import { withRouter } from 'react-router-dom'; +import { compose } from 'recompose'; + +type OwnProps = NativeButtonProps; + +interface StateProps { + user: AppState['auth']['user']; +} + +interface DispatchProps { + setAuthForwardLocation: typeof authActions['setAuthForwardLocation']; +} + +type Props = OwnProps & RouteProps & StateProps & DispatchProps; + +const STATE = { + sendToAuth: false, +}; +type State = typeof STATE; + +class AuthButton extends React.Component { + state: State = { ...STATE }; + public render() { + const { location, children, loading } = this.props; + if (this.state.sendToAuth) { + return ; + } + return ( + + ); + } + private handleClick = (e: React.MouseEvent) => { + if (!this.props.onClick) { + return; + } + if (this.props.user) { + this.props.onClick(e); + } else { + const { location, setAuthForwardLocation } = this.props; + setAuthForwardLocation(location); + setTimeout(() => { + this.setState({ sendToAuth: true }); + }, 200); + } + }; +} + +const withConnect = connect( + (state: AppState) => ({ + user: state.auth.user, + }), + { setAuthForwardLocation: authActions.setAuthForwardLocation }, +); + +export default compose( + withRouter, + withConnect, +)(AuthButton); diff --git a/frontend/client/components/Follow/index.less b/frontend/client/components/Follow/index.less new file mode 100644 index 00000000..c4b8aa2b --- /dev/null +++ b/frontend/client/components/Follow/index.less @@ -0,0 +1,24 @@ +@import '~styles/variables.less'; + +@collapse-width: 800px; + +.Follow { + white-space: nowrap; + + .ant-btn:focus, + .ant-btn:active { + border-color: inherit; + outline-color: inherit; + color: inherit; + } + + &-label { + @media (max-width: @collapse-width) { + display: none !important; + } + } + + &-count { + color: @text-color !important; + } +} diff --git a/frontend/client/components/Follow/index.tsx b/frontend/client/components/Follow/index.tsx new file mode 100644 index 00000000..ed0db2a6 --- /dev/null +++ b/frontend/client/components/Follow/index.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Icon, Button, Input, message } from 'antd'; +import { AppState } from 'store/reducers'; +import { proposalActions } from 'modules/proposals'; +import { ProposalDetail } from 'modules/proposals/reducers'; +import { followProposal } from 'api/api'; +import AuthButton from 'components/AuthButton'; +import './index.less'; + +interface OwnProps { + proposal: ProposalDetail; +} + +interface StateProps { + authUser: AppState['auth']['user']; +} + +interface DispatchProps { + fetchProposal: typeof proposalActions['fetchProposal']; +} + +type Props = OwnProps & StateProps & DispatchProps; + +const STATE = { + loading: false, +}; +type State = typeof STATE; + +class Follow extends React.Component { + state: State = { ...STATE }; + render() { + const { authedFollows, followersCount } = this.props.proposal; + const { loading } = this.state; + return ( + + + + {authedFollows ? ' Unfollow' : ' Follow'} + + + + ); + } + + private handleFollow = async () => { + const { proposalId, authedFollows } = this.props.proposal; + this.setState({ loading: true }); + try { + await followProposal(proposalId, !authedFollows); + await this.props.fetchProposal(proposalId); + message.success(<>Proposal {authedFollows ? 'unfollowed' : 'followed'}); + } catch (error) { + // tslint:disable:no-console + console.error('Follow.handleFollow - unable to change follow state', error); + message.error('Unable to follow proposal'); + } + this.setState({ loading: false }); + }; +} + +const withConnect = connect( + state => ({ + authUser: state.auth.user, + }), + { + fetchProposal: proposalActions.fetchProposal, + }, +); + +export default withConnect(Follow); diff --git a/frontend/client/components/Proposal/index.less b/frontend/client/components/Proposal/index.less index c533ec65..a81616b8 100644 --- a/frontend/client/components/Proposal/index.less +++ b/frontend/client/components/Proposal/index.less @@ -65,23 +65,43 @@ } &-title { - font-size: 2rem; - line-height: 3rem; - margin-bottom: 0.75rem; - margin-left: 0.5rem; - - @media (min-width: @collapse-width) { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - - @media (max-width: @collapse-width) { - font-size: 1.8rem; - } + display: flex; @media (max-width: @single-col-width) { - font-size: 1.6rem; + flex-direction: column-reverse; + margin-top: -1rem; + } + + h1 { + font-size: 2rem; + line-height: 3rem; + margin-bottom: 0.75rem; + margin-left: 0.5rem; + flex-grow: 1; + + @media (min-width: @collapse-width) { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + @media (max-width: @collapse-width) { + font-size: 1.8rem; + } + + @media (max-width: @single-col-width) { + font-size: 1.6rem; + } + } + + &-menu { + display: flex; + align-items: center; + height: 3rem; + + & > * + * { + margin-left: 0.5rem; + } } } diff --git a/frontend/client/components/Proposal/index.tsx b/frontend/client/components/Proposal/index.tsx index 9c3e9927..7e91a3ae 100644 --- a/frontend/client/components/Proposal/index.tsx +++ b/frontend/client/components/Proposal/index.tsx @@ -25,6 +25,7 @@ import CancelModal from './CancelModal'; import classnames from 'classnames'; import { withRouter } from 'react-router'; import SocialShare from 'components/SocialShare'; +import Follow from 'components/Follow'; import './index.less'; interface OwnProps { @@ -184,9 +185,27 @@ export class ProposalDetail extends React.Component { )}
-

- {proposal ? proposal.title :  } -

+
+

{proposal ? proposal.title :  }

+ {isLive && ( +
+ {isTrustee && ( + + + + )} + +
+ )} +
+
(this.bodyEl = el)} @@ -206,21 +225,6 @@ export class ProposalDetail extends React.Component { )}
- {isLive && - isTrustee && ( -
- - - -
- )}
diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index b3c7db7b..001617c2 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -249,6 +249,8 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta status: PROPOSAL_ARBITER_STATUS.ACCEPTED, }, acceptedWithFunding: false, + authedFollows: false, + followersCount: 0, isVersionTwo: true, milestones: draft.milestones.map((m, idx) => ({ id: idx, diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index 17c0447a..db29422d 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -162,6 +162,8 @@ export function generateProposal({ stage: PROPOSAL_STAGE.WIP, category: PROPOSAL_CATEGORY.COMMUNITY, isStaked: true, + authedFollows: false, + followersCount: 0, arbiter: { status: PROPOSAL_ARBITER_STATUS.ACCEPTED, user: { diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts index e491fa7a..b19dcc41 100644 --- a/frontend/types/proposal.ts +++ b/frontend/types/proposal.ts @@ -64,6 +64,8 @@ export interface Proposal extends Omit { arbiter: ProposalProposalArbiter; acceptedWithFunding: boolean | null; isVersionTwo: boolean; + authedFollows: boolean; + followersCount: number; isTeamMember?: boolean; // FE derived isArbiter?: boolean; // FE derived } From 85c21d4cbf5d3476e900bc244f7569971cab0c0d Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Wed, 23 Oct 2019 17:34:31 -0400 Subject: [PATCH 08/54] apply style directly to buttons (#39) --- admin/src/components/ProposalDetail/index.less | 10 +++++----- admin/src/components/ProposalDetail/index.tsx | 3 +++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/admin/src/components/ProposalDetail/index.less b/admin/src/components/ProposalDetail/index.less index 17b9009e..63ec023e 100644 --- a/admin/src/components/ProposalDetail/index.less +++ b/admin/src/components/ProposalDetail/index.less @@ -26,11 +26,6 @@ .ant-alert, .ant-collapse { margin-bottom: 16px; - - button { - margin-right: 0.5rem; - margin-bottom: 0.25rem; - } } &-popover { @@ -47,4 +42,9 @@ white-space: inherit; } } + + &-review { + margin-right: 0.5rem; + margin-bottom: 0.25rem; + } } diff --git a/admin/src/components/ProposalDetail/index.tsx b/admin/src/components/ProposalDetail/index.tsx index 747144bd..8db30ea3 100644 --- a/admin/src/components/ProposalDetail/index.tsx +++ b/admin/src/components/ProposalDetail/index.tsx @@ -144,6 +144,7 @@ class ProposalDetailNaked extends React.Component {

Please review this proposal and render your judgment.

+ + ); + }; + + const renderChangeToAcceptedWithFundingControl = () => { + return ( + + Are you sure you want to accept the proposal +
+ with funding? This cannot be undone. +

+ } + placement="left" + cancelText="cancel" + okText="confirm" + visible={this.state.showChangeToAcceptedWithFundingPopover} + okButtonProps={{ + loading: store.proposalDetailCanceling, + }} + onCancel={this.handleChangeToAcceptWithFundingCancel} + onConfirm={this.handleChangeToAcceptWithFundingConfirm} + > +
); @@ -120,7 +155,6 @@ class ProposalDetailNaked extends React.Component { /> ); - const renderApproved = () => p.status === PROPOSAL_STATUS.APPROVED && ( { ); const renderNominateArbiter = () => - needsArbiter && shouldShowArbiter && ( + needsArbiter && + shouldShowArbiter && ( { {renderCancelControl()} {renderArbiterControl()} + {shouldShowChangeToAcceptedWithFunding && + renderChangeToAcceptedWithFundingControl()} {/* DETAILS */} @@ -405,7 +442,10 @@ class ProposalDetailNaked extends React.Component { {renderDeetItem('matching', p.contributionMatching)} {renderDeetItem('bounty', p.contributionBounty)} {renderDeetItem('rfpOptIn', JSON.stringify(p.rfpOptIn))} - {renderDeetItem('acceptedWithFunding', JSON.stringify(p.acceptedWithFunding))} + {renderDeetItem( + 'acceptedWithFunding', + JSON.stringify(p.acceptedWithFunding), + )} {renderDeetItem( 'arbiter', <> @@ -460,6 +500,20 @@ class ProposalDetailNaked extends React.Component { } }; + private handleChangeToAcceptedWithFunding = () => { + this.setState({ showChangeToAcceptedWithFundingPopover: true }); + }; + + private handleChangeToAcceptWithFundingCancel = () => { + this.setState({ showChangeToAcceptedWithFundingPopover: false }); + }; + + private handleChangeToAcceptWithFundingConfirm = () => { + if (!store.proposalDetail) return; + store.changeProposalToAcceptedWithFunding(store.proposalDetail.proposalId); + this.setState({ showChangeToAcceptedWithFundingPopover: false }); + }; + private getIdFromQuery = () => { return Number(this.props.match.params.id); }; diff --git a/admin/src/store.ts b/admin/src/store.ts index bfefb2db..e6607ad6 100644 --- a/admin/src/store.ts +++ b/admin/src/store.ts @@ -148,6 +148,11 @@ async function cancelProposal(id: number) { return data; } +async function changeProposalToAcceptedWithFunding(id: number) { + const { data } = await api.put(`/admin/proposals/${id}/accept/fund`) + return data +} + async function fetchComments(params: Partial) { const { data } = await api.get('/admin/comments', { params }); return data; @@ -288,6 +293,7 @@ const app = store({ proposalDetailCanceling: false, proposalDetailUpdating: false, proposalDetailUpdated: false, + proposalDetailChangingToAcceptedWithFunding: false, comments: { page: createDefaultPageData('CREATED:DESC'), @@ -571,6 +577,19 @@ const app = store({ app.proposalDetailCanceling = false; }, + async changeProposalToAcceptedWithFunding(id: number) { + app.proposalDetailChangingToAcceptedWithFunding = true + + try { + const res = await changeProposalToAcceptedWithFunding(id) + app.updateProposalInStore(res) + } catch (e) { + handleApiError(e) + } + + app.proposalDetailChangingToAcceptedWithFunding = false + }, + async markMilestonePaid(proposalId: number, milestoneId: number, txId: string) { app.proposalDetailMarkingMilestonePaid = true; try { diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index 7642d9a9..77112a91 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -369,6 +369,26 @@ def approve_proposal(id, is_accepted, with_funding, reject_reason=None): return {"message": "No proposal found."}, 404 +@blueprint.route('/proposals//accept/fund', methods=['PUT']) +@admin.admin_auth_required +def change_proposal_to_accepted_with_funding(id): + proposal = Proposal.query.filter_by(id=id).first() + if not proposal: + return {"message": "No proposal found."}, 404 + if proposal.accepted_with_funding: + return {"message": "Proposal already accepted with funding."}, 404 + if proposal.version != '2': + return {"message": "Only version two proposals can be accepted with funding"}, 404 + if proposal.status != ProposalStatus.LIVE and proposal.status != ProposalStatus.APPROVED: + return {"message": "Only live or approved proposals can be modified by this endpoint"}, 404 + + proposal.update_proposal_with_funding() + db.session.add(proposal) + db.session.commit() + + return proposal_schema.dump(proposal) + + @blueprint.route('/proposals//cancel', methods=['PUT']) @admin.admin_auth_required def cancel_proposal(id): diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index fd90c0f4..a81a692d 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -542,6 +542,10 @@ class Proposal(db.Model): 'admin_note': reject_reason }) + def update_proposal_with_funding(self): + self.accepted_with_funding = True + self.fully_fund_contibution_bounty() + # state: status APPROVE -> LIVE, stage PREVIEW -> FUNDING_REQUIRED def publish(self): self.validate_publishable() diff --git a/backend/tests/admin/test_admin_api.py b/backend/tests/admin/test_admin_api.py index c896b312..f0b5c9f8 100644 --- a/backend/tests/admin/test_admin_api.py +++ b/backend/tests/admin/test_admin_api.py @@ -278,6 +278,55 @@ class TestAdminAPI(BaseProposalCreatorConfig): self.assertEqual(resp.json["acceptedWithFunding"], False) self.assertEqual(resp.json["contributionBounty"], "0") + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_change_proposal_to_accepted_with_funding(self, mock_get): + self.login_admin() + + # proposal needs to be PENDING + self.proposal.status = ProposalStatus.PENDING + + # accept without funding + resp = self.app.put( + "/api/v1/admin/proposals/{}/accept".format(self.proposal.id), + data=json.dumps({"isAccepted": True, "withFunding": False}) + ) + self.assert200(resp) + self.assertEqual(resp.json["acceptedWithFunding"], False) + + # change to accepted with funding + resp = self.app.put( + f"/api/v1/admin/proposals/{self.proposal.id}/accept/fund" + ) + self.assert200(resp) + self.assertEqual(resp.json["acceptedWithFunding"], True) + + # should fail if proposal is already accepted with funding + resp = self.app.put( + f"/api/v1/admin/proposals/{self.proposal.id}/accept/fund" + ) + self.assert404(resp) + self.assertEqual(resp.json['message'], "Proposal already accepted with funding.") + self.proposal.accepted_with_funding = False + + # should fail if proposal is not version two + self.proposal.version = '' + resp = self.app.put( + f"/api/v1/admin/proposals/{self.proposal.id}/accept/fund" + ) + self.assert404(resp) + self.assertEqual(resp.json['message'], "Only version two proposals can be accepted with funding") + self.proposal.version = '2' + + # should failed if proposal is not LIVE or APPROVED + self.proposal.status = ProposalStatus.PENDING + self.proposal.accepted_with_funding = False + resp = self.app.put( + f"/api/v1/admin/proposals/{self.proposal.id}/accept/fund" + ) + self.assert404(resp) + self.assertEqual(resp.json["message"], 'Only live or approved proposals can be modified by this endpoint') + + @patch('requests.get', side_effect=mock_blockchain_api_requests) def test_reject_proposal(self, mock_get): From 9f485fabc4a762cd9425f5b8e7525b8f43330af0 Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Wed, 23 Oct 2019 17:46:25 -0400 Subject: [PATCH 10/54] disable milestones for proposals accepted without funding (#40) --- .../components/Proposal/Milestones/index.tsx | 16 ++++++++++++++-- frontend/client/components/Proposal/index.tsx | 6 +++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/frontend/client/components/Proposal/Milestones/index.tsx b/frontend/client/components/Proposal/Milestones/index.tsx index eceba29c..c6daddb4 100644 --- a/frontend/client/components/Proposal/Milestones/index.tsx +++ b/frontend/client/components/Proposal/Milestones/index.tsx @@ -156,8 +156,15 @@ class ProposalMilestones extends React.Component { if (!proposal) { return ; } - const { milestones, currentMilestone, isRejectingPayout } = proposal; + const { + milestones, + currentMilestone, + isRejectingPayout, + isVersionTwo, + acceptedWithFunding, + } = proposal; const milestoneCount = milestones.length; + const milestonesDisabled = isVersionTwo ? !acceptedWithFunding : false; // arbiter reject modal const rejectModal = ( @@ -220,7 +227,12 @@ class ProposalMilestones extends React.Component { ['do-titles-overflow']: this.state.doTitlesOverflow, })} > - {!!milestoneSteps.length ? ( + {milestonesDisabled ? ( + + ) : !!milestoneSteps.length ? ( <> {milestoneSteps.map(mss => ( diff --git a/frontend/client/components/Proposal/index.tsx b/frontend/client/components/Proposal/index.tsx index 7e91a3ae..161bc908 100644 --- a/frontend/client/components/Proposal/index.tsx +++ b/frontend/client/components/Proposal/index.tsx @@ -102,6 +102,10 @@ export class ProposalDetail extends React.Component { const isTrustee = !!proposal.team.find(tm => tm.userid === (user && user.userid)); const isLive = proposal.status === STATUS.LIVE; + const milestonesDisabled = proposal.isVersionTwo + ? !proposal.acceptedWithFunding + : false; + const defaultTab = milestonesDisabled ? 'discussions' : 'milestones'; const adminMenu = ( @@ -234,7 +238,7 @@ export class ProposalDetail extends React.Component {
- +
From 39f9cea42e629673b28330040838dcef2e1a2f7d Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Thu, 24 Oct 2019 13:14:03 -0400 Subject: [PATCH 11/54] remove progress bar for v2 proposals (#37) --- .../Proposal/CampaignBlock/index.tsx | 75 ++++++++++--------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/frontend/client/components/Proposal/CampaignBlock/index.tsx b/frontend/client/components/Proposal/CampaignBlock/index.tsx index 0088bb6d..e4b41ab7 100644 --- a/frontend/client/components/Proposal/CampaignBlock/index.tsx +++ b/frontend/client/components/Proposal/CampaignBlock/index.tsx @@ -145,44 +145,45 @@ export class ProposalCampaignBlock extends React.Component {
)} - {!isVersionTwo && isFundingOver ? ( -
- {isCancelled ? ( - <> - - Proposal was canceled - - ) : isRaiseGoalReached ? ( - <> - - Proposal has been funded - - ) : ( - <> - - Proposal didn’t get funded - - )} -
- ) : ( - <> -
-
+ {!isVersionTwo && + (isFundingOver ? ( +
+ {isCancelled ? ( + <> + + Proposal was canceled + + ) : isRaiseGoalReached ? ( + <> + + Proposal has been funded + + ) : ( + <> + + Proposal didn’t get funded + + )}
+ ) : ( + <> +
+
+
- {/* TODO: use this as a base for tipjar? */} + {/* TODO: use this as a base for tipjar? */} - {/*
+ {/* { Fund this project */} - - )} + + ))} {isVersionTwo && isJudged && ( From 5f049d899ba33f20a479b5a7585f5d745d2338c3 Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Thu, 24 Oct 2019 13:32:00 -0400 Subject: [PATCH 12/54] Add Signalling of Support (#41) * init proposal subscribe be and fe * add subscription email templates * wire up subscription emails * email subscribers on proposal milestone, update, cancel * disallow subscriptions if email not verified * update spelling, titles * disallow proposal subscribe if user is team member * hide subscribe if not signed in, is team member, canceled * port follow from grant-base * remove subscribed * convert subscribed to follower * backend - update tests * frontend - fix typings * finish follower port * update comment * fix email button display issues * init liking backend * init liking frontend * fix lint * add liking backend tests * refactor like component --- backend/grant/comment/models.py | 44 ++++- backend/grant/comment/views.py | 24 ++- backend/grant/proposal/models.py | 48 ++++- backend/grant/proposal/views.py | 18 ++ backend/grant/rfp/models.py | 44 +++++ backend/grant/rfp/views.py | 24 ++- backend/grant/user/models.py | 10 + backend/grant/utils/auth.py | 10 +- backend/tests/proposal/test_api.py | 56 +++++- backend/tests/proposal/test_comment_api.py | 58 +++++- backend/tests/rfp/__init__.py | 0 backend/tests/rfp/test_rfp_api.py | 85 +++++++++ frontend/client/api/api.ts | 14 ++ frontend/client/components/Comment/index.tsx | 27 ++- frontend/client/components/Comment/style.less | 1 + frontend/client/components/Like/index.less | 24 +++ frontend/client/components/Like/index.tsx | 177 ++++++++++++++++++ .../client/components/Proposal/index.less | 5 + frontend/client/components/Proposal/index.tsx | 2 + frontend/client/components/RFP/index.less | 10 +- frontend/client/components/RFP/index.tsx | 2 + frontend/client/modules/create/utils.ts | 2 + frontend/client/modules/proposals/actions.ts | 14 ++ frontend/client/modules/proposals/reducers.ts | 3 + frontend/client/modules/proposals/types.ts | 2 + frontend/stories/props.tsx | 2 + frontend/types/comment.ts | 2 + frontend/types/proposal.ts | 2 + frontend/types/rfp.ts | 2 + 29 files changed, 689 insertions(+), 23 deletions(-) create mode 100644 backend/tests/rfp/__init__.py create mode 100644 backend/tests/rfp/test_rfp_api.py create mode 100644 frontend/client/components/Like/index.less create mode 100644 frontend/client/components/Like/index.tsx diff --git a/backend/grant/comment/models.py b/backend/grant/comment/models.py index 395be41b..55c82e9e 100644 --- a/backend/grant/comment/models.py +++ b/backend/grant/comment/models.py @@ -4,10 +4,19 @@ from functools import reduce from grant.extensions import ma, db from grant.utils.ma_fields import UnixDate from grant.utils.misc import gen_random_id -from sqlalchemy.orm import raiseload +from sqlalchemy.orm import raiseload, column_property +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy import func, select HIDDEN_CONTENT = '~~comment removed by admin~~' +comment_liker = db.Table( + "comment_liker", + db.Model.metadata, + db.Column("user_id", db.Integer, db.ForeignKey("user.id")), + db.Column("comment_id", db.Integer, db.ForeignKey("comment.id")), +) + class Comment(db.Model): __tablename__ = "comment" @@ -25,6 +34,15 @@ class Comment(db.Model): author = db.relationship("User", back_populates="comments") replies = db.relationship("Comment") + likes = db.relationship( + "User", secondary=comment_liker, back_populates="liked_comments" + ) + likes_count = column_property( + select([func.count(comment_liker.c.comment_id)]) + .where(comment_liker.c.comment_id == id) + .correlate_except(comment_liker) + ) + def __init__(self, proposal_id, user_id, parent_comment_id, content): self.id = gen_random_id(Comment) self.proposal_id = proposal_id @@ -49,6 +67,28 @@ class Comment(db.Model): self.hidden = hidden db.session.add(self) + @hybrid_property + def authed_liked(self): + from grant.utils.auth import get_authed_user + + authed = get_authed_user() + if not authed: + return False + res = ( + db.session.query(comment_liker) + .filter_by(user_id=authed.id, comment_id=self.id) + .count() + ) + if res: + return True + return False + + def like(self, user, is_liked): + if is_liked: + self.likes.append(user) + else: + self.likes.remove(user) + db.session.flush() # are all of the replies hidden? def all_hidden(replies): @@ -74,6 +114,8 @@ class CommentSchema(ma.Schema): "replies", "reported", "hidden", + "authed_liked", + "likes_count" ) content = ma.Method("get_content") diff --git a/backend/grant/comment/views.py b/backend/grant/comment/views.py index 59af911b..55aa002e 100644 --- a/backend/grant/comment/views.py +++ b/backend/grant/comment/views.py @@ -1,4 +1,26 @@ -from flask import Blueprint +from flask import Blueprint, g + +from grant.utils.auth import requires_auth +from grant.parser import body +from marshmallow import fields +from .models import Comment, db, comment_schema blueprint = Blueprint("comment", __name__, url_prefix="/api/v1/comment") + +@blueprint.route("//like", methods=["PUT"]) +@requires_auth +@body({"isLiked": fields.Bool(required=True)}) +def like_comment(comment_id, is_liked): + + user = g.current_user + # Make sure comment exists + comment = Comment.query.filter_by(id=comment_id).first() + if not comment: + return {"message": "No comment matching id"}, 404 + + comment.like(user, is_liked) + db.session.commit() + + return comment_schema.dump(comment), 201 + diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index a81a692d..571f6e49 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -38,6 +38,14 @@ proposal_follower = db.Table( db.Column("proposal_id", db.Integer, db.ForeignKey("proposal.id")), ) +proposal_liker = db.Table( + "proposal_liker", + db.Model.metadata, + db.Column("user_id", db.Integer, db.ForeignKey("user.id")), + db.Column("proposal_id", db.Integer, db.ForeignKey("proposal.id")), +) + + class ProposalTeamInvite(db.Model): __tablename__ = "proposal_team_invite" @@ -150,6 +158,8 @@ class ProposalContribution(db.Model): raise ValidationException('Proposal ID is required') # User ID (must belong to an existing user) if user_id: + from grant.user.models import User + user = User.query.filter(User.id == user_id).first() if not user: raise ValidationException('No user matching that ID') @@ -263,6 +273,14 @@ class Proposal(db.Model): .where(proposal_follower.c.proposal_id == id) .correlate_except(proposal_follower) ) + likes = db.relationship( + "User", secondary=proposal_liker, back_populates="liked_proposals" + ) + likes_count = column_property( + select([func.count(proposal_liker.c.proposal_id)]) + .where(proposal_liker.c.proposal_id == id) + .correlate_except(proposal_liker) + ) def __init__( self, @@ -596,6 +614,13 @@ class Proposal(db.Model): self.followers.remove(user) db.session.flush() + def like(self, user, is_liked): + if is_liked: + self.likes.append(user) + else: + self.likes.remove(user) + db.session.flush() + def send_follower_email(self, type: str, email_args={}, url_suffix=""): for u in self.followers: send_email( @@ -692,6 +717,22 @@ class Proposal(db.Model): return True return False + @hybrid_property + def authed_liked(self): + from grant.utils.auth import get_authed_user + + authed = get_authed_user() + if not authed: + return False + res = ( + db.session.query(proposal_liker) + .filter_by(user_id=authed.id, proposal_id=self.id) + .count() + ) + if res: + return True + return False + class ProposalSchema(ma.Schema): class Meta: @@ -729,7 +770,9 @@ class ProposalSchema(ma.Schema): "accepted_with_funding", "is_version_two", "authed_follows", - "followers_count" + "followers_count", + "authed_liked", + "likes_count" ) date_created = ma.Method("get_date_created") @@ -778,7 +821,8 @@ user_fields = [ "reject_reason", "team", "is_version_two", - "authed_follows" + "authed_follows", + "authed_liked" ] user_proposal_schema = ProposalSchema(only=user_fields) user_proposals_schema = ProposalSchema(many=True, only=user_fields) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index dd7a051e..8cf29860 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -684,3 +684,21 @@ def follow_proposal(proposal_id, is_follow): db.session.commit() return {"message": "ok"}, 200 + +@blueprint.route("//like", methods=["PUT"]) +@requires_auth +@body({"isLiked": fields.Bool(required=True)}) +def like_proposal(proposal_id, is_liked): + user = g.current_user + # Make sure proposal exists + proposal = Proposal.query.filter_by(id=proposal_id).first() + if not proposal: + return {"message": "No proposal matching id"}, 404 + + if not proposal.status == ProposalStatus.LIVE: + return {"message": "Cannot like a proposal that's not live"}, 404 + + proposal.like(user, is_liked) + db.session.commit() + return {"message": "ok"}, 200 + diff --git a/backend/grant/rfp/models.py b/backend/grant/rfp/models.py index 2a13b4ae..fbbe35b8 100644 --- a/backend/grant/rfp/models.py +++ b/backend/grant/rfp/models.py @@ -2,10 +2,19 @@ from datetime import datetime from decimal import Decimal from grant.extensions import ma, db from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy import func, select +from sqlalchemy.orm import column_property from grant.utils.enums import RFPStatus from grant.utils.misc import dt_to_unix, gen_random_id from grant.utils.enums import Category +rfp_liker = db.Table( + "rfp_liker", + db.Model.metadata, + db.Column("user_id", db.Integer, db.ForeignKey("user.id")), + db.Column("rfp_id", db.Integer, db.ForeignKey("rfp.id")), +) + class RFP(db.Model): __tablename__ = "rfp" @@ -38,6 +47,16 @@ class RFP(db.Model): cascade="all, delete-orphan", ) + likes = db.relationship( + "User", secondary=rfp_liker, back_populates="liked_rfps" + ) + likes_count = column_property( + select([func.count(rfp_liker.c.rfp_id)]) + .where(rfp_liker.c.rfp_id == id) + .correlate_except(rfp_liker) + ) + + @hybrid_property def bounty(self): return self._bounty @@ -49,6 +68,29 @@ class RFP(db.Model): else: self._bounty = None + @hybrid_property + def authed_liked(self): + from grant.utils.auth import get_authed_user + + authed = get_authed_user() + if not authed: + return False + res = ( + db.session.query(rfp_liker) + .filter_by(user_id=authed.id, rfp_id=self.id) + .count() + ) + if res: + return True + return False + + def like(self, user, is_liked): + if is_liked: + self.likes.append(user) + else: + self.likes.remove(user) + db.session.flush() + def __init__( self, title: str, @@ -92,6 +134,8 @@ class RFPSchema(ma.Schema): "date_opened", "date_closed", "accepted_proposals", + "authed_liked", + "likes_count" ) status = ma.Method("get_status") diff --git a/backend/grant/rfp/views.py b/backend/grant/rfp/views.py index f60da4d8..c8d5d046 100644 --- a/backend/grant/rfp/views.py +++ b/backend/grant/rfp/views.py @@ -1,8 +1,11 @@ -from flask import Blueprint +from flask import Blueprint, g from sqlalchemy import or_ from grant.utils.enums import RFPStatus -from .models import RFP, rfp_schema, rfps_schema +from grant.utils.auth import requires_auth +from grant.parser import body +from .models import RFP, rfp_schema, rfps_schema, db +from marshmallow import fields blueprint = Blueprint("rfp", __name__, url_prefix="/api/v1/rfps") @@ -25,3 +28,20 @@ def get_rfp(rfp_id): if not rfp or rfp.status == RFPStatus.DRAFT: return {"message": "No RFP with that ID"}, 404 return rfp_schema.dump(rfp) + + +@blueprint.route("//like", methods=["PUT"]) +@requires_auth +@body({"isLiked": fields.Bool(required=True)}) +def like_rfp(rfp_id, is_liked): + user = g.current_user + # Make sure rfp exists + rfp = RFP.query.filter_by(id=rfp_id).first() + if not rfp: + return {"message": "No RFP matching id"}, 404 + if not rfp.status == RFPStatus.LIVE: + return {"message": "RFP is not live"}, 404 + + rfp.like(user, is_liked) + db.session.commit() + return {"message": "ok"}, 200 diff --git a/backend/grant/user/models.py b/backend/grant/user/models.py index a2831a49..8eb08994 100644 --- a/backend/grant/user/models.py +++ b/backend/grant/user/models.py @@ -136,6 +136,16 @@ class User(db.Model, UserMixin): followed_proposals = db.relationship( "Proposal", secondary="proposal_follower", back_populates="followers" ) + liked_proposals = db.relationship( + "Proposal", secondary="proposal_liker", back_populates="likes" + ) + liked_comments = db.relationship( + "Comment", secondary="comment_liker", back_populates="likes" + ) + liked_rfps = db.relationship( + "RFP", secondary="rfp_liker", back_populates="likes" + ) + def __init__( self, diff --git a/backend/grant/utils/auth.py b/backend/grant/utils/auth.py index 88fb6985..96252834 100644 --- a/backend/grant/utils/auth.py +++ b/backend/grant/utils/auth.py @@ -5,9 +5,7 @@ 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.proposal.models import Proposal from grant.settings import BLOCKCHAIN_API_SECRET -from grant.user.models import User class AuthException(Exception): @@ -41,6 +39,8 @@ def is_email_verified(): def auth_user(email, password): + from grant.user.models import User + existing_user = User.get_by_email(email) if not existing_user: raise AuthException("No user exists with that email") @@ -85,6 +85,8 @@ def requires_auth(f): def requires_same_user_auth(f): @wraps(f) def decorated(*args, **kwargs): + from grant.user.models import User + user_id = kwargs["user_id"] if not user_id: return jsonify(message="Decorator requires_same_user_auth requires path variable "), 500 @@ -114,6 +116,8 @@ def requires_email_verified_auth(f): def requires_team_member_auth(f): @wraps(f) def decorated(*args, **kwargs): + from grant.proposal.models import Proposal + proposal_id = kwargs["proposal_id"] if not proposal_id: return jsonify(message="Decorator requires_team_member_auth requires path variable "), 500 @@ -134,6 +138,8 @@ def requires_team_member_auth(f): def requires_arbiter_auth(f): @wraps(f) def decorated(*args, **kwargs): + from grant.proposal.models import Proposal + proposal_id = kwargs["proposal_id"] if not proposal_id: return jsonify(message="Decorator requires_arbiter_auth requires path variable "), 500 diff --git a/backend/tests/proposal/test_api.py b/backend/tests/proposal/test_api.py index a645d9e9..8d9b8937 100644 --- a/backend/tests/proposal/test_api.py +++ b/backend/tests/proposal/test_api.py @@ -281,4 +281,58 @@ class TestProposalAPI(BaseProposalCreatorConfig): self.assertEqual(resp.json["authedFollows"], False) self.assertEqual(len(self.proposal.followers), 0) - self.assertEqual(len(self.user.followed_proposals), 0) \ No newline at end of file + self.assertEqual(len(self.user.followed_proposals), 0) + + def test_like_proposal(self): + # not logged in + resp = self.app.put( + f"/api/v1/proposals/{self.proposal.id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert401(resp) + + # logged in + self.login_default_user() + + # proposal not yet live + resp = self.app.put( + f"/api/v1/proposals/{self.proposal.id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert404(resp) + self.assertEquals(resp.json["message"], "Cannot like a proposal that's not live") + + # proposal is live + self.proposal.status = ProposalStatus.LIVE + resp = self.app.put( + f"/api/v1/proposals/{self.proposal.id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert200(resp) + self.assertTrue(self.user in self.proposal.likes) + + resp = self.app.get( + f"/api/v1/proposals/{self.proposal.id}" + ) + self.assert200(resp) + self.assertEqual(resp.json["authedLiked"], True) + self.assertEqual(resp.json["likesCount"], 1) + + # test unliking a proposal + resp = self.app.put( + f"/api/v1/proposals/{self.proposal.id}/like", + data=json.dumps({"isLiked": False}), + content_type="application/json", + ) + self.assert200(resp) + self.assertTrue(self.user not in self.proposal.likes) + + resp = self.app.get( + f"/api/v1/proposals/{self.proposal.id}" + ) + self.assert200(resp) + self.assertEqual(resp.json["authedLiked"], False) + self.assertEqual(resp.json["likesCount"], 0) diff --git a/backend/tests/proposal/test_comment_api.py b/backend/tests/proposal/test_comment_api.py index 9dbdeef1..e73e7933 100644 --- a/backend/tests/proposal/test_comment_api.py +++ b/backend/tests/proposal/test_comment_api.py @@ -1,6 +1,6 @@ import json -from grant.proposal.models import Proposal, db +from grant.proposal.models import Proposal, Comment, db from grant.utils.enums import ProposalStatus from ..config import BaseUserConfig from ..test_data import test_comment, test_reply, test_comment_large @@ -148,3 +148,59 @@ class TestProposalCommentAPI(BaseUserConfig): self.assertStatus(comment_res, 403) self.assertIn('silenced', comment_res.json['message']) + + def test_like_comment(self): + proposal = Proposal(status=ProposalStatus.LIVE) + db.session.add(proposal) + + comment = Comment( + proposal_id=proposal.id, + user_id=self.other_user.id, + parent_comment_id=None, + content=test_comment["comment"] + ) + comment_id = comment.id + db.session.add(comment) + db.session.commit() + + # comment not found + resp = self.app.put( + f"/api/v1/comment/123456789/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert401(resp) + + # not logged in + resp = self.app.put( + f"/api/v1/comment/{comment_id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert401(resp) + + # logged in + self.login_default_user() + resp = self.app.put( + f"/api/v1/comment/{comment_id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + + self.assertStatus(resp, 201) + self.assertEqual(resp.json["authedLiked"], True) + self.assertEqual(resp.json["likesCount"], 1) + comment = Comment.query.get(comment_id) + self.assertTrue(self.user in comment.likes) + + # test unliking a proposal + resp = self.app.put( + f"/api/v1/comment/{comment.id}/like", + data=json.dumps({"isLiked": False}), + content_type="application/json", + ) + self.assertStatus(resp, 201) + self.assertEqual(resp.json["authedLiked"], False) + self.assertEqual(resp.json["likesCount"], 0) + comment = Comment.query.get(comment_id) + self.assertTrue(self.user not in comment.likes) diff --git a/backend/tests/rfp/__init__.py b/backend/tests/rfp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/rfp/test_rfp_api.py b/backend/tests/rfp/test_rfp_api.py new file mode 100644 index 00000000..d7062849 --- /dev/null +++ b/backend/tests/rfp/test_rfp_api.py @@ -0,0 +1,85 @@ + +import json +import datetime +from ..config import BaseProposalCreatorConfig +from grant.rfp.models import RFP, RFPStatus, db, Category + + +class TestRfpApi(BaseProposalCreatorConfig): + def test_rfp_like(self): + rfp = RFP( + title="title", + brief="brief", + content="content", + category=Category.DEV_TOOL, + date_closes=datetime.datetime(2030, 1, 1), + bounty="10", + status=RFPStatus.DRAFT, + ) + rfp_id = rfp.id + db.session.add(rfp) + db.session.commit() + + # not logged in + resp = self.app.put( + f"/api/v1/rfps/{rfp_id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert401(resp) + + # logged in, but rfp does not exist + self.login_default_user() + resp = self.app.put( + "/api/v1/rfps/123456789/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert404(resp) + + # RFP is not live + resp = self.app.put( + f"/api/v1/rfps/{rfp_id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert404(resp) + self.assertEqual(resp.json["message"], "RFP is not live") + + # set RFP live, test like + rfp = RFP.query.get(rfp_id) + rfp.status = RFPStatus.LIVE + db.session.add(rfp) + db.session.commit() + + resp = self.app.put( + f"/api/v1/rfps/{rfp_id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert200(resp) + rfp = RFP.query.get(rfp_id) + self.assertTrue(self.user in rfp.likes) + resp = self.app.get( + f"/api/v1/rfps/{rfp_id}" + ) + self.assert200(resp) + self.assertEqual(resp.json["authedLiked"], True) + self.assertEqual(resp.json["likesCount"], 1) + + # test unliking + resp = self.app.put( + f"/api/v1/rfps/{rfp_id}/like", + data=json.dumps({"isLiked": False}), + content_type="application/json", + ) + self.assert200(resp) + rfp = RFP.query.get(rfp_id) + self.assertTrue(self.user not in rfp.likes) + resp = self.app.get( + f"/api/v1/rfps/{rfp_id}" + ) + self.assert200(resp) + self.assertEqual(resp.json["authedLiked"], False) + self.assertEqual(resp.json["likesCount"], 0) + diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index b37d9809..9120fa3a 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -46,6 +46,20 @@ export function followProposal(proposalId: number, isFollow: boolean) { return axios.put(`/api/v1/proposals/${proposalId}/follow`, { isFollow }); } +export function likeProposal(proposalId: number, isLiked: boolean) { + return axios.put(`/api/v1/proposals/${proposalId}/like`, { isLiked }); +} + +export function likeRfp(rfpId: number, isLiked: boolean) { + return axios.put(`/api/v1/rfps/${rfpId}/like`, { isLiked }); +} + +export function likeComment(commentId: number, isLiked: boolean) { + return axios + .put(`/api/v1/comment/${commentId}/like`, { isLiked }) + .then(({ data }) => data); +} + export function getProposalComments(proposalId: number | string, params: PageParams) { return axios.get(`/api/v1/proposals/${proposalId}/comments`, { params }); } diff --git a/frontend/client/components/Comment/index.tsx b/frontend/client/components/Comment/index.tsx index a2ada52f..43e97e3c 100644 --- a/frontend/client/components/Comment/index.tsx +++ b/frontend/client/components/Comment/index.tsx @@ -10,6 +10,7 @@ import { postProposalComment, reportProposalComment } from 'modules/proposals/ac import { getIsSignedIn } from 'modules/auth/selectors'; import { Comment as IComment } from 'types'; import { AppState } from 'store/reducers'; +import Like from 'components/Like'; import './style.less'; interface OwnProps { @@ -20,6 +21,7 @@ interface StateProps { isPostCommentPending: AppState['proposal']['isPostCommentPending']; postCommentError: AppState['proposal']['postCommentError']; isSignedIn: ReturnType; + detail: AppState['proposal']['detail']; } interface DispatchProps { @@ -71,19 +73,23 @@ class Comment extends React.Component {
- {isSignedIn && ( -
+
+
+ +
+ {isSignedIn && ( {isReplying ? 'Cancel' : 'Reply'} - {!comment.hidden && - !comment.reported && ( - - Report - - )} -
- )} + )} + {isSignedIn && + !comment.hidden && + !comment.reported && ( + + Report + + )} +
{(comment.replies.length || isReplying) && (
@@ -143,6 +149,7 @@ const ConnectedComment = connect( isPostCommentPending: state.proposal.isPostCommentPending, postCommentError: state.proposal.postCommentError, isSignedIn: getIsSignedIn(state), + detail: state.proposal.detail, }), { postProposalComment, diff --git a/frontend/client/components/Comment/style.less b/frontend/client/components/Comment/style.less index 09928863..d055ebb7 100644 --- a/frontend/client/components/Comment/style.less +++ b/frontend/client/components/Comment/style.less @@ -48,6 +48,7 @@ &-controls { display: flex; margin-left: -0.5rem; + align-items: center; &-button { font-size: 0.65rem; diff --git a/frontend/client/components/Like/index.less b/frontend/client/components/Like/index.less new file mode 100644 index 00000000..c59d7832 --- /dev/null +++ b/frontend/client/components/Like/index.less @@ -0,0 +1,24 @@ +@import '~styles/variables.less'; + +@collapse-width: 800px; + +.Like { + white-space: nowrap; + + .ant-btn:focus, + .ant-btn:active { + border-color: inherit; + outline-color: inherit; + color: inherit; + } + + &-label { + @media (max-width: @collapse-width) { + display: none !important; + } + } + + &-count { + color: @text-color !important; + } +} diff --git a/frontend/client/components/Like/index.tsx b/frontend/client/components/Like/index.tsx new file mode 100644 index 00000000..7e370ac0 --- /dev/null +++ b/frontend/client/components/Like/index.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Icon, Button, Input, message } from 'antd'; +import { AppState } from 'store/reducers'; +import { proposalActions } from 'modules/proposals'; +import { rfpActions } from 'modules/rfps'; +import { ProposalDetail } from 'modules/proposals/reducers'; +import { Comment, RFP } from 'types'; +import { likeProposal, likeComment, likeRfp } from 'api/api'; +import AuthButton from 'components/AuthButton'; +import './index.less'; + +interface OwnProps { + proposal?: ProposalDetail | null; + comment?: Comment; + rfp?: RFP; +} + +interface StateProps { + authUser: AppState['auth']['user']; +} + +interface DispatchProps { + fetchProposal: typeof proposalActions['fetchProposal']; + updateComment: typeof proposalActions['updateProposalComment']; + fetchRfp: typeof rfpActions['fetchRfp']; +} + +type Props = OwnProps & StateProps & DispatchProps; + +const STATE = { + loading: false, +}; +type State = typeof STATE; + +class Follow extends React.Component { + state: State = { ...STATE }; + + render() { + const { likesCount, authedLiked } = this.deriveInfo(); + const { proposal, rfp, comment } = this.props; + const { loading } = this.state; + const zoom = comment ? 0.8 : 1; + const shouldShowLikeText = !!proposal || !!rfp; + + return ( + + + + {shouldShowLikeText && ( + {authedLiked ? ' Unlike' : ' Like'} + )} + + + + ); + } + + private deriveInfo = () => { + let authedLiked = false; + let likesCount = 0; + + const { proposal, comment, rfp } = this.props; + + if (comment) { + authedLiked = comment.authedLiked; + likesCount = comment.likesCount; + } else if (proposal) { + authedLiked = proposal.authedLiked; + likesCount = proposal.likesCount; + } else if (rfp) { + authedLiked = rfp.authedLiked; + likesCount = rfp.likesCount; + } + + return { + authedLiked, + likesCount, + }; + }; + + private handleLike = () => { + if (this.state.loading) return; + const { proposal, rfp, comment } = this.props; + + if (proposal) { + return this.handleProposalLike(); + } + if (comment) { + return this.handleCommentLike(); + } + if (rfp) { + return this.handleRfpLike(); + } + }; + + private handleProposalLike = async () => { + if (!this.props.proposal) return; + + const { + proposal: { proposalId, authedLiked }, + fetchProposal, + } = this.props; + + this.setState({ loading: true }); + 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); + message.error('Unable to like proposal'); + } + this.setState({ loading: false }); + }; + + private handleCommentLike = async () => { + if (!this.props.comment) return; + + const { + comment: { id, authedLiked }, + updateComment, + } = this.props; + + this.setState({ loading: true }); + try { + const updatedComment = await likeComment(id, !authedLiked); + updateComment(id, updatedComment); + message.success(<>Comment {authedLiked ? 'unliked' : 'liked'}); + } catch (error) { + // tslint:disable:no-console + console.error('Like.handleCommentLike - unable to change like state', error); + message.error('Unable to like comment'); + } + this.setState({ loading: false }); + }; + + private handleRfpLike = async () => { + if (!this.props.rfp) return; + + const { + rfp: { id, authedLiked }, + fetchRfp, + } = this.props; + + this.setState({ loading: true }); + try { + await likeRfp(id, !authedLiked); + await fetchRfp(id); + message.success(<>Request for proposal {authedLiked ? 'unliked' : 'liked'}); + } catch (error) { + // tslint:disable:no-console + console.error('Like.handleRfpLike - unable to change like state', error); + message.error('Unable to like rfp'); + } + this.setState({ loading: false }); + }; +} + +const withConnect = connect( + state => ({ + authUser: state.auth.user, + }), + { + fetchProposal: proposalActions.fetchProposal, + updateComment: proposalActions.updateProposalComment, + fetchRfp: rfpActions.fetchRfp, + }, +); + +export default withConnect(Follow); diff --git a/frontend/client/components/Proposal/index.less b/frontend/client/components/Proposal/index.less index a81616b8..5daffd59 100644 --- a/frontend/client/components/Proposal/index.less +++ b/frontend/client/components/Proposal/index.less @@ -77,6 +77,7 @@ line-height: 3rem; margin-bottom: 0.75rem; margin-left: 0.5rem; + margin-right: 1rem; flex-grow: 1; @media (min-width: @collapse-width) { @@ -99,6 +100,10 @@ align-items: center; height: 3rem; + & .ant-input-group { + width: inherit + } + & > * + * { margin-left: 0.5rem; } diff --git a/frontend/client/components/Proposal/index.tsx b/frontend/client/components/Proposal/index.tsx index 161bc908..17a146b9 100644 --- a/frontend/client/components/Proposal/index.tsx +++ b/frontend/client/components/Proposal/index.tsx @@ -26,6 +26,7 @@ import classnames from 'classnames'; import { withRouter } from 'react-router'; import SocialShare from 'components/SocialShare'; import Follow from 'components/Follow'; +import Like from 'components/Like'; import './index.less'; interface OwnProps { @@ -205,6 +206,7 @@ export class ProposalDetail extends React.Component { )} +
)} diff --git a/frontend/client/components/RFP/index.less b/frontend/client/components/RFP/index.less index 74513ef7..95181234 100644 --- a/frontend/client/components/RFP/index.less +++ b/frontend/client/components/RFP/index.less @@ -21,6 +21,10 @@ &-date { opacity: 0.7; } + + & .ant-input-group { + width: inherit; + } } &-brief { @@ -97,8 +101,8 @@ // Only has this class while affixed .ant-affix { - box-shadow: 0 0 10px 10px #FFF; - background: #FFF; + box-shadow: 0 0 10px 10px #fff; + background: #fff; } } -} \ No newline at end of file +} diff --git a/frontend/client/components/RFP/index.tsx b/frontend/client/components/RFP/index.tsx index 245baffa..de77f111 100644 --- a/frontend/client/components/RFP/index.tsx +++ b/frontend/client/components/RFP/index.tsx @@ -13,6 +13,7 @@ import Markdown from 'components/Markdown'; import ProposalCard from 'components/Proposals/ProposalCard'; import UnitDisplay from 'components/UnitDisplay'; import HeaderDetails from 'components/HeaderDetails'; +import Like from 'components/Like'; import { RFP_STATUS } from 'api/constants'; import './index.less'; @@ -87,6 +88,7 @@ class RFPDetail extends React.Component {
Opened {moment(rfp.dateOpened * 1000).format('LL')}
+

{rfp.title}

diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index 001617c2..b096186a 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -251,6 +251,8 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta acceptedWithFunding: false, authedFollows: false, followersCount: 0, + authedLiked: false, + likesCount: 0, isVersionTwo: true, milestones: draft.milestones.map((m, idx) => ({ id: idx, diff --git a/frontend/client/modules/proposals/actions.ts b/frontend/client/modules/proposals/actions.ts index 47cdd719..9ff79121 100644 --- a/frontend/client/modules/proposals/actions.ts +++ b/frontend/client/modules/proposals/actions.ts @@ -230,3 +230,17 @@ export function reportProposalComment( } }; } + +export function updateProposalComment( + commentId: Comment['id'], + commentUpdate: Partial, +) { + return (dispatch: Dispatch) => + dispatch({ + type: types.UPDATE_PROPOSAL_COMMENT, + payload: { + commentId, + commentUpdate, + }, + }); +} diff --git a/frontend/client/modules/proposals/reducers.ts b/frontend/client/modules/proposals/reducers.ts index 15af0181..d3316241 100644 --- a/frontend/client/modules/proposals/reducers.ts +++ b/frontend/client/modules/proposals/reducers.ts @@ -375,6 +375,9 @@ export default (state = INITIAL_STATE, action: any) => { case types.REPORT_PROPOSAL_COMMENT_FULFILLED: return updateCommentInStore(state, payload.commentId, { reported: true }); + case types.UPDATE_PROPOSAL_COMMENT: + return updateCommentInStore(state, payload.commentId, payload.commentUpdate); + case types.PROPOSAL_UPDATES_PENDING: return { ...state, diff --git a/frontend/client/modules/proposals/types.ts b/frontend/client/modules/proposals/types.ts index 25e31b04..b2cc1f90 100644 --- a/frontend/client/modules/proposals/types.ts +++ b/frontend/client/modules/proposals/types.ts @@ -52,6 +52,8 @@ enum proposalTypes { REPORT_PROPOSAL_COMMENT_PENDING = 'REPORT_PROPOSAL_COMMENT_PENDING', REPORT_PROPOSAL_COMMENT_FULFILLED = 'REPORT_PROPOSAL_COMMENT_FULFILLED', REPORT_PROPOSAL_COMMENT_REJECTED = 'REPORT_PROPOSAL_COMMENT_REJECTED', + + UPDATE_PROPOSAL_COMMENT = 'UPDATE_PROPOSAL_COMMENT', } export default proposalTypes; diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index db29422d..424c08a7 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -164,6 +164,8 @@ export function generateProposal({ isStaked: true, authedFollows: false, followersCount: 0, + authedLiked: false, + likesCount: 0, arbiter: { status: PROPOSAL_ARBITER_STATUS.ACCEPTED, user: { diff --git a/frontend/types/comment.ts b/frontend/types/comment.ts index bed55429..a47a418f 100644 --- a/frontend/types/comment.ts +++ b/frontend/types/comment.ts @@ -9,6 +9,8 @@ export interface Comment { replies: Comment[]; reported: boolean; hidden: boolean; + authedLiked: boolean; + likesCount: number; } export interface UserComment { diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts index b19dcc41..244bf91c 100644 --- a/frontend/types/proposal.ts +++ b/frontend/types/proposal.ts @@ -66,6 +66,8 @@ export interface Proposal extends Omit { isVersionTwo: boolean; authedFollows: boolean; followersCount: number; + authedLiked: boolean; + likesCount: number; isTeamMember?: boolean; // FE derived isArbiter?: boolean; // FE derived } diff --git a/frontend/types/rfp.ts b/frontend/types/rfp.ts index ad1f72eb..9addc8cf 100644 --- a/frontend/types/rfp.ts +++ b/frontend/types/rfp.ts @@ -16,4 +16,6 @@ export interface RFP { dateOpened: number; dateClosed?: number; dateCloses?: number; + authedLiked: boolean; + likesCount: number; } From dd9bcb8865a51c0f88cdca97011eb6ecd6b64cf6 Mon Sep 17 00:00:00 2001 From: Daniel Ternyak Date: Thu, 24 Oct 2019 12:50:57 -0500 Subject: [PATCH 13/54] Batch migrations from model updates (#42) --- backend/migrations/versions/515abdefed7a_.py | 43 ++++++++++++++++++ backend/migrations/versions/7fea7427e9d6_.py | 47 ++++++++++++++++++++ backend/migrations/versions/f24d53f211ef_.py | 40 +++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 backend/migrations/versions/515abdefed7a_.py create mode 100644 backend/migrations/versions/7fea7427e9d6_.py create mode 100644 backend/migrations/versions/f24d53f211ef_.py diff --git a/backend/migrations/versions/515abdefed7a_.py b/backend/migrations/versions/515abdefed7a_.py new file mode 100644 index 00000000..57529e6d --- /dev/null +++ b/backend/migrations/versions/515abdefed7a_.py @@ -0,0 +1,43 @@ +"""empty message + +Revision ID: 515abdefed7a +Revises: 4505f00c4ebd +Create Date: 2019-10-17 16:41:58.519224 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '515abdefed7a' +down_revision = '4505f00c4ebd' +branch_labels = None +depends_on = None + + +def upgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.create_table('proposal_subscribers', + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('proposal_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ) + ) + op.add_column('proposal', sa.Column('accepted_with_funding', sa.Boolean(), nullable=True)) + op.add_column('proposal', sa.Column('version', sa.String(length=255), nullable=True)) + op.alter_column('proposal', 'deadline_duration', + existing_type=sa.INTEGER(), + nullable=True) + # ### end Alembic commands ### + + +def downgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.alter_column('proposal', 'deadline_duration', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_column('proposal', 'version') + op.drop_column('proposal', 'accepted_with_funding') + op.drop_table('proposal_subscribers') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/7fea7427e9d6_.py b/backend/migrations/versions/7fea7427e9d6_.py new file mode 100644 index 00000000..3f88ba08 --- /dev/null +++ b/backend/migrations/versions/7fea7427e9d6_.py @@ -0,0 +1,47 @@ +"""empty message + +Revision ID: 7fea7427e9d6 +Revises: f24d53f211ef +Create Date: 2019-10-24 12:18:39.734758 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7fea7427e9d6' +down_revision = 'f24d53f211ef' +branch_labels = None +depends_on = None + + +def upgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.create_table('rfp_liker', + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('rfp_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['rfp_id'], ['rfp.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ) + ) + op.create_table('proposal_liker', + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('proposal_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ) + ) + op.create_table('comment_liker', + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('comment_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['comment_id'], ['comment.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ) + ) + # ### end Alembic commands ### + + +def downgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.drop_table('comment_liker') + op.drop_table('proposal_liker') + op.drop_table('rfp_liker') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/f24d53f211ef_.py b/backend/migrations/versions/f24d53f211ef_.py new file mode 100644 index 00000000..89219882 --- /dev/null +++ b/backend/migrations/versions/f24d53f211ef_.py @@ -0,0 +1,40 @@ +"""empty message + +Revision ID: f24d53f211ef +Revises: 515abdefed7a +Create Date: 2019-10-23 16:32:02.161367 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f24d53f211ef' +down_revision = '515abdefed7a' +branch_labels = None +depends_on = None + + +def upgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.create_table('proposal_follower', + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('proposal_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ) + ) + op.drop_table('proposal_subscribers') + # ### end Alembic commands ### + + +def downgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.create_table('proposal_subscribers', + sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column('proposal_id', sa.INTEGER(), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], name='proposal_subscribers_proposal_id_fkey'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name='proposal_subscribers_user_id_fkey') + ) + op.drop_table('proposal_follower') + # ### end Alembic commands ### From 494303883ae44c3e3a250a8f92ee416ef7f2b2d8 Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Wed, 30 Oct 2019 22:01:38 -0400 Subject: [PATCH 14/54] apply margin directly to like & follow (#53) --- frontend/client/components/Follow/index.tsx | 4 +++- frontend/client/components/Like/index.tsx | 5 +++-- frontend/client/components/Proposal/index.less | 4 ---- frontend/client/components/Proposal/index.tsx | 4 ++-- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/frontend/client/components/Follow/index.tsx b/frontend/client/components/Follow/index.tsx index ed0db2a6..9f8d93cb 100644 --- a/frontend/client/components/Follow/index.tsx +++ b/frontend/client/components/Follow/index.tsx @@ -10,6 +10,7 @@ import './index.less'; interface OwnProps { proposal: ProposalDetail; + style?: React.CSSProperties; } interface StateProps { @@ -30,10 +31,11 @@ type State = typeof STATE; class Follow extends React.Component { state: State = { ...STATE }; render() { + const { style } = this.props; const { authedFollows, followersCount } = this.props.proposal; const { loading } = this.state; return ( - + { render() { const { likesCount, authedLiked } = this.deriveInfo(); - const { proposal, rfp, comment } = this.props; + const { proposal, rfp, comment, style } = this.props; const { loading } = this.state; const zoom = comment ? 0.8 : 1; const shouldShowLikeText = !!proposal || !!rfp; return ( - + * + * { - margin-left: 0.5rem; - } } } diff --git a/frontend/client/components/Proposal/index.tsx b/frontend/client/components/Proposal/index.tsx index 17a146b9..1fbfbd67 100644 --- a/frontend/client/components/Proposal/index.tsx +++ b/frontend/client/components/Proposal/index.tsx @@ -206,8 +206,8 @@ export class ProposalDetail extends React.Component { )} - - + +
)}
From c66be86c54724ffc21f5992388795a9d5569e7cc Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Tue, 5 Nov 2019 14:38:34 -0500 Subject: [PATCH 15/54] Prune Empty Drafts (#54) * prune empty drafts after 72 hours * add additional noops, update tests --- backend/grant/proposal/views.py | 5 +- backend/grant/task/jobs.py | 47 ++++++++++++- backend/tests/task/test_api.py | 115 +++++++++++++++++++++++++++++++- 3 files changed, 163 insertions(+), 4 deletions(-) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 8cf29860..8a508508 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -14,7 +14,7 @@ from grant.milestone.models import Milestone from grant.parser import body, query, paginated_fields from grant.rfp.models import RFP from grant.settings import PROPOSAL_STAKING_AMOUNT -from grant.task.jobs import ProposalDeadline +from grant.task.jobs import ProposalDeadline, PruneDraft from grant.user.models import User from grant.utils import pagination from grant.utils.auth import ( @@ -196,6 +196,9 @@ def make_proposal_draft(rfp_id): rfp.proposals.append(proposal) db.session.add(rfp) + task = PruneDraft(proposal) + task.make_task() + db.session.add(proposal) db.session.commit() return proposal_schema.dump(proposal), 201 diff --git a/backend/grant/task/jobs.py b/backend/grant/task/jobs.py index ae148f1e..10fcb95f 100644 --- a/backend/grant/task/jobs.py +++ b/backend/grant/task/jobs.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta from grant.extensions import db from grant.email.send import send_email -from grant.utils.enums import ProposalStage, ContributionStatus +from grant.utils.enums import ProposalStage, ContributionStatus, ProposalStatus from grant.utils.misc import make_url from flask import current_app @@ -126,8 +126,53 @@ class ContributionExpired: }) +class PruneDraft: + JOB_TYPE = 4 + PRUNE_TIME = 259200 # 72 hours in seconds + + def __init__(self, proposal): + self.proposal = proposal + + def blobify(self): + return { + "proposal_id": self.proposal.id, + } + + def make_task(self): + from .models import Task + + task = Task( + job_type=self.JOB_TYPE, + blob=self.blobify(), + execute_after=self.proposal.date_created + timedelta(seconds=self.PRUNE_TIME), + ) + db.session.add(task) + db.session.commit() + + @staticmethod + def process_task(task): + from grant.proposal.models import Proposal + 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": + return + + if proposal.payout_address or proposal.milestones: + return + + # Otherwise, delete the empty proposal + db.session.delete(proposal) + db.session.commit() + + JOBS = { 1: ProposalReminder.process_task, 2: ProposalDeadline.process_task, 3: ContributionExpired.process_task, + 4: PruneDraft.process_task } diff --git a/backend/tests/task/test_api.py b/backend/tests/task/test_api.py index 7dd0cbe2..5072b561 100644 --- a/backend/tests/task/test_api.py +++ b/backend/tests/task/test_api.py @@ -1,6 +1,12 @@ -from datetime import datetime +from datetime import datetime, timedelta -from grant.task.models import Task +from grant.task.models import Task, db +from grant.task.jobs import PruneDraft +from grant.milestone.models import Milestone +from grant.proposal.models import Proposal +from grant.utils.enums import ProposalStatus, Category + +from mock import patch, Mock from ..config import BaseProposalCreatorConfig @@ -22,3 +28,108 @@ class TestTaskAPI(BaseProposalCreatorConfig): tasks = Task.query.filter(Task.execute_after <= datetime.now()).filter_by(completed=False).all() self.assertEqual(tasks, []) + @patch('grant.task.views.datetime') + def test_proposal_pruning(self, mock_datetime): + self.login_default_user() + resp = self.app.post( + "/api/v1/proposals/drafts", + ) + proposal_id = resp.json['proposalId'] + + # make sure proposal was created + proposal = Proposal.query.get(proposal_id) + self.assertIsNotNone(proposal) + + # make sure the task was created + self.assertStatus(resp, 201) + tasks = Task.query.all() + self.assertEqual(len(tasks), 1) + task = tasks[0] + self.assertEqual(resp.json['proposalId'], task.blob['proposal_id']) + self.assertFalse(task.completed) + + # mock time so task will run when called + after_time = datetime.now() + timedelta(seconds=PruneDraft.PRUNE_TIME + 100) + mock_datetime.now = Mock(return_value=after_time) + + # run task + resp = self.app.get("/api/v1/task") + self.assert200(resp) + + # make sure task ran successfully + tasks = Task.query.all() + self.assertEqual(len(tasks), 1) + task = tasks[0] + self.assertTrue(task.completed) + proposal = Proposal.query.get(proposal_id) + self.assertIsNone(proposal) + + def test_proposal_pruning_noops(self): + # ensure all proposal noop states work as expected + + def status(p): + p.status = ProposalStatus.LIVE + + def title(p): + p.title = 'title' + + def brief(p): + p.brief = 'brief' + + def content(p): + p.content = 'content' + + def category(p): + p.category = Category.DEV_TOOL + + def target(p): + p.target = '100' + + def payout_address(p): + p.payout_address = 'address' + + def milestones(p): + milestones_data = [ + { + "title": "All the money straightaway", + "content": "cool stuff with it", + "date_estimated": 1549505307, + "payout_percent": "100", + "immediate_payout": False + } + ] + Milestone.make(milestones_data, p) + + modifiers = [ + status, + title, + brief, + content, + category, + target, + payout_address, + milestones + ] + + for modifier in modifiers: + proposal = Proposal.create(status=ProposalStatus.DRAFT) + proposal_id = proposal.id + modifier(proposal) + + db.session.add(proposal) + db.session.commit() + + blob = { + "proposal_id": proposal_id, + } + + task = Task( + job_type=PruneDraft.JOB_TYPE, + blob=blob, + execute_after=datetime.now() + ) + + PruneDraft.process_task(task) + + proposal = Proposal.query.get(proposal_id) + self.assertIsNotNone(proposal) From 8255f0174c50c40e82c73d48259241443151feba Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Tue, 5 Nov 2019 14:41:08 -0500 Subject: [PATCH 16/54] remove funding required filter (#62) --- frontend/client/components/Proposals/Filters/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/client/components/Proposals/Filters/index.tsx b/frontend/client/components/Proposals/Filters/index.tsx index 26933ef5..ac1479a0 100644 --- a/frontend/client/components/Proposals/Filters/index.tsx +++ b/frontend/client/components/Proposals/Filters/index.tsx @@ -72,6 +72,7 @@ export default class ProposalFilters extends React.Component { PROPOSAL_STAGE.PREVIEW, PROPOSAL_STAGE.FAILED, PROPOSAL_STAGE.CANCELED, + PROPOSAL_STAGE.FUNDING_REQUIRED ].includes(s as PROPOSAL_STAGE), ) // skip a few .map(s => ( From 67fbbae9bf4af2f8ae01a6244c707b9aa93539d3 Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Thu, 7 Nov 2019 22:58:55 -0500 Subject: [PATCH 17/54] Port Landing Hooks (#30) * port & adapt landing hooks from grant-base * fix type guard * CSS Adjustments for Illustration and Content layout. --- backend/grant/app.py | 3 +- backend/grant/home/__init__.py | 1 + backend/grant/home/views.py | 34 +++++ frontend/client/api/api.ts | 15 ++ frontend/client/components/Home/Intro.less | 11 +- frontend/client/components/Home/Latest.less | 75 ++++++++++ frontend/client/components/Home/Latest.tsx | 130 ++++++++++++++++++ frontend/client/components/Home/index.tsx | 2 + frontend/client/static/locales/en/common.json | 7 + 9 files changed, 270 insertions(+), 8 deletions(-) create mode 100644 backend/grant/home/__init__.py create mode 100644 backend/grant/home/views.py create mode 100644 frontend/client/components/Home/Latest.less create mode 100644 frontend/client/components/Home/Latest.tsx diff --git a/backend/grant/app.py b/backend/grant/app.py index 1bbd4212..9a0a7673 100644 --- a/backend/grant/app.py +++ b/backend/grant/app.py @@ -10,7 +10,7 @@ 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 +from grant import commands, proposal, user, 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 @@ -138,6 +138,7 @@ def register_blueprints(app): app.register_blueprint(blockchain.views.blueprint) app.register_blueprint(task.views.blueprint) app.register_blueprint(rfp.views.blueprint) + app.register_blueprint(home.views.blueprint) if E2E_TESTING and DEBUG: print('Warning: e2e end-points are open, this should only be the case for development or testing') app.register_blueprint(e2e.views.blueprint) diff --git a/backend/grant/home/__init__.py b/backend/grant/home/__init__.py new file mode 100644 index 00000000..14cd5bd9 --- /dev/null +++ b/backend/grant/home/__init__.py @@ -0,0 +1 @@ +from . import views diff --git a/backend/grant/home/views.py b/backend/grant/home/views.py new file mode 100644 index 00000000..bfffd657 --- /dev/null +++ b/backend/grant/home/views.py @@ -0,0 +1,34 @@ +from datetime import datetime + +from flask import Blueprint +from sqlalchemy import or_ + +from grant.proposal.models import Proposal, proposals_schema +from grant.rfp.models import RFP, rfps_schema +from grant.utils.enums import ProposalStatus, ProposalStage, RFPStatus + +blueprint = Blueprint("home", __name__, url_prefix="/api/v1/home") + + +@blueprint.route("/latest", methods=["GET"]) +def get_home_content(): + latest_proposals = ( + Proposal.query.filter_by(status=ProposalStatus.LIVE) + .filter(Proposal.stage != ProposalStage.CANCELED) + .filter(Proposal.stage != ProposalStage.FAILED) + .order_by(Proposal.date_created.desc()) + .limit(3) + .all() + ) + latest_rfps = ( + RFP.query.filter_by(status=RFPStatus.LIVE) + .filter(or_(RFP.date_closes == None, RFP.date_closes > datetime.now())) + .order_by(RFP.date_opened) + .limit(3) + .all() + ) + + return { + "latest_proposals": proposals_schema.dump(latest_proposals), + "latest_rfps": rfps_schema.dump(latest_rfps), + } diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index 9120fa3a..b1fd724c 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -385,3 +385,18 @@ export function getRFP(rfpId: number | string): Promise<{ data: RFP }> { export function resendEmailVerification(): Promise<{ data: void }> { return axios.put(`/api/v1/users/me/resend-verification`); } + +export function getHomeLatest(): Promise<{ + data: { + latestProposals: Proposal[]; + latestRfps: RFP[]; + }; +}> { + return axios.get('/api/v1/home/latest').then(res => { + res.data = { + latestProposals: res.data.latestProposals.map(formatProposalFromGet), + latestRfps: res.data.latestRfps.map(formatRFPFromGet), + }; + return res; + }); +} diff --git a/frontend/client/components/Home/Intro.less b/frontend/client/components/Home/Intro.less index e6bcea83..42505675 100644 --- a/frontend/client/components/Home/Intro.less +++ b/frontend/client/components/Home/Intro.less @@ -3,11 +3,11 @@ .HomeIntro { position: relative; display: flex; - justify-content: space-between; + justify-content: space-around; align-items: center; max-width: 1440px; padding: 0 4rem; - margin: 0 auto 6rem; + margin: 0 auto 4rem; overflow: hidden; @media @thin-query { @@ -19,10 +19,7 @@ } &-content { - width: 50%; - min-width: 600px; - padding-right: 2rem; - margin: 0 auto; + &-title { margin-bottom: 2rem; @@ -98,7 +95,7 @@ &-illustration { position: relative; width: 100%; - max-width: 640px; + max-width: 480px; background-size: contain; &:after { diff --git a/frontend/client/components/Home/Latest.less b/frontend/client/components/Home/Latest.less new file mode 100644 index 00000000..592e3934 --- /dev/null +++ b/frontend/client/components/Home/Latest.less @@ -0,0 +1,75 @@ +@import '~styles/variables.less'; + +.HomeLatest { + &-inner { + display: flex; + flex-wrap: wrap; + max-width: @max-content-width; + margin: 0 auto 10rem; + padding: 0 2rem; + + @media @thin-query { + flex-direction: column; + } + } + + &-loader { + flex: 1; + position: relative; + height: 14rem; + } + + &-column { + flex: 1; + margin: 0 2rem; + + @media @thin-query { + margin: 0 1.25rem 2rem; + } + + @media @mobile-query { + margin: 0 0 2rem; + } + + &-title { + font-size: 1.6rem; + font-weight: 600; + margin-bottom: 2rem; + } + + &-item { + margin-bottom: 1.5rem; + + &-title { + font-size: 1.3rem; + font-weight: 600; + margin-bottom: 0.5rem; + } + + &-brief { + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + font-size: 1rem; + line-height: 1.6; + height: 3.2rem; + color: @text-color; + + @media @thin-query { + height: auto; + max-height: 3.2rem; + } + } + } + + .Placeholder { + padding-left: 1rem; + padding-right: 1rem; + + &-title { + font-size: 1.3rem; + } + } + } +} \ No newline at end of file diff --git a/frontend/client/components/Home/Latest.tsx b/frontend/client/components/Home/Latest.tsx new file mode 100644 index 00000000..04b982cb --- /dev/null +++ b/frontend/client/components/Home/Latest.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withNamespaces, WithNamespaces } from 'react-i18next'; +import Loader from 'components/Loader'; +import Placeholder from 'components/Placeholder'; +import { getHomeLatest } from 'api/api'; +import { Proposal, RFP } from 'types'; +import './Latest.less'; + +interface State { + latestProposals: Proposal[]; + latestRfps: RFP[]; + isLoading: boolean; + error: string | null; +} + +class HomeLatest extends React.Component { + state: State = { + latestProposals: [], + latestRfps: [], + isLoading: true, + error: null, + }; + + async componentDidMount() { + try { + const res = await getHomeLatest(); + this.setState({ + ...res.data, + error: null, + isLoading: false, + }); + } catch (err) { + // tslint:disable-next-line + console.error('Failed to load homepage content:', err); + this.setState({ + error: err.message, + isLoading: false, + }); + } + } + + render() { + const { t } = this.props; + const { latestProposals, latestRfps, isLoading } = this.state; + const numItems = latestProposals.length + latestRfps.length; + + let content; + if (isLoading) { + content = ( +
+ +
+ ); + } else if (numItems) { + const columns: ContentColumnProps[] = [ + { + title: t('home.latest.proposalsTitle'), + placeholder: t('home.latest.proposalsPlaceholder'), + path: 'proposals', + items: latestProposals, + }, + { + title: t('home.latest.requestsTitle'), + placeholder: t('home.latest.requestsPlaceholder'), + path: 'requests', + items: latestRfps, + }, + ]; + content = columns.filter(c => !!c.items.length).map((col, idx) => ( +
+ +
+ )); + } else { + return null; + } + + return ( +
+
{content}
+
+ ); + } +} + +interface ContentColumnProps { + title: string; + placeholder: string; + path: string; + items: Array; +} + +const ContentColumn: React.SFC = p => { + let content: React.ReactNode; + if (p.items.length) { + content = ( + <> + {p.items.map(item => { + const isProposal = (x: Proposal | RFP): x is Proposal => + (x as Proposal).proposalUrlId !== undefined; + const id = isProposal(item) ? item.proposalId : item.id; + const urlId = isProposal(item) ? item.proposalUrlId : item.urlId; + + return ( + +
+
{item.title}
+
{item.brief}
+
+ + ); + })} + + See more → + + + ); + } else { + content = ; + } + return ( +
+

{p.title}

+ {content} +
+ ); +}; + +export default withNamespaces()(HomeLatest); diff --git a/frontend/client/components/Home/index.tsx b/frontend/client/components/Home/index.tsx index a383088b..b1119080 100644 --- a/frontend/client/components/Home/index.tsx +++ b/frontend/client/components/Home/index.tsx @@ -5,6 +5,7 @@ import Intro from './Intro'; import Requests from './Requests'; import Guide from './Guide'; import Actions from './Actions'; +import Latest from './Latest'; import './style.less'; class Home extends React.Component { @@ -14,6 +15,7 @@ class Home extends React.Component {
+ diff --git a/frontend/client/static/locales/en/common.json b/frontend/client/static/locales/en/common.json index 430d808a..b5fad0cc 100644 --- a/frontend/client/static/locales/en/common.json +++ b/frontend/client/static/locales/en/common.json @@ -14,6 +14,13 @@ "learn": "or learn more below" }, + "latest": { + "proposalsTitle": "Latest proposals", + "proposalsPlaceholder": "No proposals found", + "requestsTitle": "Latest requests", + "requestsPlaceholder": "No requests found" + }, + "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>!", From 8ced452411946618b8eeaa30d82e48467b1c1c2f Mon Sep 17 00:00:00 2001 From: Daniel Ternyak Date: Wed, 13 Nov 2019 15:37:12 -0600 Subject: [PATCH 18/54] Add Explainer to Proposal Creation Flow. (#60) * Add Explainer step to Proposal Create Flow * Add Explainer step to Proposal Create Flow * remove unneeded localstorage file --- .../components/CreateFlow/Explainer.less | 85 +++++++++++++++++++ .../components/CreateFlow/Explainer.tsx | 66 ++++++++++++++ .../client/components/CreateFlow/index.tsx | 26 +++++- frontend/package.json | 1 + frontend/yarn.lock | 5 ++ 5 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 frontend/client/components/CreateFlow/Explainer.less create mode 100644 frontend/client/components/CreateFlow/Explainer.tsx diff --git a/frontend/client/components/CreateFlow/Explainer.less b/frontend/client/components/CreateFlow/Explainer.less new file mode 100644 index 00000000..7f27bd17 --- /dev/null +++ b/frontend/client/components/CreateFlow/Explainer.less @@ -0,0 +1,85 @@ +@import '~styles/variables.less'; + +@small-query: ~'(max-width: 640px)'; + +.Explainer { + 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; + height: 3.2rem; + } + + &-actions { + margin: 4rem 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/CreateFlow/Explainer.tsx b/frontend/client/components/CreateFlow/Explainer.tsx new file mode 100644 index 00000000..9de73250 --- /dev/null +++ b/frontend/client/components/CreateFlow/Explainer.tsx @@ -0,0 +1,66 @@ +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 CommunityIcon from 'static/images/guide-community.svg'; +import './Explainer.less'; +import * as ls from 'local-storage'; +import { Button, Checkbox, Icon } from 'antd'; + +interface CreateProps { + startSteps: () => void; +} + +type Props = WithNamespaces & CreateProps; + +const Explainer: React.SFC = ({ t, startSteps }) => { + const items = [ + { + text: t('home.guide.submit'), + icon: , + }, + { + text: t('home.guide.review'), + icon: , + }, + { + text: t('home.guide.community'), + icon: , + }, + ]; + + return ( +
+
+

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

+
+ You're almost ready to create a proposal. +
+
+
+ {items.map((item, idx) => ( +
+
{item.icon}
+
{item.text}
+
+ ))} +
+
+ ls.set('noExplain', ev.target.checked)}> + Don't show this again + + +
+
+ ); +}; + +export default withNamespaces()(Explainer); diff --git a/frontend/client/components/CreateFlow/index.tsx b/frontend/client/components/CreateFlow/index.tsx index d99a604e..58c32763 100644 --- a/frontend/client/components/CreateFlow/index.tsx +++ b/frontend/client/components/CreateFlow/index.tsx @@ -14,11 +14,13 @@ import Payment from './Payment'; import Review from './Review'; import Preview from './Preview'; import Final from './Final'; +import Explainer from './Explainer'; import SubmitWarningModal from './SubmitWarningModal'; import createExampleProposal from './example'; import { createActions } from 'modules/create'; import { ProposalDraft } from 'types'; import { getCreateErrors } from 'modules/create/utils'; +import ls from 'local-storage'; import { AppState } from 'store/reducers'; @@ -49,6 +51,11 @@ interface StepInfo { help: React.ReactNode; component: any; } + +interface LSExplainer { + noExplain: boolean; +} + const STEP_INFO: { [key in CREATE_STEP]: StepInfo } = { [CREATE_STEP.BASICS]: { short: 'Basics', @@ -117,6 +124,7 @@ interface State { isPreviewing: boolean; isShowingSubmitWarning: boolean; isSubmitting: boolean; + isExplaining: boolean; isExample: boolean; } @@ -132,12 +140,15 @@ class CreateFlow extends React.Component { queryStep && CREATE_STEP[queryStep] ? (CREATE_STEP[queryStep] as CREATE_STEP) : CREATE_STEP.BASICS; + const noExplain = !!ls('noExplain'); + 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); @@ -151,7 +162,13 @@ class CreateFlow extends React.Component { render() { const { isSavingDraft, saveDraftError } = this.props; - const { step, isPreviewing, isSubmitting, isShowingSubmitWarning } = this.state; + const { + step, + isPreviewing, + isSubmitting, + isShowingSubmitWarning, + isExplaining, + } = this.state; const info = STEP_INFO[step]; const currentIndex = STEP_ORDER.indexOf(step); @@ -165,6 +182,9 @@ class CreateFlow extends React.Component { 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; @@ -264,6 +284,10 @@ class CreateFlow extends React.Component { this.props.updateForm(form); }; + private startSteps = () => { + this.setState({ step: CREATE_STEP.BASICS, isExplaining: false }); + }; + private setStep = (step: CREATE_STEP, skipHistory?: boolean) => { this.setState({ step }); if (!skipHistory) { diff --git a/frontend/package.json b/frontend/package.json index 5021c581..f9d49b7e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -117,6 +117,7 @@ "less": "3.9.0", "less-loader": "^4.1.0", "lint-staged": "^7.2.2", + "local-storage": "^2.0.0", "lodash": "^4.17.15", "lodash-es": "^4.17.15", "lodash.defaultsdeep": "^4.6.1", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 232c5ac7..77c42477 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -7326,6 +7326,11 @@ loader-utils@1.1.0, loader-utils@^1.0.2, loader-utils@^1.1.0: emojis-list "^2.0.0" json5 "^0.5.0" +local-storage@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/local-storage/-/local-storage-2.0.0.tgz#748b7d041b197f46f3ec7393640851c175b64db8" + integrity sha512-/0sRoeijw7yr/igbVVygDuq6dlYCmtsuTmmpnweVlVtl/s10pf5BCq8LWBxW/AMyFJ3MhMUuggMZiYlx6qr9tw== + locate-path@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" From ed6d98ceec63fca380052497a0e66f87e88c30a3 Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Wed, 13 Nov 2019 17:38:17 -0500 Subject: [PATCH 19/54] Milestone Estimate in Days (#59) * init admin milestone estimate in days * init frontend milestone estimate in days * init backend milestone estimate in days * fix bugs * fix bugs * fix tests * add tests * add milestone_deadline email to examples * fix type errors * fix tests * remove comment * temp prep for merge * restore changes, update tests * add db migration * add tests and comments for set_v2_date_estimates --- admin/src/components/Emails/emails.ts | 5 + admin/src/components/ProposalDetail/index.tsx | 12 +- admin/src/types.ts | 3 +- backend/grant/admin/example_emails.py | 4 + backend/grant/admin/views.py | 12 + backend/grant/email/send.py | 12 + backend/grant/milestone/models.py | 61 ++++- backend/grant/proposal/models.py | 30 ++- backend/grant/task/jobs.py | 62 ++++- .../templates/emails/milestone_deadline.html | 32 +++ .../templates/emails/milestone_deadline.txt | 3 + backend/migrations/versions/4ca14e6e8976_.py | 34 +++ backend/tests/admin/test_admin_api.py | 12 + backend/tests/config.py | 4 +- backend/tests/milestone/__init__.py | 0 .../tests/milestone/test_milestone_methods.py | 151 ++++++++++++ backend/tests/task/test_api.py | 232 +++++++++++++++++- backend/tests/test_data.py | 2 +- .../components/CreateFlow/Milestones.tsx | 58 ++--- .../client/components/CreateFlow/Review.tsx | 5 +- .../client/components/CreateFlow/example.ts | 13 +- .../components/Proposal/Milestones/index.tsx | 4 +- frontend/client/modules/create/utils.ts | 38 +-- frontend/types/milestone.ts | 5 +- 24 files changed, 683 insertions(+), 111 deletions(-) create mode 100644 backend/grant/templates/emails/milestone_deadline.html create mode 100644 backend/grant/templates/emails/milestone_deadline.txt create mode 100644 backend/migrations/versions/4ca14e6e8976_.py create mode 100644 backend/tests/milestone/__init__.py create mode 100644 backend/tests/milestone/test_milestone_methods.py diff --git a/admin/src/components/Emails/emails.ts b/admin/src/components/Emails/emails.ts index f7df432b..4ea886cb 100644 --- a/admin/src/components/Emails/emails.ts +++ b/admin/src/components/Emails/emails.ts @@ -130,6 +130,11 @@ export default [ title: 'Milestone paid', description: 'Sent when milestone is paid', }, + { + id: 'milestone_deadline', + title: 'Milestone deadline', + description: 'Sent when the estimated deadline for milestone has been reached', + }, { id: 'admin_approval', title: 'Admin Approval', diff --git a/admin/src/components/ProposalDetail/index.tsx b/admin/src/components/ProposalDetail/index.tsx index b2826bef..c9e3b62b 100644 --- a/admin/src/components/ProposalDetail/index.tsx +++ b/admin/src/components/ProposalDetail/index.tsx @@ -391,9 +391,19 @@ class ProposalDetailNaked extends React.Component { extra={`${milestone.payoutPercent}% Payout`} key={i} > + {p.isVersionTwo && ( +

+ Estimated Days to Complete:{' '} + {milestone.immediatePayout ? 'N/A' : milestone.daysEstimated}{' '} +

+ )}

- Estimated Date: {formatDateSeconds(milestone.dateEstimated)}{' '} + Estimated Date:{' '} + {milestone.dateEstimated + ? formatDateSeconds(milestone.dateEstimated) + : 'N/A'}{' '}

+

{milestone.content}

))} diff --git a/admin/src/types.ts b/admin/src/types.ts index 729efb46..5c094dae 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -17,7 +17,8 @@ export interface Milestone { index: number; content: string; dateCreated: number; - dateEstimated: number; + dateEstimated?: number; + daysEstimated?: string; dateRequested: number; dateAccepted: number; dateRejected: number; diff --git a/backend/grant/admin/example_emails.py b/backend/grant/admin/example_emails.py index a357c534..f4f9f24b 100644 --- a/backend/grant/admin/example_emails.py +++ b/backend/grant/admin/example_emails.py @@ -149,6 +149,10 @@ example_email_args = { 'proposal': proposal, 'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones', }, + 'milestone_deadline': { + 'proposal': proposal, + 'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones', + }, 'milestone_reject': { 'proposal': proposal, 'admin_note': 'We noticed that the tests were failing for the features outlined in this milestone. Please address these issues.', diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index 77112a91..ccdc4aa7 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -363,6 +363,10 @@ def approve_proposal(id, is_accepted, with_funding, reject_reason=None): proposal = Proposal.query.filter_by(id=id).first() if proposal: proposal.approve_pending(is_accepted, with_funding, reject_reason) + + if is_accepted and with_funding: + Milestone.set_v2_date_estimates(proposal) + db.session.commit() return proposal_schema.dump(proposal) @@ -383,6 +387,7 @@ def change_proposal_to_accepted_with_funding(id): return {"message": "Only live or approved proposals can be modified by this endpoint"}, 404 proposal.update_proposal_with_funding() + Milestone.set_v2_date_estimates(proposal) db.session.add(proposal) db.session.commit() @@ -415,12 +420,14 @@ def paid_milestone_payout_request(id, mid, tx_id): return {"message": "Proposal is not fully funded"}, 400 for ms in proposal.milestones: if ms.id == int(mid): + is_final_milestone = False ms.mark_paid(tx_id) db.session.add(ms) db.session.flush() # check if this is the final ms, and update proposal.stage num_paid = reduce(lambda a, x: a + (1 if x.stage == MilestoneStage.PAID else 0), proposal.milestones, 0) if num_paid == len(proposal.milestones): + is_final_milestone = True proposal.stage = ProposalStage.COMPLETED # WIP -> COMPLETED db.session.add(proposal) db.session.flush() @@ -442,6 +449,11 @@ def paid_milestone_payout_request(id, mid, tx_id): email_args={"milestone": ms}, url_suffix="?tab=milestones", ) + + if not is_final_milestone: + Milestone.set_v2_date_estimates(proposal) + db.session.commit() + return proposal_schema.dump(proposal), 200 return {"message": "No milestone matching id"}, 404 diff --git a/backend/grant/email/send.py b/backend/grant/email/send.py index cdf9830e..ba65db8c 100644 --- a/backend/grant/email/send.py +++ b/backend/grant/email/send.py @@ -245,6 +245,17 @@ def milestone_request(email_args): } +def milestone_deadline(email_args): + p = email_args['proposal'] + ms = p.current_milestone + return { + 'subject': f'Milestone deadline reached for {p.title} - {ms.title}', + 'title': f'Milestone deadline reached', + 'preview': f'The estimated deadline for milestone {ms.title} has been reached.', + 'subscription': EmailSubscription.ARBITER, + } + + def milestone_reject(email_args): p = email_args['proposal'] ms = p.current_milestone @@ -351,6 +362,7 @@ get_info_lookup = { 'comment_reply': comment_reply, 'proposal_arbiter': proposal_arbiter, 'milestone_request': milestone_request, + 'milestone_deadline': milestone_deadline, 'milestone_reject': milestone_reject, 'milestone_accept': milestone_accept, 'milestone_paid': milestone_paid, diff --git a/backend/grant/milestone/models.py b/backend/grant/milestone/models.py index 695a90b6..48040922 100644 --- a/backend/grant/milestone/models.py +++ b/backend/grant/milestone/models.py @@ -5,6 +5,7 @@ from grant.utils.enums import MilestoneStage from grant.utils.exceptions import ValidationException from grant.utils.ma_fields import UnixDate from grant.utils.misc import gen_random_id +from grant.task.jobs import MilestoneDeadline class MilestoneException(Exception): @@ -22,7 +23,8 @@ class Milestone(db.Model): content = db.Column(db.Text, nullable=False) payout_percent = db.Column(db.String(255), nullable=False) immediate_payout = db.Column(db.Boolean) - date_estimated = db.Column(db.DateTime, nullable=False) + date_estimated = db.Column(db.DateTime, nullable=True) + days_estimated = db.Column(db.String(255), nullable=True) stage = db.Column(db.String(255), nullable=False) @@ -46,7 +48,7 @@ class Milestone(db.Model): index: int, title: str, content: str, - date_estimated: datetime, + days_estimated: str, payout_percent: str, immediate_payout: bool, stage: str = MilestoneStage.IDLE, @@ -56,13 +58,14 @@ class Milestone(db.Model): self.title = title[:255] self.content = content[:255] self.stage = stage - self.date_estimated = date_estimated + self.days_estimated = days_estimated[:255] self.payout_percent = payout_percent[:255] self.immediate_payout = immediate_payout self.proposal_id = proposal_id self.date_created = datetime.datetime.now() self.index = index + @staticmethod def make(milestones_data, proposal): if milestones_data: @@ -72,7 +75,7 @@ class Milestone(db.Model): m = Milestone( title=milestone_data["title"][:255], content=milestone_data["content"][:255], - date_estimated=datetime.datetime.fromtimestamp(milestone_data["date_estimated"]), + days_estimated=str(milestone_data["days_estimated"])[:255], payout_percent=str(milestone_data["payout_percent"])[:255], immediate_payout=milestone_data["immediate_payout"], proposal_id=proposal.id, @@ -80,6 +83,55 @@ class Milestone(db.Model): ) db.session.add(m) + # The purpose of this method is to set the `date_estimated` property on all milestones in a proposal. This works + # by figuring out a starting point for each milestone (the `base_date` below) and adding `days_estimated`. + # + # As proposal creators now estimate their milestones in days (instead of picking months), this method allows us to + # keep `date_estimated` in sync throughout the lifecycle of a proposal. For example, if a user misses their + # first milestone deadline by a week, this method would take the actual completion date of that milestone and + # adjust the `date_estimated` of the remaining milestones accordingly. + # + @staticmethod + def set_v2_date_estimates(proposal): + if not proposal.date_approved: + raise MilestoneException(f'Cannot estimate milestone dates because proposal has no date_approved set') + + # The milestone being actively worked on + current_milestone = proposal.current_milestone + + if current_milestone.stage == MilestoneStage.PAID: + raise MilestoneException(f'Cannot estimate milestone dates because they are all completed') + + # The starting point for `date_estimated` calculation for each uncompleted milestone + # We add `days_estimated` to `base_date` to calculate `date_estimated` + base_date = None + + for index, milestone in enumerate(proposal.milestones): + if index == 0: + # If it's the first milestone, use the proposal approval date as a `base_date` + base_date = proposal.date_approved + + if milestone.date_paid: + # If milestone has been paid, set `base_date` for the next milestone and noop out + base_date = milestone.date_paid + continue + + days_estimated = milestone.days_estimated if not milestone.immediate_payout else "0" + date_estimated = base_date + datetime.timedelta(days=int(days_estimated)) + milestone.date_estimated = date_estimated + + # Set the `base_date` for the next milestone using the estimate completion date of the current milestone + base_date = date_estimated + db.session.add(milestone) + + # Skip task creation if current milestone has an immediate payout + if current_milestone.immediate_payout: + return + + # Create MilestoneDeadline task for the current milestone so arbiters will be alerted if the deadline is missed + task = MilestoneDeadline(proposal, current_milestone) + task.make_task() + def request_payout(self, user_id: int): if self.stage not in [MilestoneStage.IDLE, MilestoneStage.REJECTED]: raise MilestoneException(f'Cannot request payout for milestone at {self.stage} stage') @@ -140,6 +192,7 @@ class MilestoneSchema(ma.Schema): "date_rejected", "date_accepted", "date_paid", + "days_estimated" ) date_created = UnixDate(attribute='date_created') diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 571f6e49..45e38c9b 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -381,13 +381,24 @@ class Proposal(db.Model): # Then run through regular validation Proposal.simple_validate(vars(self)) - # only do this when user submits for approval, there is a chance the dates will - # be passed by the time admin approval / user publishing occurs - def validate_milestone_dates(self): - present = datetime.datetime.today().replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + def validate_milestone_days(self): for milestone in self.milestones: - if present > milestone.date_estimated: - raise ValidationException("Milestone date estimate must be in the future ") + if milestone.immediate_payout: + continue + + try: + p = float(milestone.days_estimated) + if not p.is_integer(): + raise ValidationException("Milestone days estimated must be whole numbers, no decimals") + if p <= 0: + raise ValidationException("Milestone days estimated must be greater than zero") + if p > 365: + raise ValidationException("Milestone days estimated must be less than 365") + + except ValueError: + raise ValidationException("Milestone days estimated must be a number") + return @staticmethod def create(**kwargs): @@ -499,7 +510,7 @@ class Proposal(db.Model): # state: status (DRAFT || REJECTED) -> (PENDING || STAKING) def submit_for_approval(self): self.validate_publishable() - self.validate_milestone_dates() + self.validate_milestone_days() allowed_statuses = [ProposalStatus.DRAFT, ProposalStatus.REJECTED] # specific validation if self.status not in allowed_statuses: @@ -536,6 +547,11 @@ class Proposal(db.Model): self.status = ProposalStatus.LIVE self.date_approved = datetime.datetime.now() self.accepted_with_funding = with_funding + + # also update date_published and stage since publish() is no longer called by user + self.date_published = datetime.datetime.now() + self.stage = ProposalStage.WIP + with_or_out = 'without' if with_funding: self.fully_fund_contibution_bounty() diff --git a/backend/grant/task/jobs.py b/backend/grant/task/jobs.py index 10fcb95f..b5ce9c11 100644 --- a/backend/grant/task/jobs.py +++ b/backend/grant/task/jobs.py @@ -170,9 +170,69 @@ class PruneDraft: db.session.commit() +class MilestoneDeadline: + JOB_TYPE = 5 + + def __init__(self, proposal, milestone): + self.proposal = proposal + self.milestone = milestone + + def blobify(self): + from grant.proposal.models import ProposalUpdate + + update_count = len(ProposalUpdate.query.filter_by(proposal_id=self.proposal.id).all()) + return { + "proposal_id": self.proposal.id, + "milestone_id": self.milestone.id, + "update_count": update_count + } + + def make_task(self): + from .models import Task + task = Task( + job_type=self.JOB_TYPE, + blob=self.blobify(), + execute_after=self.milestone.date_estimated, + ) + db.session.add(task) + db.session.commit() + + @staticmethod + def process_task(task): + from grant.proposal.models import Proposal, ProposalUpdate + from grant.milestone.models import Milestone + + proposal_id = task.blob["proposal_id"] + milestone_id = task.blob["milestone_id"] + update_count = task.blob["update_count"] + + proposal = Proposal.query.filter_by(id=proposal_id).first() + milestone = Milestone.query.filter_by(id=milestone_id).first() + current_update_count = len(ProposalUpdate.query.filter_by(proposal_id=proposal_id).all()) + + # if proposal was deleted or cancelled, noop out + if not proposal or proposal.status == ProposalStatus.DELETED or proposal.stage == ProposalStage.CANCELED: + return + + # if milestone was deleted, noop out + if not milestone: + return + + # if milestone payout has been requested or an update has been posted, noop out + if current_update_count > update_count or milestone.date_requested: + return + + # send email to arbiter notifying milestone deadline has been missed + send_email(proposal.arbiter.user.email_address, 'milestone_deadline', { + 'proposal': proposal, + 'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'), + }) + + JOBS = { 1: ProposalReminder.process_task, 2: ProposalDeadline.process_task, 3: ContributionExpired.process_task, - 4: PruneDraft.process_task + 4: PruneDraft.process_task, + 5: MilestoneDeadline.process_task } diff --git a/backend/grant/templates/emails/milestone_deadline.html b/backend/grant/templates/emails/milestone_deadline.html new file mode 100644 index 00000000..e70a8688 --- /dev/null +++ b/backend/grant/templates/emails/milestone_deadline.html @@ -0,0 +1,32 @@ +

+ The estimated deadline has been reached for proposal milestone + + {{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}. +

+ + + + + +
+ + + + +
+ + View the milestone + +
+
diff --git a/backend/grant/templates/emails/milestone_deadline.txt b/backend/grant/templates/emails/milestone_deadline.txt new file mode 100644 index 00000000..be948c6f --- /dev/null +++ b/backend/grant/templates/emails/milestone_deadline.txt @@ -0,0 +1,3 @@ +The estimated deadline has been reached for proposal milestone "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}". + +View the milestone: {{ args.proposal_milestones_url }} diff --git a/backend/migrations/versions/4ca14e6e8976_.py b/backend/migrations/versions/4ca14e6e8976_.py new file mode 100644 index 00000000..849440d2 --- /dev/null +++ b/backend/migrations/versions/4ca14e6e8976_.py @@ -0,0 +1,34 @@ +"""empty message + +Revision ID: 4ca14e6e8976 +Revises: 7fea7427e9d6 +Create Date: 2019-11-06 12:58:45.503087 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '4ca14e6e8976' +down_revision = '7fea7427e9d6' +branch_labels = None +depends_on = None + + +def upgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.add_column('milestone', sa.Column('days_estimated', sa.String(length=255), nullable=True)) + op.alter_column('milestone', 'date_estimated', + existing_type=postgresql.TIMESTAMP(), + nullable=True) + # ### end Alembic commands ### + + +def downgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.alter_column('milestone', 'date_estimated', + existing_type=postgresql.TIMESTAMP(), + nullable=False) + op.drop_column('milestone', 'days_estimated') + # ### end Alembic commands ### diff --git a/backend/tests/admin/test_admin_api.py b/backend/tests/admin/test_admin_api.py index f0b5c9f8..9bbc50b5 100644 --- a/backend/tests/admin/test_admin_api.py +++ b/backend/tests/admin/test_admin_api.py @@ -260,6 +260,10 @@ class TestAdminAPI(BaseProposalCreatorConfig): self.assertEqual(resp.json["acceptedWithFunding"], True) self.assertEqual(resp.json["target"], resp.json["contributionBounty"]) + # milestones should have estimated dates + for milestone in resp.json["milestones"]: + self.assertIsNotNone(milestone["dateEstimated"]) + @patch('requests.get', side_effect=mock_blockchain_api_requests) def test_accept_proposal_without_funding(self, mock_get): self.login_admin() @@ -278,6 +282,10 @@ class TestAdminAPI(BaseProposalCreatorConfig): self.assertEqual(resp.json["acceptedWithFunding"], False) self.assertEqual(resp.json["contributionBounty"], "0") + # milestones should not have estimated dates + for milestone in resp.json["milestones"]: + self.assertIsNone(milestone["dateEstimated"]) + @patch('requests.get', side_effect=mock_blockchain_api_requests) def test_change_proposal_to_accepted_with_funding(self, mock_get): self.login_admin() @@ -300,6 +308,10 @@ class TestAdminAPI(BaseProposalCreatorConfig): self.assert200(resp) self.assertEqual(resp.json["acceptedWithFunding"], True) + # milestones should have estimated dates + for milestone in resp.json["milestones"]: + self.assertIsNotNone(milestone["dateEstimated"]) + # should fail if proposal is already accepted with funding resp = self.app.put( f"/api/v1/admin/proposals/{self.proposal.id}/accept/fund" diff --git a/backend/tests/config.py b/backend/tests/config.py index bdf196b3..bd8c8b5a 100644 --- a/backend/tests/config.py +++ b/backend/tests/config.py @@ -138,14 +138,14 @@ class BaseProposalCreatorConfig(BaseUserConfig): { "title": "Milestone 1", "content": "Content 1", - "date_estimated": (datetime.now() + timedelta(days=364)).timestamp(), # random unix time in the future + "days_estimated": "30", "payout_percent": 50, "immediate_payout": True }, { "title": "Milestone 2", "content": "Content 2", - "date_estimated": (datetime.now() + timedelta(days=365)).timestamp(), # random unix time in the future + "days_estimated": "20", "payout_percent": 50, "immediate_payout": False } diff --git a/backend/tests/milestone/__init__.py b/backend/tests/milestone/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/milestone/test_milestone_methods.py b/backend/tests/milestone/test_milestone_methods.py new file mode 100644 index 00000000..59804986 --- /dev/null +++ b/backend/tests/milestone/test_milestone_methods.py @@ -0,0 +1,151 @@ +import json +import datetime +from mock import patch +from grant.proposal.models import Proposal, db, proposal_schema +from grant.milestone.models import Milestone +from grant.task.models import Task +from grant.task.jobs import MilestoneDeadline +from grant.utils.enums import ProposalStatus, Category, MilestoneStage +from ..config import BaseUserConfig +from ..test_data import test_team, mock_blockchain_api_requests + + +test_milestones = [ + { + "title": "first milestone", + "content": "content", + "daysEstimated": "30", + "payoutPercent": "25", + "immediatePayout": False + }, + { + "title": "second milestone", + "content": "content", + "daysEstimated": "10", + "payoutPercent": "25", + "immediatePayout": False + }, + { + "title": "third milestone", + "content": "content", + "daysEstimated": "20", + "payoutPercent": "25", + "immediatePayout": False + }, + { + "title": "fourth milestone", + "content": "content", + "daysEstimated": "30", + "payoutPercent": "25", + "immediatePayout": False + } +] + +test_proposal = { + "team": test_team, + "content": "## My Proposal", + "title": "Give Me Money", + "brief": "$$$", + "milestones": test_milestones, + "category": Category.ACCESSIBILITY, + "target": "123.456", + "payoutAddress": "123", +} + + +class TestMilestoneMethods(BaseUserConfig): + + def init_proposal(self, proposal_data): + self.login_default_user() + resp = self.app.post( + "/api/v1/proposals/drafts" + ) + self.assertStatus(resp, 201) + proposal_id = resp.json["proposalId"] + + resp = self.app.put( + f"/api/v1/proposals/{proposal_id}", + data=json.dumps(proposal_data), + content_type='application/json' + ) + self.assert200(resp) + + proposal = Proposal.query.get(proposal_id) + proposal.status = ProposalStatus.PENDING + + # accept with funding + proposal.approve_pending(True, True) + Milestone.set_v2_date_estimates(proposal) + + db.session.add(proposal) + db.session.commit() + + print(proposal_schema.dump(proposal)) + return proposal + + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_set_v2_date_estimates(self, mock_get): + proposal_data = test_proposal.copy() + proposal = self.init_proposal(proposal_data) + total_days_estimated = 0 + + # make sure date_estimated has been populated on all milestones + for milestone in proposal.milestones: + total_days_estimated += int(milestone.days_estimated) + self.assertIsNotNone(milestone.date_estimated) + + # check the proposal `date_approved` has been used for first milestone calculation + first_milestone = proposal.milestones[0] + expected_base_date = proposal.date_approved + expected_days_estimated = first_milestone.days_estimated + expected_date_estimated = expected_base_date + datetime.timedelta(days=int(expected_days_estimated)) + + self.assertEqual(first_milestone.date_estimated, expected_date_estimated) + + # check that the `date_estimated` of the final milestone has been calculated with the cumulative + # `days_estimated` of the previous milestones + last_milestone = proposal.milestones[-1] + expected_date_estimated = expected_base_date + datetime.timedelta(days=int(total_days_estimated)) + self.assertEqual(last_milestone.date_estimated, expected_date_estimated) + + # check to see a task has been created + tasks = Task.query.filter_by(job_type=MilestoneDeadline.JOB_TYPE).all() + self.assertEqual(len(tasks), 1) + + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_set_v2_date_estimates_immediate_payout(self, mock_get): + proposal_data = test_proposal.copy() + proposal_data["milestones"][0]["immediate_payout"] = True + + self.init_proposal(proposal_data) + tasks = Task.query.filter_by(job_type=MilestoneDeadline.JOB_TYPE).all() + + # ensure MilestoneDeadline task not created when immediate payout is set + self.assertEqual(len(tasks), 0) + + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_set_v2_date_estimates_deadline_recalculation(self, mock_get): + proposal_data = test_proposal.copy() + proposal = self.init_proposal(proposal_data) + + first_ms = proposal.milestones[0] + second_ms = proposal.milestones[1] + + first_ms.stage = MilestoneStage.PAID + first_ms.date_paid = datetime.datetime.now() + + expected_base_date = datetime.datetime.now() + datetime.timedelta(days=42) + second_ms.stage = MilestoneStage.PAID + second_ms.date_paid = expected_base_date + + db.session.add(proposal) + db.session.commit() + + Milestone.set_v2_date_estimates(proposal) + + proposal = Proposal.query.get(proposal.id) + third_ms = proposal.milestones[2] + expected_date_estimated = expected_base_date + datetime.timedelta(days=int(third_ms.days_estimated)) + + # ensure `date_estimated` was recalculated as expected + self.assertEqual(third_ms.date_estimated, expected_date_estimated) diff --git a/backend/tests/task/test_api.py b/backend/tests/task/test_api.py index 5072b561..d878e04c 100644 --- a/backend/tests/task/test_api.py +++ b/backend/tests/task/test_api.py @@ -1,17 +1,71 @@ +import json + +from grant.utils import totp_2fa +from grant.task.jobs import MilestoneDeadline from datetime import datetime, timedelta from grant.task.models import Task, db from grant.task.jobs import PruneDraft from grant.milestone.models import Milestone -from grant.proposal.models import Proposal -from grant.utils.enums import ProposalStatus, Category +from grant.proposal.models import Proposal, ProposalUpdate +from grant.utils.enums import ProposalStatus, ProposalStage, Category + +from ..config import BaseProposalCreatorConfig +from ..test_data import mock_blockchain_api_requests from mock import patch, Mock -from ..config import BaseProposalCreatorConfig +test_update = { + "title": "Update Title", + "content": "Update content." +} +milestones_data = [ + { + "title": "All the money straightaway", + "content": "cool stuff with it", + "days_estimated": 30, + "payout_percent": "100", + "immediate_payout": False + } +] class TestTaskAPI(BaseProposalCreatorConfig): + def p(self, path, data): + return self.app.post(path, data=json.dumps(data), content_type="application/json") + + def login_admin(self): + # set admin + self.user.set_admin(True) + db.session.commit() + + # login + r = self.p("/api/v1/admin/login", { + "username": self.user.email_address, + "password": self.user_password + }) + self.assert200(r) + + # 2fa on the natch + r = self.app.get("/api/v1/admin/2fa") + self.assert200(r) + + # ... init + r = self.app.get("/api/v1/admin/2fa/init") + self.assert200(r) + + codes = r.json['backupCodes'] + secret = r.json['totpSecret'] + uri = r.json['totpUri'] + + # ... enable/verify + r = self.p("/api/v1/admin/2fa/enable", { + "backupCodes": codes, + "totpSecret": secret, + "verifyCode": totp_2fa.current_totp(secret) + }) + self.assert200(r) + return r def test_proposal_reminder_task_is_created(self): tasks = Task.query.filter(Task.execute_after <= datetime.now()).filter_by(completed=False).all() @@ -89,15 +143,6 @@ class TestTaskAPI(BaseProposalCreatorConfig): p.payout_address = 'address' def milestones(p): - milestones_data = [ - { - "title": "All the money straightaway", - "content": "cool stuff with it", - "date_estimated": 1549505307, - "payout_percent": "100", - "immediate_payout": False - } - ] Milestone.make(milestones_data, p) modifiers = [ @@ -133,3 +178,166 @@ class TestTaskAPI(BaseProposalCreatorConfig): proposal = Proposal.query.get(proposal_id) self.assertIsNotNone(proposal) + + @patch('grant.task.jobs.send_email') + @patch('grant.task.views.datetime') + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_milestone_deadline(self, mock_get, mock_datetime, mock_send_email): + tasks = Task.query.filter_by(completed=False).all() + self.assertEqual(len(tasks), 0) + + self.proposal.arbiter.user = self.user + db.session.add(self.proposal) + + # unset immediate_payout so task will be added + for milestone in self.proposal.milestones: + if milestone.immediate_payout: + milestone.immediate_payout = False + db.session.add(milestone) + + db.session.commit() + + self.login_admin() + + # proposal needs to be PENDING + self.proposal.status = ProposalStatus.PENDING + + # approve proposal with funding + resp = self.app.put( + "/api/v1/admin/proposals/{}/accept".format(self.proposal.id), + data=json.dumps({"isAccepted": True, "withFunding": True}) + ) + self.assert200(resp) + + tasks = Task.query.filter_by(completed=False).all() + self.assertEqual(len(tasks), 1) + + # fast forward the clock so task will run + after_time = datetime.now() + timedelta(days=365) + mock_datetime.now = Mock(return_value=after_time) + + # run task + resp = self.app.get("/api/v1/task") + self.assert200(resp) + + # make sure task ran + tasks = Task.query.filter_by(completed=False).all() + self.assertEqual(len(tasks), 0) + mock_send_email.assert_called() + + @patch('grant.task.jobs.send_email') + def test_milestone_deadline_update_posted(self, mock_send_email): + tasks = Task.query.all() + self.assertEqual(len(tasks), 0) + + # set date_estimated on milestone to be in the past + milestone = self.proposal.milestones[0] + milestone.date_estimated = datetime.now() - timedelta(hours=1) + db.session.add(milestone) + db.session.commit() + + # make task + ms_deadline = MilestoneDeadline(self.proposal, milestone) + ms_deadline.make_task() + + # check make task + tasks = Task.query.all() + self.assertEqual(len(tasks), 1) + + # login and post proposal update + self.login_default_user() + resp = self.app.post( + "/api/v1/proposals/{}/updates".format(self.proposal.id), + data=json.dumps(test_update), + content_type='application/json' + ) + self.assertStatus(resp, 201) + + # run task + resp = self.app.get("/api/v1/task") + self.assert200(resp) + + # make sure task ran and did NOT send out an email + tasks = Task.query.filter_by(completed=False).all() + self.assertEqual(len(tasks), 0) + mock_send_email.assert_not_called() + + @patch('grant.task.jobs.send_email') + def test_milestone_deadline_noops(self, mock_send_email): + # make sure all milestone deadline noop states work as expected + + def proposal_delete(p, m): + db.session.delete(p) + + def proposal_status(p, m): + p.status = ProposalStatus.DELETED + db.session.add(p) + + def proposal_stage(p, m): + p.stage = ProposalStage.CANCELED + db.session.add(p) + + def milestone_delete(p, m): + db.session.delete(m) + + def milestone_date_requested(p, m): + m.date_requested = datetime.now() + db.session.add(m) + + def update_posted(p, m): + # login and post proposal update + self.login_default_user() + resp = self.app.post( + "/api/v1/proposals/{}/updates".format(proposal.id), + data=json.dumps(test_update), + content_type='application/json' + ) + self.assertStatus(resp, 201) + + modifiers = [ + proposal_delete, + proposal_status, + proposal_stage, + milestone_delete, + milestone_date_requested, + update_posted + ] + + for modifier in modifiers: + # make proposal and milestone + proposal = Proposal.create(status=ProposalStatus.LIVE) + proposal.arbiter.user = self.other_user + proposal.team.append(self.user) + proposal_id = proposal.id + Milestone.make(milestones_data, proposal) + + db.session.add(proposal) + db.session.commit() + + # grab update count for blob + update_count = len(ProposalUpdate.query.filter_by(proposal_id=proposal_id).all()) + + # run modifications to trigger noop + proposal = Proposal.query.get(proposal_id) + milestone = proposal.milestones[0] + milestone_id = milestone.id + modifier(proposal, milestone) + db.session.commit() + + # make task + blob = { + "proposal_id": proposal_id, + "milestone_id": milestone_id, + "update_count": update_count + } + task = Task( + job_type=MilestoneDeadline.JOB_TYPE, + blob=blob, + execute_after=datetime.now() + ) + + # run task + MilestoneDeadline.process_task(task) + + # check to make sure noop occurred + mock_send_email.assert_not_called() diff --git a/backend/tests/test_data.py b/backend/tests/test_data.py index d7c323c3..d39d3434 100644 --- a/backend/tests/test_data.py +++ b/backend/tests/test_data.py @@ -31,7 +31,7 @@ milestones = [ { "title": "All the money straightaway", "content": "cool stuff with it", - "dateEstimated": 1549505307, + "daysEstimated": "30", "payoutPercent": "100", "immediatePayout": False } diff --git a/frontend/client/components/CreateFlow/Milestones.tsx b/frontend/client/components/CreateFlow/Milestones.tsx index 2e060621..33df5988 100644 --- a/frontend/client/components/CreateFlow/Milestones.tsx +++ b/frontend/client/components/CreateFlow/Milestones.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { Form, Input, DatePicker, Card, Icon, Alert, Checkbox, Button } from 'antd'; -import moment from 'moment'; +import { Form, Input, Card, Icon, Alert, Checkbox, Button } from 'antd'; import { ProposalDraft, CreateMilestone } from 'types'; import { getCreateErrors } from 'modules/create/utils'; @@ -18,7 +17,7 @@ const DEFAULT_STATE: State = { { title: '', content: '', - dateEstimated: moment().unix(), + daysEstimated: '', payoutPercent: '', immediatePayout: false, }, @@ -78,11 +77,7 @@ export default class CreateFlowMilestones extends React.Component milestone={milestone} index={idx} error={errors.milestones && errors.milestones[idx]} - previousMilestoneDateEstimate={ - milestones[idx - 1] && milestones[idx - 1].dateEstimated - ? moment(milestones[idx - 1].dateEstimated * 1000) - : undefined - } + onChange={this.handleMilestoneChange} onRemove={this.removeMilestone} /> @@ -101,7 +96,7 @@ export default class CreateFlowMilestones extends React.Component interface MilestoneFieldsProps { index: number; milestone: CreateMilestone; - previousMilestoneDateEstimate: moment.Moment | undefined; + // previousMilestoneDateEstimate: moment.Moment | undefined; error: Falsy | string; onChange(index: number, milestone: CreateMilestone): void; onRemove(index: number): void; @@ -113,7 +108,6 @@ const MilestoneFields = ({ error, onChange, onRemove, - previousMilestoneDateEstimate, }: MilestoneFieldsProps) => (
@@ -153,35 +147,20 @@ const MilestoneFields = ({
- - onChange(index, { ...milestone, dateEstimated: time.startOf('month').unix() }) - } + { - if (!previousMilestoneDateEstimate) { - return current - ? current < - moment() - .subtract(1, 'month') - .endOf('month') - : false; - } else { - return current - ? current < - moment() - .subtract(1, 'month') - .endOf('month') || current < previousMilestoneDateEstimate - : false; - } - }} + placeholder="Estimated days to complete" + onChange={ev =>{ + return onChange(index, { + ...milestone, + daysEstimated: ev.currentTarget.value + }) + } + } + addonAfter="days" + style={{ flex: 1, marginRight: '0.5rem' }} + maxLength={6} /> diff --git a/frontend/client/components/CreateFlow/Review.tsx b/frontend/client/components/CreateFlow/Review.tsx index 187e9028..cb9ce0a6 100644 --- a/frontend/client/components/CreateFlow/Review.tsx +++ b/frontend/client/components/CreateFlow/Review.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; import { Icon, Timeline } from 'antd'; -import moment from 'moment'; import { getCreateErrors, KeyOfForm, FIELD_NAME_MAP } from 'modules/create/utils'; import Markdown from 'components/Markdown'; import UserAvatar from 'components/UserAvatar'; @@ -190,9 +189,9 @@ const ReviewMilestones = ({
{m.title || No title}
- {moment(m.dateEstimated * 1000).format('MMMM YYYY')} + {m.immediatePayout || !m.daysEstimated ? '0' : m.daysEstimated} days {' – '} - {m.payoutPercent}% of funds + {m.payoutPercent || '0'}% of funds
{m.content || No description} diff --git a/frontend/client/components/CreateFlow/example.ts b/frontend/client/components/CreateFlow/example.ts index 6168dee2..d46953c1 100644 --- a/frontend/client/components/CreateFlow/example.ts +++ b/frontend/client/components/CreateFlow/example.ts @@ -1,4 +1,3 @@ -import moment from 'moment'; import { PROPOSAL_CATEGORY } from 'api/constants'; import { ProposalDraft } from 'types'; @@ -20,9 +19,7 @@ const createExampleProposal = (): Partial => { title: 'Initial Funding', content: 'This will be used to pay for a professional designer to hand-craft each letter on the shirt.', - dateEstimated: moment() - .add(1, 'month') - .unix(), + daysEstimated: '40', payoutPercent: '30', immediatePayout: true, }, @@ -30,9 +27,7 @@ const createExampleProposal = (): Partial => { title: 'Test Prints', content: "We'll get test prints from 5 different factories and choose the highest quality shirts. Once we've decided, we'll order a full batch of prints.", - dateEstimated: moment() - .add(2, 'month') - .unix(), + daysEstimated: '30', payoutPercent: '20', immediatePayout: false, }, @@ -40,9 +35,7 @@ const createExampleProposal = (): Partial => { title: 'All Shirts Printed', content: "All of the shirts have been printed, hooray! They'll be given out at conferences and meetups.", - dateEstimated: moment() - .add(3, 'month') - .unix(), + daysEstimated: '30', payoutPercent: '50', immediatePayout: false, }, diff --git a/frontend/client/components/Proposal/Milestones/index.tsx b/frontend/client/components/Proposal/Milestones/index.tsx index c6daddb4..6984ffc4 100644 --- a/frontend/client/components/Proposal/Milestones/index.tsx +++ b/frontend/client/components/Proposal/Milestones/index.tsx @@ -331,7 +331,9 @@ interface MilestoneProps extends MSProps { isFunded: boolean; } const Milestone: React.SFC = p => { - const estimatedDate = moment(p.dateEstimated * 1000).format('MMMM YYYY'); + const estimatedDate = p.dateEstimated + ? moment(p.dateEstimated * 1000).format('MMMM YYYY') + : 'N/A'; const reward = ; const getAlertProps = { [MILESTONE_STAGE.IDLE]: () => null, diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index b096186a..69c7a87f 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -1,11 +1,4 @@ -import { - ProposalDraft, - STATUS, - MILESTONE_STAGE, - PROPOSAL_ARBITER_STATUS, - CreateMilestone, -} from 'types'; -import moment from 'moment'; +import { ProposalDraft, STATUS, MILESTONE_STAGE, PROPOSAL_ARBITER_STATUS } from 'types'; import { User } from 'types'; import { getAmountError, @@ -131,7 +124,6 @@ export function getCreateErrors( // Milestones if (milestones) { let cumulativeMilestonePct = 0; - let lastMsEst: CreateMilestone['dateEstimated'] = 0; const milestoneErrors = milestones.map((ms, idx) => { // check payout first so we collect the cumulativePayout even if other fields are invalid if (!ms.payoutPercent) { @@ -161,22 +153,18 @@ export function getCreateErrors( return 'Description can only be 200 characters maximum'; } - if (!ms.dateEstimated) { - return 'Estimate date is required'; - } else { - // FE validation on milestone estimation - if ( - ms.dateEstimated < - moment(Date.now()) - .startOf('month') - .unix() - ) { - return 'Estimate date should be in the future'; + if (!ms.immediatePayout) { + if (!ms.daysEstimated) { + return 'Estimate in days is required'; + } else if (Number.isNaN(parseInt(ms.daysEstimated, 10))) { + return 'Days estimated must be a valid number'; + } else if (parseInt(ms.daysEstimated, 10) !== parseFloat(ms.daysEstimated)) { + return 'Days estimated must be a whole number, no decimals'; + } else if (parseInt(ms.daysEstimated, 10) <= 0) { + return 'Days estimated must be greater than 0'; + } else if (parseInt(ms.daysEstimated, 10) > 365) { + return 'Days estimated must be less than or equal to 365'; } - if (ms.dateEstimated <= lastMsEst) { - return 'Estimate date should be later than previous estimate date'; - } - lastMsEst = ms.dateEstimated; } if ( @@ -260,7 +248,7 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta title: m.title, content: m.content, amount: toZat(target * (parseInt(m.payoutPercent, 10) / 100)), - dateEstimated: m.dateEstimated, + daysEstimated: m.daysEstimated, immediatePayout: m.immediatePayout, payoutPercent: m.payoutPercent.toString(), stage: MILESTONE_STAGE.IDLE, diff --git a/frontend/types/milestone.ts b/frontend/types/milestone.ts index 2809503f..22c43d92 100644 --- a/frontend/types/milestone.ts +++ b/frontend/types/milestone.ts @@ -21,7 +21,8 @@ export interface Milestone { stage: MILESTONE_STAGE; amount: Zat; immediatePayout: boolean; - dateEstimated: number; + dateEstimated?: number; + daysEstimated?: string; dateRequested?: number; dateRejected?: number; dateAccepted?: number; @@ -40,7 +41,7 @@ export interface ProposalMilestone extends Milestone { export interface CreateMilestone { title: string; content: string; - dateEstimated: number; + daysEstimated?: string; payoutPercent: string; immediatePayout: boolean; } From 8cfec5de5d271bc9a47d2a92bf5fb8c5f5b94dad Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Wed, 13 Nov 2019 18:23:36 -0500 Subject: [PATCH 20/54] Remove Categories (#63) * remove category from admin * remove category from frontend, add likes to proposal card view * make category nullable in backend, remove from views * add db migration * remove category from frontend rfp * update tests * remove category from admin proposal * remove category from rfp put * remove moment * remove moment --- admin/src/components/ProposalDetail/index.tsx | 1 - admin/src/components/RFPDetail/index.tsx | 14 ++++--- admin/src/components/RFPForm/index.tsx | 40 +------------------ admin/src/types.ts | 3 -- backend/grant/admin/views.py | 5 +-- backend/grant/proposal/models.py | 8 +--- backend/grant/proposal/views.py | 2 - backend/grant/rfp/models.py | 7 +--- backend/migrations/versions/2013e180c438_.py | 38 ++++++++++++++++++ backend/tests/admin/test_admin_api.py | 16 -------- backend/tests/rfp/test_rfp_api.py | 1 - frontend/client/components/Card/index.tsx | 11 +++-- .../client/components/CreateFlow/Basics.tsx | 34 +--------------- .../client/components/CreateFlow/Review.tsx | 13 +----- .../client/components/CreateFlow/example.ts | 4 -- frontend/client/components/Like/index.tsx | 29 +++++++++----- .../Proposal/CampaignBlock/index.tsx | 12 +----- .../components/Proposals/Filters/index.tsx | 32 +-------------- .../Proposals/ProposalCard/index.tsx | 3 +- frontend/client/modules/create/utils.ts | 6 +-- frontend/stories/props.tsx | 3 +- frontend/types/proposal.ts | 3 +- frontend/types/rfp.ts | 3 +- 23 files changed, 87 insertions(+), 201 deletions(-) create mode 100644 backend/migrations/versions/2013e180c438_.py diff --git a/admin/src/components/ProposalDetail/index.tsx b/admin/src/components/ProposalDetail/index.tsx index c9e3b62b..76ddc4a1 100644 --- a/admin/src/components/ProposalDetail/index.tsx +++ b/admin/src/components/ProposalDetail/index.tsx @@ -445,7 +445,6 @@ class ProposalDetailNaked extends React.Component { {renderDeetItem('isFailed', JSON.stringify(p.isFailed))} {renderDeetItem('status', p.status)} {renderDeetItem('stage', p.stage)} - {renderDeetItem('category', p.category)} {renderDeetItem('target', p.target)} {renderDeetItem('contributed', p.contributed)} {renderDeetItem('funded (inc. matching)', p.funded)} diff --git a/admin/src/components/RFPDetail/index.tsx b/admin/src/components/RFPDetail/index.tsx index d6c5c8df..fc270c5c 100644 --- a/admin/src/components/RFPDetail/index.tsx +++ b/admin/src/components/RFPDetail/index.tsx @@ -37,9 +37,11 @@ class RFPDetail extends React.Component {
); - const pendingProposals = rfp.proposals.filter(p => p.status === PROPOSAL_STATUS.PENDING); - const acceptedProposals = rfp.proposals.filter(p => - p.status === PROPOSAL_STATUS.LIVE || p.status === PROPOSAL_STATUS.APPROVED + const pendingProposals = rfp.proposals.filter( + p => p.status === PROPOSAL_STATUS.PENDING, + ); + const acceptedProposals = rfp.proposals.filter( + p => p.status === PROPOSAL_STATUS.LIVE || p.status === PROPOSAL_STATUS.APPROVED, ); return ( @@ -90,10 +92,12 @@ class RFPDetail extends React.Component { {renderDeetItem('id', rfp.id)} {renderDeetItem('created', formatDateSeconds(rfp.dateCreated))} {renderDeetItem('status', rfp.status)} - {renderDeetItem('category', rfp.category)} {renderDeetItem('matching', String(rfp.matching))} {renderDeetItem('bounty', `${rfp.bounty} ZEC`)} - {renderDeetItem('dateCloses', rfp.dateCloses && formatDateSeconds(rfp.dateCloses))} + {renderDeetItem( + 'dateCloses', + rfp.dateCloses && formatDateSeconds(rfp.dateCloses), + )} {/* PROPOSALS */} diff --git a/admin/src/components/RFPForm/index.tsx b/admin/src/components/RFPForm/index.tsx index 30e6cb8e..a9ebe121 100644 --- a/admin/src/components/RFPForm/index.tsx +++ b/admin/src/components/RFPForm/index.tsx @@ -3,22 +3,10 @@ import moment from 'moment'; import { view } from 'react-easy-state'; import { RouteComponentProps, withRouter } from 'react-router'; import { Link } from 'react-router-dom'; -import { - Form, - Input, - Select, - Icon, - Button, - message, - Spin, - Row, - Col, - DatePicker, -} from 'antd'; +import { Form, Input, Select, Button, message, Spin, Row, Col, DatePicker } from 'antd'; import Exception from 'ant-design-pro/lib/Exception'; import { FormComponentProps } from 'antd/lib/form'; -import { PROPOSAL_CATEGORY, RFP_STATUS, RFPArgs } from 'src/types'; -import { CATEGORY_UI } from 'util/ui'; +import { RFP_STATUS, RFPArgs } from 'src/types'; import { typedKeys } from 'util/ts'; import { RFP_STATUSES, getStatusById } from 'util/statuses'; import Markdown from 'components/Markdown'; @@ -53,7 +41,6 @@ class RFPForm extends React.Component { title: '', brief: '', content: '', - category: '', status: '', matching: false, bounty: undefined, @@ -71,7 +58,6 @@ class RFPForm extends React.Component { title: rfp.title, brief: rfp.brief, content: rfp.content, - category: rfp.category, status: rfp.status, matching: rfp.matching, bounty: rfp.bounty, @@ -130,28 +116,6 @@ class RFPForm extends React.Component { )} - - {getFieldDecorator('category', { - initialValue: defaults.category, - rules: [ - { required: true, message: 'Category is required' }, - { max: 60, message: 'Max 60 chars' }, - ], - })( - , - )} - - {getFieldDecorator('brief', { initialValue: defaults.brief, diff --git a/admin/src/types.ts b/admin/src/types.ts index 5c094dae..1c3a84ab 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -42,7 +42,6 @@ export interface RFP { title: string; brief: string; content: string; - category: string; status: string; proposals: Proposal[]; matching: boolean; @@ -53,7 +52,6 @@ export interface RFPArgs { title: string; brief: string; content: string; - category: string; matching: boolean; dateCloses: number | null | undefined; bounty: string | null | undefined; @@ -103,7 +101,6 @@ export interface Proposal { title: string; content: string; stage: PROPOSAL_STAGE; - category: string; milestones: Milestone[]; currentMilestone?: Milestone; team: User[]; diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index ccdc4aa7..b71e4c3b 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -487,7 +487,6 @@ def get_rfps(): "title": fields.Str(required=True), "brief": fields.Str(required=True), "content": fields.Str(required=True), - "category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list())), "bounty": fields.Str(required=False, missing=0), "matching": fields.Bool(required=False, missing=False), "dateCloses": fields.Int(required=False, missing=None) @@ -519,13 +518,12 @@ def get_rfp(rfp_id): "brief": fields.Str(required=True), "status": fields.Str(required=True, validate=validate.OneOf(choices=RFPStatus.list())), "content": fields.Str(required=True), - "category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list())), "bounty": fields.Str(required=False, allow_none=True, missing=None), "matching": fields.Bool(required=False, default=False, missing=False), "dateCloses": fields.Int(required=False, missing=None), }) @admin.admin_auth_required -def update_rfp(rfp_id, title, brief, content, category, bounty, matching, date_closes, status): +def update_rfp(rfp_id, title, brief, content, bounty, matching, date_closes, status): rfp = RFP.query.filter(RFP.id == rfp_id).first() if not rfp: return {"message": "No RFP matching that id"}, 404 @@ -534,7 +532,6 @@ def update_rfp(rfp_id, title, brief, content, category, bounty, matching, date_c rfp.title = title rfp.brief = brief rfp.content = content - rfp.category = category rfp.matching = matching rfp.bounty = bounty rfp.date_closes = datetime.fromtimestamp(date_closes) if date_closes else None diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 45e38c9b..a9dbf8e9 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -241,7 +241,7 @@ class Proposal(db.Model): brief = db.Column(db.String(255), nullable=False) stage = db.Column(db.String(255), nullable=False) content = db.Column(db.Text, nullable=False) - category = db.Column(db.String(255), nullable=False) + category = db.Column(db.String(255), nullable=True) date_approved = db.Column(db.DateTime) date_published = db.Column(db.DateTime) reject_reason = db.Column(db.String()) @@ -312,12 +312,9 @@ class Proposal(db.Model): # Validate fields to be database save-able. # Stricter validation is done in validate_publishable. stage = proposal.get('stage') - category = proposal.get('category') if stage and not ProposalStage.includes(stage): raise ValidationException("Proposal stage {} is not a valid stage".format(stage)) - if category and not Category.includes(category): - raise ValidationException("Category {} not a valid category".format(category)) def validate_publishable_milestones(self): payout_total = 0.0 @@ -350,7 +347,7 @@ class Proposal(db.Model): self.validate_publishable_milestones() # Require certain fields - required_fields = ['title', 'content', 'brief', 'category', 'target', 'payout_address'] + required_fields = ['title', 'content', 'brief', 'target', 'payout_address'] for field in required_fields: if not hasattr(self, field): raise ValidationException("Proposal must have a {}".format(field)) @@ -773,7 +770,6 @@ class ProposalSchema(ma.Schema): "updates", "milestones", "current_milestone", - "category", "team", "payout_address", "deadline_duration", diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 8a508508..bcfdc35d 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -192,7 +192,6 @@ def make_proposal_draft(rfp_id): return {"message": "The request this proposal was made for has expired"}, 400 if rfp.status == RFPStatus.CLOSED: return {"message": "The request this proposal was made for has been closed"}, 400 - proposal.category = rfp.category rfp.proposals.append(proposal) db.session.add(rfp) @@ -227,7 +226,6 @@ def get_proposal_drafts(): # Length checks are to prevent database errors, not actual user limits imposed "title": fields.Str(required=True), "brief": fields.Str(required=True), - "category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list() + [''])), "content": fields.Str(required=True), "target": fields.Str(required=True), "payoutAddress": fields.Str(required=True), diff --git a/backend/grant/rfp/models.py b/backend/grant/rfp/models.py index fbbe35b8..1b8c3252 100644 --- a/backend/grant/rfp/models.py +++ b/backend/grant/rfp/models.py @@ -25,7 +25,7 @@ class RFP(db.Model): title = db.Column(db.String(255), nullable=False) brief = db.Column(db.String(255), nullable=False) content = db.Column(db.Text, nullable=False) - category = db.Column(db.String(255), nullable=False) + category = db.Column(db.String(255), nullable=True) status = db.Column(db.String(255), nullable=False) matching = db.Column(db.Boolean, default=False, nullable=False) _bounty = db.Column("bounty", db.String(255), nullable=True) @@ -96,20 +96,17 @@ class RFP(db.Model): title: str, brief: str, content: str, - category: str, bounty: str, date_closes: datetime, matching: bool = False, status: str = RFPStatus.DRAFT, ): assert RFPStatus.includes(status) - assert Category.includes(category) self.id = gen_random_id(RFP) self.date_created = datetime.now() self.title = title[:255] self.brief = brief[:255] self.content = content - self.category = category self.bounty = bounty self.date_closes = date_closes self.matching = matching @@ -125,7 +122,6 @@ class RFPSchema(ma.Schema): "title", "brief", "content", - "category", "status", "matching", "bounty", @@ -173,7 +169,6 @@ class AdminRFPSchema(ma.Schema): "title", "brief", "content", - "category", "status", "matching", "bounty", diff --git a/backend/migrations/versions/2013e180c438_.py b/backend/migrations/versions/2013e180c438_.py new file mode 100644 index 00000000..1a1f38de --- /dev/null +++ b/backend/migrations/versions/2013e180c438_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 2013e180c438 +Revises: 7fea7427e9d6 +Create Date: 2019-11-05 15:53:00.533347 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2013e180c438' +down_revision = '7fea7427e9d6' +branch_labels = None +depends_on = None + + +def upgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.alter_column('proposal', 'category', + existing_type=sa.VARCHAR(length=255), + nullable=True) + op.alter_column('rfp', 'category', + existing_type=sa.VARCHAR(length=255), + nullable=True) + # ### end Alembic commands ### + + +def downgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.alter_column('rfp', 'category', + existing_type=sa.VARCHAR(length=255), + nullable=False) + op.alter_column('proposal', 'category', + existing_type=sa.VARCHAR(length=255), + nullable=False) + # ### end Alembic commands ### diff --git a/backend/tests/admin/test_admin_api.py b/backend/tests/admin/test_admin_api.py index 9bbc50b5..2b441732 100644 --- a/backend/tests/admin/test_admin_api.py +++ b/backend/tests/admin/test_admin_api.py @@ -386,19 +386,3 @@ class TestAdminAPI(BaseProposalCreatorConfig): }) ) self.assert200(resp) - - def test_create_rfp_fails_with_bad_category(self): - self.login_admin() - - resp = self.app.post( - "/api/v1/admin/rfps", - data=json.dumps({ - "brief": "Some brief", - "category": "NOT_CORE_DEV", - "content": "CONTENT", - "dateCloses": 1553980004, - "status": "DRAFT", - "title": "TITLE" - }) - ) - self.assert400(resp) diff --git a/backend/tests/rfp/test_rfp_api.py b/backend/tests/rfp/test_rfp_api.py index d7062849..eb086492 100644 --- a/backend/tests/rfp/test_rfp_api.py +++ b/backend/tests/rfp/test_rfp_api.py @@ -11,7 +11,6 @@ class TestRfpApi(BaseProposalCreatorConfig): title="title", brief="brief", content="content", - category=Category.DEV_TOOL, date_closes=datetime.datetime(2030, 1, 1), bounty="10", status=RFPStatus.DRAFT, diff --git a/frontend/client/components/Card/index.tsx b/frontend/client/components/Card/index.tsx index 193578e1..c98ebb5e 100644 --- a/frontend/client/components/Card/index.tsx +++ b/frontend/client/components/Card/index.tsx @@ -1,23 +1,22 @@ import React from 'react'; import moment from 'moment'; import classnames from 'classnames'; -import { Icon } from 'antd'; -import { PROPOSAL_CATEGORY, CATEGORY_UI } from 'api/constants'; import './index.less'; import { Link } from 'react-router-dom'; +import { Proposal } from 'types'; +import Like from 'components/Like' interface CardInfoProps { - category: PROPOSAL_CATEGORY; + proposal: Proposal; time: number; } -export const CardInfo: React.SFC = ({ category, time }) => ( +export const CardInfo: React.SFC = ({ proposal, time }) => (
- {CATEGORY_UI[category].label} +
{moment(time).fromNow()} diff --git a/frontend/client/components/CreateFlow/Basics.tsx b/frontend/client/components/CreateFlow/Basics.tsx index ed2b4230..18a9a11f 100644 --- a/frontend/client/components/CreateFlow/Basics.tsx +++ b/frontend/client/components/CreateFlow/Basics.tsx @@ -1,12 +1,9 @@ import React from 'react'; import { connect } from 'react-redux'; -import { Input, Form, Icon, Select, Alert, Popconfirm, message, Radio } from 'antd'; -import { SelectValue } from 'antd/lib/select'; +import { Input, Form, Alert, Popconfirm, message, Radio } from 'antd'; import { RadioChangeEvent } from 'antd/lib/radio'; -import { PROPOSAL_CATEGORY, CATEGORY_UI } from 'api/constants'; import { ProposalDraft, RFP } from 'types'; import { getCreateErrors } from 'modules/create/utils'; -import { typedKeys } from 'utils/ts'; import { Link } from 'react-router-dom'; import { unlinkProposalRFP } from 'modules/create/actions'; import { AppState } from 'store/reducers'; @@ -31,7 +28,6 @@ type Props = OwnProps & StateProps & DispatchProps; interface State extends Partial { title: string; brief: string; - category?: PROPOSAL_CATEGORY; target: string; rfp?: RFP; } @@ -42,7 +38,6 @@ class CreateFlowBasics extends React.Component { this.state = { title: '', brief: '', - category: undefined, target: '', ...(props.initialState || {}), }; @@ -64,7 +59,7 @@ class CreateFlowBasics extends React.Component { render() { const { isUnlinkingProposalRFP } = this.props; - const { title, brief, category, target, rfp, rfpOptIn } = this.state; + const { title, brief, target, rfp, rfpOptIn } = this.state; const errors = getCreateErrors(this.state, true); // Don't show target error at zero since it defaults to that @@ -171,25 +166,6 @@ class CreateFlowBasics extends React.Component { /> - - - - { }); }; - private handleCategoryChange = (value: SelectValue) => { - this.setState({ category: value as PROPOSAL_CATEGORY }, () => { - this.props.updateForm(this.state); - }); - }; - private handleRfpOptIn = (e: RadioChangeEvent) => { this.setState({ rfpOptIn: e.target.value }, () => { this.props.updateForm(this.state); diff --git a/frontend/client/components/CreateFlow/Review.tsx b/frontend/client/components/CreateFlow/Review.tsx index cb9ce0a6..f2630d1a 100644 --- a/frontend/client/components/CreateFlow/Review.tsx +++ b/frontend/client/components/CreateFlow/Review.tsx @@ -1,12 +1,11 @@ import React from 'react'; import { connect } from 'react-redux'; -import { Icon, Timeline } from 'antd'; +import { Timeline } from 'antd'; import { getCreateErrors, KeyOfForm, FIELD_NAME_MAP } from 'modules/create/utils'; import Markdown from 'components/Markdown'; import UserAvatar from 'components/UserAvatar'; import { AppState } from 'store/reducers'; import { CREATE_STEP } from './index'; -import { CATEGORY_UI, PROPOSAL_CATEGORY } from 'api/constants'; import { ProposalDraft } from 'types'; import './Review.less'; @@ -37,7 +36,6 @@ class CreateReview extends React.Component { render() { const { form } = this.props; const errors = getCreateErrors(this.props.form); - const catUI = CATEGORY_UI[form.category as PROPOSAL_CATEGORY] || ({} as any); const sections: Section[] = [ { step: CREATE_STEP.BASICS, @@ -59,15 +57,6 @@ class CreateReview extends React.Component { content: form.brief, error: errors.brief, }, - { - key: 'category', - content: ( -
- {catUI.label} -
- ), - error: errors.category, - }, { key: 'target', content:
{form.target} ZEC
, diff --git a/frontend/client/components/CreateFlow/example.ts b/frontend/client/components/CreateFlow/example.ts index d46953c1..2525872f 100644 --- a/frontend/client/components/CreateFlow/example.ts +++ b/frontend/client/components/CreateFlow/example.ts @@ -1,13 +1,9 @@ -import { PROPOSAL_CATEGORY } from 'api/constants'; import { ProposalDraft } from 'types'; const createExampleProposal = (): Partial => { - const cats = Object.keys(PROPOSAL_CATEGORY); - const category = cats[Math.floor(Math.random() * cats.length)] as PROPOSAL_CATEGORY; return { title: 'Grant.io T-Shirts', brief: "The most stylish wear, sporting your favorite brand's logo", - category, content: '![](https://i.imgur.com/aQagS0D.png)\n\nWe all know it, Grant.io is the bee\'s knees. But wouldn\'t it be great if you could show all your friends and family how much you love it? Well that\'s what we\'re here to offer today.\n\n# What We\'re Building\n\nWhy, T-Shirts of course! These beautiful shirts made out of 100% cotton and laser printed for long lasting goodness come from American Apparel. We\'ll be offering them in 4 styles:\n\n* Crew neck (wrinkled)\n* Crew neck (straight)\n* Scoop neck (fitted)\n* V neck (fitted)\n\nShirt sizings will be as follows:\n\n| Size | S | M | L | XL |\n|--------|-----|-----|-----|------|\n| **Width** | 18" | 20" | 22" | 24" |\n| **Length** | 28" | 29" | 30" | 31" |\n\n# Who We Are\n\nWe are the team behind grant.io. In addition to our software engineering experience, we have over 78 years of T-Shirt printing expertise combined. Sometimes I wake up at night and realize I was printing shirts in my dreams. Weird, man.\n\n# Expense Breakdown\n\n* $1,000 - A professional designer will hand-craft each letter on the shirt.\n* $500 - We\'ll get the shirt printed from 5 different factories and choose the best quality one.\n* $3,000 - The full run of prints, with 20 smalls, 20 mediums, and 20 larges.\n* $500 - Pizza. Lots of pizza.\n\n**Total**: $5,000', target: '5', diff --git a/frontend/client/components/Like/index.tsx b/frontend/client/components/Like/index.tsx index f993620e..ba0994d8 100644 --- a/frontend/client/components/Like/index.tsx +++ b/frontend/client/components/Like/index.tsx @@ -4,14 +4,15 @@ import { Icon, Button, Input, message } from 'antd'; import { AppState } from 'store/reducers'; import { proposalActions } from 'modules/proposals'; import { rfpActions } from 'modules/rfps'; -import { ProposalDetail } from 'modules/proposals/reducers'; +import { Proposal } from 'types'; import { Comment, RFP } from 'types'; import { likeProposal, likeComment, likeRfp } from 'api/api'; import AuthButton from 'components/AuthButton'; import './index.less'; interface OwnProps { - proposal?: ProposalDetail | null; + proposal?: Proposal; + proposal_card?: boolean; comment?: Comment; rfp?: RFP; style?: React.CSSProperties; @@ -34,19 +35,27 @@ const STATE = { }; type State = typeof STATE; -class Follow extends React.Component { +class Like extends React.Component { state: State = { ...STATE }; render() { const { likesCount, authedLiked } = this.deriveInfo(); - const { proposal, rfp, comment, style } = this.props; + const { proposal, rfp, comment, style, proposal_card } = this.props; const { loading } = this.state; - const zoom = comment ? 0.8 : 1; - const shouldShowLikeText = !!proposal || !!rfp; + const zoom = comment || proposal_card ? 0.8 : 1; + const shouldShowLikeText = (!!proposal && !proposal_card) || !!rfp; + + // if like button is on a proposal card... + // 1) use regular button to prevent login redirect + const IconButton = proposal_card ? Button : AuthButton; + // 2) prevent mouseover effects + const pointerEvents = proposal_card ? 'none' : undefined; + // 3) make button click a noop + const handleIconButtonClick = proposal_card ? undefined : this.handleLike; return ( - - + + { {shouldShowLikeText && ( {authedLiked ? ' Unlike' : ' Like'} )} - + @@ -175,4 +184,4 @@ const withConnect = connect( }, ); -export default withConnect(Follow); +export default withConnect(Like); diff --git a/frontend/client/components/Proposal/CampaignBlock/index.tsx b/frontend/client/components/Proposal/CampaignBlock/index.tsx index e4b41ab7..c60167e6 100644 --- a/frontend/client/components/Proposal/CampaignBlock/index.tsx +++ b/frontend/client/components/Proposal/CampaignBlock/index.tsx @@ -9,7 +9,7 @@ import { AppState } from 'store/reducers'; import { withRouter } from 'react-router'; import UnitDisplay from 'components/UnitDisplay'; import Loader from 'components/Loader'; -import { CATEGORY_UI, PROPOSAL_STAGE } from 'api/constants'; +import { PROPOSAL_STAGE } from 'api/constants'; import './style.less'; interface OwnProps { @@ -87,16 +87,6 @@ export class ProposalCampaignBlock extends React.Component {
)} -
-
Category
-
- {' '} - {CATEGORY_UI[proposal.category].label} -
-
{!isVersionTwo && !isFundingOver && (
diff --git a/frontend/client/components/Proposals/Filters/index.tsx b/frontend/client/components/Proposals/Filters/index.tsx index ac1479a0..5362ff9a 100644 --- a/frontend/client/components/Proposals/Filters/index.tsx +++ b/frontend/client/components/Proposals/Filters/index.tsx @@ -1,12 +1,10 @@ import React from 'react'; -import { Select, Checkbox, Radio, Card, Divider } from 'antd'; +import { Select, Radio, Card } from 'antd'; import { RadioChangeEvent } from 'antd/lib/radio'; import { SelectValue } from 'antd/lib/select'; import { PROPOSAL_SORT, SORT_LABELS, - PROPOSAL_CATEGORY, - CATEGORY_UI, PROPOSAL_STAGE, STAGE_UI, } from 'api/constants'; @@ -39,21 +37,6 @@ export default class ProposalFilters extends React.Component {
Reset}> -

Category

- {typedKeys(PROPOSAL_CATEGORY).map(c => ( -
- - {CATEGORY_UI[c].label} - -
- ))} - - -

Proposal stage

{ ); } - private handleCategoryChange = (ev: RadioChangeEvent) => { - const { filters } = this.props; - const cat = ev.target.value as PROPOSAL_CATEGORY; - const category = ev.target.checked - ? [...filters.category, cat] - : filters.category.filter(c => c !== cat); - - this.props.handleChangeFilters({ - ...filters, - category, - }); - }; - private handleStageChange = (ev: RadioChangeEvent) => { let stage = [] as PROPOSAL_STAGE[]; if (ev.target.value !== 'ALL') { diff --git a/frontend/client/components/Proposals/ProposalCard/index.tsx b/frontend/client/components/Proposals/ProposalCard/index.tsx index 2df2aa7b..8ca40089 100644 --- a/frontend/client/components/Proposals/ProposalCard/index.tsx +++ b/frontend/client/components/Proposals/ProposalCard/index.tsx @@ -18,7 +18,6 @@ export class ProposalCard extends React.Component { title, proposalAddress, proposalUrlId, - category, datePublished, dateCreated, team, @@ -74,7 +73,7 @@ export class ProposalCard extends React.Component {
{proposalAddress}
- + ); } diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index 69c7a87f..49b27826 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -7,7 +7,7 @@ import { isValidSproutAddress, } from 'utils/validators'; import { Zat, toZat } from 'utils/units'; -import { PROPOSAL_CATEGORY, PROPOSAL_STAGE } from 'api/constants'; +import { PROPOSAL_STAGE } from 'api/constants'; import { ProposalDetail, PROPOSAL_DETAIL_INITIAL_STATE, @@ -17,7 +17,6 @@ interface CreateFormErrors { rfpOptIn?: string; title?: string; brief?: string; - category?: string; target?: string; team?: string[]; content?: string; @@ -30,7 +29,6 @@ export const FIELD_NAME_MAP: { [key in KeyOfForm]: string } = { rfpOptIn: 'RFP KYC', title: 'Title', brief: 'Brief', - category: 'Category', target: 'Target amount', team: 'Team', content: 'Details', @@ -41,7 +39,6 @@ export const FIELD_NAME_MAP: { [key in KeyOfForm]: string } = { const requiredFields = [ 'title', 'brief', - 'category', 'target', 'content', 'payoutAddress', @@ -231,7 +228,6 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta contributionBounty: Zat('0'), percentFunded: 0, stage: PROPOSAL_STAGE.PREVIEW, - category: draft.category || PROPOSAL_CATEGORY.CORE_DEV, isStaked: true, arbiter: { status: PROPOSAL_ARBITER_STATUS.ACCEPTED, diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index 424c08a7..e217ba8b 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -6,7 +6,7 @@ import { STATUS, PROPOSAL_ARBITER_STATUS, } from 'types'; -import { PROPOSAL_CATEGORY, PROPOSAL_STAGE } from 'api/constants'; +import { PROPOSAL_STAGE } from 'api/constants'; import BN from 'bn.js'; import moment from 'moment'; @@ -160,7 +160,6 @@ export function generateProposal({ brief: 'A cool test crowdfund', content: 'body', stage: PROPOSAL_STAGE.WIP, - category: PROPOSAL_CATEGORY.COMMUNITY, isStaked: true, authedFollows: false, followersCount: 0, diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts index 244bf91c..1b6371ec 100644 --- a/frontend/types/proposal.ts +++ b/frontend/types/proposal.ts @@ -1,5 +1,5 @@ import { Zat } from 'utils/units'; -import { PROPOSAL_CATEGORY, PROPOSAL_STAGE } from 'api/constants'; +import { PROPOSAL_STAGE } from 'api/constants'; import { CreateMilestone, Update, User, Comment, ContributionWithUser } from 'types'; import { ProposalMilestone } from './milestone'; import { RFP } from './rfp'; @@ -34,7 +34,6 @@ export interface ProposalDraft { dateCreated: number; title: string; brief: string; - category: PROPOSAL_CATEGORY; content: string; stage: PROPOSAL_STAGE; target: string; diff --git a/frontend/types/rfp.ts b/frontend/types/rfp.ts index 9addc8cf..8e7efca8 100644 --- a/frontend/types/rfp.ts +++ b/frontend/types/rfp.ts @@ -1,5 +1,5 @@ import { Proposal } from './proposal'; -import { PROPOSAL_CATEGORY, RFP_STATUS } from 'api/constants'; +import { RFP_STATUS } from 'api/constants'; import { Zat } from 'utils/units'; export interface RFP { @@ -8,7 +8,6 @@ export interface RFP { title: string; brief: string; content: string; - category: PROPOSAL_CATEGORY; status: RFP_STATUS; acceptedProposals: Proposal[]; bounty: Zat | null; From 4b7d85872a62afadf4cf989463f55eaa242dbf83 Mon Sep 17 00:00:00 2001 From: Daniel Ternyak Date: Wed, 13 Nov 2019 17:26:53 -0600 Subject: [PATCH 21/54] fix multiple migration heads --- .../versions/{4ca14e6e8976_.py => 0ba15ddf5053_.py} | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) rename backend/migrations/versions/{4ca14e6e8976_.py => 0ba15ddf5053_.py} (85%) diff --git a/backend/migrations/versions/4ca14e6e8976_.py b/backend/migrations/versions/0ba15ddf5053_.py similarity index 85% rename from backend/migrations/versions/4ca14e6e8976_.py rename to backend/migrations/versions/0ba15ddf5053_.py index 849440d2..49e0b8d2 100644 --- a/backend/migrations/versions/4ca14e6e8976_.py +++ b/backend/migrations/versions/0ba15ddf5053_.py @@ -1,8 +1,8 @@ """empty message -Revision ID: 4ca14e6e8976 -Revises: 7fea7427e9d6 -Create Date: 2019-11-06 12:58:45.503087 +Revision ID: 0ba15ddf5053 +Revises: 2013e180c438 +Create Date: 2019-11-13 17:26:36.584040 """ from alembic import op @@ -10,8 +10,8 @@ import sqlalchemy as sa from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. -revision = '4ca14e6e8976' -down_revision = '7fea7427e9d6' +revision = '0ba15ddf5053' +down_revision = '2013e180c438' branch_labels = None depends_on = None From d98b25537871a5904a07d86b691753ed2c976e03 Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Wed, 13 Nov 2019 18:44:35 -0500 Subject: [PATCH 22/54] Tip Jar Profile (#64) * init profile tipjar backend * init profile tipjar frontend * fix lint * implement tip jar block * fix wrapping, hide tip block on self * add hide title, fix bug * rename vars, use null check * allow address and view key to be unset * add api tests * fix migrations --- backend/grant/user/models.py | 11 +- backend/grant/user/views.py | 13 +- backend/migrations/versions/9d2f7db5b5a6_.py | 30 ++++ backend/tests/user/test_user_api.py | 33 ++++- frontend/client/api/api.ts | 2 + .../components/Profile/ProfileUser.less | 6 + .../client/components/Profile/ProfileUser.tsx | 12 +- .../Settings/Account/RefundAddress.tsx | 87 ++++++------ .../Settings/Account/TipJarAddress.tsx | 131 ++++++++++++++++++ .../Settings/Account/TipJarViewKey.tsx | 120 ++++++++++++++++ .../components/Settings/Account/index.tsx | 116 +++++++++++++++- .../client/components/TipJar/TipJarBlock.less | 36 +++++ .../client/components/TipJar/TipJarBlock.tsx | 97 +++++++++++++ .../client/components/TipJar/TipJarModal.less | 64 +++++++++ .../client/components/TipJar/TipJarModal.tsx | 126 +++++++++++++++++ frontend/client/components/TipJar/index.tsx | 2 + frontend/client/utils/validators.ts | 12 ++ frontend/types/user.ts | 3 + 18 files changed, 854 insertions(+), 47 deletions(-) create mode 100644 backend/migrations/versions/9d2f7db5b5a6_.py create mode 100644 frontend/client/components/Settings/Account/TipJarAddress.tsx create mode 100644 frontend/client/components/Settings/Account/TipJarViewKey.tsx create mode 100644 frontend/client/components/TipJar/TipJarBlock.less create mode 100644 frontend/client/components/TipJar/TipJarBlock.tsx create mode 100644 frontend/client/components/TipJar/TipJarModal.less create mode 100644 frontend/client/components/TipJar/TipJarModal.tsx create mode 100644 frontend/client/components/TipJar/index.tsx diff --git a/backend/grant/user/models.py b/backend/grant/user/models.py index 8eb08994..e847a840 100644 --- a/backend/grant/user/models.py +++ b/backend/grant/user/models.py @@ -58,6 +58,8 @@ class UserSettings(db.Model): user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) _email_subscriptions = db.Column("email_subscriptions", db.Integer, default=0) # bitmask refund_address = db.Column(db.String(255), unique=False, nullable=True) + tip_jar_address = db.Column(db.String(255), unique=False, nullable=True) + tip_jar_view_key = db.Column(db.String(255), unique=False, nullable=True) user = db.relationship("User", back_populates="settings") @@ -356,13 +358,15 @@ class UserSchema(ma.Schema): "avatar", "display_name", "userid", - "email_verified" + "email_verified", + "tip_jar_address" ) social_medias = ma.Nested("SocialMediaSchema", many=True) avatar = ma.Nested("AvatarSchema") userid = ma.Method("get_userid") email_verified = ma.Method("get_email_verified") + tip_jar_address = ma.Method("get_tip_jar_address") def get_userid(self, obj): return obj.id @@ -370,6 +374,9 @@ class UserSchema(ma.Schema): def get_email_verified(self, obj): return obj.email_verification.has_verified + def get_tip_jar_address(self, obj): + return obj.settings.tip_jar_address + user_schema = UserSchema() users_schema = UserSchema(many=True) @@ -412,6 +419,8 @@ class UserSettingsSchema(ma.Schema): fields = ( "email_subscriptions", "refund_address", + "tip_jar_address", + "tip_jar_view_key" ) diff --git a/backend/grant/user/views.py b/backend/grant/user/views.py index 4f43a7c9..492f2698 100644 --- a/backend/grant/user/views.py +++ b/backend/grant/user/views.py @@ -349,9 +349,12 @@ def get_user_settings(user_id): @body({ "emailSubscriptions": fields.Dict(required=False, missing=None), "refundAddress": fields.Str(required=False, missing=None, - validate=lambda r: validate_blockchain_get('/validate/address', {'address': r})) + validate=lambda r: validate_blockchain_get('/validate/address', {'address': r})), + "tipJarAddress": fields.Str(required=False, missing=None, + validate=lambda r: validate_blockchain_get('/validate/address', {'address': r})), + "tipJarViewKey": fields.Str(required=False, missing=None) # TODO: add viewkey validation here }) -def set_user_settings(user_id, email_subscriptions, refund_address): +def set_user_settings(user_id, email_subscriptions, refund_address, tip_jar_address, tip_jar_view_key): if email_subscriptions: try: email_subscriptions = keys_to_snake_case(email_subscriptions) @@ -364,6 +367,12 @@ def set_user_settings(user_id, email_subscriptions, refund_address): if refund_address: g.current_user.settings.refund_address = refund_address + # TODO: is additional validation needed similar to refund_address? + if tip_jar_address is not None: + g.current_user.settings.tip_jar_address = tip_jar_address + if tip_jar_view_key is not None: + g.current_user.settings.tip_jar_view_key = tip_jar_view_key + db.session.commit() return user_settings_schema.dump(g.current_user.settings) diff --git a/backend/migrations/versions/9d2f7db5b5a6_.py b/backend/migrations/versions/9d2f7db5b5a6_.py new file mode 100644 index 00000000..af433250 --- /dev/null +++ b/backend/migrations/versions/9d2f7db5b5a6_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: 9d2f7db5b5a6 +Revises: 0ba15ddf5053 +Create Date: 2019-11-13 17:29:46.810554 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9d2f7db5b5a6' +down_revision = '0ba15ddf5053' +branch_labels = None +depends_on = None + + +def upgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.add_column('user_settings', sa.Column('tip_jar_address', sa.String(length=255), nullable=True)) + op.add_column('user_settings', sa.Column('tip_jar_view_key', sa.String(length=255), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user_settings', 'tip_jar_view_key') + op.drop_column('user_settings', 'tip_jar_address') + # ### end Alembic commands ### diff --git a/backend/tests/user/test_user_api.py b/backend/tests/user/test_user_api.py index 24144ede..904696c9 100644 --- a/backend/tests/user/test_user_api.py +++ b/backend/tests/user/test_user_api.py @@ -8,7 +8,7 @@ from grant.user.models import User, user_schema, db from mock import patch from ..config import BaseUserConfig -from ..test_data import test_user +from ..test_data import test_user, mock_blockchain_api_requests class TestUserAPI(BaseUserConfig): @@ -385,3 +385,34 @@ class TestUserAPI(BaseUserConfig): content_type='application/json' ) self.assert400(resp) + + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_put_user_settings_tip_jar_address(self, mock_get): + address = "address" + + self.login_default_user() + resp = self.app.put( + "/api/v1/users/{}/settings".format(self.user.id), + data=json.dumps({'tipJarAddress': address}), + content_type='application/json' + ) + self.assert200(resp) + self.assertEqual(resp.json["tipJarAddress"], address) + user = User.query.get(self.user.id) + self.assertEqual(user.settings.tip_jar_address, address) + + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_put_user_settings_tip_jar_view_key(self, mock_get): + view_key = "view_key" + + self.login_default_user() + resp = self.app.put( + "/api/v1/users/{}/settings".format(self.user.id), + data=json.dumps({'tipJarViewKey': view_key}), + content_type='application/json' + ) + self.assert200(resp) + self.assertEqual(resp.json["tipJarViewKey"], view_key) + user = User.query.get(self.user.id) + self.assertEqual(user.settings.tip_jar_view_key, view_key) + diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index b1fd724c..ac23aefe 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -155,6 +155,8 @@ export function getUserSettings( interface SettingsArgs { emailSubscriptions?: EmailSubscriptions; refundAddress?: string; + tipJarAddress?: string; + tipJarViewKey?: string; } export function updateUserSettings( userId: string | number, diff --git a/frontend/client/components/Profile/ProfileUser.less b/frontend/client/components/Profile/ProfileUser.less index 7e126fa3..5cc73eb5 100644 --- a/frontend/client/components/Profile/ProfileUser.less +++ b/frontend/client/components/Profile/ProfileUser.less @@ -2,8 +2,10 @@ display: flex; align-items: center; margin-bottom: 1.5rem; + flex-wrap: wrap; &-avatar { + margin-bottom: 2rem; position: relative; flex: 0 0 auto; height: 10.5rem; @@ -20,6 +22,8 @@ &-info { // no overflow of flexbox min-width: 0; + margin-bottom: 2rem; + flex-grow: 1; &-name { font-size: 1.6rem; @@ -30,6 +34,8 @@ font-size: 1rem; opacity: 0.7; margin-bottom: 0.3rem; + max-width: fit-content; + margin-right: 1rem; } &-address { diff --git a/frontend/client/components/Profile/ProfileUser.tsx b/frontend/client/components/Profile/ProfileUser.tsx index e257b145..fd8fd96b 100644 --- a/frontend/client/components/Profile/ProfileUser.tsx +++ b/frontend/client/components/Profile/ProfileUser.tsx @@ -5,6 +5,7 @@ import { Button } from 'antd'; import { SocialMedia } from 'types'; import { UserState } from 'modules/users/reducers'; import UserAvatar from 'components/UserAvatar'; +import { TipJarBlock } from 'components/TipJar'; import { SOCIAL_INFO } from 'utils/social'; import { AppState } from 'store/reducers'; import './ProfileUser.less'; @@ -19,7 +20,15 @@ interface StateProps { type Props = OwnProps & StateProps; -class ProfileUser extends React.Component { +const STATE = { + tipJarModalOpen: false, +}; + +type State = typeof STATE; + +class ProfileUser extends React.Component { + state = STATE; + render() { const { authUser, @@ -52,6 +61,7 @@ class ProfileUser extends React.Component {
)}
+ {!isSelf && }
); } diff --git a/frontend/client/components/Settings/Account/RefundAddress.tsx b/frontend/client/components/Settings/Account/RefundAddress.tsx index 77b9d62c..7a561997 100644 --- a/frontend/client/components/Settings/Account/RefundAddress.tsx +++ b/frontend/client/components/Settings/Account/RefundAddress.tsx @@ -1,33 +1,57 @@ import React from 'react'; -import { connect } from 'react-redux'; import { Form, Input, Button, message } from 'antd'; -import { AppState } from 'store/reducers'; -import { updateUserSettings, getUserSettings } from 'api/api'; +import { updateUserSettings } from 'api/api'; import { isValidAddress } from 'utils/validators'; +import { UserSettings } from 'types'; -interface StateProps { +interface Props { + userSettings?: UserSettings; + isFetching: boolean; + errorFetching: boolean; userid: number; + onAddressSet: (refundAddress: UserSettings['refundAddress']) => void; } -type Props = StateProps; +interface State { + isSaving: boolean + refundAddress: string | null + refundAddressSet: string | null +} -const STATE = { - refundAddress: '', - isFetching: false, - isSaving: false, -}; -type State = typeof STATE; +export default class RefundAddress extends React.Component { -class RefundAddress extends React.Component { - state: State = { ...STATE }; + static getDerivedStateFromProps(nextProps: Props, prevState: State) { + const { userSettings } = nextProps; + const { refundAddress, refundAddressSet } = prevState; - componentDidMount() { - this.fetchRefundAddress(); + const ret: Partial = {}; + + if (!userSettings || !userSettings.refundAddress) { + return ret; + } + + if (userSettings.refundAddress !== refundAddressSet) { + ret.refundAddressSet = userSettings.refundAddress; + + if (refundAddress === null) { + ret.refundAddress = userSettings.refundAddress; + } + } + + return ret; } + state: State = { + isSaving: false, + refundAddress: null, + refundAddressSet: null + }; + render() { - const { refundAddress, isFetching, isSaving } = this.state; + const { isSaving, refundAddress, refundAddressSet } = this.state; + const { isFetching, errorFetching } = this.props; + const addressChanged = refundAddress !== refundAddressSet; let status: 'validating' | 'error' | undefined; let help; @@ -42,10 +66,10 @@ class RefundAddress extends React.Component {
@@ -53,7 +77,9 @@ class RefundAddress extends React.Component { type="primary" htmlType="submit" size="large" - disabled={!refundAddress || isSaving || !!status} + disabled={ + !refundAddress || isSaving || !!status || errorFetching || !addressChanged + } loading={isSaving} block > @@ -63,19 +89,6 @@ class RefundAddress extends React.Component { ); } - private async fetchRefundAddress() { - const { userid } = this.props; - this.setState({ isFetching: true }); - try { - const res = await getUserSettings(userid); - this.setState({ refundAddress: res.data.refundAddress || '' }); - } catch (err) { - console.error(err); - message.error('Failed to get refund address'); - } - this.setState({ isFetching: false }); - } - private handleChange = (ev: React.ChangeEvent) => { this.setState({ refundAddress: ev.currentTarget.value }); }; @@ -92,7 +105,9 @@ class RefundAddress extends React.Component { try { const res = await updateUserSettings(userid, { refundAddress }); message.success('Settings saved'); - this.setState({ refundAddress: res.data.refundAddress || '' }); + const refundAddressNew = res.data.refundAddress || ''; + this.setState({ refundAddress: refundAddressNew }); + this.props.onAddressSet(refundAddressNew); } catch (err) { console.error(err); message.error(err.message || err.toString(), 5); @@ -100,9 +115,3 @@ class RefundAddress extends React.Component { this.setState({ isSaving: false }); }; } - -const withConnect = connect(state => ({ - userid: state.auth.user ? state.auth.user.userid : 0, -})); - -export default withConnect(RefundAddress); diff --git a/frontend/client/components/Settings/Account/TipJarAddress.tsx b/frontend/client/components/Settings/Account/TipJarAddress.tsx new file mode 100644 index 00000000..f08c5e71 --- /dev/null +++ b/frontend/client/components/Settings/Account/TipJarAddress.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { Form, Input, Button, message } from 'antd'; +import { updateUserSettings } from 'api/api'; +import { isValidAddress } from 'utils/validators'; +import { UserSettings } from 'types'; + +interface Props { + userSettings?: UserSettings; + isFetching: boolean; + errorFetching: boolean; + userid: number; + onAddressSet: (refundAddress: UserSettings['tipJarAddress']) => void; +} + +interface State { + isSaving: boolean; + tipJarAddress: string | null; + tipJarAddressSet: string | null; +} + +export default class TipJarAddress extends React.Component { + static getDerivedStateFromProps(nextProps: Props, prevState: State) { + const { userSettings } = nextProps; + const { tipJarAddress, tipJarAddressSet } = prevState; + + const ret: Partial = {}; + + if (!userSettings || userSettings.tipJarAddress === undefined) { + return ret; + } + + if (userSettings.tipJarAddress !== tipJarAddressSet) { + ret.tipJarAddressSet = userSettings.tipJarAddress; + + if (tipJarAddress === null) { + ret.tipJarAddress = userSettings.tipJarAddress; + } + } + + return ret; + } + + state: State = { + isSaving: false, + tipJarAddress: null, + tipJarAddressSet: null, + }; + + render() { + const { isSaving, tipJarAddress, tipJarAddressSet } = this.state; + const { isFetching, errorFetching, userSettings } = this.props; + const addressChanged = tipJarAddress !== tipJarAddressSet; + const hasViewKeySet = userSettings && userSettings.tipJarViewKey + + let addressIsValid; + let status: 'validating' | 'error' | undefined; + let help; + + if (tipJarAddress !== null) { + addressIsValid = tipJarAddress === '' || isValidAddress(tipJarAddress); + } + + if (isFetching) { + status = 'validating'; + } else if (tipJarAddress !== null && !addressIsValid) { + status = 'error'; + help = 'That doesn’t look like a valid address'; + } + + if (tipJarAddress === '' && hasViewKeySet) { + status = 'error'; + help = 'You must unset your view key before unsetting your address' + } + + return ( + + + + + + + + ); + } + + private handleChange = (ev: React.ChangeEvent) => { + this.setState({ tipJarAddress: ev.currentTarget.value }); + }; + + private handleSubmit = async (ev: React.FormEvent) => { + ev.preventDefault(); + const { userid } = this.props; + const { tipJarAddress } = this.state; + + if (tipJarAddress === null) return; + + this.setState({ isSaving: true }); + try { + const res = await updateUserSettings(userid, { tipJarAddress }); + message.success('Settings saved'); + const tipJarAddressNew = + res.data.tipJarAddress === undefined ? null : res.data.tipJarAddress; + this.setState({ tipJarAddress: tipJarAddressNew }); + this.props.onAddressSet(tipJarAddressNew); + } catch (err) { + console.error(err); + message.error(err.message || err.toString(), 5); + } + this.setState({ isSaving: false }); + }; +} diff --git a/frontend/client/components/Settings/Account/TipJarViewKey.tsx b/frontend/client/components/Settings/Account/TipJarViewKey.tsx new file mode 100644 index 00000000..7a9ca3f4 --- /dev/null +++ b/frontend/client/components/Settings/Account/TipJarViewKey.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { Form, Input, Button, message } from 'antd'; +import { updateUserSettings } from 'api/api'; +import { UserSettings } from 'types'; + +interface Props { + userSettings?: UserSettings; + isFetching: boolean; + errorFetching: boolean; + userid: number; + onViewKeySet: (viewKey: UserSettings['tipJarViewKey']) => void; +} + +interface State { + isSaving: boolean; + tipJarViewKey: string | null; + tipJarViewKeySet: string | null; +} + +export default class TipJarViewKey extends React.Component { + static getDerivedStateFromProps(nextProps: Props, prevState: State) { + const { userSettings } = nextProps; + const { tipJarViewKey, tipJarViewKeySet } = prevState; + + const ret: Partial = {}; + + if (!userSettings || userSettings.tipJarViewKey === undefined) { + return ret; + } + + if (userSettings.tipJarViewKey !== tipJarViewKeySet) { + ret.tipJarViewKeySet = userSettings.tipJarViewKey; + + if (tipJarViewKey === null) { + ret.tipJarViewKey = userSettings.tipJarViewKey; + } + } + + return ret; + } + + state: State = { + isSaving: false, + tipJarViewKey: null, + tipJarViewKeySet: null, + }; + + render() { + const { isSaving, tipJarViewKey, tipJarViewKeySet } = this.state; + const { isFetching, errorFetching, userSettings } = this.props; + const viewKeyChanged = tipJarViewKey !== tipJarViewKeySet; + const viewKeyDisabled = !(userSettings && userSettings.tipJarAddress); + + // TODO: add view key validation + + // let status: 'validating' | 'error' | undefined; + // let help; + // if (isFetching) { + // status = 'validating'; + // } else if (tipJarAddress && !isValidAddress(tipJarAddress)) { + // status = 'error'; + // help = 'That doesn’t look like a valid address'; + // } + + return ( +
+ + + + + +
+ ); + } + + private handleChange = (ev: React.ChangeEvent) => { + this.setState({ tipJarViewKey: ev.currentTarget.value }); + }; + + private handleSubmit = async (ev: React.FormEvent) => { + ev.preventDefault(); + const { userid } = this.props; + const { tipJarViewKey } = this.state; + + if (tipJarViewKey === null) return; + + this.setState({ isSaving: true }); + try { + const res = await updateUserSettings(userid, { tipJarViewKey }); + message.success('Settings saved'); + const tipJarViewKeyNew = res.data.tipJarViewKey || ''; + this.setState({ tipJarViewKey: tipJarViewKeyNew }); + this.props.onViewKeySet(tipJarViewKeyNew); + } catch (err) { + console.error(err); + message.error(err.message || err.toString(), 5); + } + this.setState({ isSaving: false }); + }; +} diff --git a/frontend/client/components/Settings/Account/index.tsx b/frontend/client/components/Settings/Account/index.tsx index 58d9b197..90a3818b 100644 --- a/frontend/client/components/Settings/Account/index.tsx +++ b/frontend/client/components/Settings/Account/index.tsx @@ -1,16 +1,126 @@ import React from 'react'; -import { Divider } from 'antd'; +import { Divider, message } from 'antd'; +import { connect } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { getUserSettings } from 'api/api'; import ChangeEmail from './ChangeEmail'; import RefundAddress from './RefundAddress'; +import TipJarAddress from './TipJarAddress'; +import ViewingKey from './TipJarViewKey'; +import { UserSettings } from 'types'; + +interface StateProps { + userid: number; +} + +type Props = StateProps; +interface State { + userSettings: UserSettings | undefined; + isFetching: boolean; + errorFetching: boolean; +}; + +const STATE: State = { + userSettings: undefined, + isFetching: false, + errorFetching: false, +}; + +class AccountSettings extends React.Component { + state: State = { ...STATE }; + + componentDidMount() { + this.fetchUserSettings(); + } -export default class AccountSettings extends React.Component<{}> { render() { + const { userid } = this.props; + const { userSettings, isFetching, errorFetching } = this.state; + return (
- + + + + +
); } + + private async fetchUserSettings() { + const { userid } = this.props; + this.setState({ isFetching: true }); + try { + const res = await getUserSettings(userid); + this.setState({ userSettings: res.data || undefined }); + } catch (err) { + console.error(err); + message.error('Failed to get user settings'); + this.setState({ errorFetching: true }); + } + this.setState({ isFetching: false }); + } + + private handleRefundAddressSet = (refundAddress: UserSettings['refundAddress']) => { + const { userSettings } = this.state; + if (!userSettings) return; + + this.setState({ + userSettings: { + ...userSettings, + refundAddress, + }, + }); + }; + + private handleTipJarAddressSet = (tipJarAddress: UserSettings['tipJarAddress']) => { + const { userSettings } = this.state; + if (!userSettings) return; + + this.setState({ + userSettings: { + ...userSettings, + tipJarAddress, + }, + }); + }; + + private handleTipJarViewKeySet = (tipJarViewKey: UserSettings['tipJarViewKey']) => { + const { userSettings } = this.state; + if (!userSettings) return; + + this.setState({ + userSettings: { + ...userSettings, + tipJarViewKey, + }, + }); + }; } + +const withConnect = connect(state => ({ + userid: state.auth.user ? state.auth.user.userid : 0, +})); + +export default withConnect(AccountSettings); diff --git a/frontend/client/components/TipJar/TipJarBlock.less b/frontend/client/components/TipJar/TipJarBlock.less new file mode 100644 index 00000000..ee963259 --- /dev/null +++ b/frontend/client/components/TipJar/TipJarBlock.less @@ -0,0 +1,36 @@ +@import '~styles/variables.less'; + +.TipJarBlock { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + .ant-form-item { + width: 100%; + } + + .ant-radio-wrapper { + margin-bottom: 0.5rem; + opacity: 0.7; + font-size: 0.8rem; + + &:hover { + opacity: 1; + } + + .anticon { + margin-left: 0.2rem; + color: @primary-color; + } + } + + &-title { + font-size: 1.4rem; + line-height: 2rem; + margin-bottom: 1rem; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } +} diff --git a/frontend/client/components/TipJar/TipJarBlock.tsx b/frontend/client/components/TipJar/TipJarBlock.tsx new file mode 100644 index 00000000..28085edd --- /dev/null +++ b/frontend/client/components/TipJar/TipJarBlock.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { Button, Form, Input, Tooltip } from 'antd'; +import { TipJarModal } from './TipJarModal'; +import { getAmountErrorFromString } from 'utils/validators'; +import './TipJarBlock.less'; +import '../Proposal/index.less'; + +interface Props { + isCard?: boolean; + hideTitle?: boolean; + address?: string | null; + type: 'user' | 'proposal'; +} + +const STATE = { + tipAmount: '', + modalOpen: false, +}; + +type State = typeof STATE; + +export class TipJarBlock extends React.Component { + state = STATE; + + render() { + const { isCard, address, type, hideTitle } = this.props; + const { tipAmount, modalOpen } = this.state; + const amountError = tipAmount ? getAmountErrorFromString(tipAmount) : ''; + + const addressNotSet = !address; + const buttonTooltip = addressNotSet + ? `Tipping address has not been set for ${type}` + : ''; + const isDisabled = addressNotSet || !tipAmount || !!amountError; + + return ( +
+
+ {!hideTitle &&

Tip

} + + + + + + +
+ + {address && tipAmount && ( + + )} +
+ ); + } + + private handleAmountChange = (e: React.ChangeEvent) => + this.setState({ + tipAmount: e.currentTarget.value, + }); + + private handleTipJarModalOpen = () => + this.setState({ + modalOpen: true, + }); + + private handleTipJarModalClose = () => + this.setState({ + modalOpen: false, + }); +} diff --git a/frontend/client/components/TipJar/TipJarModal.less b/frontend/client/components/TipJar/TipJarModal.less new file mode 100644 index 00000000..1096be42 --- /dev/null +++ b/frontend/client/components/TipJar/TipJarModal.less @@ -0,0 +1,64 @@ +@import '~styles/variables.less'; + +.TipJarModal { + + &-uri { + display: flex; + padding-bottom: 1.5rem; + margin-bottom: 0.75rem; + border-bottom: 1px solid rgba(#000, 0.06); + + &-qr { + position: relative; + padding: 0.5rem; + margin-right: 1rem; + border-radius: 4px; + box-shadow: 0 1px 2px rgba(#000, 0.1), 0 1px 4px rgba(#000, 0.2); + + canvas { + display: flex; + flex-shrink: 1; + } + &.is-loading canvas { + opacity: 0; + } + + .ant-spin { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + } + + &-info { + flex: 1; + } + } + + &-fields { + &-row { + display: flex; + + > * { + flex: 1; + margin-right: 0.75rem; + + &:last-child { + margin-right: 0; + } + } + + &-address { + min-width: 300px; + } + } + } + + // Ant overrides + input[readonly], + textarea[readonly] { + background: rgba(#000, 0.05); + } +} + diff --git a/frontend/client/components/TipJar/TipJarModal.tsx b/frontend/client/components/TipJar/TipJarModal.tsx new file mode 100644 index 00000000..07b027c6 --- /dev/null +++ b/frontend/client/components/TipJar/TipJarModal.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { Modal, Icon, Button, Form, Input } from 'antd'; +import classnames from 'classnames'; +import QRCode from 'qrcode.react'; +import { formatZcashCLI, formatZcashURI } from 'utils/formatters'; +import { getAmountErrorFromString } from 'utils/validators' +import Loader from 'components/Loader'; +import './TipJarModal.less'; +import CopyInput from 'components/CopyInput'; + +interface Props { + isOpen: boolean; + onClose: () => void; + type: 'user' | 'proposal'; + address: string; + amount: string; +} + +interface State { + amount: string | null; +} + +export class TipJarModal extends React.Component { + static getDerivedStateFromProps = (nextProps: Props, prevState: State) => { + return prevState.amount === null ? { amount: nextProps.amount } : {}; + }; + + state: State = { + amount: null, + }; + + render() { + const { isOpen, onClose, type, address } = this.props; + const { amount } = this.state; + + // should not be possible due to derived state, but makes TS happy + if (amount === null) return; + + const amountError = getAmountErrorFromString(amount) + const amountIsValid = !amountError + + const cli = amountIsValid ? formatZcashCLI(address, amount) : ''; + const uri = amountIsValid ? formatZcashURI(address, amount) : ''; + + const content = ( +
+
+
+
+ + + + {!uri && } +
+
+
+ + + + + +
+
+ +
+
+ +
+
+ +
+
+
+ ); + return ( + + Done + + } + afterClose={this.handleAfterClose} + > + {content} + + ); + } + + private handleAmountChange = (e: React.ChangeEvent) => + this.setState({ + amount: e.currentTarget.value, + }); + + private handleAfterClose = () => this.setState({ amount: null }); +} diff --git a/frontend/client/components/TipJar/index.tsx b/frontend/client/components/TipJar/index.tsx new file mode 100644 index 00000000..0aa9f9b9 --- /dev/null +++ b/frontend/client/components/TipJar/index.tsx @@ -0,0 +1,2 @@ +export * from './TipJarBlock' +export * from './TipJarModal' \ No newline at end of file diff --git a/frontend/client/utils/validators.ts b/frontend/client/utils/validators.ts index 0fa80bf4..a572d590 100644 --- a/frontend/client/utils/validators.ts +++ b/frontend/client/utils/validators.ts @@ -15,6 +15,18 @@ export function getAmountError(amount: number, max: number = Infinity, min?: num return null; } +export function getAmountErrorFromString(amount: string, max?: number, min?: number) { + const parsedAmount = parseFloat(amount) + if (Number.isNaN(parsedAmount)) { + return 'Not a valid number' + } + // prevents "-0" from being valid... + if (amount[0] === '-') { + return 'Amount must be a positive number' + } + return getAmountError(parsedAmount, max, min) +} + export function isValidEmail(email: string): boolean { return /\S+@\S+\.\S+/.test(email); } diff --git a/frontend/types/user.ts b/frontend/types/user.ts index 8601d7db..6694352d 100644 --- a/frontend/types/user.ts +++ b/frontend/types/user.ts @@ -10,9 +10,12 @@ export interface User { socialMedias: SocialMedia[]; avatar: { imageUrl: string } | null; isAdmin?: boolean; + tipJarAddress?: string; } export interface UserSettings { emailSubscriptions: EmailSubscriptions; refundAddress?: string | null; + tipJarAddress?: string | null; + tipJarViewKey?: string | null; } From 08fe3efca5494e72cb994126aaaa53c2e7e183c1 Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Wed, 13 Nov 2019 18:45:01 -0500 Subject: [PATCH 23/54] upgrade mini-css-extract-plugin, use in dev (#68) --- frontend/config/webpack.config.js/loaders.js | 20 +++++---- frontend/package.json | 2 +- frontend/yarn.lock | 45 ++++++++++++++++++-- 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/frontend/config/webpack.config.js/loaders.js b/frontend/config/webpack.config.js/loaders.js index 464fd680..7aa29fe7 100644 --- a/frontend/config/webpack.config.js/loaders.js +++ b/frontend/config/webpack.config.js/loaders.js @@ -61,12 +61,16 @@ const cssLoaderClient = { test: /\.css$/, exclude: [/node_modules/], use: [ - isDev && 'style-loader', - !isDev && MiniCssExtractPlugin.loader, + { + loader: MiniCssExtractPlugin.loader, + options: { + hmr: isDev, + }, + }, { loader: 'css-loader', }, - ].filter(Boolean), + ] }; const lessLoaderClient = { @@ -188,21 +192,19 @@ const externalCssLoaderClient = { test: /\.css$/, include: [/node_modules/], use: [ - isDev && 'style-loader', - !isDev && MiniCssExtractPlugin.loader, + MiniCssExtractPlugin.loader, 'css-loader', - ].filter(Boolean), + ] }; const externalLessLoaderClient = { test: /\.less$/, include: [/node_modules/], use: [ - isDev && 'style-loader', - !isDev && MiniCssExtractPlugin.loader, + MiniCssExtractPlugin.loader, 'css-loader', lessLoader, - ].filter(Boolean), + ] }; // Server build needs a loader to handle external .css files diff --git a/frontend/package.json b/frontend/package.json index f9d49b7e..3242b2f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -123,7 +123,7 @@ "lodash.defaultsdeep": "^4.6.1", "lodash.mergewith": "^4.6.2", "markdown-loader": "^4.0.0", - "mini-css-extract-plugin": "^0.4.2", + "mini-css-extract-plugin": "^0.8.0", "moment": "^2.22.2", "node-sass": "^4.9.2", "nodemon": "^1.18.4", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 77c42477..04cae4db 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -6767,6 +6767,11 @@ is-path-inside@^1.0.0: dependencies: path-is-inside "^1.0.1" +is-plain-obj@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" + integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4= + is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" @@ -7777,11 +7782,13 @@ min-document@^2.19.0: dependencies: dom-walk "^0.1.0" -mini-css-extract-plugin@^0.4.2: - version "0.4.2" - resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.4.2.tgz#b3ecc0d6b1bbe5ff14add42b946a7b200cf78651" +mini-css-extract-plugin@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.8.0.tgz#81d41ec4fe58c713a96ad7c723cdb2d0bd4d70e1" + integrity sha512-MNpRGbNA52q6U92i0qbVpQNsgk7LExy41MdAlG84FeytfDOtRIf/mCHdEgG8rpTKOaNKiqUnZdlptF469hxqOw== dependencies: loader-utils "^1.1.0" + normalize-url "1.9.1" schema-utils "^1.0.0" webpack-sources "^1.1.0" @@ -8154,6 +8161,16 @@ normalize-range@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" +normalize-url@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-1.9.1.tgz#2cc0d66b31ea23036458436e3620d85954c66c3c" + integrity sha1-LMDWazHqIwNkWENuNiDYWVTGbDw= + dependencies: + object-assign "^4.0.1" + prepend-http "^1.0.0" + query-string "^4.1.0" + sort-keys "^1.0.0" + npm-bundled@^1.0.1: version "1.0.5" resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.5.tgz#3c1732b7ba936b3a10325aef616467c0ccbcc979" @@ -8873,7 +8890,7 @@ prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" -prepend-http@^1.0.1: +prepend-http@^1.0.0, prepend-http@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" @@ -9071,6 +9088,14 @@ query-string@6.1.0: decode-uri-component "^0.2.0" strict-uri-encode "^2.0.0" +query-string@^4.1.0: + version "4.3.4" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb" + integrity sha1-u7aTucqRXCMlFbIosaArYJBD2+s= + dependencies: + object-assign "^4.1.0" + strict-uri-encode "^1.0.0" + querystring-es3@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" @@ -10821,6 +10846,13 @@ sockjs@0.3.19: faye-websocket "^0.10.0" uuid "^3.0.1" +sort-keys@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" + integrity sha1-RBttTTRnmPG05J6JIK37oOVD+a0= + dependencies: + is-plain-obj "^1.0.0" + sort-object-keys@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/sort-object-keys/-/sort-object-keys-1.1.2.tgz#d3a6c48dc2ac97e6bc94367696e03f6d09d37952" @@ -11029,6 +11061,11 @@ stream-to@~0.2.0: version "0.2.2" resolved "https://registry.yarnpkg.com/stream-to/-/stream-to-0.2.2.tgz#84306098d85fdb990b9fa300b1b3ccf55e8ef01d" +strict-uri-encode@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" + integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= + strict-uri-encode@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" From ec3350e45f411a3feb3f8284b7a737bb91d4092f Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Wed, 13 Nov 2019 18:59:35 -0500 Subject: [PATCH 24/54] Proposal Migration Script (#47) * init script * only modify object in not dry runs --- backend/grant/app.py | 1 + backend/grant/proposal/commands.py | 46 ++++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/backend/grant/app.py b/backend/grant/app.py index 9a0a7673..f401c208 100644 --- a/backend/grant/app.py +++ b/backend/grant/app.py @@ -163,5 +163,6 @@ def register_commands(app): app.cli.add_command(commands.reset_db_chain_data) app.cli.add_command(proposal.commands.create_proposal) 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(task.commands.create_task) diff --git a/backend/grant/proposal/commands.py b/backend/grant/proposal/commands.py index e713ef99..3443a787 100644 --- a/backend/grant/proposal/commands.py +++ b/backend/grant/proposal/commands.py @@ -7,7 +7,7 @@ from flask.cli import with_appcontext from .models import Proposal, db from grant.milestone.models import Milestone from grant.comment.models import Comment -from grant.utils.enums import ProposalStatus, Category, ProposalStageEnum +from grant.utils.enums import ProposalStatus, Category, ProposalStage from grant.user.models import User @@ -35,9 +35,9 @@ def create_proposals(count): user = User.query.filter_by().first() for i in range(count): if i < 5: - stage = ProposalStageEnum.WIP + stage = ProposalStage.WIP else: - stage = ProposalStageEnum.COMPLETED + stage = ProposalStage.COMPLETED p = Proposal.create( stage=stage, status=ProposalStatus.LIVE, @@ -76,3 +76,43 @@ def create_proposals(count): db.session.commit() print(f'Added {count} LIVE fake proposals') + + +@click.command() +@click.argument('dry', required=False) +@with_appcontext +def retire_v1_proposals(dry): + now = datetime.datetime.now() + proposals = Proposal.query.filter_by(stage="FUNDING_REQUIRED").all() + modified_count = 0 + + if not proposals: + print("No proposals found. Exiting...") + return + + print(f"Found {len(proposals)} proposals to modify") + if dry: + print(f"This is a dry run. Changes will not be committed to database") + + confirm = input("Continue? (y/n) ") + + if confirm != "y": + print("Exiting...") + return + + for p in proposals: + if not dry: + new_deadline = (now - p.date_published).total_seconds() + p.stage = ProposalStage.FAILED + p.deadline_duration = int(new_deadline) + db.session.add(p) + modified_count += 1 + + print(f"Modified proposal {p.id} - {p.title}") + + if not dry: + print(f"Committing changes to database") + db.session.commit() + + print(f"Modified {modified_count} proposals") + From 506a00a2fa9103661822ec8274272db47bf43fb4 Mon Sep 17 00:00:00 2001 From: Daniel Ternyak Date: Wed, 13 Nov 2019 22:26:12 -0600 Subject: [PATCH 25/54] Design changes for Profile tips. --- frontend/client/components/Profile/index.tsx | 6 ++-- .../client/components/TipJar/TipJarBlock.tsx | 32 +++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/frontend/client/components/Profile/index.tsx b/frontend/client/components/Profile/index.tsx index 43256ef6..62b8e00e 100644 --- a/frontend/client/components/Profile/index.tsx +++ b/frontend/client/components/Profile/index.tsx @@ -31,6 +31,7 @@ import './style.less'; interface StateProps { usersMap: AppState['users']['map']; authUser: AppState['auth']['user']; + hasCheckedUser: AppState['auth']['hasCheckedUser']; } interface DispatchProps { @@ -63,7 +64,7 @@ class Profile extends React.Component { } render() { - const { authUser, match, location } = this.props; + const { authUser, match, location, hasCheckedUser } = this.props; const { activeContribution } = this.state; const userLookupParam = match.params.id; @@ -76,7 +77,7 @@ class Profile extends React.Component { } const user = this.props.usersMap[userLookupParam]; - const waiting = !user || !user.hasFetched; + const waiting = !user || !user.hasFetched || !hasCheckedUser; const isAuthedUser = user && authUser && user.userid === authUser.userid; if (waiting) { @@ -271,6 +272,7 @@ const withConnect = connect( state => ({ usersMap: state.users.map, authUser: state.auth.user, + hasCheckedUser: state.auth.hasCheckedUser, }), { fetchUser: usersActions.fetchUser, diff --git a/frontend/client/components/TipJar/TipJarBlock.tsx b/frontend/client/components/TipJar/TipJarBlock.tsx index 28085edd..e3c2533d 100644 --- a/frontend/client/components/TipJar/TipJarBlock.tsx +++ b/frontend/client/components/TipJar/TipJarBlock.tsx @@ -6,8 +6,6 @@ import './TipJarBlock.less'; import '../Proposal/index.less'; interface Props { - isCard?: boolean; - hideTitle?: boolean; address?: string | null; type: 'user' | 'proposal'; } @@ -23,7 +21,7 @@ export class TipJarBlock extends React.Component { state = STATE; render() { - const { isCard, address, type, hideTitle } = this.props; + const { address, type } = this.props; const { tipAmount, modalOpen } = this.state; const amountError = tipAmount ? getAmountErrorFromString(tipAmount) : ''; @@ -34,9 +32,8 @@ export class TipJarBlock extends React.Component { const isDisabled = addressNotSet || !tipAmount || !!amountError; return ( -
+
- {!hideTitle &&

Tip

} { name="amountToTip" type="number" value={tipAmount} - placeholder="0.5" + placeholder="Show them you care" min={0} step={0.1} onChange={this.handleAmountChange} @@ -59,23 +56,24 @@ export class TipJarBlock extends React.Component { onClick={this.handleTipJarModalOpen} size="large" type="primary" - disabled={isDisabled} block + disabled={isDisabled} > - Donate + 🎉 Tip - {address && tipAmount && ( - - )} + {address && + tipAmount && ( + + )}
); } From d64cfb6de7866585a606f3e6b4c74616e3201b32 Mon Sep 17 00:00:00 2001 From: Daniel Ternyak Date: Wed, 13 Nov 2019 22:27:07 -0600 Subject: [PATCH 26/54] fix tsc --- frontend/client/components/Profile/ProfileUser.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/client/components/Profile/ProfileUser.tsx b/frontend/client/components/Profile/ProfileUser.tsx index fd8fd96b..a445b936 100644 --- a/frontend/client/components/Profile/ProfileUser.tsx +++ b/frontend/client/components/Profile/ProfileUser.tsx @@ -61,7 +61,7 @@ class ProfileUser extends React.Component {
)}
- {!isSelf && } + {!isSelf && }
); } From b824f462f0638bd1cbdcb88e27deb18e6359f00e Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Fri, 15 Nov 2019 14:29:03 -0500 Subject: [PATCH 27/54] Fix Create Proposals Command (#70) --- backend/grant/proposal/commands.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/grant/proposal/commands.py b/backend/grant/proposal/commands.py index 3443a787..0f8d0e88 100644 --- a/backend/grant/proposal/commands.py +++ b/backend/grant/proposal/commands.py @@ -51,6 +51,10 @@ def create_proposals(count): ) p.date_published = datetime.datetime.now() p.team.append(user) + p.date_approved = datetime.datetime.now() + p.accepted_with_funding = True + p.version = '2' + p.fully_fund_contibution_bounty() db.session.add(p) db.session.flush() num_ms = randint(1, 9) @@ -58,7 +62,7 @@ def create_proposals(count): m = Milestone( title=f'Fake MS {j}', content=f'Fake milestone #{j} on fake proposal #{i}!', - date_estimated=datetime.datetime.now(), + days_estimated='10', payout_percent=str(floor(1 / num_ms * 100)), immediate_payout=j == 0, proposal_id=p.id, @@ -74,6 +78,10 @@ def create_proposals(count): ) db.session.add(c) + if stage == ProposalStage.WIP: + Milestone.set_v2_date_estimates(p) + db.session.add(p) + db.session.commit() print(f'Added {count} LIVE fake proposals') From ceb9f8cbdf7dc4769520fede3206193a5194534b Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Fri, 15 Nov 2019 14:48:41 -0500 Subject: [PATCH 28/54] always run milestone date estimation (#71) --- backend/grant/proposal/commands.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/grant/proposal/commands.py b/backend/grant/proposal/commands.py index 0f8d0e88..220494d5 100644 --- a/backend/grant/proposal/commands.py +++ b/backend/grant/proposal/commands.py @@ -78,9 +78,8 @@ def create_proposals(count): ) db.session.add(c) - if stage == ProposalStage.WIP: - Milestone.set_v2_date_estimates(p) - db.session.add(p) + Milestone.set_v2_date_estimates(p) + db.session.add(p) db.session.commit() print(f'Added {count} LIVE fake proposals') From 216b37f6a33db91e976ec09fa6c2ff943d33ed04 Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Fri, 15 Nov 2019 15:30:40 -0500 Subject: [PATCH 29/54] Stylesheet Fix (#69) * move bundle.css before custom styling * roll back previous fix * style like and proposal buttons with class --- frontend/client/components/Follow/index.tsx | 6 ++++-- frontend/client/components/Like/index.tsx | 10 ++++++++-- frontend/client/components/Proposal/index.less | 7 +++++-- frontend/client/components/Proposal/index.tsx | 10 ++++++++-- frontend/server/components/HTML.tsx | 14 +++++++++++++- 5 files changed, 38 insertions(+), 9 deletions(-) diff --git a/frontend/client/components/Follow/index.tsx b/frontend/client/components/Follow/index.tsx index 9f8d93cb..219f06b6 100644 --- a/frontend/client/components/Follow/index.tsx +++ b/frontend/client/components/Follow/index.tsx @@ -6,11 +6,13 @@ import { proposalActions } from 'modules/proposals'; import { ProposalDetail } from 'modules/proposals/reducers'; import { followProposal } from 'api/api'; import AuthButton from 'components/AuthButton'; +import classnames from 'classnames'; import './index.less'; interface OwnProps { proposal: ProposalDetail; style?: React.CSSProperties; + className?: string; } interface StateProps { @@ -31,11 +33,11 @@ type State = typeof STATE; class Follow extends React.Component { state: State = { ...STATE }; render() { - const { style } = this.props; + const { style, className } = this.props; const { authedFollows, followersCount } = this.props.proposal; const { loading } = this.state; return ( - + { render() { const { likesCount, authedLiked } = this.deriveInfo(); - const { proposal, rfp, comment, style, proposal_card } = this.props; + const { proposal, rfp, comment, style, proposal_card, className } = this.props; const { loading } = this.state; const zoom = comment || proposal_card ? 0.8 : 1; const shouldShowLikeText = (!!proposal && !proposal_card) || !!rfp; @@ -54,7 +56,11 @@ class Like extends React.Component { const handleIconButtonClick = proposal_card ? undefined : this.handleLike; return ( - + { )} - - + +
)} diff --git a/frontend/server/components/HTML.tsx b/frontend/server/components/HTML.tsx index ede71704..6bdd3576 100644 --- a/frontend/server/components/HTML.tsx +++ b/frontend/server/components/HTML.tsx @@ -22,6 +22,17 @@ const HTML: React.SFC = ({ extractor, }) => { const head = Helmet.renderStatic(); + const extractedStyleElements = extractor.getStyleElements(); + + // Move `bundle.css` to beginning of array so custom styles don't get overwritten + const bundleIndex = extractedStyleElements.findIndex(element => { + return typeof element.key === 'string' && /^.*\/bundle\.css$/.test(element.key); + }); + if (bundleIndex !== -1) { + const [bundle] = extractedStyleElements.splice(bundleIndex, 1); + extractedStyleElements.unshift(bundle); + } + return ( @@ -53,7 +64,8 @@ const HTML: React.SFC = ({ {css.map(href => { return ; })} - {extractor.getStyleElements()} + + {extractedStyleElements}