Allow editing/deleting of comments and threads

This commit is contained in:
James Allen 2017-01-24 16:18:49 +01:00
parent 2813b16ebf
commit f9ba7392e9
12 changed files with 318 additions and 34 deletions

View file

@ -58,4 +58,25 @@ module.exports = ChatApiHandler =
ChatApiHandler._apiRequest {
url: "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}/reopen"
method: "POST"
}, callback
}, callback
deleteThread: (project_id, thread_id, callback = (error) ->) ->
ChatApiHandler._apiRequest {
url: "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}"
method: "DELETE"
}, callback
editMessage: (project_id, thread_id, message_id, content, callback = (error) ->) ->
ChatApiHandler._apiRequest {
url: "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}/messages/#{message_id}/edit"
method: "POST"
json:
content: content
}, callback
deleteMessage: (project_id, thread_id, message_id, callback = (error) ->) ->
ChatApiHandler._apiRequest {
url: "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}/messages/#{message_id}"
method: "DELETE"
}, callback

View file

@ -4,6 +4,7 @@ logger = require("logger-sharelatex")
AuthenticationController = require('../Authentication/AuthenticationController')
UserInfoManager = require('../User/UserInfoManager')
UserInfoController = require('../User/UserInfoController')
DocumentUpdaterHandler = require "../DocumentUpdater/DocumentUpdaterHandler"
async = require "async"
module.exports = CommentsController =
@ -50,6 +51,33 @@ module.exports = CommentsController =
return next(err) if err?
EditorRealTimeController.emitToRoom project_id, "reopen-thread", thread_id, (err)->
res.send 204
deleteThread: (req, res, next) ->
{project_id, doc_id, thread_id} = req.params
logger.log {project_id, doc_id, thread_id}, "deleting comment thread"
DocumentUpdaterHandler.deleteThread project_id, doc_id, thread_id, (err) ->
return next(err) if err?
ChatApiHandler.deleteThread project_id, thread_id, (err, threads) ->
return next(err) if err?
EditorRealTimeController.emitToRoom project_id, "delete-thread", thread_id, (err)->
res.send 204
editMessage: (req, res, next) ->
{project_id, thread_id, message_id} = req.params
{content} = req.body
logger.log {project_id, thread_id, message_id}, "editing message thread"
ChatApiHandler.editMessage project_id, thread_id, message_id, content, (err) ->
return next(err) if err?
EditorRealTimeController.emitToRoom project_id, "edit-message", thread_id, message_id, content, (err)->
res.send 204
deleteMessage: (req, res, next) ->
{project_id, thread_id, message_id} = req.params
logger.log {project_id, thread_id, message_id}, "deleting message"
ChatApiHandler.deleteMessage project_id, thread_id, message_id, (err, threads) ->
return next(err) if err?
EditorRealTimeController.emitToRoom project_id, "delete-message", thread_id, message_id, (err)->
res.send 204
_injectUserInfoIntoThreads: (threads, callback = (error, threads) ->) ->
userCache = {}

View file

@ -153,6 +153,22 @@ module.exports = DocumentUpdaterHandler =
logger.error {project_id, doc_id, change_id}, "doc updater returned a non-success status code: #{res.statusCode}"
callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
deleteThread: (project_id, doc_id, thread_id, callback = (error) ->) ->
timer = new metrics.Timer("delete-thread")
url = "#{settings.apis.documentupdater.url}/project/#{project_id}/doc/#{doc_id}/comment/#{thread_id}"
logger.log {project_id, doc_id, thread_id}, "deleting comment range in document updater"
request.del url, (error, res, body)->
timer.done()
if error?
logger.error {err:error, project_id, doc_id, thread_id}, "error deleting comment range in doc updater"
return callback(error)
if res.statusCode >= 200 and res.statusCode < 300
logger.log {project_id, doc_id, thread_id}, "deleted comment rangee in document updater"
return callback(null)
else
logger.error {project_id, doc_id, thread_id}, "doc updater returned a non-success status code: #{res.statusCode}"
callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
PENDINGUPDATESKEY = "PendingUpdates"
DOCLINESKEY = "doclines"
DOCIDSWITHPENDINGUPDATES = "DocsWithPendingUpdates"

View file

@ -238,6 +238,9 @@ module.exports = class Router
webRouter.get "/project/:project_id/threads", AuthorizationMiddlewear.ensureUserCanReadProject, CommentsController.getThreads
webRouter.post "/project/:project_id/thread/:thread_id/resolve", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.resolveThread
webRouter.post "/project/:project_id/thread/:thread_id/reopen", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.reopenThread
webRouter.delete "/project/:project_id/doc/:doc_id/thread/:thread_id", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.deleteThread
webRouter.post "/project/:project_id/thread/:thread_id/messages/:message_id/edit", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.editMessage
webRouter.delete "/project/:project_id/thread/:thread_id/messages/:message_id", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.deleteMessage
webRouter.post "/project/:Project_id/references/index", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.index
webRouter.post "/project/:Project_id/references/indexAll", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.indexAll

View file

@ -7,7 +7,7 @@
docs="docs"
on-open="refreshResolvedCommentsDropdown();"
on-unresolve="unresolveComment(threadId);"
on-delete="deleteComment(entryId, threadId);"
on-delete="deleteThread(entryId, docId, threadId);"
is-loading="reviewPanel.dropdown.loading"
permissions="permissions"
)
@ -51,6 +51,8 @@
on-resolve="resolveComment(entry, entry_id)"
on-reply="submitReply(entry, entry_id);"
on-indicator-click="toggleReviewPanel();"
on-save-edit="saveEdit(entry.thread_id, comment)"
on-delete="deleteComment(entry.thread_id, comment)"
permissions="permissions"
ng-if="!reviewPanel.loadingThreads"
)
@ -94,6 +96,8 @@
entry="entry"
threads="reviewPanel.commentThreads"
on-reply="submitReply(entry, entry_id);"
on-save-edit="saveEdit(entry.thread_id, comment)"
on-delete="deleteComment(entry.thread_id, comment)"
on-indicator-click="toggleReviewPanel();"
ng-click="gotoEntry(doc.doc.id, entry)"
permissions="permissions"
@ -175,18 +179,42 @@ script(type='text/ng-template', id='commentEntryTemplate')
.rp-entry.rp-entry-comment(
ng-class="{ 'rp-entry-focused': entry.focused, 'rp-entry-comment-resolving': state.animating }"
)
.rp-loading(ng-if="!threads[entry.thread_id] || threads[entry.thread_id].messages.length == 0")
| No comments
div
.rp-comment(
ng-repeat="comment in threads[entry.thread_id].messages track by comment.id"
)
p.rp-comment-content
span.rp-entry-user(
style="color: hsl({{ comment.user.hue }}, 70%, 40%);"
) {{ comment.user.name }}:&nbsp;
| {{ comment.content }}
.rp-entry-metadata
| {{ comment.timestamp | date : 'MMM d, y h:mm a' }}
.rp-loading(ng-if="!threads[entry.thread_id] || threads[entry.thread_id].submitting")
p.rp-comment-content
span(ng-if="!comment.editing")
span.rp-entry-user(
style="color: hsl({{ comment.user.hue }}, 70%, 40%);",
) {{ comment.user.name }}:&nbsp;
| {{ comment.content }}
textarea.rp-comment-input(
ng-if="comment.editing"
ng-model="comment.content"
ng-keypress="saveEditOnEnter($event, comment);"
ng-blur="saveEdit(comment)"
autofocus
stop-propagation="click"
)
.rp-entry-metadata(ng-if="!comment.editing")
span(ng-if="!comment.deleting") {{ comment.timestamp | date : 'MMM d, y h:mm a' }}
span.rp-comment-actions(ng-if="comment.user.isSelf && !comment.deleting")
| &nbsp;&bull;&nbsp;
a(href, ng-click="startEditing(comment)") Edit
| &nbsp;&bull;&nbsp;
a(href, ng-click="confirmDelete(comment)") Delete
span.rp-confim-delete(ng-if="comment.user.isSelf && comment.deleting")
| Are you sure?
| &bull;&nbsp;
a(href, ng-click="doDelete(comment)") Delete
| &nbsp;&bull;&nbsp;
a(href, ng-click="cancelDelete(comment)") Cancel
.rp-loading(ng-if="threads[entry.thread_id].submitting")
i.fa.fa-spinner.fa-spin
.rp-comment-reply(ng-if="permissions.comment")
textarea.rp-comment-input(
@ -249,11 +277,11 @@ script(type='text/ng-template', id='resolvedCommentEntryTemplate')
ng-click="onUnresolve({ 'threadId': thread.threadId });"
)
| &nbsp;Re-open
//- a.rp-entry-button(
//- href
//- ng-click="onDelete({ 'entryId': thread.entryId, 'threadId': thread.threadId });"
//- )
//- | &nbsp;Delete
a.rp-entry-button(
href
ng-click="onDelete({ 'entryId': thread.entryId, 'docId': thread.docId, 'threadId': thread.threadId });"
)
| &nbsp;Delete
script(type='text/ng-template', id='addCommentEntryTemplate')
@ -324,7 +352,7 @@ script(type='text/ng-template', id='resolvedCommentsDropdownTemplate')
ng-repeat="thread in resolvedComments | orderBy:'resolved_at':true"
thread="thread"
on-unresolve="handleUnresolve(threadId);"
on-delete="handleDelete(entryId, threadId);"
on-delete="handleDelete(entryId, docId, threadId);"
permissions="permissions"
)
.rp-loading(ng-if="!resolvedComments.length")

View file

@ -105,7 +105,6 @@ load = (EventEmitter) ->
throw new Error("unknown op type")
addComment: (op, metadata) ->
# TODO: Don't allow overlapping comments?
@comments.push comment = {
id: op.t or @newId()
op: # Copy because we'll modify in place

View file

@ -65,6 +65,14 @@ define [
ide.socket.on "reopen-thread", (thread_id) ->
_onCommentReopened(thread_id)
ide.socket.on "edit-message", (thread_id, message_id, content) ->
_onCommentEdited(thread_id, message_id, content)
$scope.$apply () ->
ide.socket.on "delete-message", (thread_id, message_id) ->
_onCommentDeleted(thread_id, message_id)
$scope.$apply () ->
rangesTrackers = {}
@ -342,7 +350,8 @@ define [
event_tracking.sendMB "rp-comment-reopen"
_onCommentResolved = (thread_id, user) ->
thread = $scope.reviewPanel.commentThreads[thread_id]
thread = getThread(thread_id)
return if !thread?
thread.resolved = true
thread.resolved_by_user = formatUser(user)
thread.resolved_at = new Date()
@ -350,23 +359,63 @@ define [
$scope.$broadcast "comment:resolve_thread", thread_id
_onCommentReopened = (thread_id) ->
thread = $scope.reviewPanel.commentThreads[thread_id]
thread = getThread(thread_id)
return if !thread?
delete thread.resolved
delete thread.resolved_by_user
delete thread.resolved_at
delete $scope.reviewPanel.resolvedThreadIds[thread_id]
$scope.$broadcast "comment:unresolve_thread", thread_id
_onCommentDeleted = (thread_id) ->
if $scope.reviewPanel.resolvedThreadIds[thread_id]?
delete $scope.reviewPanel.resolvedThreadIds[thread_id]
_onThreadDeleted = (thread_id) ->
delete $scope.reviewPanel.resolvedThreadIds[thread_id]
delete $scope.reviewPanel.commentThreads[thread_id]
$scope.deleteComment = (entry_id, thread_id) ->
_onCommentDeleted(thread_id)
_onCommentEdited = (thread_id, comment_id, content) ->
thread = getThread(thread_id)
return if !thread?
for message in thread.messages
if message.id == comment_id
message.content = content
updateEntries()
_onCommentDeleted = (thread_id, comment_id) ->
thread = getThread(thread_id)
return if !thread?
thread.messages = thread.messages.filter (m) -> m.id != comment_id
updateEntries()
$scope.deleteThread = (entry_id, doc_id, thread_id) ->
_onThreadDeleted(thread_id)
$http({
method: "DELETE"
url: "/project/#{$scope.project_id}/doc/#{doc_id}/thread/#{thread_id}",
headers: {
'X-CSRF-Token': window.csrfToken
}
})
$scope.$broadcast "comment:remove", entry_id
event_tracking.sendMB "rp-comment-delete"
$scope.saveEdit = (thread_id, comment) ->
$http.post("/project/#{$scope.project_id}/thread/#{thread_id}/messages/#{comment.id}/edit", {
content: comment.content
_csrf: window.csrfToken
})
$timeout () ->
$scope.$broadcast "review-panel:layout"
$scope.deleteComment = (thread_id, comment) ->
_onCommentDeleted(thread_id, comment.id)
$http({
method: "DELETE"
url: "/project/#{$scope.project_id}/thread/#{thread_id}/messages/#{comment.id}",
headers: {
'X-CSRF-Token': window.csrfToken
}
})
$timeout () ->
$scope.$broadcast "review-panel:layout"
$scope.setSubView = (subView) ->
$scope.reviewPanel.subView = subView

View file

@ -11,6 +11,8 @@ define [
onResolve: "&"
onReply: "&"
onIndicatorClick: "&"
onSaveEdit: "&"
onDelete: "&"
link: (scope, element, attrs) ->
scope.state =
animating: false
@ -26,4 +28,33 @@ define [
scope.state.animating = true
element.find(".rp-entry").css("top", 0)
$timeout((() -> scope.onResolve()), 350)
return true
return true
scope.startEditing = (comment) ->
comment.editing = true
setTimeout () ->
scope.$emit "review-panel:layout"
scope.saveEdit = (comment) ->
comment.editing = false
scope.onSaveEdit({comment:comment})
scope.confirmDelete = (comment) ->
comment.deleting = true
setTimeout () ->
scope.$emit "review-panel:layout"
scope.cancelDelete = (comment) ->
comment.deleting = false
setTimeout () ->
scope.$emit "review-panel:layout"
scope.doDelete = (comment) ->
comment.deleting = false
scope.onDelete({comment: comment})
scope.saveEditOnEnter = (ev, comment) ->
if ev.keyCode == 13 and !ev.shiftKey and !ev.ctrlKey and !ev.metaKey
ev.preventDefault()
scope.saveEdit(comment)

View file

@ -31,8 +31,9 @@ define [
scope.onUnresolve({ threadId })
scope.resolvedComments = scope.resolvedComments.filter (c) -> c.threadId != threadId
scope.handleDelete = (entryId, threadId) ->
scope.onDelete({ entryId, threadId })
scope.handleDelete = (entryId, docId, threadId) ->
scope.onDelete({ entryId, docId, threadId })
scope.resolvedComments = scope.resolvedComments.filter (c) -> c.threadId != threadId
getDocNameById = (docId) ->
doc = _.find(scope.docs, (doc) -> doc.doc.id == docId)

View file

@ -351,6 +351,9 @@
font-weight: @rp-semibold-weight;
font-style: normal;
}
.rp-comment-actions {
a { color: @rp-type-blue; }
}
.rp-content-highlight {
color: @rp-type-darkgrey;
@ -414,12 +417,6 @@
margin: 0;
color: @rp-type-darkgrey;
}
.rp-comment-metadata {
color: @rp-type-blue;
font-size: @rp-small-font-size;
margin: 0;
}
.rp-comment-resolver {
color: @rp-type-blue;
@ -452,6 +449,7 @@
border: solid 1px @rp-border-grey;
resize: vertical;
color: @rp-type-darkgrey;
margin-top: 3px;
}
.rp-icon-delete {

View file

@ -23,6 +23,7 @@ describe "CommentsController", ->
'../Authentication/AuthenticationController': @AuthenticationController
'../User/UserInfoManager': @UserInfoManager = {}
'../User/UserInfoController': @UserInfoController = {}
"../DocumentUpdater/DocumentUpdaterHandler": @DocumentUpdaterHandler = {}
@req = {}
@res =
json: sinon.stub()
@ -134,6 +135,80 @@ describe "CommentsController", ->
it "should return a success code", ->
@res.send.calledWith(204).should.equal
describe "deleteThread", ->
beforeEach ->
@req.params =
project_id: @project_id = "mock-project-id"
doc_id: @doc_id = "mock-doc-id"
thread_id: @thread_id = "mock-thread-id"
@DocumentUpdaterHandler.deleteThread = sinon.stub().yields()
@ChatApiHandler.deleteThread = sinon.stub().yields()
@CommentsController.deleteThread @req, @res
it "should ask the doc udpater to delete the thread", ->
@DocumentUpdaterHandler.deleteThread
.calledWith(@project_id, @doc_id, @thread_id)
.should.equal true
it "should ask the chat handler to delete the thread", ->
@ChatApiHandler.deleteThread
.calledWith(@project_id, @thread_id)
.should.equal true
it "should tell the client the thread was deleted", ->
@EditorRealTimeController.emitToRoom
.calledWith(@project_id, "delete-thread", @thread_id)
.should.equal true
it "should return a success code", ->
@res.send.calledWith(204).should.equal
describe "editMessage", ->
beforeEach ->
@req.params =
project_id: @project_id = "mock-project-id"
thread_id: @thread_id = "mock-thread-id"
message_id: @message_id = "mock-thread-id"
@req.body =
content: @content = "mock-content"
@ChatApiHandler.editMessage = sinon.stub().yields()
@CommentsController.editMessage @req, @res
it "should ask the chat handler to edit the comment", ->
@ChatApiHandler.editMessage
.calledWith(@project_id, @thread_id, @message_id, @content)
.should.equal true
it "should tell the client the comment was edited", ->
@EditorRealTimeController.emitToRoom
.calledWith(@project_id, "edit-message", @thread_id, @message_id, @content)
.should.equal true
it "should return a success code", ->
@res.send.calledWith(204).should.equal
describe "deleteMessage", ->
beforeEach ->
@req.params =
project_id: @project_id = "mock-project-id"
thread_id: @thread_id = "mock-thread-id"
message_id: @message_id = "mock-thread-id"
@ChatApiHandler.deleteMessage = sinon.stub().yields()
@CommentsController.deleteMessage @req, @res
it "should ask the chat handler to deleted the message", ->
@ChatApiHandler.deleteMessage
.calledWith(@project_id, @thread_id, @message_id)
.should.equal true
it "should tell the client the message was deleted", ->
@EditorRealTimeController.emitToRoom
.calledWith(@project_id, "delete-message", @thread_id, @message_id)
.should.equal true
it "should return a success code", ->
@res.send.calledWith(204).should.equal
describe "_injectUserInfoIntoThreads", ->
beforeEach ->
@users = {

View file

@ -330,3 +330,38 @@ describe 'DocumentUpdaterHandler', ->
@callback
.calledWith(new Error("doc updater returned failure status code: 500"))
.should.equal true
describe "deleteThread", ->
beforeEach ->
@thread_id = "mock-thread-id-1"
@callback = sinon.stub()
describe "successfully", ->
beforeEach ->
@request.del = sinon.stub().callsArgWith(1, null, {statusCode: 200}, @body)
@handler.deleteThread @project_id, @doc_id, @thread_id, @callback
it 'should delete the thread in the document updater', ->
url = "#{@settings.apis.documentupdater.url}/project/#{@project_id}/doc/#{@doc_id}/comment/#{@thread_id}"
@request.del.calledWith(url).should.equal true
it "should call the callback", ->
@callback.calledWith(null).should.equal true
describe "when the document updater API returns an error", ->
beforeEach ->
@request.del = sinon.stub().callsArgWith(1, @error = new Error("something went wrong"), null, null)
@handler.deleteThread @project_id, @doc_id, @thread_id, @callback
it "should return an error to the callback", ->
@callback.calledWith(@error).should.equal true
describe "when the document updater returns a failure error code", ->
beforeEach ->
@request.del = sinon.stub().callsArgWith(1, null, { statusCode: 500 }, "")
@handler.deleteThread @project_id, @doc_id, @thread_id, @callback
it "should return the callback with an error", ->
@callback
.calledWith(new Error("doc updater returned failure status code: 500"))
.should.equal true