Vue Comments: Comments ported to Vue + DnD fileupload

* Drag and drop files to comment editor to add a file attachment
* Using Vue to render comments

Since comments now has attachments we need to update the schemas
./manage.py maintenance replace_pillar_node_type_schemas
This commit is contained in:
2018-12-12 11:45:47 +01:00
parent bba1448acd
commit fbcce7a6d8
45 changed files with 2248 additions and 1477 deletions

View File

@@ -40,7 +40,8 @@ let destination = {
let source = { let source = {
bootstrap: 'node_modules/bootstrap/', bootstrap: 'node_modules/bootstrap/',
jquery: 'node_modules/jquery/', jquery: 'node_modules/jquery/',
popper: 'node_modules/popper.js/' popper: 'node_modules/popper.js/',
vue: 'node_modules/vue/',
} }
/* Stylesheets */ /* Stylesheets */
@@ -135,6 +136,7 @@ gulp.task('scripts_concat_tutti', function(done) {
let toUglify = [ let toUglify = [
source.jquery + 'dist/jquery.min.js', source.jquery + 'dist/jquery.min.js',
source.vue + 'dist/vue.min.js',
source.popper + 'dist/umd/popper.min.js', source.popper + 'dist/umd/popper.min.js',
source.bootstrap + 'js/dist/index.js', source.bootstrap + 'js/dist/index.js',
source.bootstrap + 'js/dist/util.js', source.bootstrap + 'js/dist/util.js',

View File

@@ -38,7 +38,8 @@
"glob": "7.1.3", "glob": "7.1.3",
"jquery": "3.3.1", "jquery": "3.3.1",
"popper.js": "1.14.4", "popper.js": "1.14.4",
"video.js": "7.2.2" "video.js": "7.2.2",
"vue": "2.5.17"
}, },
"scripts": { "scripts": {
"test": "jest" "test": "jest"

View File

@@ -1,3 +1,5 @@
from pillar.api.node_types import attachments_embedded_schema
node_type_comment = { node_type_comment = {
'name': 'comment', 'name': 'comment',
'description': 'Comments for asset nodes, pages, etc.', 'description': 'Comments for asset nodes, pages, etc.',
@@ -51,7 +53,8 @@ node_type_comment = {
} }
}, },
'confidence': {'type': 'float'}, 'confidence': {'type': 'float'},
'is_reply': {'type': 'boolean'} 'is_reply': {'type': 'boolean'},
'attachments': attachments_embedded_schema,
}, },
'form_schema': {}, 'form_schema': {},
'parent': ['asset', 'comment'], 'parent': ['asset', 'comment'],

View File

@@ -6,14 +6,14 @@ import pymongo.errors
import werkzeug.exceptions as wz_exceptions import werkzeug.exceptions as wz_exceptions
from flask import current_app, Blueprint, request from flask import current_app, Blueprint, request
from pillar.api.nodes import eve_hooks from pillar.api.nodes import eve_hooks, comments
from pillar.api.utils import str2id, jsonify from pillar.api.utils import str2id, jsonify
from pillar.api.utils.authorization import check_permissions, require_login from pillar.api.utils.authorization import check_permissions, require_login
from pillar.web.utils import pretty_date from pillar.web.utils import pretty_date
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
blueprint = Blueprint('nodes_api', __name__) blueprint = Blueprint('nodes_api', __name__)
ROLES_FOR_SHARING = {'subscriber', 'demo'} ROLES_FOR_SHARING = ROLES_FOR_COMMENTING ={'subscriber', 'demo'}
@blueprint.route('/<node_id>/share', methods=['GET', 'POST']) @blueprint.route('/<node_id>/share', methods=['GET', 'POST'])
@@ -51,6 +51,41 @@ def share_node(node_id):
return jsonify(eve_hooks.short_link_info(short_code), status=status) return jsonify(eve_hooks.short_link_info(short_code), status=status)
@blueprint.route('/<string(length=24):node_path>/comments', methods=['GET'])
def get_node_comments(node_path: str):
node_id = str2id(node_path)
return comments.get_node_comments(node_id)
@blueprint.route('/<string(length=24):node_path>/comments', methods=['POST'])
@require_login(require_roles=ROLES_FOR_COMMENTING)
def post_node_comment(node_path: str):
node_id = str2id(node_path)
msg = request.json['msg']
attachments = request.json.get('attachments', {})
return comments.post_node_comment(node_id, msg, attachments)
@blueprint.route('/<string(length=24):node_path>/comments/<string(length=24):comment_path>', methods=['PATCH'])
@require_login(require_roles=ROLES_FOR_COMMENTING)
def patch_node_comment(node_path: str, comment_path: str):
node_id = str2id(node_path)
comment_id = str2id(comment_path)
msg = request.json['msg']
attachments = request.json.get('attachments', {})
return comments.patch_node_comment(node_id, comment_id, msg, attachments)
@blueprint.route('/<string(length=24):node_path>/comments/<string(length=24):comment_path>/vote', methods=['POST'])
@require_login(require_roles=ROLES_FOR_COMMENTING)
def post_node_comment_vote(node_path: str, comment_path: str):
node_id = str2id(node_path)
comment_id = str2id(comment_path)
vote_str = request.json['vote']
vote = int(vote_str)
return comments.post_node_comment_vote(node_id, comment_id, vote)
@blueprint.route('/tagged/') @blueprint.route('/tagged/')
@blueprint.route('/tagged/<tag>') @blueprint.route('/tagged/<tag>')
def tagged(tag=''): def tagged(tag=''):

View File

@@ -0,0 +1,290 @@
import logging
from datetime import datetime
import pymongo
import typing
import bson
import attr
import werkzeug.exceptions as wz_exceptions
import pillar
from pillar import current_app, shortcodes
from pillar.api.nodes.custom.comment import patch_comment
from pillar.api.utils import jsonify, gravatar
from pillar.auth import current_user
log = logging.getLogger(__name__)
@attr.s(auto_attribs=True)
class UserDO:
id: str
full_name: str
gravatar: str
badges_html: str
@attr.s(auto_attribs=True)
class CommentPropertiesDO:
attachments: typing.Dict
rating_positive: int = 0
rating_negative: int = 0
@attr.s(auto_attribs=True)
class CommentDO:
id: bson.ObjectId
parent: bson.ObjectId
project: bson.ObjectId
user: UserDO
msg_html: str
msg_markdown: str
properties: CommentPropertiesDO
created: datetime
updated: datetime
etag: str
replies: typing.List['CommentDO'] = []
current_user_rating: typing.Optional[bool] = None
@attr.s(auto_attribs=True)
class CommentTreeDO:
node_id: bson.ObjectId
project: bson.ObjectId
nbr_of_comments: int = 0
comments: typing.List[CommentDO] = []
def _get_markdowned_html(document: dict, field_name: str) -> str:
cache_field_name = pillar.markdown.cache_field_name(field_name)
html = document.get(cache_field_name)
if html is None:
markdown_src = document.get(field_name) or ''
html = pillar.markdown.markdown(markdown_src)
return html
def jsonify_data_object(data_object: attr):
return jsonify(
attr.asdict(data_object,
recurse=True)
)
class CommentTreeBuilder:
def __init__(self, node_id: bson.ObjectId):
self.node_id = node_id
self.nbr_of_Comments: int = 0
def build(self) -> CommentTreeDO:
enriched_comments = self.child_comments(self.node_id)
project_id = self.get_project_id()
return CommentTreeDO(
node_id=self.node_id,
project=project_id,
nbr_of_comments=self.nbr_of_Comments,
comments=enriched_comments
)
def child_comments(self, node_id: bson.ObjectId) -> typing.List[CommentDO]:
raw_comments = self.mongodb_comments(node_id)
return [self.enrich(comment) for comment in raw_comments]
def enrich(self, mongo_comment: dict) -> CommentDO:
self.nbr_of_Comments += 1
comment = to_comment_data_object(mongo_comment)
comment.replies = self.child_comments(mongo_comment['_id'])
return comment
def get_project_id(self):
nodes_coll = current_app.db('nodes')
result = nodes_coll.find_one({'_id': self.node_id})
return result['project']
@classmethod
def mongodb_comments(cls, node_id: bson.ObjectId) -> typing.Iterator:
nodes_coll = current_app.db('nodes')
return nodes_coll.aggregate([
{'$match': {'node_type': 'comment',
'_deleted': {'$ne': True},
'properties.status': 'published',
'parent': node_id}},
{'$lookup': {"from": "users",
"localField": "user",
"foreignField": "_id",
"as": "user"}},
{'$unwind': {'path': "$user"}},
{'$sort': {'properties.rating_positive': pymongo.DESCENDING,
'_created': pymongo.DESCENDING}},
])
def get_node_comments(node_id: bson.ObjectId):
comments_tree = CommentTreeBuilder(node_id).build()
return jsonify_data_object(comments_tree)
def post_node_comment(parent_id: bson.ObjectId, markdown_msg: str, attachments: dict):
parent_node = find_node_or_raise(parent_id,
'User %s tried to update comment with bad parent_id %s',
current_user.objectid,
parent_id)
is_reply = parent_node['node_type'] == 'comment'
comment = dict(
parent=parent_id,
project=parent_node['project'],
name='Comment',
user=current_user.objectid,
node_type='comment',
properties=dict(
content=markdown_msg,
status='published',
is_reply=is_reply,
confidence=0,
rating_positive=0,
rating_negative=0,
attachments=attachments,
)
)
r, _, _, status = current_app.post_internal('nodes', comment)
if status != 201:
log.warning('Unable to post comment on %s as %s: %s',
parent_id, current_user.objectid, r)
raise wz_exceptions.InternalServerError('Unable to create comment')
comment_do = get_comment(parent_id, r['_id'])
return jsonify_data_object(comment_do), 201
def find_node_or_raise(node_id, *args):
nodes_coll = current_app.db('nodes')
node_to_comment = nodes_coll.find_one({
'_id': node_id,
'_deleted': {'$ne': True},
})
if not node_to_comment:
log.warning(args)
raise wz_exceptions.UnprocessableEntity()
return node_to_comment
def patch_node_comment(parent_id: bson.ObjectId, comment_id: bson.ObjectId, markdown_msg: str, attachments: dict):
_, _ = find_parent_and_comment_or_raise(parent_id, comment_id)
patch = dict(
op='edit',
content=markdown_msg,
attachments=attachments
)
json_result = patch_comment(comment_id, patch)
if json_result.json['result'] != 200:
raise wz_exceptions.InternalServerError('Failed to update comment')
comment_do = get_comment(parent_id, comment_id)
return jsonify_data_object(comment_do), 200
def find_parent_and_comment_or_raise(parent_id, comment_id):
parent = find_node_or_raise(parent_id,
'User %s tried to update comment with bad parent_id %s',
current_user.objectid,
parent_id)
comment = find_node_or_raise(comment_id,
'User %s tried to update comment with bad id %s',
current_user.objectid,
comment_id)
validate_comment_parent_relation(comment, parent)
return parent, comment
def validate_comment_parent_relation(comment, parent):
if comment['parent'] != parent['_id']:
log.warning('User %s tried to update comment with bad parent/comment pair. parent_id: %s comment_id: %s',
current_user.objectid,
parent['_id'],
comment['_id'])
raise wz_exceptions.BadRequest()
def get_comment(parent_id: bson.ObjectId, comment_id: bson.ObjectId) -> CommentDO:
nodes_coll = current_app.db('nodes')
mongo_comment = list(nodes_coll.aggregate([
{'$match': {'node_type': 'comment',
'_deleted': {'$ne': True},
'properties.status': 'published',
'parent': parent_id,
'_id': comment_id}},
{'$lookup': {"from": "users",
"localField": "user",
"foreignField": "_id",
"as": "user"}},
{'$unwind': {'path': "$user"}},
]))[0]
return to_comment_data_object(mongo_comment)
def to_comment_data_object(mongo_comment: dict) -> CommentDO:
def current_user_rating():
if current_user.is_authenticated:
for rating in mongo_comment['properties'].get('ratings', ()):
if str(rating['user']) != current_user.objectid:
continue
return rating['is_positive']
return None
user_dict = mongo_comment['user']
user = UserDO(
id=str(mongo_comment['user']['_id']),
full_name=user_dict['full_name'],
gravatar=gravatar(user_dict['email']),
badges_html=user_dict.get('badges', {}).get('html', '')
)
html = _get_markdowned_html(mongo_comment['properties'], 'content')
html = shortcodes.render_commented(html, context=mongo_comment['properties'])
return CommentDO(
id=mongo_comment['_id'],
parent=mongo_comment['parent'],
project=mongo_comment['project'],
user=user,
msg_html=html,
msg_markdown=mongo_comment['properties']['content'],
current_user_rating=current_user_rating(),
created=mongo_comment['_created'],
updated=mongo_comment['_updated'],
etag=mongo_comment['_etag'],
properties=CommentPropertiesDO(
attachments=mongo_comment['properties'].get('attachments', {}),
rating_positive=mongo_comment['properties']['rating_positive'],
rating_negative=mongo_comment['properties']['rating_negative']
)
)
def post_node_comment_vote(parent_id: bson.ObjectId, comment_id: bson.ObjectId, vote: int):
normalized_vote = min(max(vote, -1), 1)
_, _ = find_parent_and_comment_or_raise(parent_id, comment_id)
actions = {
1: 'upvote',
0: 'revoke',
-1: 'downvote',
}
patch = dict(
op=actions[normalized_vote]
)
json_result = patch_comment(comment_id, patch)
if json_result.json['_status'] != 'OK':
raise wz_exceptions.InternalServerError('Failed to vote on comment')
comment_do = get_comment(parent_id, comment_id)
return jsonify_data_object(comment_do), 200

View File

@@ -5,7 +5,7 @@ import logging
from flask import current_app from flask import current_app
import werkzeug.exceptions as wz_exceptions import werkzeug.exceptions as wz_exceptions
from pillar.api.utils import authorization, authentication, jsonify from pillar.api.utils import authorization, authentication, jsonify, remove_private_keys
from . import register_patch_handler from . import register_patch_handler
@@ -135,10 +135,7 @@ def edit_comment(user_id, node_id, patch):
# we can pass this stuff to Eve's patch_internal; that way the validation & # we can pass this stuff to Eve's patch_internal; that way the validation &
# authorisation system has enough info to work. # authorisation system has enough info to work.
nodes_coll = current_app.data.driver.db['nodes'] nodes_coll = current_app.data.driver.db['nodes']
projection = {'user': 1, node = nodes_coll.find_one(node_id)
'project': 1,
'node_type': 1}
node = nodes_coll.find_one(node_id, projection=projection)
if node is None: if node is None:
log.warning('User %s wanted to patch non-existing node %s' % (user_id, node_id)) log.warning('User %s wanted to patch non-existing node %s' % (user_id, node_id))
raise wz_exceptions.NotFound('Node %s not found' % node_id) raise wz_exceptions.NotFound('Node %s not found' % node_id)
@@ -146,12 +143,12 @@ def edit_comment(user_id, node_id, patch):
if node['user'] != user_id and not authorization.user_has_role('admin'): if node['user'] != user_id and not authorization.user_has_role('admin'):
raise wz_exceptions.Forbidden('You can only edit your own comments.') raise wz_exceptions.Forbidden('You can only edit your own comments.')
# Use Eve to PATCH this node, as that also updates the etag. node = remove_private_keys(node)
r, _, _, status = current_app.patch_internal('nodes', node['properties']['content'] = patch['content']
{'properties.content': patch['content'], node['properties']['attachments'] = patch.get('attachments', {})
'project': node['project'], # Use Eve to PUT this node, as that also updates the etag and we want to replace attachments.
'user': node['user'], r, _, _, status = current_app.put_internal('nodes',
'node_type': node['node_type']}, node,
concurrency_check=False, concurrency_check=False,
_id=node_id) _id=node_id)
if status != 200: if status != 200:

View File

@@ -14,6 +14,7 @@ import werkzeug.exceptions as wz_exceptions
import pillarsdk import pillarsdk
import pillar.api.utils import pillar.api.utils
from pillar import auth
from pillar.api.utils import pretty_duration from pillar.api.utils import pretty_duration
from pillar.web.utils import pretty_date from pillar.web.utils import pretty_date
from pillar.web.nodes.routes import url_for_node from pillar.web.nodes.routes import url_for_node
@@ -206,9 +207,24 @@ def do_yesno(value, arg=None):
return no return no
def user_to_dict(user: auth.UserClass) -> dict:
return dict(
user_id=str(user.user_id),
username=user.username,
full_name=user.full_name,
gravatar=user.gravatar,
email=user.email,
capabilities=list(user.capabilities),
badges_html=user.badges_html,
is_authenticated=user.is_authenticated
)
def do_json(some_object) -> str: def do_json(some_object) -> str:
if isinstance(some_object, pillarsdk.Resource): if isinstance(some_object, pillarsdk.Resource):
some_object = some_object.to_dict() some_object = some_object.to_dict()
if isinstance(some_object, auth.UserClass):
some_object = user_to_dict(some_object)
return json.dumps(some_object) return json.dumps(some_object)

View File

@@ -1,241 +0,0 @@
import logging
from flask import current_app
from flask import request
from flask import jsonify
from flask import render_template
from flask_login import login_required, current_user
from pillarsdk import Node
from pillarsdk import Project
import werkzeug.exceptions as wz_exceptions
from pillar.api.utils import utcnow
from pillar.web import subquery
from pillar.web.nodes.routes import blueprint
from pillar.web.utils import gravatar
from pillar.web.utils import pretty_date
from pillar.web.utils import system_util
log = logging.getLogger(__name__)
@blueprint.route('/comments/create', methods=['POST'])
@login_required
def comments_create():
content = request.form['content']
parent_id = request.form.get('parent_id')
if not parent_id:
log.warning('User %s tried to create comment without parent_id', current_user.objectid)
raise wz_exceptions.UnprocessableEntity()
api = system_util.pillar_api()
parent_node = Node.find(parent_id, api=api)
if not parent_node:
log.warning('Unable to create comment for user %s, parent node %r not found',
current_user.objectid, parent_id)
raise wz_exceptions.UnprocessableEntity()
log.info('Creating comment for user %s on parent node %r',
current_user.objectid, parent_id)
comment_props = dict(
project=parent_node.project,
name='Comment',
user=current_user.objectid,
node_type='comment',
properties=dict(
content=content,
status='published',
confidence=0,
rating_positive=0,
rating_negative=0))
if parent_id:
comment_props['parent'] = parent_id
# Get the parent node and check if it's a comment. In which case we flag
# the current comment as a reply.
parent_node = Node.find(parent_id, api=api)
if parent_node.node_type == 'comment':
comment_props['properties']['is_reply'] = True
comment = Node(comment_props)
comment.create(api=api)
return jsonify({'node_id': comment._id}), 201
@blueprint.route('/comments/<string(length=24):comment_id>', methods=['POST'])
@login_required
def comment_edit(comment_id):
"""Allows a user to edit their comment."""
from pillar.web import jinja
api = system_util.pillar_api()
comment = Node({'_id': comment_id})
result = comment.patch({'op': 'edit', 'content': request.form['content']}, api=api)
assert result['_status'] == 'OK'
return jsonify({
'status': 'success',
'data': {
'content': result.properties.content or '',
'content_html': jinja.do_markdowned(result.properties, 'content'),
}})
def format_comment(comment, is_reply=False, is_team=False, replies=None):
"""Format a comment node into a simpler dictionary.
:param comment: the comment object
:param is_reply: True if the comment is a reply to another comment
:param is_team: True if the author belongs to the group that owns the node
:param replies: list of replies (formatted with this function)
"""
try:
is_own = (current_user.objectid == comment.user._id) \
if current_user.is_authenticated else False
except AttributeError:
current_app.bugsnag.notify(Exception(
'Missing user for embedded user ObjectId'),
meta_data={'nodes_info': {'node_id': comment['_id']}})
return
is_rated = False
is_rated_positive = None
if comment.properties.ratings:
for rating in comment.properties.ratings:
if current_user.is_authenticated and rating.user == current_user.objectid:
is_rated = True
is_rated_positive = rating.is_positive
break
return dict(_id=comment._id,
gravatar=gravatar(comment.user.email, size=32),
time_published=pretty_date(comment._created or utcnow(), detail=True),
rating=comment.properties.rating_positive - comment.properties.rating_negative,
author=comment.user.full_name,
author_username=comment.user.username,
content=comment.properties.content,
is_reply=is_reply,
is_own=is_own,
is_rated=is_rated,
is_rated_positive=is_rated_positive,
is_team=is_team,
replies=replies)
@blueprint.route('/<string(length=24):node_id>/comments')
def comments_for_node(node_id):
"""Shows the comments attached to the given node.
The URL can be overridden in order to define can_post_comments in a different way
"""
api = system_util.pillar_api()
node = Node.find(node_id, api=api)
project = Project({'_id': node.project})
can_post_comments = project.node_type_has_method('comment', 'POST', api=api)
can_comment_override = request.args.get('can_comment', 'True') == 'True'
can_post_comments = can_post_comments and can_comment_override
return render_comments_for_node(node_id, can_post_comments=can_post_comments)
def render_comments_for_node(node_id: str, *, can_post_comments: bool):
"""Render the list of comments for a node.
Comments are first sorted by rating_positive and then by creation date.
"""
api = system_util.pillar_api()
# Query for all children, i.e. comments on the node.
comments = Node.all({
'where': {'node_type': 'comment', 'parent': node_id},
'sort': [('properties.rating_positive', -1), ('_created', -1)],
}, api=api)
def enrich(some_comment):
some_comment['_user'] = subquery.get_user_info(some_comment['user'])
some_comment['_is_own'] = some_comment['user'] == current_user.objectid
some_comment['_current_user_rating'] = None # tri-state boolean
some_comment[
'_rating'] = some_comment.properties.rating_positive - some_comment.properties.rating_negative
if current_user.is_authenticated:
for rating in some_comment.properties.ratings or ():
if rating.user != current_user.objectid:
continue
some_comment['_current_user_rating'] = rating.is_positive
for comment in comments['_items']:
# Query for all grandchildren, i.e. replies to comments on the node.
comment['_replies'] = Node.all({
'where': {'node_type': 'comment', 'parent': comment['_id']},
'sort': [('properties.rating_positive', -1), ('_created', -1)],
}, api=api)
enrich(comment)
for reply in comment['_replies']['_items']:
enrich(reply)
nr_of_comments = sum(1 + comment['_replies']['_meta']['total']
for comment in comments['_items'])
return render_template('nodes/custom/comment/list_embed.html',
node_id=node_id,
comments=comments,
nr_of_comments=nr_of_comments,
show_comments=True,
can_post_comments=can_post_comments)
@blueprint.route('/<string(length=24):node_id>/commentform')
def commentform_for_node(node_id):
"""Shows only the comment for for comments attached to the given node.
i.e. does not show the comments themselves, just the form to post a new comment.
"""
api = system_util.pillar_api()
node = Node.find(node_id, api=api)
project = Project({'_id': node.project})
can_post_comments = project.node_type_has_method('comment', 'POST', api=api)
return render_template('nodes/custom/comment/list_embed.html',
node_id=node_id,
show_comments=False,
can_post_comments=can_post_comments)
@blueprint.route("/comments/<comment_id>/rate/<operation>", methods=['POST'])
@login_required
def comments_rate(comment_id, operation):
"""Comment rating function
:param comment_id: the comment id
:type comment_id: str
:param rating: the rating (is cast from 0 to False and from 1 to True)
:type rating: int
"""
if operation not in {'revoke', 'upvote', 'downvote'}:
raise wz_exceptions.BadRequest('Invalid operation')
api = system_util.pillar_api()
# PATCH the node and return the result.
comment = Node({'_id': comment_id})
result = comment.patch({'op': operation}, api=api)
assert result['_status'] == 'OK'
return jsonify({
'status': 'success',
'data': {
'op': operation,
'rating_positive': result.properties.rating_positive,
'rating_negative': result.properties.rating_negative,
}})

View File

@@ -4,6 +4,7 @@ import logging
from datetime import datetime from datetime import datetime
import pillarsdk import pillarsdk
from pillar import shortcodes
from pillarsdk import Node from pillarsdk import Node
from pillarsdk import Project from pillarsdk import Project
from pillarsdk.exceptions import ResourceNotFound from pillarsdk.exceptions import ResourceNotFound
@@ -487,11 +488,14 @@ def preview_markdown():
current_app.csrf.protect() current_app.csrf.protect()
try: try:
content = request.form['content'] content = request.json['content']
except KeyError: except KeyError:
return jsonify({'_status': 'ERR', return jsonify({'_status': 'ERR',
'message': 'The field "content" was not specified.'}), 400 'message': 'The field "content" was not specified.'}), 400
return jsonify(content=markdown(content)) html = markdown(content)
attachmentsdict = request.json.get('attachments', {})
html = shortcodes.render_commented(html, context={'attachments': attachmentsdict})
return jsonify(content=html)
def ensure_lists_exist_as_empty(node_doc, node_type): def ensure_lists_exist_as_empty(node_doc, node_type):
@@ -605,4 +609,4 @@ def url_for_node(node_id=None, node=None):
# Import of custom modules (using the same nodes decorator) # Import of custom modules (using the same nodes decorator)
from .custom import comments, groups, storage, posts from .custom import groups, storage, posts

View File

@@ -0,0 +1,46 @@
function thenGetComments(parentId) {
return $.getJSON(`/api/nodes/${parentId}/comments`);
}
function thenCreateComment(parentId, msg, attachments) {
let data = JSON.stringify({
msg: msg,
attachments: attachments
});
return $.ajax({
url: `/api/nodes/${parentId}/comments`,
type: 'POST',
data: data,
dataType: 'json',
contentType: 'application/json; charset=UTF-8'
});
}
function thenUpdateComment(parentId, commentId, msg, attachments) {
let data = JSON.stringify({
msg: msg,
attachments: attachments
});
return $.ajax({
url: `/api/nodes/${parentId}/comments/${commentId}`,
type: 'PATCH',
data: data,
dataType: 'json',
contentType: 'application/json; charset=UTF-8'
});
}
function thenVoteComment(parentId, commentId, vote) {
let data = JSON.stringify({
vote: vote
});
return $.ajax({
url: `/api/nodes/${parentId}/comments/${commentId}/vote`,
type: 'POST',
data: data,
dataType: 'json',
contentType: 'application/json; charset=UTF-8'
});
}
export { thenGetComments, thenCreateComment, thenUpdateComment, thenVoteComment }

View File

@@ -0,0 +1,54 @@
function thenUploadFile(projectId, file, progressCB=(total, loaded)=>{}) {
let formData = createFormData(file)
return $.ajax({
url: `/api/storage/stream/${projectId}`,
type: 'POST',
data: formData,
cache: false,
contentType: false,
processData: false,
xhr: () => {
let myxhr = $.ajaxSettings.xhr();
if (myxhr.upload) {
// For handling the progress of the upload
myxhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
progressCB(e.total, e.loaded);
}
}, false);
}
return myxhr;
}
});
}
function createFormData(file) {
let formData = new FormData();
formData.append('file', file);
return formData;
}
function thenGetFileDocument(fileId) {
return $.get(`/api/files/${fileId}`);
}
function getFileVariation(fileDoc, size = 'm') {
var show_variation = null;
if (typeof fileDoc.variations != 'undefined') {
for (var variation of fileDoc.variations) {
if (variation.size != size) continue;
show_variation = variation;
break;
}
}
if (show_variation == null) {
throw 'Image not found: ' + fileDoc._id + ' size: ' + size;
}
return show_variation;
}
export { thenUploadFile, thenGetFileDocument, getFileVariation }

View File

@@ -0,0 +1,17 @@
function thenMarkdownToHtml(markdown, attachments={}) {
let data = JSON.stringify({
content: markdown,
attachments: attachments
});
return $.ajax({
url: "/nodes/preview-markdown",
type: 'POST',
headers: {"X-CSRFToken": csrf_token},
headers: {},
data: data,
dataType: 'json',
contentType: 'application/json; charset=UTF-8'
})
}
export { thenMarkdownToHtml }

View File

@@ -1,4 +1,5 @@
import { thenLoadImage, prettyDate } from '../utils'; import { prettyDate } from '../../utils/prettydate';
import { thenLoadImage } from '../utils';
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface' import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
export class NodesBase extends ComponentCreatorInterface { export class NodesBase extends ComponentCreatorInterface {

View File

@@ -21,102 +21,4 @@ function thenLoadVideoProgress(nodeId) {
return $.get('/api/users/video/' + nodeId + '/progress') return $.get('/api/users/video/' + nodeId + '/progress')
} }
function prettyDate(time, detail=false) { export { thenLoadImage, thenLoadVideoProgress };
/**
* time is anything Date can parse, and we return a
pretty string like 'an hour ago', 'Yesterday', '3 months ago',
'just now', etc
*/
let theDate = new Date(time);
if (!time || isNaN(theDate)) {
return
}
let pretty = '';
let now = new Date(Date.now()); // Easier to mock Date.now() in tests
let second_diff = Math.round((now - theDate) / 1000);
let day_diff = Math.round(second_diff / 86400); // seconds per day (60*60*24)
if ((day_diff < 0) && (theDate.getFullYear() !== now.getFullYear())) {
// "Jul 16, 2018"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
}
else if ((day_diff < -21) && (theDate.getFullYear() == now.getFullYear())) {
// "Jul 16"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'});
}
else if (day_diff < -7){
let week_count = Math.round(-day_diff / 7);
if (week_count == 1)
pretty = "in 1 week";
else
pretty = "in " + week_count +" weeks";
}
else if (day_diff < -1)
// "next Tuesday"
pretty = 'next ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
else if (day_diff === 0) {
if (second_diff < 0) {
let seconds = Math.abs(second_diff);
if (seconds < 10)
return 'just now';
if (seconds < 60)
return 'in ' + seconds +'s';
if (seconds < 120)
return 'in a minute';
if (seconds < 3600)
return 'in ' + Math.round(seconds / 60) + 'm';
if (seconds < 7200)
return 'in an hour';
if (seconds < 86400)
return 'in ' + Math.round(seconds / 3600) + 'h';
} else {
let seconds = second_diff;
if (seconds < 10)
return "just now";
if (seconds < 60)
return seconds + "s ago";
if (seconds < 120)
return "a minute ago";
if (seconds < 3600)
return Math.round(seconds / 60) + "m ago";
if (seconds < 7200)
return "an hour ago";
if (seconds < 86400)
return Math.round(seconds / 3600) + "h ago";
}
}
else if (day_diff == 1)
pretty = "yesterday";
else if (day_diff <= 7)
// "last Tuesday"
pretty = 'last ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
else if (day_diff <= 22) {
let week_count = Math.round(day_diff / 7);
if (week_count == 1)
pretty = "1 week ago";
else
pretty = week_count + " weeks ago";
}
else if (theDate.getFullYear() === now.getFullYear())
// "Jul 16"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'});
else
// "Jul 16", 2009
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
if (detail){
// "Tuesday at 04:20"
let paddedHour = ('00' + theDate.getUTCHours()).substr(-2);
let paddedMin = ('00' + theDate.getUTCMinutes()).substr(-2);
return pretty + ' at ' + paddedHour + ':' + paddedMin;
}
return pretty;
}
export { thenLoadImage, thenLoadVideoProgress, prettyDate };

View File

@@ -1,4 +1,4 @@
import { prettyDate } from '../utils' import { prettyDate } from '../init'
describe('prettydate', () => { describe('prettydate', () => {
beforeEach(() => { beforeEach(() => {
@@ -28,7 +28,7 @@ describe('prettydate', () => {
expect(pd({minutes: -5, detailed: true})).toBe('5m ago') expect(pd({minutes: -5, detailed: true})).toBe('5m ago')
expect(pd({days: -7, detailed: true})).toBe('last Tuesday at 11:46') expect(pd({days: -7, detailed: true})).toBe('last Tuesday at 11:46')
expect(pd({days: -8, detailed: true})).toBe('1 week ago at 11:46') expect(pd({days: -8, detailed: true})).toBe('1 week ago at 11:46')
// summer time bellow // summer time below
expect(pd({days: -14, detailed: true})).toBe('2 weeks ago at 10:46') expect(pd({days: -14, detailed: true})).toBe('2 weeks ago at 10:46')
expect(pd({days: -31, detailed: true})).toBe('8 Oct at 10:46') expect(pd({days: -31, detailed: true})).toBe('8 Oct at 10:46')
expect(pd({days: -(31 + 366), detailed: true})).toBe('8 Oct 2015 at 10:46') expect(pd({days: -(31 + 366), detailed: true})).toBe('8 Oct 2015 at 10:46')

View File

@@ -0,0 +1,34 @@
class User{
constructor(kwargs) {
this.user_id = kwargs['user_id'] || '';
this.username = kwargs['username'] || '';
this.full_name = kwargs['full_name'] || '';
this.gravatar = kwargs['gravatar'] || '';
this.email = kwargs['email'] || '';
this.capabilities = kwargs['capabilities'] || [];
this.badges_html = kwargs['badges_html'] || '';
this.is_authenticated = kwargs['is_authenticated'] || false;
}
/**
* """Returns True iff the user has one or more of the given capabilities."""
* @param {...String} args
*/
hasCap(...args) {
for(let cap of args) {
if (this.capabilities.indexOf(cap) != -1) return true;
}
return false;
}
}
let currentUser;
function initCurrentUser(kwargs){
currentUser = new User(kwargs);
}
function getCurrentUser() {
return currentUser;
}
export { getCurrentUser, initCurrentUser }

View File

@@ -1 +1,35 @@
export { transformPlaceholder } from './placeholder' export { transformPlaceholder } from './placeholder'
export { prettyDate } from './prettydate'
export { getCurrentUser, initCurrentUser } from './currentuser'
export function debounced(fn, delay=1000) {
let timerId;
return function (...args) {
if (timerId) {
clearTimeout(timerId);
}
timerId = setTimeout(() => {
fn(...args);
timerId = null;
}, delay);
}
}
/**
* Extracts error message from error of type String, Error or xhrError
* @param {*} err
* @returns {String}
*/
export function messageFromError(err){
if (typeof err === "string") {
// type String
return err;
} else if(typeof err.message === "string") {
// type Error
return err.message;
} else {
// type xhr probably
return xhrErrorResponseMessage(err);
}
}

View File

@@ -0,0 +1,97 @@
export function prettyDate(time, detail=false) {
/**
* time is anything Date can parse, and we return a
pretty string like 'an hour ago', 'Yesterday', '3 months ago',
'just now', etc
*/
let theDate = new Date(time);
if (!time || isNaN(theDate)) {
return
}
let pretty = '';
let now = new Date(Date.now()); // Easier to mock Date.now() in tests
let second_diff = Math.round((now - theDate) / 1000);
let day_diff = Math.round(second_diff / 86400); // seconds per day (60*60*24)
if ((day_diff < 0) && (theDate.getFullYear() !== now.getFullYear())) {
// "Jul 16, 2018"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
}
else if ((day_diff < -21) && (theDate.getFullYear() == now.getFullYear())) {
// "Jul 16"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'});
}
else if (day_diff < -7){
let week_count = Math.round(-day_diff / 7);
if (week_count == 1)
pretty = "in 1 week";
else
pretty = "in " + week_count +" weeks";
}
else if (day_diff < -1)
// "next Tuesday"
pretty = 'next ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
else if (day_diff === 0) {
if (second_diff < 0) {
let seconds = Math.abs(second_diff);
if (seconds < 10)
return 'just now';
if (seconds < 60)
return 'in ' + seconds +'s';
if (seconds < 120)
return 'in a minute';
if (seconds < 3600)
return 'in ' + Math.round(seconds / 60) + 'm';
if (seconds < 7200)
return 'in an hour';
if (seconds < 86400)
return 'in ' + Math.round(seconds / 3600) + 'h';
} else {
let seconds = second_diff;
if (seconds < 10)
return "just now";
if (seconds < 60)
return seconds + "s ago";
if (seconds < 120)
return "a minute ago";
if (seconds < 3600)
return Math.round(seconds / 60) + "m ago";
if (seconds < 7200)
return "an hour ago";
if (seconds < 86400)
return Math.round(seconds / 3600) + "h ago";
}
}
else if (day_diff == 1)
pretty = "yesterday";
else if (day_diff <= 7)
// "last Tuesday"
pretty = 'last ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
else if (day_diff <= 22) {
let week_count = Math.round(day_diff / 7);
if (week_count == 1)
pretty = "1 week ago";
else
pretty = week_count + " weeks ago";
}
else if (theDate.getFullYear() === now.getFullYear())
// "Jul 16"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'});
else
// "Jul 16", 2009
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
if (detail){
// "Tuesday at 04:20"
let paddedHour = ('00' + theDate.getUTCHours()).substr(-2);
let paddedMin = ('00' + theDate.getUTCMinutes()).substr(-2);
return pretty + ' at ' + paddedHour + ':' + paddedMin;
}
return pretty;
}

View File

@@ -0,0 +1,120 @@
import { thenGetFileDocument, getFileVariation } from '../../api/files'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
const VALID_NAME_REGEXP = /[a-zA-Z0-9_\-]+/g;
const NON_VALID_NAME_REGEXP = /[^a-zA-Z0-9_\-]+/g;
const TEMPLATE = `
<div class="attachment"
:class="{error: !isSlugOk}"
>
<div class="thumbnail-container"
@click="$emit('insert', oid)"
title="Click to add to comment"
>
<i :class="thumbnailBackup"
v-show="!thumbnail"
/>
<img class="preview-thumbnail"
v-if="!!thumbnail"
:src="thumbnail"
width=50
height=50
/>
</div>
<input class="form-control"
title="Slug"
v-model="newSlug"
/>
<div class="actions">
<div class="action delete"
@click="$emit('delete', oid)"
>
<i class="pi-trash"/>
Delete
</div>
</div>
</div>
`;
Vue.component('comment-attachment-editor', {
template: TEMPLATE,
mixins: [UnitOfWorkTracker],
props: {
slug: String,
allSlugs: Array,
oid: String
},
data() {
return {
newSlug: this.slug,
thumbnail: '',
thumbnailBackup: 'pi-spin spin',
}
},
computed: {
isValidAttachmentName() {
let regexpMatch = this.slug.match(VALID_NAME_REGEXP);
return !!regexpMatch && regexpMatch.length === 1 && regexpMatch[0] === this.slug;
},
isUnique() {
let countOccurrences = 0;
for (let s of this.allSlugs) {
// Don't worry about unicode. isValidAttachmentName denies those anyway
if (s.toUpperCase() === this.slug.toUpperCase()) {
countOccurrences++;
}
}
return countOccurrences === 1;
},
isSlugOk() {
return this.isValidAttachmentName && this.isUnique;
}
},
watch: {
newSlug(newValue, oldValue) {
this.$emit('rename', newValue, this.oid);
},
isSlugOk(newValue, oldValue) {
this.$emit('validation', this.oid, newValue);
}
},
created() {
this.newSlug = this.makeSafeAttachmentString(this.slug);
this.$emit('validation', this.oid, this.isSlugOk);
this.unitOfWork(
thenGetFileDocument(this.oid)
.then((fileDoc) => {
let content_type = fileDoc.content_type
if (content_type.startsWith('image')) {
try {
let imgFile = getFileVariation(fileDoc, 's');
this.thumbnail = imgFile.link;
} catch (error) {
this.thumbnailBackup = 'pi-image';
}
} else if(content_type.startsWith('video')) {
this.thumbnailBackup = 'pi-video';
} else {
this.thumbnailBackup = 'pi-file';
}
})
);
},
methods: {
/**
* Replaces all spaces with underscore and removes all o
* @param {String} unsafe
* @returns {String}
*/
makeSafeAttachmentString(unsafe) {
let candidate = (unsafe);
let matchSpace = / /g;
candidate = candidate
.replace(matchSpace, '_')
.replace(NON_VALID_NAME_REGEXP, '')
return candidate || `${this.oid}`
}
}
});

View File

@@ -0,0 +1,168 @@
import '../user/Avatar'
import '../utils/PrettyCreated'
import './CommentEditor'
import './Rating'
import { Linkable } from '../mixins/Linkable'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
import { EventBus, Events } from './EventBus'
const TEMPLATE = `
<div class="comment-branch">
<div class="comment-container"
:class="{'is-first': !isReply, 'is-reply': isReply, 'comment-linked': isLinked}"
:id="comment.id">
<div class="comment-avatar">
<user-avatar
:user="comment.user"
/>
<div class="user-badges"
v-html="comment.user.badges_html">
</div>
</div>
<div class="comment-content">
<div class="comment-body"
v-if="!isUpdating"
>
<p class="comment-author">
{{ comment.user.full_name }}
</p>
<span class="comment-msg">
<p v-html="comment.msg_html"/>
</span>
</div>
<comment-editor
v-if="isUpdating"
@unit-of-work="childUnitOfWork"
:mode="editorMode"
:comment="comment"
:user="user"
:parentId="comment.id"
/>
<div class="comment-meta">
<comment-rating
:comment="comment"
@unit-of-work="childUnitOfWork"
/>
<div class="comment-action">
<span class="action" title="Reply to this comment"
v-if="canReply"
@click="showReplyEditor"
>
Reply
</span>
<span class="action" title="Edit comment"
v-if="canUpdate"
@click="showUpdateEditor"
>
Edit
</span>
<span class="action" title="Cancel changes"
v-if="canCancel"
@click="cancleEdit"
>
<i class="pi-cancel"></i>Cancel
</span>
</div>
<pretty-created
:created="comment.created"
:updated="comment.updated"
/>
</div>
</div>
</div>
<div class="comment-reply-container is-reply"
v-if="isReplying"
>
<user-avatar
:user="user"
/>
<comment-editor
v-if="isReplying"
@unit-of-work="childUnitOfWork"
:mode="editorMode"
:comment="comment"
:user="user"
:parentId="comment.id"
/>
</div>
<div class="comments-list">
<comment
v-for="c in comment.replies"
@unit-of-work="childUnitOfWork"
isReply=true
:readOnly="readOnly"
:comment="c"
:user="user"
:key="c.id"/>
</div>
</div>
`;
Vue.component('comment', {
template: TEMPLATE,
mixins: [Linkable, UnitOfWorkTracker],
props: {
user: Object,
comment: Object,
readOnly: {
type: Boolean,
default: false,
},
isReply: {
type: Boolean,
default: false,
},
},
data() {
return {
isReplying: false,
isUpdating: false,
id: this.comment.id,
}
},
computed: {
canUpdate() {
return !this.readOnly && this.comment.user.id === this.user.user_id && !this.isUpdating && !this.isReplying;
},
canReply() {
return !this.readOnly && !this.isUpdating && !this.isReplying;
},
canCancel() {
return this.isReplying || this.isUpdating;
},
editorMode() {
if(this.isReplying) {
return 'reply';
}
if(this.isUpdating) {
return 'update';
}
}
},
created() {
EventBus.$on(Events.BEFORE_SHOW_EDITOR, this.doHideEditors);
EventBus.$on(Events.EDIT_DONE, this.doHideEditors);
},
beforeDestroy() {
EventBus.$off(Events.BEFORE_SHOW_EDITOR, this.doHideEditors);
EventBus.$off(Events.EDIT_DONE, this.doHideEditors);
},
methods: {
showReplyEditor() {
EventBus.$emit(Events.BEFORE_SHOW_EDITOR, this.comment.id );
this.isReplying = true;
},
showUpdateEditor() {
EventBus.$emit(Events.BEFORE_SHOW_EDITOR, this.comment.id );
this.isUpdating = true;
},
cancleEdit() {
this.doHideEditors();
EventBus.$emit(Events.EDIT_DONE, this.comment.id );
},
doHideEditors() {
this.isReplying = false;
this.isUpdating = false;
},
}
});

View File

@@ -0,0 +1,330 @@
import '../utils/MarkdownPreview'
import './AttachmentEditor'
import './UploadProgress'
import { thenCreateComment, thenUpdateComment } from '../../api/comments'
import { thenUploadFile } from '../../api/files'
import { Droptarget } from '../mixins/Droptarget'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
import { EventBus, Events } from './EventBus'
const MAX_ATTACHMENTS = 5;
const TEMPLATE =`
<div class="comment-reply-form"
:class="dropTargetClasses"
>
<div class="attachments">
<comment-attachment-editor
v-for="a in attachments"
@delete="attachmentDelete"
@insert="insertAttachment"
@rename="attachmentRename"
@validation="attachmentValidation"
@unit-of-work="childUnitOfWork"
:slug="a.slug"
:allSlugs="allSlugs"
:oid="a.oid"
:key="a.oid"
/>
<upload-progress
v-if="uploads.nbrOfActive > 0"
:label="uploadProgressLabel"
:progress="uploadProgressPercent"
/>
</div>
<div class="comment-reply-field"
:class="{filled: isMsgLongEnough}"
>
<textarea
ref="inputField"
@keyup="keyUp"
v-model="msg"
id="comment_field"
placeholder="Join the conversation...">
</textarea>
<div class="comment-reply-meta">
<button class="comment-action-submit"
:class="{disabled: !canSubmit}"
@click="submit"
type="button"
title="Post Comment (Ctrl+Enter)">
<span>
<i :class="submitButtonIcon"/>{{ submitButtonText }}
</span>
<span class="hotkey">Ctrl + Enter</span>
</button>
</div>
</div>
<markdown-preview
v-show="msg.length > 0"
:markdown="msg"
:attachments="attachmentsAsObject"
/>
</div>
`;
Vue.component('comment-editor', {
template: TEMPLATE,
mixins: [Droptarget, UnitOfWorkTracker],
props: {
user: Object,
parentId: String,
projectId: String,
comment: Object,
mode: {
type: String,
default: 'reply', // reply or update
},
},
data() {
return {
msg: this.initialMsg(),
attachments: this.initialAttachments(),
uploads: {
nbrOfActive: 0,
nbrOfTotal: 0,
total: 0,
loaded: 0
},
}
},
computed: {
submitButtonText() {
switch(this.mode) {
case 'reply': return 'Send';
case 'update': return 'Update';
default: console.error('Unknown mode: ', this.mode);
}
},
submitButtonIcon() {
if (this.isBusyWorking) {
return 'pi-spin spin';
}else{
switch(this.mode) {
case 'reply': return 'pi-paper-plane';
case 'update': return 'pi-check';
default: console.error('Unknown mode: ', this.mode);
}
}
},
attachmentsAsObject() {
let attachmentsObject = {};
for (let a of this.attachments) {
attachmentsObject[a.slug] = {oid: a.oid};
}
return attachmentsObject;
},
allSlugs() {
return this.attachments.map((a) => {
return a['slug'];
});
},
isMsgLongEnough() {
return this.msg.length >= 5;
},
isAttachmentsValid() {
for (let att of this.attachments) {
if(!att.isSlugValid) {
return false;
}
}
return true;
},
isValid() {
return this.isAttachmentsValid && this.isMsgLongEnough;
},
canSubmit() {
return this.isValid && !this.isBusyWorking;
},
uploadProgressPercent() {
if (this.uploads.nbrOfActive === 0 || this.uploads.total === 0) {
return 100;
}
return this.uploads.loaded / this.uploads.total * 100;
},
uploadProgressLabel() {
if (this.uploadProgressPercent === 100) {
return 'Processing'
}
if (this.uploads.nbrOfTotal === 1) {
return 'Uploading file';
} else {
let fileOf = this.uploads.nbrOfTotal - this.uploads.nbrOfActive + 1;
return `Uploading ${fileOf}/${this.uploads.nbrOfTotal} files`;
}
},
},
watch:{
msg(){
this.autoSizeInputField();
}
},
mounted() {
if(this.comment) {
this.$nextTick(function () {
this.autoSizeInputField();
this.$refs.inputField.focus();
})
}
},
methods: {
initialMsg() {
if (this.comment) {
if (this.mode === 'reply') {
return `***@${this.comment.user.full_name}*** `;
}
if (this.mode === 'update') {
return this.comment.msg_markdown;
}
}
return '';
},
initialAttachments() {
// Transforming the attacmentobject to an array of attachments
let attachmentsList = []
if(this.mode === 'update') {
let attachmentsObj = this.comment.properties.attachments
for (let k in attachmentsObj) {
if (attachmentsObj.hasOwnProperty(k)) {
let a = {
slug: k,
oid: attachmentsObj[k]['oid'],
isSlugValid: true
}
attachmentsList.push(a);
}
}
}
return attachmentsList;
},
submit() {
if(!this.canSubmit) return;
this.unitOfWork(
this.thenSubmit()
.fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'Failed to submit comment')})
);
},
thenSubmit() {
if (this.mode === 'reply') {
return this.thenCreateComment();
} else {
return this.thenUpdateComment();
}
},
keyUp(e) {
if ((e.keyCode == 13 || e.key === 'Enter') && e.ctrlKey) {
this.submit();
}
},
thenCreateComment() {
return thenCreateComment(this.parentId, this.msg, this.attachmentsAsObject)
.then((newComment) => {
EventBus.$emit(Events.NEW_COMMENT, newComment);
EventBus.$emit(Events.EDIT_DONE, newComment.id );
this.cleanUp();
})
},
thenUpdateComment() {
return thenUpdateComment(this.comment.parent, this.comment.id, this.msg, this.attachmentsAsObject)
.then((updatedComment) => {
EventBus.$emit(Events.UPDATED_COMMENT, updatedComment);
EventBus.$emit(Events.EDIT_DONE, updatedComment.id);
this.cleanUp();
})
},
canHandleDrop(event) {
let dataTransfer = event.dataTransfer;
let items = [...dataTransfer.items];
let nbrOfAttachments = items.length + this.uploads.nbrOfActive + this.attachments.length;
if(nbrOfAttachments > MAX_ATTACHMENTS) {
// Exceeds the limit
return false;
}
// Only files in drop
return [...dataTransfer.items].reduce((prev, it) => {
let isFile = it.kind === 'file' && !!it.type;
return prev && isFile;
}, !!items.length);
},
onDrop(event) {
let files = [...event.dataTransfer.files];
for (let f of files) {
this.unitOfWork(
this.thenUploadFile(f)
.fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'File upload failed')})
);
}
},
thenUploadFile(file){
let lastReportedTotal = 0;
let lastReportedLoaded = 0;
let progressCB = (total, loaded) => {
this.uploads.loaded += loaded - lastReportedLoaded;
this.uploads.total += total - lastReportedTotal;
lastReportedLoaded = loaded;
lastReportedTotal = total;
}
this.uploads.nbrOfActive++;
this.uploads.nbrOfTotal++;
return thenUploadFile(this.projectId || this.comment.project, file, progressCB)
.then((resp) => {
let attachment = {
slug: file.name,
oid: resp['file_id'],
isSlugValid: false
}
this.attachments.push(attachment);
this.msg += this.getAttachmentMarkdown(attachment);
})
.always(()=>{
this.uploads.nbrOfActive--;
if(this.uploads.nbrOfActive === 0) {
this.uploads.loaded = 0;
this.uploads.total = 0;
this.uploads.nbrOfTotal = 0;
}
})
},
getAttachmentMarkdown(attachment){
return `{attachment ${attachment.slug}}`;
},
insertAttachment(oid){
let attachment = this.getAttachment(oid);
this.msg += this.getAttachmentMarkdown(attachment);
},
attachmentDelete(oid) {
let attachment = this.getAttachment(oid);
let markdownToRemove = this.getAttachmentMarkdown(attachment);
this.msg = this.msg.replace(new RegExp(markdownToRemove,'g'), '');
this.attachments = this.attachments.filter((a) => {return a.oid !== oid});
},
attachmentRename(newName, oid) {
let attachment = this.getAttachment(oid);
let oldMarkdownAttachment = this.getAttachmentMarkdown(attachment);
attachment.slug = newName;
let newMarkdownAttachment = this.getAttachmentMarkdown(attachment);
this.msg = this.msg.replace(new RegExp(oldMarkdownAttachment,'g'), newMarkdownAttachment);
},
getAttachment(oid) {
for (let a of this.attachments) {
if (a.oid === oid) return a;
}
console.error('No attachment found:', oid);
},
attachmentValidation(oid, isValid) {
let attachment = this.getAttachment(oid);
attachment.isSlugValid = isValid;
},
cleanUp() {
this.msg = '';
this.attachments = [];
},
autoSizeInputField() {
let elInputField = this.$refs.inputField;
elInputField.style.cssText = 'height:auto; padding:0';
let newInputHeight = elInputField.scrollHeight + 20;
elInputField.style.cssText = `height:${ newInputHeight }px`;
}
}
});

View File

@@ -0,0 +1,157 @@
import './CommentEditor'
import './Comment'
import './CommentsLocked'
import '../user/Avatar'
import '../utils/GenericPlaceHolder'
import { thenGetComments } from '../../api/comments'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
import { EventBus, Events } from './EventBus'
const TEMPLATE = `
<section class="comments-tree">
<div class="comment-reply-container"
v-if="canReply"
>
<user-avatar
:user="user"
/>
<comment-editor
v-if="canReply"
mode="reply"
@unit-of-work="childUnitOfWork"
:projectId="projectId"
:parentId="parentId"
:user="user"
/>
</div>
<comments-locked
v-if="readOnly||!isLoggedIn"
:user="user"
/>
<div class="comments-list-title">{{ numberOfCommentsStr }}</div>
<div class="comments-list">
<comment
v-for="c in comments"
@unit-of-work="childUnitOfWork"
:readOnly=readOnly||!isLoggedIn
:comment="c"
:user="user"
:key="c.id"/>
</div>
<generic-placeholder
v-show="showLoadingPlaceholder"
label="Loading Comments..."
/>
</section>
`;
Vue.component('comments-tree', {
template: TEMPLATE,
mixins: [UnitOfWorkTracker],
props: {
parentId: String,
readOnly: {
type: Boolean,
default: false
}
},
data() {
return {
replyHidden: false,
nbrOfComments: 0,
projectId: '',
comments: [],
showLoadingPlaceholder: true,
user: pillar.utils.getCurrentUser(),
canPostComments: this.canPostCommentsStr == 'true'
}
},
computed: {
numberOfCommentsStr() {
let pluralized = this.nbrOfComments === 1 ? 'Comment' : 'Comments'
return `${ this.nbrOfComments } ${ pluralized }`;
},
isLoggedIn() {
return this.user.is_authenticated;
},
iSubscriber() {
return this.user.hasCap('subscriber');
},
canRenewSubscription() {
return this.user.hasCap('can-renew-subscription');
},
canReply() {
return !this.readOnly && !this.replyHidden && this.isLoggedIn;
}
},
watch: {
isBusyWorking(isBusy) {
if(isBusy) {
$(document).trigger('pillar:workStart');
} else {
$(document).trigger('pillar:workStop');
}
}
},
created() {
EventBus.$on(Events.BEFORE_SHOW_EDITOR, this.doHideEditors);
EventBus.$on(Events.EDIT_DONE, this.showReplyComponent);
EventBus.$on(Events.NEW_COMMENT, this.onNewComment);
EventBus.$on(Events.UPDATED_COMMENT, this.onCommentUpdated);
this.unitOfWork(
thenGetComments(this.parentId)
.then((commentsTree) => {
this.nbrOfComments = commentsTree['nbr_of_comments'];
this.comments = commentsTree['comments'];
this.projectId = commentsTree['project'];
})
.fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'Failed to load comments')})
.always(()=>this.showLoadingPlaceholder = false)
);
},
beforeDestroy() {
EventBus.$off(Events.BEFORE_SHOW_EDITOR, this.doHideEditors);
EventBus.$off(Events.EDIT_DONE, this.showReplyComponent);
EventBus.$off(Events.NEW_COMMENT, this.onNewComment);
EventBus.$off(Events.UPDATED_COMMENT, this.onCommentUpdated);
},
methods: {
doHideEditors() {
this.replyHidden = true;
},
showReplyComponent() {
this.replyHidden = false;
},
onNewComment(newComment) {
this.nbrOfComments++;
let parentArray;
if(newComment.parent === this.parentId) {
parentArray = this.comments;
} else {
let parentComment = this.findComment(this.comments, (comment) => {
return comment.id === newComment.parent;
});
parentArray = parentComment.replies;
}
parentArray.unshift(newComment);
},
onCommentUpdated(updatedComment) {
let commentInTree = this.findComment(this.comments, (comment) => {
return comment.id === updatedComment.id;
});
delete updatedComment.replies; // No need to apply these since they should be the same
Object.assign(commentInTree, updatedComment);
},
findComment(arrayOfComments, matcherCB) {
for(let comment of arrayOfComments) {
if(matcherCB(comment)) {
return comment;
}
let match = this.findComment(comment.replies, matcherCB);
if (match) {
return match;
}
}
}
},
});

View File

@@ -0,0 +1,53 @@
const TEMPLATE = `
<div class="comments-locked">
<div
v-if="msgToShow === 'PROJECT_MEMBERS_ONLY'"
>
<i class="pi-lock"/>
Only project members can comment.
</div>
<div
v-if="msgToShow === 'RENEW'"
>
<i class="pi-heart"/>
Join the conversation!
<a href="/renew" target="_blank"> Renew your subscription </a>
to comment.
</div>
<div
v-if="msgToShow === 'JOIN'"
>
<i class="pi-heart"/>
Join the conversation!
<a href="https://store.blender.org/product/membership/" target="_blank"> Subscribe to Blender Cloud </a>
to comment.
</div>
<div
v-if="msgToShow === 'LOGIN'"
>
<a href="/login"> Log in to comment</a>
</div>
</div>
`;
Vue.component('comments-locked', {
template: TEMPLATE,
props: {user: Object},
computed: {
msgToShow() {
if(this.user && this.user.is_authenticated) {
if (this.user.hasCap('subscriber')) {
return 'PROJECT_MEMBERS_ONLY';
} else if(this.user.hasCap('can-renew-subscription')) {
return 'RENEW';
} else {
return 'JOIN';
}
}
return 'LOGIN';
}
},
});

View File

@@ -0,0 +1,7 @@
export const Events = {
NEW_COMMENT: 'new-comment',
UPDATED_COMMENT: 'updated-comment',
EDIT_DONE: 'edit-done',
BEFORE_SHOW_EDITOR: 'before-show-editor'
}
export const EventBus = new Vue();

View File

@@ -0,0 +1,52 @@
import { EventBus, Events } from './EventBus'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
import { thenVoteComment } from '../../api/comments'
const TEMPLATE = `
<div class="comment-rating"
:class="{rated: hasRating, positive: isPositive }"
>
<div class="comment-rating-value" title="Number of likes">{{ rating }}</div>
<div class="comment-action-rating up" title="Like comment"
v-if="canVote"
@click="upVote"
/>
</div>
`;
Vue.component('comment-rating', {
template: TEMPLATE,
mixins: [UnitOfWorkTracker],
props: {comment: Object},
computed: {
positiveRating() {
return this.comment.properties.rating_positive || 0;
},
negativeRating() {
return this.comment.properties.rating_negative || 0;
},
rating() {
return this.positiveRating - this.negativeRating;
},
isPositive() {
return this.rating > 0;
},
hasRating() {
return (this.positiveRating || this.negativeRating) !== 0;
},
canVote() {
return this.comment.user.id !== pillar.utils.getCurrentUser().user_id;
}
},
methods: {
upVote() {
let vote = this.comment.current_user_rating === true ? 0 : 1; // revoke if set
this.unitOfWork(
thenVoteComment(this.comment.parent, this.comment.id, vote)
.then((updatedComment) => {
EventBus.$emit(Events.UPDATED_COMMENT, updatedComment);
})
.fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'Faied to vote on comment')})
);
}
}
});

View File

@@ -0,0 +1,23 @@
const TEMPLATE = `
<div class="upload-progress">
<label>
{{ label }}
</label>
<progress class="progress-uploading"
max="100"
:value="progress"
>
</progress>
</div>
`;
Vue.component('upload-progress', {
template: TEMPLATE,
props: {
label: String,
progress: {
type: Number,
default: 0
}
},
});

View File

@@ -0,0 +1 @@
import './comments/CommentTree'

View File

@@ -0,0 +1,86 @@
/**
* Vue mixin that makes the component a droptarget
* override canHandleDrop(event) and onDrop(event)
* dragOverClasses can be bound to target class
*/
var Droptarget = {
data() {
return {
droptargetCounter: 0,
droptargetCanHandle: false
}
},
computed: {
isDragingOver() {
return this.droptargetCounter > 0;
},
dropTargetClasses() {
return {
'drag-hover': this.isDragingOver,
'unsupported-drop': this.isDragingOver && !this.droptargetCanHandle
}
}
},
mounted() {
this.$nextTick(function () {
this.$el.addEventListener('dragenter', this._onDragEnter);
this.$el.addEventListener('dragleave', this._onDragLeave);
this.$el.addEventListener('dragend', this._onDragEnd);
this.$el.addEventListener('dragover', this._onDragOver);
this.$el.addEventListener('drop', this._onDrop);
});
},
beforeDestroy() {
this.$el.removeEventListener('dragenter', this._onDragEnter);
this.$el.removeEventListener('dragleave', this._onDragLeave);
this.$el.removeEventListener('dragend', this._onDragEnd);
this.$el.removeEventListener('dragover', this._onDragOver);
this.$el.removeEventListener('drop', this._onDrop);
},
methods: {
canHandleDrop(event) {
throw Error('Not implemented');
},
onDrop(event) {
throw Error('Not implemented');
},
_onDragEnter(event) {
event.preventDefault();
event.stopPropagation();
this.droptargetCounter++;
if(this.droptargetCounter === 1) {
try {
this.droptargetCanHandle = this.canHandleDrop(event);
} catch (error) {
console.warn(error);
this.droptargetCanHandle = false;
}
}
},
_onDragLeave() {
this.droptargetCounter--;
},
_onDragEnd() {
this.droptargetCounter = 0;
},
_onDragOver() {
event.preventDefault();
event.stopPropagation();
},
_onDrop(event) {
event.preventDefault();
event.stopPropagation();
if(this.droptargetCanHandle) {
try {
this.onDrop(event);
} catch (error) {
console.console.warn(error);
}
}
this.droptargetCounter = 0;
},
}
}
export { Droptarget }

View File

@@ -0,0 +1,24 @@
/**
* Vue mixin that scrolls element into view if id matches #value in url
* @param {String} id identifier that is set by the user of the mixin
* @param {Boolean} isLinked true if Component is linked
*/
let hash = window.location.hash.substr(1).split('?')[0];
var Linkable = {
data() {
return {
id: '',
isLinked: false,
}
},
mounted: function () {
this.$nextTick(function () {
if(hash && this.id === hash) {
this.isLinked = true;
this.$el.scrollIntoView({ behavior: 'smooth' });
}
})
}
}
export { Linkable }

View File

@@ -0,0 +1,59 @@
/**
* Vue helper mixin to keep track if work is in progress or not.
* Example use:
* Keep track of work in own component:
* this.unitOfWork(
* thenDostuff()
* .then(...)
* .fail(...)
* );
*
* Keep track of work in child components:
* <myChild
* @unit-of-work="childUnitOfWork"
* />
*
* Use the information to enable class:
* <div :class="{disabled: 'isBusyWorking'}">
*/
var UnitOfWorkTracker = {
data() {
return {
unitOfWorkCounter: 0,
}
},
computed: {
isBusyWorking() {
if(this.unitOfWorkCounter < 0) {
console.error('UnitOfWork missmatch!')
}
return this.unitOfWorkCounter > 0;
}
},
watch: {
isBusyWorking(isBusy) {
if(isBusy) {
this.$emit('unit-of-work', 1);
} else {
this.$emit('unit-of-work', -1);
}
}
},
methods: {
unitOfWork(promise) {
this.unitOfWorkBegin();
return promise.always(this.unitOfWorkDone);
},
unitOfWorkBegin() {
this.unitOfWorkCounter++;
},
unitOfWorkDone() {
this.unitOfWorkCounter--;
},
childUnitOfWork(direction) {
this.unitOfWorkCounter += direction;
}
}
}
export { UnitOfWorkTracker }

View File

@@ -0,0 +1,12 @@
const TEMPLATE = `
<div class="user-avatar">
<img
:src="user.gravatar"
:alt="user.full_name">
</div>
`;
Vue.component('user-avatar', {
template: TEMPLATE,
props: {user: Object},
});

View File

@@ -0,0 +1,13 @@
const TEMPLATE =
`<div class="generic-placeholder" :title="label">
<i class="pi-spin spin"/>
{{ label }}
</div>
`;
Vue.component('generic-placeholder', {
template: TEMPLATE,
props: {
label: String,
},
});

View File

@@ -0,0 +1,56 @@
import { debounced } from '../../utils/init'
import { thenMarkdownToHtml } from '../../api/markdown'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
const TEMPLATE = `
<div class="markdown-preview">
<div class="markdown-preview-md"
v-html="asHtml"/>
<div class="markdown-preview-info">
<a
title="Handy guide of Markdown syntax"
target="_blank"
href="http://commonmark.org/help/">
<span>markdown cheatsheet</span>
</a>
</div>
</div>
`;
Vue.component('markdown-preview', {
template: TEMPLATE,
mixins: [UnitOfWorkTracker],
props: {
markdown: String,
attachments: Object
},
data() {
return {
asHtml: '',
}
},
created() {
this.markdownToHtml(this.markdown, this.attachments);
this.debouncedMarkdownToHtml = debounced(this.markdownToHtml);
},
watch: {
markdown(newValue, oldValue) {
this.debouncedMarkdownToHtml(newValue, this.attachments);
},
attachments(newValue, oldValue) {
this.debouncedMarkdownToHtml(this.markdown, newValue);
}
},
methods: {
markdownToHtml(markdown, attachments) {
this.unitOfWork(
thenMarkdownToHtml(markdown, attachments)
.then((data) => {
this.asHtml = data.content;
})
.fail((err) => {
toastr.error(xhrErrorResponseMessage(err), 'Parsing failed');
})
);
}
}
});

View File

@@ -0,0 +1,33 @@
import { prettyDate } from '../../utils/init'
const TEMPLATE =
`<div class="pretty-created" :title="'Posted ' + created">
{{ prettyCreated }}
<span
v-if="isEdited"
:title="'Updated ' + prettyUpdated"
>*</span>
</div>
`;
Vue.component('pretty-created', {
template: TEMPLATE,
props: {
created: String,
updated: String,
detailed: {
type: Boolean,
default: true
}
},
computed: {
prettyCreated() {
return prettyDate(this.created, this.detailed);
},
prettyUpdated() {
return prettyDate(this.updated, this.detailed);
},
isEdited() {
return this.updated && (this.created !== this.updated)
}
}
});

View File

@@ -1,323 +0,0 @@
/* Reply */
$(document).on('click','body .comment-action-reply',function(e){
e.preventDefault();
// container of the comment we are replying to
var parentDiv = $(this).closest('.comment-container');
// container of the first-level comment in the thread
var parentDivFirst = parentDiv.prevAll('.is-first:first');
// Get the id of the comment
if (parentDiv.hasClass('is-reply')) {
parentNodeId = parentDivFirst.data('node-id');
} else {
parentNodeId = parentDiv.data('node-id');
}
if (!parentNodeId) {
if (console) console.log('No parent ID found on ', parentDiv.toArray(), parentDivFirst.toArray());
return;
}
// Get the textarea and set its parent_id data
var commentField = document.getElementById('comment_field');
commentField.dataset.originalParentId = commentField.dataset.parentId;
commentField.dataset.parentId = parentNodeId;
// Start the comment field with @authorname:
var replyAuthor = parentDiv.find('.comment-author:first').html();
$(commentField).val("**@" + replyAuthor + ":** ");
// Add class for styling
$('.comment-container').removeClass('is-replying');
parentDiv.addClass('is-replying');
// Move comment-reply container field after the parent container
var commentForm = $('.comment-reply-container').detach();
parentDiv.after(commentForm);
// document.getElementById('comment_field').focus();
$(commentField).focus();
$('.comment-reply-field').addClass('filled');
});
/* Cancel Reply */
$(document).on('click','body .comment-action-cancel',function(e){
e.preventDefault();
$('.comment-reply-container').detach().prependTo('#comments-list');
var commentField = document.getElementById('comment_field');
commentField.dataset.parentId = commentField.dataset.originalParentId;
delete commentField.dataset.originalParentId;
$(commentField).val('');
$('.comment-reply-field').removeClass('filled');
$('.comment-container').removeClass('is-replying');
});
/* Rate */
$(document).on('click','body .comment-action-rating',function(e){
e.preventDefault();
var $this = $(this);
var nodeId = $this.closest('.comment-container').data('node-id');
var is_positive = !$this.hasClass('down');
var parentDiv = $this.parent();
var rated_positive = parentDiv.hasClass('positive');
if (typeof nodeId === 'undefined') {
if (console) console.log('Undefined node ID');
return;
}
var op;
if (parentDiv.hasClass('rated') && is_positive == rated_positive) {
op = 'revoke';
} else if (is_positive) {
op = 'upvote';
} else {
op = 'downvote';
}
$.post("/nodes/comments/" + nodeId + "/rate/" + op)
.done(function(data){
// Add/remove styles for rated statuses
switch(op) {
case 'revoke':
parentDiv.removeClass('rated');
break;
case 'upvote':
parentDiv.addClass('rated');
parentDiv.addClass('positive');
break;
case 'downvote':
parentDiv.addClass('rated');
parentDiv.removeClass('positive');
break;
}
var rating = data['data']['rating_positive'] - data['data']['rating_negative'];
$this.siblings('.comment-rating-value').text(rating);
});
});
/**
* Fetches a comment, returns a promise object.
*/
function loadComment(comment_id, projection)
{
if (typeof comment_id === 'undefined') {
console.log('Error, loadComment(', comment_id, ', ', projection, ') called.');
return $.Deferred().reject();
}
// Add required projections for the permission system to work.
projection.node_type = 1;
projection.project = 1;
var url = '/api/nodes/' + comment_id;
return $.get({
url: url,
data: {projection: projection},
cache: false, // user should be ensured the latest comment to edit.
});
}
function loadComments(commentsUrl)
{
var commentsContainer = $('#comments-embed');
return $.get(commentsUrl)
.done(function(dataHtml) {
// Update the DOM injecting the generate HTML into the page
commentsContainer.html(dataHtml);
$('body').trigger('pillar:comments-loaded');
})
.fail(function(xhr) {
toastr.error('Could not load comments', xhr.responseText);
commentsContainer.html('<a id="comments-reload"><i class="pi-refresh"></i> Reload comments</a>');
});
}
/**
* Shows an error in the "Post Comment" button.
*/
function show_comment_button_error(msg) {
var $button = $('.comment-action-submit');
var $textarea = $('#comment_field');
$button.addClass('error');
$textarea.addClass('error');
$button.html(msg);
setTimeout(function(){
$button.html('<span><i class="pi-paper-plane"></i> Send</span>');
$button.removeClass('error');
$textarea.removeClass('error');
}, 2500);
}
/**
* Shows an error in the "edit comment" button.
*/
function show_comment_edit_button_error($button, msg) {
var $textarea = $('#comment_field');
$button.addClass('error');
$textarea.addClass('field-error');
$button.html(msg);
setTimeout(function(){
$button.html('<i class="pi-check"></i> Save Changes');
$button.removeClass('button-field-error');
$textarea.removeClass('field-error');
}, 2500);
}
/**
* Switches the comment to either 'edit' or 'view' mode.
*/
function comment_mode(clicked_item, mode)
{
var $container = $(clicked_item).closest('.comment-container');
var comment_id = $container.data('node-id');
var $edit_buttons = $container.find('.comment-action-edit');
if (mode == 'edit') {
$edit_buttons.find('.edit_mode').hide();
$edit_buttons.find('.edit_cancel').show();
$edit_buttons.find('.edit_save').show();
} else {
$edit_buttons.find('.edit_mode').show();
$edit_buttons.find('.edit_cancel').hide();
$edit_buttons.find('.edit_save').hide();
$container.find('.comment-body').removeClass('editing');
}
}
/**
* Return UI to normal, when cancelling or saving.
*
* clicked_item: save/cancel button.
*
* Returns a promise on the comment loading if reload_comment=true.
*/
function commentEditCancel(clicked_item, reload_comment) {
comment_mode(clicked_item, 'view');
var comment_container = $(clicked_item).closest('.comment-container');
var comment_id = comment_container.data('node-id');
if (!reload_comment) return;
return loadComment(comment_id, {'properties.content': 1})
.done(function(data) {
var comment_html = data['properties']['content_html'];
comment_container
.find('.comment-body span')
.removeClass('editing')
.html(comment_html);
})
.fail(function(xhr) {
if (console) console.log('Error fetching comment: ', xhr);
toastr.error('Error canceling', xhr.responseText);
});
}
function save_comment(is_new_comment, $commentContainer)
{
var promise = $.Deferred();
var commentField;
var commentId;
var parent_id;
// Get data from HTML, and validate it.
if (is_new_comment)
commentField = $('#comment_field');
else {
commentField = $commentContainer.find('textarea');
commentId = $commentContainer.data('node-id');
}
if (!commentField.length)
return promise.reject("Unable to find comment field.");
if (is_new_comment) {
parent_id = commentField.data('parent-id');
if (!parent_id) {
if (console) console.log("No parent ID found in comment field data.");
return promise.reject("No parent ID!");
}
}
// Validate the comment itself.
var comment = commentField.val();
if (comment.length < 5) {
if (comment.length == 0) promise.reject("Say something...");
else promise.reject("Minimum 5 characters.");
return promise;
}
// Notify callers of the fact that client-side validation has passed.
promise.notify();
// Actually post the comment.
if (is_new_comment) {
$.post('/nodes/comments/create',
{'content': comment, 'parent_id': parent_id})
.fail(promise.reject)
.done(function(data) { promise.resolve(data.node_id, comment); });
} else {
$.post('/nodes/comments/' + commentId,
{'content': comment})
.fail(promise.reject)
.done(function(resp) {
promise.resolve(commentId, resp.data.content_html);
});
}
return promise;
}
/* Used when clicking the .comment-action-submit button or by a shortcut */
function post_comment($submit_button){
save_comment(true)
.progress(function() {
$submit_button
.addClass('submitting')
.html('<span><i class="pi-spin spin"></i> Sending...</span>');
})
.fail(function(xhr){
if (typeof xhr === 'string') {
show_comment_button_error(xhr);
} else {
// If it's not a string, we assume it's a jQuery XHR object.
if (console) console.log('Error posting comment: ', xhr.responseText);
show_comment_button_error("Try again?");
}
toastr.error(xhr.responseText, 'Error posting comment');
})
.done(function(comment_node_id) {
$submit_button
.removeClass('submitting')
.html('<i class="pi-paper-plane"></i> Send');
$('#comment_field').val('');
$('body').trigger('pillar:comment-posted', [comment_node_id]);
toastr.success('Comment posted!');
});
}

View File

@@ -1,27 +1,11 @@
$comments-width-max: 710px $comments-width-max: 710px
.comments-container .comments-tree
max-width: $comments-width-max max-width: $comments-width-max
position: relative position: relative
width: 100% width: 100%
#comments-reload .comments-list-title
text-align: center
cursor: pointer
padding: 15px 0
display: block
.comments-list
&-loading
+spin
color: $color-background
font-size: 2em
margin-bottom: 10px
position: relative
text-align: center
top: 25px
&-title
padding: 15px 0 10px 0 padding: 15px 0 10px 0
font-size: 1.1em font-size: 1.1em
font-weight: 300 font-weight: 300
@@ -49,22 +33,6 @@ $comments-width-max: 710px
right: 20px right: 20px
text-transform: uppercase text-transform: uppercase
&.is-replying
margin-bottom: 15px !important
.comment-avatar
padding-right: 5px
padding-left: 5px
.comment-avatar
padding-right: 10px
img
border-radius: 50%
height: 24px
margin-top: 5px
width: 24px
p.comment-author p.comment-author
color: $color-text-dark color: $color-text-dark
display: inline-block display: inline-block
@@ -76,7 +44,7 @@ $comments-width-max: 710px
&.op &.op
color: $color-primary-dark color: $color-primary-dark
.comment-time .pretty-created
padding-left: 10px padding-left: 10px
margin-left: 10px margin-left: 10px
color: $color-text-dark-hint color: $color-text-dark-hint
@@ -133,29 +101,28 @@ $comments-width-max: 710px
padding-top: inherit padding-top: inherit
padding-bottom: inherit padding-bottom: inherit
.editing
background-color: $color-background-light
display: flex
margin: 30px 0 10px
textarea
background-color: $color-background
border-radius: 3px
border: none
box-shadow: inset 0 0 2px 0 rgba(darken($color-background-dark, 30%), .5)
display: block
padding: 10px
width: 100%
&:focus
box-shadow: inset 0 0 2px 0 darken($color-background-dark, 30%)
.comment-content .comment-content
display: flex display: flex
flex-direction: column flex-direction: column
padding-bottom: 0 padding-bottom: 0
width: 100% width: 100%
&.is-reply
padding:
left: 20px
top: 5px
margin-left: 35px
box-shadow: inset 3px 0 0 $color-background
+media-xs
padding-left: 15px
&.comment-linked
box-shadow: inset 3px 0 0 $color-info
&.is-first
border-top: 1px solid $color-background
/* Rating, and actions such as reply */ /* Rating, and actions such as reply */
.comment-meta .comment-meta
+media-xs +media-xs
@@ -184,7 +151,7 @@ $comments-width-max: 710px
color: $color-text-dark-secondary color: $color-text-dark-secondary
.comment-action-rating.up:before .comment-action-rating.up:before
content: '\e83f' content: '\e83f' // Heart filled
.comment-rating-value .comment-rating-value
@@ -202,28 +169,22 @@ $comments-width-max: 710px
&:hover &:hover
color: $color-upvote color: $color-upvote
&:before &:before
content: '\e83e' content: '\e83e' // Heart outlined
top: 2px top: 2px
position: relative position: relative
.comment-action-rating.down .comment-action
&:hover
color: $color-downvote
&:before
content: '\e838'
/* Reply button */
.comment-action-reply
color: $color-primary color: $color-primary
&:hover
text-decoration: underline
.comment-action-reply,
.comment-action-edit
padding-left: 10px padding-left: 10px
margin-left: 10px margin-left: 10px
.action
cursor: pointer
margin-left: 15px
&:hover
color: $color-primary
text-decoration: underline
&:before &:before
color: $color-text-dark-secondary color: $color-text-dark-secondary
content: '·' content: '·'
@@ -232,97 +193,39 @@ $comments-width-max: 710px
position: relative position: relative
text-decoration: none text-decoration: none
span .attachments
cursor: pointer display: flex
&:hover flex-direction: column
color: $color-primary .attachment
display: flex
flex-direction: row
align-items: center
span.edit_save, .preview-thumbnail
color: $color-success margin: 0.1em
display: none
&:hover .actions
color: lighten($color-success, 10%) margin-left: auto
.action
cursor: pointer
color: $color-primary
margin-left: 2em
&.delete
color: $color-danger
input
max-height: 2em
&.error &.error
color: $color-danger input
border-color: $color-danger
&.saving .comments-locked
user-select: none display: block
pointer-events: none padding: 10px
color: $color-text-dark-primary
cursor: default cursor: default
i
font-size: .8em
margin-right: 5px
span.edit_cancel
display: none
margin-left: 15px
&.is-reply
padding:
left: 20px
top: 5px
margin-left: 35px
box-shadow: inset 3px 0 0 $color-background
+media-xs
padding-left: 15px
&.comment-linked
box-shadow: inset 3px 0 0 $color-info
&.is-replying+.comment-reply-container
margin-left: 35px
padding-left: 25px
&.is-first
border-top: 1px solid $color-background
&.is-team
.comment-author
color: $color-success
&.is-replying
box-shadow: -5px 0 0 $color-primary
@extend .pl-3
&.is-replying+.comment-reply-container
box-shadow: -5px 0 0 $color-primary
margin-left: 0
padding-left: 55px
.comment-badge
border-radius: 3px
border: 1px solid $color-text-dark-hint
color: $color-text-dark-hint
display: inline-block
font:
size: .7em
weight: 400
margin: 0 5px 0 10px
padding: 1px 4px
text-transform: uppercase
&.badge-team
border-color: $color-info
color: $color-info
&.badge-op
border-color: $color-primary
color: $color-primary
&.badge-own
border-color: $color-success
color: $color-success
.comment-reply .comment-reply
/* Little gravatar icon on the left */
&-avatar
img
border-radius: 50%
width: 25px
height: 25px
box-shadow: 0 0 0 3px $color-background-light
/* textarea field, submit button and reply details */ /* textarea field, submit button and reply details */
&-form &-form
padding: padding:
@@ -354,7 +257,8 @@ $comments-width-max: 710px
margin: 0 margin: 0
min-height: 35px min-height: 35px
padding: 10px 0 10px 10px padding: 10px 0 10px 10px
resize: vertical resize: none
overflow: hidden
transition: box-shadow 250ms ease-in-out transition: box-shadow 250ms ease-in-out
width: 100% width: 100%
@@ -380,9 +284,6 @@ $comments-width-max: 710px
&:focus &:focus
border-bottom-left-radius: 0 border-bottom-left-radius: 0
&+.comment-reply-preview
display: flex
.comment-reply-meta .comment-reply-meta
background-color: $color-success background-color: $color-success
@@ -393,49 +294,8 @@ $comments-width-max: 710px
span.hotkey span.hotkey
display: block display: block
&.sign-in .comment-action
display: block .action
padding: 10px
color: $color-text-dark-primary
cursor: default
&-preview
background-color: $color-background-light
border-bottom-left-radius: 3px
border-bottom-right-radius: 3px
box-shadow: 1px 2px 2px rgba($color-background-dark, .5)
display: none // flex when comment-reply-field has .filled class
position: relative
transition: all 150ms ease-in-out
p
padding-left: 0
padding-right: 0
&-md
flex: 1
padding: 5px 10px
&:empty
color: transparent
margin: 0 auto
padding: 0 10px
border: none
&:before
content: ''
color: transparent
&-info
background-color: $color-background-dark
display: flex
flex-direction: column
font-size: .8em
padding-bottom: 10px
text-align: center
width: 100px
.comment-action-cancel
cursor: pointer cursor: pointer
padding: 10px padding: 10px
text-decoration: underline text-decoration: underline
@@ -489,19 +349,43 @@ $comments-width-max: 710px
font-weight: normal font-weight: normal
font-size: .9em font-size: .9em
.markdown-preview
background-color: $color-background-light
border-bottom-left-radius: 3px
border-bottom-right-radius: 3px
box-shadow: 1px 2px 2px rgba($color-background-dark, .5)
display: flex
position: relative
transition: all 150ms ease-in-out
/* Style the comment container when we're replying */ p
.comment-container + .comment-reply-container padding-left: 0
margin-left: 30px padding-right: 0
padding-top: 10px
.comment-reply-form &-md
.comment-reply-meta flex: 1
button.comment-action-cancel padding: 5px 10px
display: inline-block
&-info
background-color: $color-background-dark
display: flex
flex-direction: column
font-size: .8em
padding-bottom: 10px
text-align: center
width: 100px
.comment-badges ul.blender-id-badges &:empty
color: transparent
margin: 0 auto
padding: 0 10px
border: none
&:before
content: ''
color: transparent
.user-badges ul.blender-id-badges
list-style: none list-style: none
padding: 0 padding: 0
margin: 4px 0 0 0 margin: 4px 0 0 0
@@ -516,3 +400,28 @@ $comments-width-max: 710px
img img
width: 16px width: 16px
height: 16px height: 16px
.user-avatar
img
border-radius: 50%
width: 2em
height: 2em
box-shadow: 0 0 0 0.2em $color-background-light
.drag-hover
&:before
background-color: $color-success
content: " "
display: block
height: 100%
position: absolute
top: 0
left: 0
width: 100%
z-index: $z-index-base + 1
opacity: 0.2
border-radius: 1em
&.unsupported-drop
&:before
background-color: $color-danger

View File

@@ -415,7 +415,8 @@
ul li img ul li img
@extend .d-block @extend .d-block
@extend .mx-auto @extend .mx-auto
@extend .my-3 margin-top: 1rem
margin-bottom: 1rem
max-width: 100% max-width: 100%
&.emoji &.emoji

View File

@@ -30,6 +30,8 @@ html(lang="en")
| {% endblock %} | {% endblock %}
script(src="{{ url_for('static_pillar', filename='assets/js/tutti.min.js') }}") script(src="{{ url_for('static_pillar', filename='assets/js/tutti.min.js') }}")
script.
pillar.utils.initCurrentUser({{ current_user | json | safe }});
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.typeahead-0.11.1.min.js')}}") script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.typeahead-0.11.1.min.js')}}")
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/js.cookie-2.0.3.min.js')}}") script(src="{{ url_for('static_pillar', filename='assets/js/vendor/js.cookie-2.0.3.min.js')}}")
| {% if current_user.is_authenticated %} | {% if current_user.is_authenticated %}

View File

@@ -56,8 +56,7 @@ script(type="text/javascript").
} }
if (ProjectUtils.nodeType() == 'asset' || ProjectUtils.nodeType() == 'post') { if (ProjectUtils.nodeType() == 'asset' || ProjectUtils.nodeType() == 'post') {
var commentsUrl = "{{ url_for('nodes.comments_for_node', node_id=node._id) }}"; new Vue({el:'#comments-embed'});
loadComments(commentsUrl);
} }
{% if node.has_method('PUT') %} {% if node.has_method('PUT') %}

View File

@@ -42,7 +42,9 @@
a(href="{{ node.short_link }}") {{ node.short_link }} a(href="{{ node.short_link }}") {{ node.short_link }}
| {% endif %} | {% endif %}
#comments-embed comments-tree#comments-embed(
parent-id="{{ node._id }}"
)
include ../_scripts include ../_scripts

View File

@@ -1,51 +0,0 @@
| {%- macro render_comment(comment, is_reply) -%}
.comment-container(
id="{{ comment._id }}",
data-node-id="{{ comment._id }}",
class="{% if is_reply %}is-reply{% else %}is-first{% endif %}")
.comment-avatar
img(src="{{ comment._user.email | gravatar }}", alt="{{ comment._user.full_name }}")
.comment-badges
| {{ comment._user.badges_html|safe }}
.comment-content
.comment-body
p.comment-author {{ comment._user.full_name }}
span {{comment.properties | markdowned('content') }}
| {# TODO(Pablo): Markdown preview when editing #}
.comment-meta
.comment-rating(
class="{% if comment._current_user_rating is not none %}rated{% if comment._current_user_rating %} positive{% endif %}{% endif %}")
.comment-rating-value(title="{{ _('Number of likes') }}") {{ comment._rating }}
| {% if not comment._is_own %}
.comment-action-rating.up(title="{{ _('Like comment') }}")
| {% endif %}
.comment-action-reply(title="{{ _('Reply to this comment') }}")
span {{ _("Reply") }}
| {% if comment._is_own %}
.comment-action-edit
span.edit_mode(title="{{ _('Edit comment') }}")
| {{ _("Edit") }}
span.edit_save(title="{{ _('Save comment') }}")
i.pi-check
| {{ _("Save Changes") }}
span.edit_cancel(title="{{ _('Cancel changes') }}")
i.pi-cancel
| {{ _("Cancel") }}
| {% endif %}
.comment-time(title='{{ comment._created }}')
| {{ comment._created | pretty_date_time }}
| {% if comment._created != comment._updated %}
span(title="{{ _('edited') }} {{ comment._updated | pretty_date_time }}") *
| {% endif %}
| {% for reply in comment['_replies']['_items'] %}
| {{ render_comment(reply, True) }}
| {% endfor %}
| {%- endmacro -%}

View File

@@ -1 +0,0 @@
| {% extends "nodes/custom/comment/list_embed_base.html" %}

View File

@@ -1,258 +0,0 @@
| {% import 'nodes/custom/comment/_macros.html' as macros %}
#comments.comments-container
section#comments-list.comments-list
.comment-reply-container
| {% if can_post_comments %}
| {% block can_post_comment %}
.comment-reply-avatar
img(
title="{{ _('Commenting as') }} {{ current_user.full_name }}",
src="{{ current_user.gravatar }}")
.comment-reply-form
.comment-reply-field
textarea(
id="comment_field",
data-parent-id="{{ node_id }}",
placeholder="{{ _('Join the conversation...') }}")
.comment-reply-meta
button.comment-action-submit(
id="comment_submit",
type="button",
title="Post Comment (Ctrl+Enter)")
span
i.pi-paper-plane
| {{ _('Send') }}
span.hotkey Ctrl + Enter
.comment-reply-preview
.comment-reply-preview-md
.comment-reply-info
.comment-action-cancel(
title="{{ _('cancel') }}")
span {{ _('cancel') }}
a(
title="{{ _('Handy guide of Markdown syntax') }}",
target="_blank",
href="http://commonmark.org/help/")
span {{ _('markdown cheatsheet') }}
| {% endblock can_post_comment %}
| {% else %}
| {% block can_not_post_comment %}
| {% if current_user.is_authenticated %}
| {# * User is authenticated, but has no subscription or 'POST' permission #}
.comment-reply-form
.comment-reply-field.sign-in
| {% if current_user.has_cap('subscriber') %}
i.pi-lock
| Only project members can comment.
| {% elif current_user.has_cap('can-renew-subscription') %}
i.pi-heart
| Join the conversation! #[a(href='/renew', target='_blank') Renew your subscription] to comment.
| {% else %}
| Join the conversation! #[a(href="https://store.blender.org/product/membership/") Subscribe to Blender Cloud] to comment.
| {% endif %}
| {% else %}
| {# * User is not autenticated #}
.comment-reply-form
.comment-reply-field.sign-in
a(href="{{ url_for('users.login') }}") {{ _('Log in to comment') }}
| {% endif %}
| {% endblock can_not_post_comment %}
| {% endif %}
| {% if show_comments and (nr_of_comments > 0) %}
section.comments-list-header
.comments-list-title
| {{ nr_of_comments }} {{ _('comment') }}{{ nr_of_comments|pluralize }}
.comments-list-items
| {% for comment in comments['_items'] %}
| {{ macros.render_comment(comment, False) }}
| {% endfor %}
| {% endif %}
| {% block comment_scripts %}
script.
{% if show_comments %}
$('body')
.off('pillar:comment-posted')
.on('pillar:comment-posted', function(e, comment_node_id) {
var commentsUrl = "{{ url_for('nodes.comments_for_node', node_id=node_id) }}";
loadComments(commentsUrl)
.done(function() {
$('#' + comment_node_id).scrollHere();
});
});
// If there's a comment link in the URL, scroll there
function scrollToLinkedComment() {
var scrollToId = location.hash;
// Check that it's a valid ObjectID before passing it to jQuery.
if (!/^[a-fA-F0-9]{24}$/.test(scrollToId.replace('#',''))) return;
$(scrollToId)
.addClass('comment-linked')
.scrollHere();
}
$(scrollToLinkedComment);
{% endif %}
{% if can_post_comments %}
// If we can actually comment, load the tools to do it
// Submit new comment
$(document)
.off('click','body .comment-action-submit')
.on( 'click','body .comment-action-submit', function(e){
post_comment($(this));
});
// Writing comment
var $commentField = $("#comment_field");
var $commentContainer = $commentField.parent();
var $commentPreview = $commentField.parent().parent().find('.comment-reply-preview-md');
function parseCommentContent(content) {
$.ajax({
url: "{{ url_for('nodes.preview_markdown')}}",
type: 'post',
data: {content: content},
headers: {"X-CSRFToken": csrf_token},
headers: {},
dataType: 'json'
})
.done(function (data) {
$commentPreview.html(data.content);
})
.fail(function (err) {
toastr.error(xhrErrorResponseMessage(err), 'Parsing failed');
});
}
var options = {
callback: parseCommentContent,
wait: 750,
highlight: false,
allowSubmit: false,
captureLength: 2
}
$commentField.typeWatch(options);
$(document)
.off('keyup','body .comment-reply-field textarea')
.on( 'keyup','body .comment-reply-field textarea',function(e){
// While we are at it, style if empty
if ($commentField.val()) {
$commentContainer.addClass('filled');
} else {
$commentContainer.removeClass('filled');
}
// Send on ctrl+enter
if ($commentField.is(":focus")) {
if ((e.keyCode == 10 || e.keyCode == 13) && e.ctrlKey){
post_comment($commentContainer.find('.comment-action-submit'));
}
}
});
// Autoresize the textarea as we type
$('#comment_field').autoResize();
// Edit comment
// Enter edit mode
$(document)
.off('click','body .comment-action-edit span.edit_mode')
.on( 'click','body .comment-action-edit span.edit_mode',function(){
comment_mode(this, 'edit');
var $parent_div = $(this).closest('.comment-container');
var comment_id = $parent_div.data('node-id');
var comment_content = $parent_div.find('.comment-body span');
var height = comment_content.height();
loadComment(comment_id, {'properties.content': 1})
.done(function(data) {
var comment_raw = data['properties']['content'];
comment_content.html($('<textarea>').text(comment_raw));
comment_content
.addClass('editing')
.find('textarea')
.autoResize()
.focus()
.trigger('keyup');
})
.fail(function(xhr) {
if (console) console.log('Error fetching comment: ', xhr);
toastr.error('Error ' + xhr.status + ' entering edit mode.');
});
});
// Abort, abort
$(document)
.off('click','body .comment-action-edit span.edit_cancel')
.on( 'click','body .comment-action-edit span.edit_cancel',function(e){
commentEditCancel(this, true);
});
// Save edited comment
$(document)
.off('click','body .comment-action-edit span.edit_save')
.on( 'click','body .comment-action-edit span.edit_save',function(e){
var $button = $(this);
var $container = $button.closest('.comment-container');
save_comment(false, $container)
.progress(function() {
$button
.addClass('submitting')
.html('<i class="pi-spin spin"></i> Posting...');
})
.fail(function(xhr) {
if (typeof xhr === 'string') {
show_comment_edit_button_error($button, xhr);
} else {
// If it's not a string, we assume it's a jQuery XHR object.
if (console) console.log('Error saving comment:', xhr.responseText);
show_comment_edit_button_error($button, "Houston! Try again?");
}
})
.done(function(comment_id, comment_html) {
commentEditCancel($button, false)
$container.find('.comment-body span')
.html(comment_html)
.removeClass('editing')
.flashOnce();
$button
.html('<i class="pi-check"></i> save changes')
.removeClass('saving');
});
});
{% endif %}
| {% endblock %}

View File

@@ -122,9 +122,9 @@ section.node-details-meta.pl-4.pr-2.py-2.border-bottom
.row .row
| {% block node_comments %} | {% block node_comments %}
.col-md-8.col-sm-12 .col-md-8.col-sm-12
#comments-embed.p-2 comments-tree#comments-embed(
.comments-list-loading parent-id="{{ node._id }}"
i.pi-spin )
| {% endblock node_comments %} | {% endblock node_comments %}
| {# Check if tags is defined and there is _actually_ a tag at least #} | {# Check if tags is defined and there is _actually_ a tag at least #}

View File

@@ -35,12 +35,11 @@ class CommentTest(AbstractPillarTest):
def test_write_comment(self): def test_write_comment(self):
with self.login_as(self.user_uid): with self.login_as(self.user_uid):
comment_url = flask.url_for('nodes.comments_create') comment_url = flask.url_for('nodes_api.post_node_comment', node_path=str(self.node_id))
self.post( self.post(
comment_url, comment_url,
data={ json={
'content': 'je möder lives at [home](https://cloud.blender.org/)', 'msg': 'je möder lives at [home](https://cloud.blender.org/)',
'parent_id': str(self.node_id),
}, },
expected_status=201, expected_status=201,
) )
@@ -51,14 +50,16 @@ class CommentEditTest(AbstractPillarTest):
super().setUp(**kwargs) super().setUp(**kwargs)
self.pid, self.project = self.ensure_project_exists() self.pid, self.project = self.ensure_project_exists()
self.uid = self.create_user(groups=[ctd.EXAMPLE_ADMIN_GROUP_ID]) self.owner_uid = self.create_user(24 * 'a',
self.create_valid_auth_token(self.uid, 'token') groups=[ctd.EXAMPLE_ADMIN_GROUP_ID],
token='admin-token')
# Create a node people can comment on.
self.node_id = self.create_node({ self.node_id = self.create_node({
'_id': ObjectId('572761099837730efe8e120d'), '_id': ObjectId('572761099837730efe8e120d'),
'description': 'This is an asset without file', 'description': 'This is an asset without file',
'node_type': 'asset', 'node_type': 'asset',
'user': self.uid, 'user': self.owner_uid,
'properties': { 'properties': {
'status': 'published', 'status': 'published',
'content_type': 'image', 'content_type': 'image',
@@ -67,31 +68,35 @@ class CommentEditTest(AbstractPillarTest):
'project': self.pid, 'project': self.pid,
}) })
self.user_uid = self.create_user(24 * 'b', groups=[ctd.EXAMPLE_ADMIN_GROUP_ID],
token='user-token')
def test_edit_comment(self): def test_edit_comment(self):
from pillar import auth
from pillar.web.nodes.custom import comments
# Create the comment # Create the comment
with self.app.test_request_context(method='POST', data={ with self.login_as(self.user_uid):
'content': 'My first comment', comment_url = flask.url_for('nodes_api.post_node_comment', node_path=str(self.node_id))
'parent_id': str(self.node_id), resp = self.post(
}): comment_url,
auth.login_user('token', load_from_db=True) json={
resp, status = comments.comments_create() 'msg': 'je möder lives at [home](https://cloud.blender.org/)',
},
expected_status=201,
)
self.assertEqual(201, status)
payload = json.loads(resp.data) payload = json.loads(resp.data)
comment_id = payload['node_id'] comment_id = payload['id']
comment_url = flask.url_for('nodes_api.patch_node_comment', node_path=str(self.node_id), comment_path=comment_id)
# Edit the comment # Edit the comment
with self.app.test_request_context(method='POST', data={ resp = self.patch(
'content': 'Edited comment', comment_url,
}): json={
auth.login_user('token', load_from_db=True) 'msg': 'Edited comment',
resp = comments.comment_edit(comment_id) },
expected_status=200,
)
self.assertEqual(200, resp.status_code) self.assertEqual(200, resp.status_code)
payload = json.loads(resp.data) payload = json.loads(resp.data)
self.assertEqual('success', payload['status']) self.assertEqual('Edited comment', payload['msg_markdown'])
self.assertEqual('Edited comment', payload['data']['content']) self.assertEqual('<p>Edited comment</p>\n', payload['msg_html'])
self.assertEqual('<p>Edited comment</p>\n', payload['data']['content_html'])