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',
|
||||
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[];
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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("/<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',
|
||||
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,
|
||||
|
|
|
@ -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)
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<Props, State> {
|
|||
</div>
|
||||
)}
|
||||
<div className="Proposal-top-main">
|
||||
<h1 className="Proposal-top-main-title">
|
||||
{proposal ? proposal.title : <span> </span>}
|
||||
</h1>
|
||||
<div className="Proposal-top-main-title">
|
||||
<h1>{proposal ? proposal.title : <span> </span>}</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
|
||||
ref={el => (this.bodyEl = el)}
|
||||
|
@ -206,21 +225,6 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
</button>
|
||||
)}
|
||||
</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 className="Proposal-top-side">
|
||||
<CampaignBlock proposal={proposal} isPreview={!isLive} />
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -64,6 +64,8 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
|
|||
arbiter: ProposalProposalArbiter;
|
||||
acceptedWithFunding: boolean | null;
|
||||
isVersionTwo: boolean;
|
||||
authedFollows: boolean;
|
||||
followersCount: number;
|
||||
isTeamMember?: boolean; // FE derived
|
||||
isArbiter?: boolean; // FE derived
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue