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.extensions import ma, db
from grant.utils.ma_fields import UnixDate from grant.utils.ma_fields import UnixDate
from grant.utils.misc import gen_random_id 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~~' 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): class Comment(db.Model):
__tablename__ = "comment" __tablename__ = "comment"
@ -25,6 +34,15 @@ class Comment(db.Model):
author = db.relationship("User", back_populates="comments") author = db.relationship("User", back_populates="comments")
replies = db.relationship("Comment") 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): def __init__(self, proposal_id, user_id, parent_comment_id, content):
self.id = gen_random_id(Comment) self.id = gen_random_id(Comment)
self.proposal_id = proposal_id self.proposal_id = proposal_id
@ -49,6 +67,28 @@ class Comment(db.Model):
self.hidden = hidden self.hidden = hidden
db.session.add(self) 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? # are all of the replies hidden?
def all_hidden(replies): def all_hidden(replies):
@ -74,6 +114,8 @@ class CommentSchema(ma.Schema):
"replies", "replies",
"reported", "reported",
"hidden", "hidden",
"authed_liked",
"likes_count"
) )
content = ma.Method("get_content") 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 = 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")), 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): class ProposalTeamInvite(db.Model):
__tablename__ = "proposal_team_invite" __tablename__ = "proposal_team_invite"
@ -150,6 +158,8 @@ class ProposalContribution(db.Model):
raise ValidationException('Proposal ID is required') raise ValidationException('Proposal ID is required')
# User ID (must belong to an existing user) # User ID (must belong to an existing user)
if user_id: if user_id:
from grant.user.models import User
user = User.query.filter(User.id == user_id).first() user = User.query.filter(User.id == user_id).first()
if not user: if not user:
raise ValidationException('No user matching that ID') raise ValidationException('No user matching that ID')
@ -263,6 +273,14 @@ class Proposal(db.Model):
.where(proposal_follower.c.proposal_id == id) .where(proposal_follower.c.proposal_id == id)
.correlate_except(proposal_follower) .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__( def __init__(
self, self,
@ -596,6 +614,13 @@ class Proposal(db.Model):
self.followers.remove(user) self.followers.remove(user)
db.session.flush() 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=""): def send_follower_email(self, type: str, email_args={}, url_suffix=""):
for u in self.followers: for u in self.followers:
send_email( send_email(
@ -692,6 +717,22 @@ class Proposal(db.Model):
return True return True
return False 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 ProposalSchema(ma.Schema):
class Meta: class Meta:
@ -729,7 +770,9 @@ class ProposalSchema(ma.Schema):
"accepted_with_funding", "accepted_with_funding",
"is_version_two", "is_version_two",
"authed_follows", "authed_follows",
"followers_count" "followers_count",
"authed_liked",
"likes_count"
) )
date_created = ma.Method("get_date_created") date_created = ma.Method("get_date_created")
@ -778,7 +821,8 @@ user_fields = [
"reject_reason", "reject_reason",
"team", "team",
"is_version_two", "is_version_two",
"authed_follows" "authed_follows",
"authed_liked"
] ]
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)

View File

@ -684,3 +684,21 @@ def follow_proposal(proposal_id, is_follow):
db.session.commit() db.session.commit()
return {"message": "ok"}, 200 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 decimal import Decimal
from grant.extensions import ma, db from grant.extensions import ma, db
from sqlalchemy.ext.hybrid import hybrid_property 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.enums import RFPStatus
from grant.utils.misc import dt_to_unix, gen_random_id from grant.utils.misc import dt_to_unix, gen_random_id
from grant.utils.enums import Category 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): class RFP(db.Model):
__tablename__ = "rfp" __tablename__ = "rfp"
@ -38,6 +47,16 @@ class RFP(db.Model):
cascade="all, delete-orphan", 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 @hybrid_property
def bounty(self): def bounty(self):
return self._bounty return self._bounty
@ -49,6 +68,29 @@ class RFP(db.Model):
else: else:
self._bounty = None 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__( def __init__(
self, self,
title: str, title: str,
@ -92,6 +134,8 @@ class RFPSchema(ma.Schema):
"date_opened", "date_opened",
"date_closed", "date_closed",
"accepted_proposals", "accepted_proposals",
"authed_liked",
"likes_count"
) )
status = ma.Method("get_status") 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 sqlalchemy import or_
from grant.utils.enums import RFPStatus 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") 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: if not rfp or rfp.status == RFPStatus.DRAFT:
return {"message": "No RFP with that ID"}, 404 return {"message": "No RFP with that ID"}, 404
return rfp_schema.dump(rfp) 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( followed_proposals = db.relationship(
"Proposal", secondary="proposal_follower", back_populates="followers" "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__( def __init__(
self, self,

View File

@ -5,9 +5,7 @@ import sentry_sdk
from flask import request, g, jsonify, session, current_app from flask import request, g, jsonify, session, current_app
from flask_security.core import current_user from flask_security.core import current_user
from flask_security.utils import logout_user from flask_security.utils import logout_user
from grant.proposal.models import Proposal
from grant.settings import BLOCKCHAIN_API_SECRET from grant.settings import BLOCKCHAIN_API_SECRET
from grant.user.models import User
class AuthException(Exception): class AuthException(Exception):
@ -41,6 +39,8 @@ def is_email_verified():
def auth_user(email, password): def auth_user(email, password):
from grant.user.models import User
existing_user = User.get_by_email(email) existing_user = User.get_by_email(email)
if not existing_user: if not existing_user:
raise AuthException("No user exists with that email") raise AuthException("No user exists with that email")
@ -85,6 +85,8 @@ def requires_auth(f):
def requires_same_user_auth(f): def requires_same_user_auth(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
from grant.user.models import User
user_id = kwargs["user_id"] user_id = kwargs["user_id"]
if not user_id: if not user_id:
return jsonify(message="Decorator requires_same_user_auth requires path variable <user_id>"), 500 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): def requires_team_member_auth(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
from grant.proposal.models import Proposal
proposal_id = kwargs["proposal_id"] proposal_id = kwargs["proposal_id"]
if not proposal_id: if not proposal_id:
return jsonify(message="Decorator requires_team_member_auth requires path variable <proposal_id>"), 500 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): def requires_arbiter_auth(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
from grant.proposal.models import Proposal
proposal_id = kwargs["proposal_id"] proposal_id = kwargs["proposal_id"]
if not proposal_id: if not proposal_id:
return jsonify(message="Decorator requires_arbiter_auth requires path variable <proposal_id>"), 500 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(resp.json["authedFollows"], False)
self.assertEqual(len(self.proposal.followers), 0) 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 import json
from grant.proposal.models import Proposal, db from grant.proposal.models import Proposal, Comment, db
from grant.utils.enums import ProposalStatus from grant.utils.enums import ProposalStatus
from ..config import BaseUserConfig from ..config import BaseUserConfig
from ..test_data import test_comment, test_reply, test_comment_large from ..test_data import test_comment, test_reply, test_comment_large
@ -148,3 +148,59 @@ class TestProposalCommentAPI(BaseUserConfig):
self.assertStatus(comment_res, 403) self.assertStatus(comment_res, 403)
self.assertIn('silenced', comment_res.json['message']) 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 }); 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) { 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 });
} }

View File

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

View File

@ -48,6 +48,7 @@
&-controls { &-controls {
display: flex; display: flex;
margin-left: -0.5rem; margin-left: -0.5rem;
align-items: center;
&-button { &-button {
font-size: 0.65rem; 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; line-height: 3rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
margin-left: 0.5rem; margin-left: 0.5rem;
margin-right: 1rem;
flex-grow: 1; flex-grow: 1;
@media (min-width: @collapse-width) { @media (min-width: @collapse-width) {
@ -99,6 +100,10 @@
align-items: center; align-items: center;
height: 3rem; height: 3rem;
& .ant-input-group {
width: inherit
}
& > * + * { & > * + * {
margin-left: 0.5rem; margin-left: 0.5rem;
} }

View File

@ -26,6 +26,7 @@ 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 Follow from 'components/Follow';
import Like from 'components/Like';
import './index.less'; import './index.less';
interface OwnProps { interface OwnProps {
@ -205,6 +206,7 @@ export class ProposalDetail extends React.Component<Props, State> {
</Button> </Button>
</Dropdown> </Dropdown>
)} )}
<Like proposal={proposal} />
<Follow proposal={proposal} /> <Follow proposal={proposal} />
</div> </div>
)} )}

View File

@ -21,6 +21,10 @@
&-date { &-date {
opacity: 0.7; opacity: 0.7;
} }
& .ant-input-group {
width: inherit;
}
} }
&-brief { &-brief {
@ -97,8 +101,8 @@
// Only has this class while affixed // Only has this class while affixed
.ant-affix { .ant-affix {
box-shadow: 0 0 10px 10px #FFF; box-shadow: 0 0 10px 10px #fff;
background: #FFF; background: #fff;
} }
} }
} }

View File

@ -13,6 +13,7 @@ import Markdown from 'components/Markdown';
import ProposalCard from 'components/Proposals/ProposalCard'; import ProposalCard from 'components/Proposals/ProposalCard';
import UnitDisplay from 'components/UnitDisplay'; import UnitDisplay from 'components/UnitDisplay';
import HeaderDetails from 'components/HeaderDetails'; import HeaderDetails from 'components/HeaderDetails';
import Like from 'components/Like';
import { RFP_STATUS } from 'api/constants'; import { RFP_STATUS } from 'api/constants';
import './index.less'; import './index.less';
@ -87,6 +88,7 @@ class RFPDetail extends React.Component<Props> {
<div className="RFPDetail-top-date"> <div className="RFPDetail-top-date">
Opened {moment(rfp.dateOpened * 1000).format('LL')} Opened {moment(rfp.dateOpened * 1000).format('LL')}
</div> </div>
<Like rfp={rfp} />
</div> </div>
<h1 className="RFPDetail-title">{rfp.title}</h1> <h1 className="RFPDetail-title">{rfp.title}</h1>

View File

@ -251,6 +251,8 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta
acceptedWithFunding: false, acceptedWithFunding: false,
authedFollows: false, authedFollows: false,
followersCount: 0, followersCount: 0,
authedLiked: false,
likesCount: 0,
isVersionTwo: true, isVersionTwo: true,
milestones: draft.milestones.map((m, idx) => ({ milestones: draft.milestones.map((m, idx) => ({
id: 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: case types.REPORT_PROPOSAL_COMMENT_FULFILLED:
return updateCommentInStore(state, payload.commentId, { reported: true }); return updateCommentInStore(state, payload.commentId, { reported: true });
case types.UPDATE_PROPOSAL_COMMENT:
return updateCommentInStore(state, payload.commentId, payload.commentUpdate);
case types.PROPOSAL_UPDATES_PENDING: case types.PROPOSAL_UPDATES_PENDING:
return { return {
...state, ...state,

View File

@ -52,6 +52,8 @@ enum proposalTypes {
REPORT_PROPOSAL_COMMENT_PENDING = 'REPORT_PROPOSAL_COMMENT_PENDING', REPORT_PROPOSAL_COMMENT_PENDING = 'REPORT_PROPOSAL_COMMENT_PENDING',
REPORT_PROPOSAL_COMMENT_FULFILLED = 'REPORT_PROPOSAL_COMMENT_FULFILLED', REPORT_PROPOSAL_COMMENT_FULFILLED = 'REPORT_PROPOSAL_COMMENT_FULFILLED',
REPORT_PROPOSAL_COMMENT_REJECTED = 'REPORT_PROPOSAL_COMMENT_REJECTED', REPORT_PROPOSAL_COMMENT_REJECTED = 'REPORT_PROPOSAL_COMMENT_REJECTED',
UPDATE_PROPOSAL_COMMENT = 'UPDATE_PROPOSAL_COMMENT',
} }
export default proposalTypes; export default proposalTypes;

View File

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

View File

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

View File

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

View File

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