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
This commit is contained in:
parent
58eb8f2455
commit
5799ffab19
|
@ -145,4 +145,15 @@ export default [
|
||||||
title: 'Admin Payout',
|
title: 'Admin Payout',
|
||||||
description: 'Sent when milestone payout has been approved',
|
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[];
|
] as Email[];
|
||||||
|
|
|
@ -178,4 +178,13 @@ example_email_args = {
|
||||||
'proposal': proposal,
|
'proposal': proposal,
|
||||||
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
|
'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",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -415,6 +415,13 @@ def paid_milestone_payout_request(id, mid, tx_id):
|
||||||
'tx_explorer_url': make_explore_url(tx_id),
|
'tx_explorer_url': make_explore_url(tx_id),
|
||||||
'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'),
|
'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 proposal_schema.dump(proposal), 200
|
||||||
|
|
||||||
return {"message": "No milestone matching id"}, 404
|
return {"message": "No milestone matching id"}, 404
|
||||||
|
|
|
@ -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 = {
|
get_info_lookup = {
|
||||||
'signup': signup_info,
|
'signup': signup_info,
|
||||||
'team_invite': team_invite_info,
|
'team_invite': team_invite_info,
|
||||||
|
@ -335,7 +356,9 @@ get_info_lookup = {
|
||||||
'milestone_paid': milestone_paid,
|
'milestone_paid': milestone_paid,
|
||||||
'admin_approval': admin_approval,
|
'admin_approval': admin_approval,
|
||||||
'admin_arbiter': admin_arbiter,
|
'admin_arbiter': admin_arbiter,
|
||||||
'admin_payout': admin_payout
|
'admin_payout': admin_payout,
|
||||||
|
'followed_proposal_milestone': followed_proposal_milestone,
|
||||||
|
'followed_proposal_update': followed_proposal_update
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -65,6 +65,10 @@ class EmailSubscription(Enum):
|
||||||
'bit': 14,
|
'bit': 14,
|
||||||
'key': 'admin_payout'
|
'key': 'admin_payout'
|
||||||
}
|
}
|
||||||
|
FOLLOWED_PROPOSAL = {
|
||||||
|
'bit': 15,
|
||||||
|
'key': 'followed_proposal'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def is_email_sub_key(k: str):
|
def is_email_sub_key(k: str):
|
||||||
|
|
|
@ -2,12 +2,11 @@ import datetime
|
||||||
from decimal import Decimal, ROUND_DOWN
|
from decimal import Decimal, ROUND_DOWN
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
|
||||||
from flask import current_app
|
|
||||||
from marshmallow import post_dump
|
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.ext.hybrid import hybrid_property
|
||||||
|
from sqlalchemy.orm import column_property
|
||||||
|
|
||||||
from flask import current_app
|
|
||||||
from grant.comment.models import Comment
|
from grant.comment.models import Comment
|
||||||
from grant.email.send import send_email
|
from grant.email.send import send_email
|
||||||
from grant.extensions import ma, db
|
from grant.extensions import ma, db
|
||||||
|
@ -32,6 +31,12 @@ proposal_team = db.Table(
|
||||||
db.Column('proposal_id', db.Integer, db.ForeignKey('proposal.id'))
|
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):
|
class ProposalTeamInvite(db.Model):
|
||||||
__tablename__ = "proposal_team_invite"
|
__tablename__ = "proposal_team_invite"
|
||||||
|
@ -250,6 +255,14 @@ class Proposal(db.Model):
|
||||||
order_by="asc(Milestone.index)", lazy=True, cascade="all, delete-orphan")
|
order_by="asc(Milestone.index)", lazy=True, cascade="all, delete-orphan")
|
||||||
invites = db.relationship(ProposalTeamInvite, backref="proposal", 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")
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
@ -572,6 +585,26 @@ class Proposal(db.Model):
|
||||||
'account_settings_url': make_url('/profile/settings?tab=account')
|
'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
|
@hybrid_property
|
||||||
def contributed(self):
|
def contributed(self):
|
||||||
contributions = ProposalContribution.query \
|
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}
|
d = {c.user.id: c.user for c in self.contributions if c.user and c.status == ContributionStatus.CONFIRMED}
|
||||||
return d.values()
|
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 ProposalSchema(ma.Schema):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -674,7 +723,9 @@ class ProposalSchema(ma.Schema):
|
||||||
"rfp_opt_in",
|
"rfp_opt_in",
|
||||||
"arbiter",
|
"arbiter",
|
||||||
"accepted_with_funding",
|
"accepted_with_funding",
|
||||||
"is_version_two"
|
"is_version_two",
|
||||||
|
"authed_follows",
|
||||||
|
"followers_count"
|
||||||
)
|
)
|
||||||
|
|
||||||
date_created = ma.Method("get_date_created")
|
date_created = ma.Method("get_date_created")
|
||||||
|
@ -722,7 +773,8 @@ user_fields = [
|
||||||
"date_published",
|
"date_published",
|
||||||
"reject_reason",
|
"reject_reason",
|
||||||
"team",
|
"team",
|
||||||
"is_version_two"
|
"is_version_two",
|
||||||
|
"authed_follows"
|
||||||
]
|
]
|
||||||
user_proposal_schema = ProposalSchema(only=user_fields)
|
user_proposal_schema = ProposalSchema(only=user_fields)
|
||||||
user_proposals_schema = ProposalSchema(many=True, only=user_fields)
|
user_proposals_schema = ProposalSchema(many=True, only=user_fields)
|
||||||
|
|
|
@ -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}'),
|
'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)
|
dumped_update = proposal_update_schema.dump(update)
|
||||||
return dumped_update, 201
|
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 proposal_schema.dump(g.current_proposal), 200
|
||||||
|
|
||||||
return {"message": "No milestone matching id"}, 404
|
return {"message": "No milestone matching id"}, 404
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<proposal_id>/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
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
<p style="margin: 0;">
|
||||||
|
Your followed proposal {{ args.proposal.title }} has had its
|
||||||
|
{{ args.milestone.title }}
|
||||||
|
milestone accepted!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#ffffff" style="padding: 40px 30px 40px 30px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="center"
|
||||||
|
bgcolor="{{ UI.PRIMARY }}"
|
||||||
|
style="border-radius: 3px;"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="{{ args.proposal_url }}"
|
||||||
|
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
|
||||||
|
UI.PRIMARY
|
||||||
|
}}; display: inline-block;"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Check it out
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
|
@ -0,0 +1,3 @@
|
||||||
|
Your followed proposal {{ args.proposal.title }} has had its {{ args.milestone.title }} milestone accepted!
|
||||||
|
|
||||||
|
Check it out: {{ args.proposal_url }}
|
|
@ -0,0 +1,29 @@
|
||||||
|
<p style="margin: 0;">
|
||||||
|
Your followed proposal {{ args.proposal.title }} has an update!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#ffffff" style="padding: 40px 30px 40px 30px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="center"
|
||||||
|
bgcolor="{{ UI.PRIMARY }}"
|
||||||
|
style="border-radius: 3px;"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="{{ args.proposal_url }}"
|
||||||
|
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
|
||||||
|
UI.PRIMARY
|
||||||
|
}}; display: inline-block;"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Check it out
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
|
@ -0,0 +1,3 @@
|
||||||
|
Your followed proposal {{ args.proposal.title }} has an update!
|
||||||
|
|
||||||
|
Check it out: {{ args.proposal_url }}
|
|
@ -133,6 +133,9 @@ class User(db.Model, UserMixin):
|
||||||
roles = db.relationship('Role', secondary='roles_users',
|
roles = db.relationship('Role', secondary='roles_users',
|
||||||
backref=db.backref('users', lazy='dynamic'))
|
backref=db.backref('users', lazy='dynamic'))
|
||||||
arbiter_proposals = db.relationship("ProposalArbiter", lazy=True, back_populates="user")
|
arbiter_proposals = db.relationship("ProposalArbiter", lazy=True, back_populates="user")
|
||||||
|
followed_proposals = db.relationship(
|
||||||
|
"Proposal", secondary="proposal_follower", back_populates="followers"
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
|
@ -234,3 +234,51 @@ class TestProposalAPI(BaseProposalCreatorConfig):
|
||||||
for each_proposal in resp.json['items']:
|
for each_proposal in resp.json['items']:
|
||||||
for team_member in each_proposal["team"]:
|
for team_member in each_proposal["team"]:
|
||||||
self.assertIsNone(team_member.get('email_address'))
|
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)
|
|
@ -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) {
|
export function getProposalComments(proposalId: number | string, params: PageParams) {
|
||||||
return axios.get(`/api/v1/proposals/${proposalId}/comments`, { params });
|
return axios.get(`/api/v1/proposals/${proposalId}/comments`, { params });
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<Props, State> {
|
||||||
|
state: State = { ...STATE };
|
||||||
|
public render() {
|
||||||
|
const { location, children, loading } = this.props;
|
||||||
|
if (this.state.sendToAuth) {
|
||||||
|
return <Redirect to={{ ...location, pathname: '/profile' }} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button loading={loading} onClick={this.handleClick}>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
private handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
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<StateProps, DispatchProps, OwnProps, AppState>(
|
||||||
|
(state: AppState) => ({
|
||||||
|
user: state.auth.user,
|
||||||
|
}),
|
||||||
|
{ setAuthForwardLocation: authActions.setAuthForwardLocation },
|
||||||
|
);
|
||||||
|
|
||||||
|
export default compose<Props, OwnProps>(
|
||||||
|
withRouter,
|
||||||
|
withConnect,
|
||||||
|
)(AuthButton);
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<Props, State> {
|
||||||
|
state: State = { ...STATE };
|
||||||
|
render() {
|
||||||
|
const { authedFollows, followersCount } = this.props.proposal;
|
||||||
|
const { loading } = this.state;
|
||||||
|
return (
|
||||||
|
<Input.Group className="Follow" compact>
|
||||||
|
<AuthButton onClick={this.handleFollow}>
|
||||||
|
<Icon
|
||||||
|
theme={authedFollows ? 'filled' : 'outlined'}
|
||||||
|
type={loading ? 'loading' : 'star'}
|
||||||
|
/>
|
||||||
|
<span className="Follow-label">{authedFollows ? ' Unfollow' : ' Follow'}</span>
|
||||||
|
</AuthButton>
|
||||||
|
<Button className="Follow-count" disabled>
|
||||||
|
<span>{followersCount}</span>
|
||||||
|
</Button>
|
||||||
|
</Input.Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<StateProps, DispatchProps, OwnProps, AppState>(
|
||||||
|
state => ({
|
||||||
|
authUser: state.auth.user,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
fetchProposal: proposalActions.fetchProposal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default withConnect(Follow);
|
|
@ -65,10 +65,19 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&-title {
|
&-title {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
@media (max-width: @single-col-width) {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
margin-top: -1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
line-height: 3rem;
|
line-height: 3rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
@media (min-width: @collapse-width) {
|
@media (min-width: @collapse-width) {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
@ -85,6 +94,17 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-menu {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 3rem;
|
||||||
|
|
||||||
|
& > * + * {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// shared
|
// shared
|
||||||
&-block {
|
&-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -25,6 +25,7 @@ import CancelModal from './CancelModal';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { withRouter } from 'react-router';
|
import { withRouter } from 'react-router';
|
||||||
import SocialShare from 'components/SocialShare';
|
import SocialShare from 'components/SocialShare';
|
||||||
|
import Follow from 'components/Follow';
|
||||||
import './index.less';
|
import './index.less';
|
||||||
|
|
||||||
interface OwnProps {
|
interface OwnProps {
|
||||||
|
@ -184,9 +185,27 @@ export class ProposalDetail extends React.Component<Props, State> {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="Proposal-top-main">
|
<div className="Proposal-top-main">
|
||||||
<h1 className="Proposal-top-main-title">
|
<div className="Proposal-top-main-title">
|
||||||
{proposal ? proposal.title : <span> </span>}
|
<h1>{proposal ? proposal.title : <span> </span>}</h1>
|
||||||
</h1>
|
{isLive && (
|
||||||
|
<div className="Proposal-top-main-title-menu">
|
||||||
|
{isTrustee && (
|
||||||
|
<Dropdown
|
||||||
|
overlay={adminMenu}
|
||||||
|
trigger={['click']}
|
||||||
|
placement="bottomRight"
|
||||||
|
>
|
||||||
|
<Button>
|
||||||
|
<span>Actions</span>
|
||||||
|
<Icon type="down" style={{ marginRight: '-0.25rem' }} />
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
<Follow proposal={proposal} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="Proposal-top-main-block" style={{ flexGrow: 1 }}>
|
<div className="Proposal-top-main-block" style={{ flexGrow: 1 }}>
|
||||||
<div
|
<div
|
||||||
ref={el => (this.bodyEl = el)}
|
ref={el => (this.bodyEl = el)}
|
||||||
|
@ -206,21 +225,6 @@ export class ProposalDetail extends React.Component<Props, State> {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isLive &&
|
|
||||||
isTrustee && (
|
|
||||||
<div className="Proposal-top-main-menu">
|
|
||||||
<Dropdown
|
|
||||||
overlay={adminMenu}
|
|
||||||
trigger={['click']}
|
|
||||||
placement="bottomRight"
|
|
||||||
>
|
|
||||||
<Button>
|
|
||||||
<span>Actions</span>
|
|
||||||
<Icon type="down" style={{ marginRight: '-0.25rem' }} />
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="Proposal-top-side">
|
<div className="Proposal-top-side">
|
||||||
<CampaignBlock proposal={proposal} isPreview={!isLive} />
|
<CampaignBlock proposal={proposal} isPreview={!isLive} />
|
||||||
|
|
|
@ -249,6 +249,8 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta
|
||||||
status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
|
status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
|
||||||
},
|
},
|
||||||
acceptedWithFunding: false,
|
acceptedWithFunding: false,
|
||||||
|
authedFollows: false,
|
||||||
|
followersCount: 0,
|
||||||
isVersionTwo: true,
|
isVersionTwo: true,
|
||||||
milestones: draft.milestones.map((m, idx) => ({
|
milestones: draft.milestones.map((m, idx) => ({
|
||||||
id: idx,
|
id: idx,
|
||||||
|
|
|
@ -162,6 +162,8 @@ export function generateProposal({
|
||||||
stage: PROPOSAL_STAGE.WIP,
|
stage: PROPOSAL_STAGE.WIP,
|
||||||
category: PROPOSAL_CATEGORY.COMMUNITY,
|
category: PROPOSAL_CATEGORY.COMMUNITY,
|
||||||
isStaked: true,
|
isStaked: true,
|
||||||
|
authedFollows: false,
|
||||||
|
followersCount: 0,
|
||||||
arbiter: {
|
arbiter: {
|
||||||
status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
|
status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
|
||||||
user: {
|
user: {
|
||||||
|
|
|
@ -64,6 +64,8 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
|
||||||
arbiter: ProposalProposalArbiter;
|
arbiter: ProposalProposalArbiter;
|
||||||
acceptedWithFunding: boolean | null;
|
acceptedWithFunding: boolean | null;
|
||||||
isVersionTwo: boolean;
|
isVersionTwo: boolean;
|
||||||
|
authedFollows: boolean;
|
||||||
|
followersCount: number;
|
||||||
isTeamMember?: boolean; // FE derived
|
isTeamMember?: boolean; // FE derived
|
||||||
isArbiter?: boolean; // FE derived
|
isArbiter?: boolean; // FE derived
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue