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
This commit is contained in:
Danny Skubak 2019-10-24 13:32:00 -04:00 committed by Daniel Ternyak
parent 39f9cea42e
commit 5f049d899b
29 changed files with 689 additions and 23 deletions

View File

@ -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")

View File

@ -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("/<comment_id>/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

View File

@ -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)

View File

@ -684,3 +684,21 @@ def follow_proposal(proposal_id, is_follow):
db.session.commit()
return {"message": "ok"}, 200
@blueprint.route("/<proposal_id>/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

View File

@ -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")

View File

@ -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("/<rfp_id>/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

View File

@ -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,

View File

@ -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 <user_id>"), 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 <proposal_id>"), 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 <proposal_id>"), 500

View File

@ -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)
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)

View File

@ -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)

View File

View File

@ -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)

View File

@ -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 });
}

View File

@ -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<typeof getIsSignedIn>;
detail: AppState['proposal']['detail'];
}
interface DispatchProps {
@ -71,19 +73,23 @@ class Comment extends React.Component<Props> {
<Markdown source={comment.content} type={MARKDOWN_TYPE.REDUCED} />
</div>
{isSignedIn && (
<div className="Comment-controls">
<div className="Comment-controls">
<div className="Comment-controls-button">
<Like comment={comment} />
</div>
{isSignedIn && (
<a className="Comment-controls-button" onClick={this.toggleReply}>
{isReplying ? 'Cancel' : 'Reply'}
</a>
{!comment.hidden &&
!comment.reported && (
<a className="Comment-controls-button" onClick={this.report}>
Report
</a>
)}
</div>
)}
)}
{isSignedIn &&
!comment.hidden &&
!comment.reported && (
<a className="Comment-controls-button" onClick={this.report}>
Report
</a>
)}
</div>
{(comment.replies.length || isReplying) && (
<div className="Comment-replies">
@ -143,6 +149,7 @@ const ConnectedComment = connect<StateProps, DispatchProps, OwnProps, AppState>(
isPostCommentPending: state.proposal.isPostCommentPending,
postCommentError: state.proposal.postCommentError,
isSignedIn: getIsSignedIn(state),
detail: state.proposal.detail,
}),
{
postProposalComment,

View File

@ -48,6 +48,7 @@
&-controls {
display: flex;
margin-left: -0.5rem;
align-items: center;
&-button {
font-size: 0.65rem;

View File

@ -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;
}
}

View File

@ -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<Props, State> {
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 (
<Input.Group className="Like" compact style={{ zoom }}>
<AuthButton onClick={this.handleLike}>
<Icon
theme={authedLiked ? 'filled' : 'outlined'}
type={loading ? 'loading' : 'like'}
/>
{shouldShowLikeText && (
<span className="Like-label">{authedLiked ? ' Unlike' : ' Like'}</span>
)}
</AuthButton>
<Button className="Like-count" disabled>
<span>{likesCount}</span>
</Button>
</Input.Group>
);
}
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<StateProps, DispatchProps, OwnProps, AppState>(
state => ({
authUser: state.auth.user,
}),
{
fetchProposal: proposalActions.fetchProposal,
updateComment: proposalActions.updateProposalComment,
fetchRfp: rfpActions.fetchRfp,
},
);
export default withConnect(Follow);

View File

@ -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;
}

View File

@ -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<Props, State> {
</Button>
</Dropdown>
)}
<Like proposal={proposal} />
<Follow proposal={proposal} />
</div>
)}

View File

@ -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;
}
}
}
}

View File

@ -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<Props> {
<div className="RFPDetail-top-date">
Opened {moment(rfp.dateOpened * 1000).format('LL')}
</div>
<Like rfp={rfp} />
</div>
<h1 className="RFPDetail-title">{rfp.title}</h1>

View File

@ -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,

View File

@ -230,3 +230,17 @@ export function reportProposalComment(
}
};
}
export function updateProposalComment(
commentId: Comment['id'],
commentUpdate: Partial<Comment>,
) {
return (dispatch: Dispatch<any>) =>
dispatch({
type: types.UPDATE_PROPOSAL_COMMENT,
payload: {
commentId,
commentUpdate,
},
});
}

View File

@ -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,

View File

@ -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;

View File

@ -164,6 +164,8 @@ export function generateProposal({
isStaked: true,
authedFollows: false,
followersCount: 0,
authedLiked: false,
likesCount: 0,
arbiter: {
status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
user: {

View File

@ -9,6 +9,8 @@ export interface Comment {
replies: Comment[];
reported: boolean;
hidden: boolean;
authedLiked: boolean;
likesCount: number;
}
export interface UserComment {

View File

@ -66,6 +66,8 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
isVersionTwo: boolean;
authedFollows: boolean;
followersCount: number;
authedLiked: boolean;
likesCount: number;
isTeamMember?: boolean; // FE derived
isArbiter?: boolean; // FE derived
}

View File

@ -16,4 +16,6 @@ export interface RFP {
dateOpened: number;
dateClosed?: number;
dateCloses?: number;
authedLiked: boolean;
likesCount: number;
}