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:
parent
39f9cea42e
commit
5f049d899b
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -282,3 +282,57 @@ class TestProposalAPI(BaseProposalCreatorConfig):
|
|||
|
||||
self.assertEqual(len(self.proposal.followers), 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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -48,6 +48,7 @@
|
|||
&-controls {
|
||||
display: flex;
|
||||
margin-left: -0.5rem;
|
||||
align-items: center;
|
||||
|
||||
&-button {
|
||||
font-size: 0.65rem;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -164,6 +164,8 @@ export function generateProposal({
|
|||
isStaked: true,
|
||||
authedFollows: false,
|
||||
followersCount: 0,
|
||||
authedLiked: false,
|
||||
likesCount: 0,
|
||||
arbiter: {
|
||||
status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
|
||||
user: {
|
||||
|
|
|
@ -9,6 +9,8 @@ export interface Comment {
|
|||
replies: Comment[];
|
||||
reported: boolean;
|
||||
hidden: boolean;
|
||||
authedLiked: boolean;
|
||||
likesCount: number;
|
||||
}
|
||||
|
||||
export interface UserComment {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -16,4 +16,6 @@ export interface RFP {
|
|||
dateOpened: number;
|
||||
dateClosed?: number;
|
||||
dateCloses?: number;
|
||||
authedLiked: boolean;
|
||||
likesCount: number;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue