From 5f049d899ba33f20a479b5a7585f5d745d2338c3 Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Thu, 24 Oct 2019 13:32:00 -0400 Subject: [PATCH] Add Signalling of Support (#41) * init proposal subscribe be and fe * add subscription email templates * wire up subscription emails * email subscribers on proposal milestone, update, cancel * disallow subscriptions if email not verified * update spelling, titles * disallow proposal subscribe if user is team member * hide subscribe if not signed in, is team member, canceled * port follow from grant-base * remove subscribed * convert subscribed to follower * backend - update tests * frontend - fix typings * finish follower port * update comment * fix email button display issues * init liking backend * init liking frontend * fix lint * add liking backend tests * refactor like component --- backend/grant/comment/models.py | 44 ++++- backend/grant/comment/views.py | 24 ++- backend/grant/proposal/models.py | 48 ++++- backend/grant/proposal/views.py | 18 ++ backend/grant/rfp/models.py | 44 +++++ backend/grant/rfp/views.py | 24 ++- backend/grant/user/models.py | 10 + backend/grant/utils/auth.py | 10 +- backend/tests/proposal/test_api.py | 56 +++++- backend/tests/proposal/test_comment_api.py | 58 +++++- backend/tests/rfp/__init__.py | 0 backend/tests/rfp/test_rfp_api.py | 85 +++++++++ frontend/client/api/api.ts | 14 ++ frontend/client/components/Comment/index.tsx | 27 ++- frontend/client/components/Comment/style.less | 1 + frontend/client/components/Like/index.less | 24 +++ frontend/client/components/Like/index.tsx | 177 ++++++++++++++++++ .../client/components/Proposal/index.less | 5 + frontend/client/components/Proposal/index.tsx | 2 + frontend/client/components/RFP/index.less | 10 +- frontend/client/components/RFP/index.tsx | 2 + frontend/client/modules/create/utils.ts | 2 + frontend/client/modules/proposals/actions.ts | 14 ++ frontend/client/modules/proposals/reducers.ts | 3 + frontend/client/modules/proposals/types.ts | 2 + frontend/stories/props.tsx | 2 + frontend/types/comment.ts | 2 + frontend/types/proposal.ts | 2 + frontend/types/rfp.ts | 2 + 29 files changed, 689 insertions(+), 23 deletions(-) create mode 100644 backend/tests/rfp/__init__.py create mode 100644 backend/tests/rfp/test_rfp_api.py create mode 100644 frontend/client/components/Like/index.less create mode 100644 frontend/client/components/Like/index.tsx diff --git a/backend/grant/comment/models.py b/backend/grant/comment/models.py index 395be41b..55c82e9e 100644 --- a/backend/grant/comment/models.py +++ b/backend/grant/comment/models.py @@ -4,10 +4,19 @@ from functools import reduce from grant.extensions import ma, db from grant.utils.ma_fields import UnixDate from grant.utils.misc import gen_random_id -from sqlalchemy.orm import raiseload +from sqlalchemy.orm import raiseload, column_property +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy import func, select HIDDEN_CONTENT = '~~comment removed by admin~~' +comment_liker = db.Table( + "comment_liker", + db.Model.metadata, + db.Column("user_id", db.Integer, db.ForeignKey("user.id")), + db.Column("comment_id", db.Integer, db.ForeignKey("comment.id")), +) + class Comment(db.Model): __tablename__ = "comment" @@ -25,6 +34,15 @@ class Comment(db.Model): author = db.relationship("User", back_populates="comments") replies = db.relationship("Comment") + likes = db.relationship( + "User", secondary=comment_liker, back_populates="liked_comments" + ) + likes_count = column_property( + select([func.count(comment_liker.c.comment_id)]) + .where(comment_liker.c.comment_id == id) + .correlate_except(comment_liker) + ) + def __init__(self, proposal_id, user_id, parent_comment_id, content): self.id = gen_random_id(Comment) self.proposal_id = proposal_id @@ -49,6 +67,28 @@ class Comment(db.Model): self.hidden = hidden db.session.add(self) + @hybrid_property + def authed_liked(self): + from grant.utils.auth import get_authed_user + + authed = get_authed_user() + if not authed: + return False + res = ( + db.session.query(comment_liker) + .filter_by(user_id=authed.id, comment_id=self.id) + .count() + ) + if res: + return True + return False + + def like(self, user, is_liked): + if is_liked: + self.likes.append(user) + else: + self.likes.remove(user) + db.session.flush() # are all of the replies hidden? def all_hidden(replies): @@ -74,6 +114,8 @@ class CommentSchema(ma.Schema): "replies", "reported", "hidden", + "authed_liked", + "likes_count" ) content = ma.Method("get_content") diff --git a/backend/grant/comment/views.py b/backend/grant/comment/views.py index 59af911b..55aa002e 100644 --- a/backend/grant/comment/views.py +++ b/backend/grant/comment/views.py @@ -1,4 +1,26 @@ -from flask import Blueprint +from flask import Blueprint, g + +from grant.utils.auth import requires_auth +from grant.parser import body +from marshmallow import fields +from .models import Comment, db, comment_schema blueprint = Blueprint("comment", __name__, url_prefix="/api/v1/comment") + +@blueprint.route("//like", methods=["PUT"]) +@requires_auth +@body({"isLiked": fields.Bool(required=True)}) +def like_comment(comment_id, is_liked): + + user = g.current_user + # Make sure comment exists + comment = Comment.query.filter_by(id=comment_id).first() + if not comment: + return {"message": "No comment matching id"}, 404 + + comment.like(user, is_liked) + db.session.commit() + + return comment_schema.dump(comment), 201 + diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index a81a692d..571f6e49 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -38,6 +38,14 @@ proposal_follower = db.Table( db.Column("proposal_id", db.Integer, db.ForeignKey("proposal.id")), ) +proposal_liker = db.Table( + "proposal_liker", + db.Model.metadata, + db.Column("user_id", db.Integer, db.ForeignKey("user.id")), + db.Column("proposal_id", db.Integer, db.ForeignKey("proposal.id")), +) + + class ProposalTeamInvite(db.Model): __tablename__ = "proposal_team_invite" @@ -150,6 +158,8 @@ class ProposalContribution(db.Model): raise ValidationException('Proposal ID is required') # User ID (must belong to an existing user) if user_id: + from grant.user.models import User + user = User.query.filter(User.id == user_id).first() if not user: raise ValidationException('No user matching that ID') @@ -263,6 +273,14 @@ class Proposal(db.Model): .where(proposal_follower.c.proposal_id == id) .correlate_except(proposal_follower) ) + likes = db.relationship( + "User", secondary=proposal_liker, back_populates="liked_proposals" + ) + likes_count = column_property( + select([func.count(proposal_liker.c.proposal_id)]) + .where(proposal_liker.c.proposal_id == id) + .correlate_except(proposal_liker) + ) def __init__( self, @@ -596,6 +614,13 @@ class Proposal(db.Model): self.followers.remove(user) db.session.flush() + def like(self, user, is_liked): + if is_liked: + self.likes.append(user) + else: + self.likes.remove(user) + db.session.flush() + def send_follower_email(self, type: str, email_args={}, url_suffix=""): for u in self.followers: send_email( @@ -692,6 +717,22 @@ class Proposal(db.Model): return True return False + @hybrid_property + def authed_liked(self): + from grant.utils.auth import get_authed_user + + authed = get_authed_user() + if not authed: + return False + res = ( + db.session.query(proposal_liker) + .filter_by(user_id=authed.id, proposal_id=self.id) + .count() + ) + if res: + return True + return False + class ProposalSchema(ma.Schema): class Meta: @@ -729,7 +770,9 @@ class ProposalSchema(ma.Schema): "accepted_with_funding", "is_version_two", "authed_follows", - "followers_count" + "followers_count", + "authed_liked", + "likes_count" ) date_created = ma.Method("get_date_created") @@ -778,7 +821,8 @@ user_fields = [ "reject_reason", "team", "is_version_two", - "authed_follows" + "authed_follows", + "authed_liked" ] user_proposal_schema = ProposalSchema(only=user_fields) user_proposals_schema = ProposalSchema(many=True, only=user_fields) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index dd7a051e..8cf29860 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -684,3 +684,21 @@ def follow_proposal(proposal_id, is_follow): db.session.commit() return {"message": "ok"}, 200 + +@blueprint.route("//like", methods=["PUT"]) +@requires_auth +@body({"isLiked": fields.Bool(required=True)}) +def like_proposal(proposal_id, is_liked): + user = g.current_user + # Make sure proposal exists + proposal = Proposal.query.filter_by(id=proposal_id).first() + if not proposal: + return {"message": "No proposal matching id"}, 404 + + if not proposal.status == ProposalStatus.LIVE: + return {"message": "Cannot like a proposal that's not live"}, 404 + + proposal.like(user, is_liked) + db.session.commit() + return {"message": "ok"}, 200 + diff --git a/backend/grant/rfp/models.py b/backend/grant/rfp/models.py index 2a13b4ae..fbbe35b8 100644 --- a/backend/grant/rfp/models.py +++ b/backend/grant/rfp/models.py @@ -2,10 +2,19 @@ from datetime import datetime from decimal import Decimal from grant.extensions import ma, db from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy import func, select +from sqlalchemy.orm import column_property from grant.utils.enums import RFPStatus from grant.utils.misc import dt_to_unix, gen_random_id from grant.utils.enums import Category +rfp_liker = db.Table( + "rfp_liker", + db.Model.metadata, + db.Column("user_id", db.Integer, db.ForeignKey("user.id")), + db.Column("rfp_id", db.Integer, db.ForeignKey("rfp.id")), +) + class RFP(db.Model): __tablename__ = "rfp" @@ -38,6 +47,16 @@ class RFP(db.Model): cascade="all, delete-orphan", ) + likes = db.relationship( + "User", secondary=rfp_liker, back_populates="liked_rfps" + ) + likes_count = column_property( + select([func.count(rfp_liker.c.rfp_id)]) + .where(rfp_liker.c.rfp_id == id) + .correlate_except(rfp_liker) + ) + + @hybrid_property def bounty(self): return self._bounty @@ -49,6 +68,29 @@ class RFP(db.Model): else: self._bounty = None + @hybrid_property + def authed_liked(self): + from grant.utils.auth import get_authed_user + + authed = get_authed_user() + if not authed: + return False + res = ( + db.session.query(rfp_liker) + .filter_by(user_id=authed.id, rfp_id=self.id) + .count() + ) + if res: + return True + return False + + def like(self, user, is_liked): + if is_liked: + self.likes.append(user) + else: + self.likes.remove(user) + db.session.flush() + def __init__( self, title: str, @@ -92,6 +134,8 @@ class RFPSchema(ma.Schema): "date_opened", "date_closed", "accepted_proposals", + "authed_liked", + "likes_count" ) status = ma.Method("get_status") diff --git a/backend/grant/rfp/views.py b/backend/grant/rfp/views.py index f60da4d8..c8d5d046 100644 --- a/backend/grant/rfp/views.py +++ b/backend/grant/rfp/views.py @@ -1,8 +1,11 @@ -from flask import Blueprint +from flask import Blueprint, g from sqlalchemy import or_ from grant.utils.enums import RFPStatus -from .models import RFP, rfp_schema, rfps_schema +from grant.utils.auth import requires_auth +from grant.parser import body +from .models import RFP, rfp_schema, rfps_schema, db +from marshmallow import fields blueprint = Blueprint("rfp", __name__, url_prefix="/api/v1/rfps") @@ -25,3 +28,20 @@ def get_rfp(rfp_id): if not rfp or rfp.status == RFPStatus.DRAFT: return {"message": "No RFP with that ID"}, 404 return rfp_schema.dump(rfp) + + +@blueprint.route("//like", methods=["PUT"]) +@requires_auth +@body({"isLiked": fields.Bool(required=True)}) +def like_rfp(rfp_id, is_liked): + user = g.current_user + # Make sure rfp exists + rfp = RFP.query.filter_by(id=rfp_id).first() + if not rfp: + return {"message": "No RFP matching id"}, 404 + if not rfp.status == RFPStatus.LIVE: + return {"message": "RFP is not live"}, 404 + + rfp.like(user, is_liked) + db.session.commit() + return {"message": "ok"}, 200 diff --git a/backend/grant/user/models.py b/backend/grant/user/models.py index a2831a49..8eb08994 100644 --- a/backend/grant/user/models.py +++ b/backend/grant/user/models.py @@ -136,6 +136,16 @@ class User(db.Model, UserMixin): followed_proposals = db.relationship( "Proposal", secondary="proposal_follower", back_populates="followers" ) + liked_proposals = db.relationship( + "Proposal", secondary="proposal_liker", back_populates="likes" + ) + liked_comments = db.relationship( + "Comment", secondary="comment_liker", back_populates="likes" + ) + liked_rfps = db.relationship( + "RFP", secondary="rfp_liker", back_populates="likes" + ) + def __init__( self, diff --git a/backend/grant/utils/auth.py b/backend/grant/utils/auth.py index 88fb6985..96252834 100644 --- a/backend/grant/utils/auth.py +++ b/backend/grant/utils/auth.py @@ -5,9 +5,7 @@ import sentry_sdk from flask import request, g, jsonify, session, current_app from flask_security.core import current_user from flask_security.utils import logout_user -from grant.proposal.models import Proposal from grant.settings import BLOCKCHAIN_API_SECRET -from grant.user.models import User class AuthException(Exception): @@ -41,6 +39,8 @@ def is_email_verified(): def auth_user(email, password): + from grant.user.models import User + existing_user = User.get_by_email(email) if not existing_user: raise AuthException("No user exists with that email") @@ -85,6 +85,8 @@ def requires_auth(f): def requires_same_user_auth(f): @wraps(f) def decorated(*args, **kwargs): + from grant.user.models import User + user_id = kwargs["user_id"] if not user_id: return jsonify(message="Decorator requires_same_user_auth requires path variable "), 500 @@ -114,6 +116,8 @@ def requires_email_verified_auth(f): def requires_team_member_auth(f): @wraps(f) def decorated(*args, **kwargs): + from grant.proposal.models import Proposal + proposal_id = kwargs["proposal_id"] if not proposal_id: return jsonify(message="Decorator requires_team_member_auth requires path variable "), 500 @@ -134,6 +138,8 @@ def requires_team_member_auth(f): def requires_arbiter_auth(f): @wraps(f) def decorated(*args, **kwargs): + from grant.proposal.models import Proposal + proposal_id = kwargs["proposal_id"] if not proposal_id: return jsonify(message="Decorator requires_arbiter_auth requires path variable "), 500 diff --git a/backend/tests/proposal/test_api.py b/backend/tests/proposal/test_api.py index a645d9e9..8d9b8937 100644 --- a/backend/tests/proposal/test_api.py +++ b/backend/tests/proposal/test_api.py @@ -281,4 +281,58 @@ class TestProposalAPI(BaseProposalCreatorConfig): self.assertEqual(resp.json["authedFollows"], False) self.assertEqual(len(self.proposal.followers), 0) - self.assertEqual(len(self.user.followed_proposals), 0) \ No newline at end of file + self.assertEqual(len(self.user.followed_proposals), 0) + + def test_like_proposal(self): + # not logged in + resp = self.app.put( + f"/api/v1/proposals/{self.proposal.id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert401(resp) + + # logged in + self.login_default_user() + + # proposal not yet live + resp = self.app.put( + f"/api/v1/proposals/{self.proposal.id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert404(resp) + self.assertEquals(resp.json["message"], "Cannot like a proposal that's not live") + + # proposal is live + self.proposal.status = ProposalStatus.LIVE + resp = self.app.put( + f"/api/v1/proposals/{self.proposal.id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert200(resp) + self.assertTrue(self.user in self.proposal.likes) + + resp = self.app.get( + f"/api/v1/proposals/{self.proposal.id}" + ) + self.assert200(resp) + self.assertEqual(resp.json["authedLiked"], True) + self.assertEqual(resp.json["likesCount"], 1) + + # test unliking a proposal + resp = self.app.put( + f"/api/v1/proposals/{self.proposal.id}/like", + data=json.dumps({"isLiked": False}), + content_type="application/json", + ) + self.assert200(resp) + self.assertTrue(self.user not in self.proposal.likes) + + resp = self.app.get( + f"/api/v1/proposals/{self.proposal.id}" + ) + self.assert200(resp) + self.assertEqual(resp.json["authedLiked"], False) + self.assertEqual(resp.json["likesCount"], 0) diff --git a/backend/tests/proposal/test_comment_api.py b/backend/tests/proposal/test_comment_api.py index 9dbdeef1..e73e7933 100644 --- a/backend/tests/proposal/test_comment_api.py +++ b/backend/tests/proposal/test_comment_api.py @@ -1,6 +1,6 @@ import json -from grant.proposal.models import Proposal, db +from grant.proposal.models import Proposal, Comment, db from grant.utils.enums import ProposalStatus from ..config import BaseUserConfig from ..test_data import test_comment, test_reply, test_comment_large @@ -148,3 +148,59 @@ class TestProposalCommentAPI(BaseUserConfig): self.assertStatus(comment_res, 403) self.assertIn('silenced', comment_res.json['message']) + + def test_like_comment(self): + proposal = Proposal(status=ProposalStatus.LIVE) + db.session.add(proposal) + + comment = Comment( + proposal_id=proposal.id, + user_id=self.other_user.id, + parent_comment_id=None, + content=test_comment["comment"] + ) + comment_id = comment.id + db.session.add(comment) + db.session.commit() + + # comment not found + resp = self.app.put( + f"/api/v1/comment/123456789/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert401(resp) + + # not logged in + resp = self.app.put( + f"/api/v1/comment/{comment_id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert401(resp) + + # logged in + self.login_default_user() + resp = self.app.put( + f"/api/v1/comment/{comment_id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + + self.assertStatus(resp, 201) + self.assertEqual(resp.json["authedLiked"], True) + self.assertEqual(resp.json["likesCount"], 1) + comment = Comment.query.get(comment_id) + self.assertTrue(self.user in comment.likes) + + # test unliking a proposal + resp = self.app.put( + f"/api/v1/comment/{comment.id}/like", + data=json.dumps({"isLiked": False}), + content_type="application/json", + ) + self.assertStatus(resp, 201) + self.assertEqual(resp.json["authedLiked"], False) + self.assertEqual(resp.json["likesCount"], 0) + comment = Comment.query.get(comment_id) + self.assertTrue(self.user not in comment.likes) diff --git a/backend/tests/rfp/__init__.py b/backend/tests/rfp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/rfp/test_rfp_api.py b/backend/tests/rfp/test_rfp_api.py new file mode 100644 index 00000000..d7062849 --- /dev/null +++ b/backend/tests/rfp/test_rfp_api.py @@ -0,0 +1,85 @@ + +import json +import datetime +from ..config import BaseProposalCreatorConfig +from grant.rfp.models import RFP, RFPStatus, db, Category + + +class TestRfpApi(BaseProposalCreatorConfig): + def test_rfp_like(self): + rfp = RFP( + title="title", + brief="brief", + content="content", + category=Category.DEV_TOOL, + date_closes=datetime.datetime(2030, 1, 1), + bounty="10", + status=RFPStatus.DRAFT, + ) + rfp_id = rfp.id + db.session.add(rfp) + db.session.commit() + + # not logged in + resp = self.app.put( + f"/api/v1/rfps/{rfp_id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert401(resp) + + # logged in, but rfp does not exist + self.login_default_user() + resp = self.app.put( + "/api/v1/rfps/123456789/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert404(resp) + + # RFP is not live + resp = self.app.put( + f"/api/v1/rfps/{rfp_id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert404(resp) + self.assertEqual(resp.json["message"], "RFP is not live") + + # set RFP live, test like + rfp = RFP.query.get(rfp_id) + rfp.status = RFPStatus.LIVE + db.session.add(rfp) + db.session.commit() + + resp = self.app.put( + f"/api/v1/rfps/{rfp_id}/like", + data=json.dumps({"isLiked": True}), + content_type="application/json", + ) + self.assert200(resp) + rfp = RFP.query.get(rfp_id) + self.assertTrue(self.user in rfp.likes) + resp = self.app.get( + f"/api/v1/rfps/{rfp_id}" + ) + self.assert200(resp) + self.assertEqual(resp.json["authedLiked"], True) + self.assertEqual(resp.json["likesCount"], 1) + + # test unliking + resp = self.app.put( + f"/api/v1/rfps/{rfp_id}/like", + data=json.dumps({"isLiked": False}), + content_type="application/json", + ) + self.assert200(resp) + rfp = RFP.query.get(rfp_id) + self.assertTrue(self.user not in rfp.likes) + resp = self.app.get( + f"/api/v1/rfps/{rfp_id}" + ) + self.assert200(resp) + self.assertEqual(resp.json["authedLiked"], False) + self.assertEqual(resp.json["likesCount"], 0) + diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index b37d9809..9120fa3a 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -46,6 +46,20 @@ export function followProposal(proposalId: number, isFollow: boolean) { return axios.put(`/api/v1/proposals/${proposalId}/follow`, { isFollow }); } +export function likeProposal(proposalId: number, isLiked: boolean) { + return axios.put(`/api/v1/proposals/${proposalId}/like`, { isLiked }); +} + +export function likeRfp(rfpId: number, isLiked: boolean) { + return axios.put(`/api/v1/rfps/${rfpId}/like`, { isLiked }); +} + +export function likeComment(commentId: number, isLiked: boolean) { + return axios + .put(`/api/v1/comment/${commentId}/like`, { isLiked }) + .then(({ data }) => data); +} + export function getProposalComments(proposalId: number | string, params: PageParams) { return axios.get(`/api/v1/proposals/${proposalId}/comments`, { params }); } diff --git a/frontend/client/components/Comment/index.tsx b/frontend/client/components/Comment/index.tsx index a2ada52f..43e97e3c 100644 --- a/frontend/client/components/Comment/index.tsx +++ b/frontend/client/components/Comment/index.tsx @@ -10,6 +10,7 @@ import { postProposalComment, reportProposalComment } from 'modules/proposals/ac import { getIsSignedIn } from 'modules/auth/selectors'; import { Comment as IComment } from 'types'; import { AppState } from 'store/reducers'; +import Like from 'components/Like'; import './style.less'; interface OwnProps { @@ -20,6 +21,7 @@ interface StateProps { isPostCommentPending: AppState['proposal']['isPostCommentPending']; postCommentError: AppState['proposal']['postCommentError']; isSignedIn: ReturnType; + detail: AppState['proposal']['detail']; } interface DispatchProps { @@ -71,19 +73,23 @@ class Comment extends React.Component { - {isSignedIn && ( -
+
+
+ +
+ {isSignedIn && ( {isReplying ? 'Cancel' : 'Reply'} - {!comment.hidden && - !comment.reported && ( - - Report - - )} -
- )} + )} + {isSignedIn && + !comment.hidden && + !comment.reported && ( + + Report + + )} +
{(comment.replies.length || isReplying) && (
@@ -143,6 +149,7 @@ const ConnectedComment = connect( isPostCommentPending: state.proposal.isPostCommentPending, postCommentError: state.proposal.postCommentError, isSignedIn: getIsSignedIn(state), + detail: state.proposal.detail, }), { postProposalComment, diff --git a/frontend/client/components/Comment/style.less b/frontend/client/components/Comment/style.less index 09928863..d055ebb7 100644 --- a/frontend/client/components/Comment/style.less +++ b/frontend/client/components/Comment/style.less @@ -48,6 +48,7 @@ &-controls { display: flex; margin-left: -0.5rem; + align-items: center; &-button { font-size: 0.65rem; diff --git a/frontend/client/components/Like/index.less b/frontend/client/components/Like/index.less new file mode 100644 index 00000000..c59d7832 --- /dev/null +++ b/frontend/client/components/Like/index.less @@ -0,0 +1,24 @@ +@import '~styles/variables.less'; + +@collapse-width: 800px; + +.Like { + white-space: nowrap; + + .ant-btn:focus, + .ant-btn:active { + border-color: inherit; + outline-color: inherit; + color: inherit; + } + + &-label { + @media (max-width: @collapse-width) { + display: none !important; + } + } + + &-count { + color: @text-color !important; + } +} diff --git a/frontend/client/components/Like/index.tsx b/frontend/client/components/Like/index.tsx new file mode 100644 index 00000000..7e370ac0 --- /dev/null +++ b/frontend/client/components/Like/index.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Icon, Button, Input, message } from 'antd'; +import { AppState } from 'store/reducers'; +import { proposalActions } from 'modules/proposals'; +import { rfpActions } from 'modules/rfps'; +import { ProposalDetail } from 'modules/proposals/reducers'; +import { Comment, RFP } from 'types'; +import { likeProposal, likeComment, likeRfp } from 'api/api'; +import AuthButton from 'components/AuthButton'; +import './index.less'; + +interface OwnProps { + proposal?: ProposalDetail | null; + comment?: Comment; + rfp?: RFP; +} + +interface StateProps { + authUser: AppState['auth']['user']; +} + +interface DispatchProps { + fetchProposal: typeof proposalActions['fetchProposal']; + updateComment: typeof proposalActions['updateProposalComment']; + fetchRfp: typeof rfpActions['fetchRfp']; +} + +type Props = OwnProps & StateProps & DispatchProps; + +const STATE = { + loading: false, +}; +type State = typeof STATE; + +class Follow extends React.Component { + state: State = { ...STATE }; + + render() { + const { likesCount, authedLiked } = this.deriveInfo(); + const { proposal, rfp, comment } = this.props; + const { loading } = this.state; + const zoom = comment ? 0.8 : 1; + const shouldShowLikeText = !!proposal || !!rfp; + + return ( + + + + {shouldShowLikeText && ( + {authedLiked ? ' Unlike' : ' Like'} + )} + + + + ); + } + + private deriveInfo = () => { + let authedLiked = false; + let likesCount = 0; + + const { proposal, comment, rfp } = this.props; + + if (comment) { + authedLiked = comment.authedLiked; + likesCount = comment.likesCount; + } else if (proposal) { + authedLiked = proposal.authedLiked; + likesCount = proposal.likesCount; + } else if (rfp) { + authedLiked = rfp.authedLiked; + likesCount = rfp.likesCount; + } + + return { + authedLiked, + likesCount, + }; + }; + + private handleLike = () => { + if (this.state.loading) return; + const { proposal, rfp, comment } = this.props; + + if (proposal) { + return this.handleProposalLike(); + } + if (comment) { + return this.handleCommentLike(); + } + if (rfp) { + return this.handleRfpLike(); + } + }; + + private handleProposalLike = async () => { + if (!this.props.proposal) return; + + const { + proposal: { proposalId, authedLiked }, + fetchProposal, + } = this.props; + + this.setState({ loading: true }); + try { + await likeProposal(proposalId, !authedLiked); + await fetchProposal(proposalId); + message.success(<>Proposal {authedLiked ? 'unliked' : 'liked'}); + } catch (error) { + // tslint:disable:no-console + console.error('Like.handleProposalLike - unable to change like state', error); + message.error('Unable to like proposal'); + } + this.setState({ loading: false }); + }; + + private handleCommentLike = async () => { + if (!this.props.comment) return; + + const { + comment: { id, authedLiked }, + updateComment, + } = this.props; + + this.setState({ loading: true }); + try { + const updatedComment = await likeComment(id, !authedLiked); + updateComment(id, updatedComment); + message.success(<>Comment {authedLiked ? 'unliked' : 'liked'}); + } catch (error) { + // tslint:disable:no-console + console.error('Like.handleCommentLike - unable to change like state', error); + message.error('Unable to like comment'); + } + this.setState({ loading: false }); + }; + + private handleRfpLike = async () => { + if (!this.props.rfp) return; + + const { + rfp: { id, authedLiked }, + fetchRfp, + } = this.props; + + this.setState({ loading: true }); + try { + await likeRfp(id, !authedLiked); + await fetchRfp(id); + message.success(<>Request for proposal {authedLiked ? 'unliked' : 'liked'}); + } catch (error) { + // tslint:disable:no-console + console.error('Like.handleRfpLike - unable to change like state', error); + message.error('Unable to like rfp'); + } + this.setState({ loading: false }); + }; +} + +const withConnect = connect( + state => ({ + authUser: state.auth.user, + }), + { + fetchProposal: proposalActions.fetchProposal, + updateComment: proposalActions.updateProposalComment, + fetchRfp: rfpActions.fetchRfp, + }, +); + +export default withConnect(Follow); diff --git a/frontend/client/components/Proposal/index.less b/frontend/client/components/Proposal/index.less index a81616b8..5daffd59 100644 --- a/frontend/client/components/Proposal/index.less +++ b/frontend/client/components/Proposal/index.less @@ -77,6 +77,7 @@ line-height: 3rem; margin-bottom: 0.75rem; margin-left: 0.5rem; + margin-right: 1rem; flex-grow: 1; @media (min-width: @collapse-width) { @@ -99,6 +100,10 @@ align-items: center; height: 3rem; + & .ant-input-group { + width: inherit + } + & > * + * { margin-left: 0.5rem; } diff --git a/frontend/client/components/Proposal/index.tsx b/frontend/client/components/Proposal/index.tsx index 161bc908..17a146b9 100644 --- a/frontend/client/components/Proposal/index.tsx +++ b/frontend/client/components/Proposal/index.tsx @@ -26,6 +26,7 @@ import classnames from 'classnames'; import { withRouter } from 'react-router'; import SocialShare from 'components/SocialShare'; import Follow from 'components/Follow'; +import Like from 'components/Like'; import './index.less'; interface OwnProps { @@ -205,6 +206,7 @@ export class ProposalDetail extends React.Component { )} +
)} diff --git a/frontend/client/components/RFP/index.less b/frontend/client/components/RFP/index.less index 74513ef7..95181234 100644 --- a/frontend/client/components/RFP/index.less +++ b/frontend/client/components/RFP/index.less @@ -21,6 +21,10 @@ &-date { opacity: 0.7; } + + & .ant-input-group { + width: inherit; + } } &-brief { @@ -97,8 +101,8 @@ // Only has this class while affixed .ant-affix { - box-shadow: 0 0 10px 10px #FFF; - background: #FFF; + box-shadow: 0 0 10px 10px #fff; + background: #fff; } } -} \ No newline at end of file +} diff --git a/frontend/client/components/RFP/index.tsx b/frontend/client/components/RFP/index.tsx index 245baffa..de77f111 100644 --- a/frontend/client/components/RFP/index.tsx +++ b/frontend/client/components/RFP/index.tsx @@ -13,6 +13,7 @@ import Markdown from 'components/Markdown'; import ProposalCard from 'components/Proposals/ProposalCard'; import UnitDisplay from 'components/UnitDisplay'; import HeaderDetails from 'components/HeaderDetails'; +import Like from 'components/Like'; import { RFP_STATUS } from 'api/constants'; import './index.less'; @@ -87,6 +88,7 @@ class RFPDetail extends React.Component {
Opened {moment(rfp.dateOpened * 1000).format('LL')}
+

{rfp.title}

diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index 001617c2..b096186a 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -251,6 +251,8 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta acceptedWithFunding: false, authedFollows: false, followersCount: 0, + authedLiked: false, + likesCount: 0, isVersionTwo: true, milestones: draft.milestones.map((m, idx) => ({ id: idx, diff --git a/frontend/client/modules/proposals/actions.ts b/frontend/client/modules/proposals/actions.ts index 47cdd719..9ff79121 100644 --- a/frontend/client/modules/proposals/actions.ts +++ b/frontend/client/modules/proposals/actions.ts @@ -230,3 +230,17 @@ export function reportProposalComment( } }; } + +export function updateProposalComment( + commentId: Comment['id'], + commentUpdate: Partial, +) { + return (dispatch: Dispatch) => + dispatch({ + type: types.UPDATE_PROPOSAL_COMMENT, + payload: { + commentId, + commentUpdate, + }, + }); +} diff --git a/frontend/client/modules/proposals/reducers.ts b/frontend/client/modules/proposals/reducers.ts index 15af0181..d3316241 100644 --- a/frontend/client/modules/proposals/reducers.ts +++ b/frontend/client/modules/proposals/reducers.ts @@ -375,6 +375,9 @@ export default (state = INITIAL_STATE, action: any) => { case types.REPORT_PROPOSAL_COMMENT_FULFILLED: return updateCommentInStore(state, payload.commentId, { reported: true }); + case types.UPDATE_PROPOSAL_COMMENT: + return updateCommentInStore(state, payload.commentId, payload.commentUpdate); + case types.PROPOSAL_UPDATES_PENDING: return { ...state, diff --git a/frontend/client/modules/proposals/types.ts b/frontend/client/modules/proposals/types.ts index 25e31b04..b2cc1f90 100644 --- a/frontend/client/modules/proposals/types.ts +++ b/frontend/client/modules/proposals/types.ts @@ -52,6 +52,8 @@ enum proposalTypes { REPORT_PROPOSAL_COMMENT_PENDING = 'REPORT_PROPOSAL_COMMENT_PENDING', REPORT_PROPOSAL_COMMENT_FULFILLED = 'REPORT_PROPOSAL_COMMENT_FULFILLED', REPORT_PROPOSAL_COMMENT_REJECTED = 'REPORT_PROPOSAL_COMMENT_REJECTED', + + UPDATE_PROPOSAL_COMMENT = 'UPDATE_PROPOSAL_COMMENT', } export default proposalTypes; diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index db29422d..424c08a7 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -164,6 +164,8 @@ export function generateProposal({ isStaked: true, authedFollows: false, followersCount: 0, + authedLiked: false, + likesCount: 0, arbiter: { status: PROPOSAL_ARBITER_STATUS.ACCEPTED, user: { diff --git a/frontend/types/comment.ts b/frontend/types/comment.ts index bed55429..a47a418f 100644 --- a/frontend/types/comment.ts +++ b/frontend/types/comment.ts @@ -9,6 +9,8 @@ export interface Comment { replies: Comment[]; reported: boolean; hidden: boolean; + authedLiked: boolean; + likesCount: number; } export interface UserComment { diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts index b19dcc41..244bf91c 100644 --- a/frontend/types/proposal.ts +++ b/frontend/types/proposal.ts @@ -66,6 +66,8 @@ export interface Proposal extends Omit { isVersionTwo: boolean; authedFollows: boolean; followersCount: number; + authedLiked: boolean; + likesCount: number; isTeamMember?: boolean; // FE derived isArbiter?: boolean; // FE derived } diff --git a/frontend/types/rfp.ts b/frontend/types/rfp.ts index ad1f72eb..9addc8cf 100644 --- a/frontend/types/rfp.ts +++ b/frontend/types/rfp.ts @@ -16,4 +16,6 @@ export interface RFP { dateOpened: number; dateClosed?: number; dateCloses?: number; + authedLiked: boolean; + likesCount: number; }