Merge pull request #125 from openHPI/rework-comment-modal

Rework comment modal
This commit is contained in:
rteusner
2017-09-20 16:51:47 +02:00
committed by GitHub
27 changed files with 679 additions and 226 deletions

View File

@ -14,6 +14,7 @@
// //
//= require ace/ace //= require ace/ace
//= require chosen.jquery.min //= require chosen.jquery.min
//= require jquery-ui.min
//= require d3 //= require d3
//= require jquery.turbolinks //= require jquery.turbolinks
//= require jquery_ujs //= require jquery_ujs

View File

@ -22,45 +22,132 @@
display: none !important; display: none !important;
} }
p.comment { .modal-content {
width: 400px; min-height: 512px;
min-width: 360px;
max-height: 90vh;
display: flex;
flex-direction: column;
.modal-body {
flex-grow: 1;
display: flex;
flex-direction: column;
#otherComments {
flex-grow: 1;
display: flex;
flex-direction: column;
.container {
flex-grow: 1;
}
}
}
} }
.popover-header { .comment {
width: 100%;
min-width: 200px;
.comment-header {
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
padding-bottom: 10px; padding-bottom: 10px;
margin: auto; margin: auto;
}
.popover-username { .comment-username {
font-weight: bold; font-weight: bold;
width: 60%; width: 60%;
float: left; float: left;
} }
.popover-date { .comment-date {
text-align: right; text-align: right;
color: #008cba; color: #008cba;
margin-left: 60%; margin-left: 60%;
font-size: x-small; font-size: x-small;
} }
.popover-updated { .comment-updated {
text-align: right; text-align: right;
margin-left: 60%; margin-left: 60%;
font-size: x-small; font-size: x-small;
} }
}
.popover-comment { .comment-content {
word-wrap: break-word; word-wrap: break-word;
margin-bottom: 10px; margin-bottom: 10px;
}
.comment-editor {
display: none;
width: 100%;
height: auto;
background-color: inherit;
}
.comment-actions {
display: none;
}
} }
.popover-divider { .comment-divider {
width: 100%; width: 100%;
height: 1px; height: 1px;
background-color: #008cba; background-color: #008cba;
overflow: hidden; overflow: hidden;
margin-top: 10px;
margin-bottom: 10px; margin-bottom: 10px;
} }
#otherComments {
h5 {
margin-top: 0;
}
.container {
width: 100%;
overflow-y: auto;
border: 1px solid #cccccc;
padding: 15px;
.comment-removed {
margin-top: 20px;
margin-bottom: 20px;
font-style: italic;
}
.comment-actions {
display: flex;
button {
margin-right: 5px;
}
}
}
input#subscribe {
margin-top: 5px;
margin-right: 5px;
}
}
#myComment {
margin-top: 20px;
margin-bottom: 10px;
textarea {
resize: none;
}
button {
margin-top: 10px;
}
}
.popover-footer {
color: #008cba;
margin-top: 10px;
}

View File

@ -1,5 +1,5 @@
class CommentsController < ApplicationController class CommentsController < ApplicationController
before_action :set_comment, only: [:show, :edit, :update, :destroy_by_id] before_action :set_comment, only: [:show, :edit, :update, :destroy]
# to disable authorization check: comment the line below back in # to disable authorization check: comment the line below back in
# skip_after_action :verify_authorized # skip_after_action :verify_authorized
@ -21,6 +21,7 @@ class CommentsController < ApplicationController
comment.username = comment.user.displayname comment.username = comment.user.displayname
comment.date = comment.created_at.strftime('%d.%m.%Y %k:%M') comment.date = comment.created_at.strftime('%d.%m.%Y %k:%M')
comment.updated = (comment.created_at != comment.updated_at) comment.updated = (comment.created_at != comment.updated_at)
comment.editable = comment.user == current_user
} }
else else
@comments = [] @comments = []
@ -50,12 +51,14 @@ class CommentsController < ApplicationController
def create def create
@comment = Comment.new(comment_params_without_request_id) @comment = Comment.new(comment_params_without_request_id)
if comment_params[:request_id]
UserMailer.got_new_comment(@comment, RequestForComment.find(comment_params[:request_id]), current_user).deliver_now
end
respond_to do |format| respond_to do |format|
if @comment.save if @comment.save
if comment_params[:request_id]
request_for_comment = RequestForComment.find(comment_params[:request_id])
send_mail_to_author @comment, request_for_comment
send_mail_to_subscribers @comment, request_for_comment
end
format.html { redirect_to @comment, notice: 'Comment was successfully created.' } format.html { redirect_to @comment, notice: 'Comment was successfully created.' }
format.json { render :show, status: :created, location: @comment } format.json { render :show, status: :created, location: @comment }
else else
@ -83,7 +86,8 @@ class CommentsController < ApplicationController
# DELETE /comments/1 # DELETE /comments/1
# DELETE /comments/1.json # DELETE /comments/1.json
def destroy_by_id def destroy
authorize!
@comment.destroy @comment.destroy
respond_to do |format| respond_to do |format|
format.html { head :no_content, notice: 'Comment was successfully destroyed.' } format.html { head :no_content, notice: 'Comment was successfully destroyed.' }
@ -91,17 +95,8 @@ class CommentsController < ApplicationController
end end
end end
def destroy
@comments = Comment.where(file_id: params[:file_id], row: params[:row], user: current_user)
@comments.each { |comment| authorize comment; comment.destroy }
respond_to do |format|
#format.html { redirect_to comments_url, notice: 'Comments were successfully destroyed.' }
format.html { head :no_content, notice: 'Comments were successfully destroyed.' }
format.json { head :no_content }
end
end
private private
# Use callbacks to share common setup or constraints between actions. # Use callbacks to share common setup or constraints between actions.
def set_comment def set_comment
@comment = Comment.find(params[:id]) @comment = Comment.find(params[:id])
@ -117,4 +112,25 @@ class CommentsController < ApplicationController
# fuer production mode, damit böse menschen keine falsche user_id uebergeben: # fuer production mode, damit böse menschen keine falsche user_id uebergeben:
params.require(:comment).permit(:file_id, :row, :column, :text, :request_id).merge(user_id: current_user.id, user_type: current_user.class.name) params.require(:comment).permit(:file_id, :row, :column, :text, :request_id).merge(user_id: current_user.id, user_type: current_user.class.name)
end end
def send_mail_to_author(comment, request_for_comment)
if current_user != request_for_comment.user
UserMailer.got_new_comment(comment, request_for_comment, current_user).deliver_now
end
end
def send_mail_to_subscribers(comment, request_for_comment)
request_for_comment.commenters.each do |commenter|
subscriptions = Subscription.where(
:request_for_comment_id => request_for_comment.id,
:user_id => commenter.id, :user_type => commenter.class.name)
subscriptions.each do |subscription|
if (subscription.subscription_type == 'author' and current_user == request_for_comment.user) or subscription.subscription_type == 'all'
if subscription.user != current_user
UserMailer.got_new_comment_for_subscription(comment, subscription, current_user).deliver_now
end
end
end
end
end
end end

View File

@ -68,11 +68,8 @@ class RequestForCommentsController < ApplicationController
def set_thank_you_note def set_thank_you_note
authorize! authorize!
@request_for_comment.thank_you_note = params[:note] @request_for_comment.thank_you_note = params[:note]
commenters = []
@request_for_comment.comments.distinct.to_a.each {|comment| commenters = @request_for_comment.commenters
commenters.append comment.user
}
commenters = commenters.uniq {|user| user.id}
commenters.each {|commenter| UserMailer.send_thank_you_note(@request_for_comment, commenter).deliver_now} commenters.each {|commenter| UserMailer.send_thank_you_note(@request_for_comment, commenter).deliver_now}
respond_to do |format| respond_to do |format|

View File

@ -0,0 +1,61 @@
class SubscriptionsController < ApplicationController
def authorize!
authorize(@subscription || @subscriptions)
end
private :authorize!
# POST /subscriptions.json
def create
@subscription = Subscription.new(subscription_params)
respond_to do |format|
if @subscription.save
format.json { render json: @subscription, status: :created }
else
format.json { render json: @subscription.errors, status: :unprocessable_entity }
end
end
authorize!
end
# DELETE /subscriptions/1
# DELETE /subscriptions/1.json
def destroy
begin
@subscription = Subscription.find(params[:id])
rescue
skip_authorization
respond_to do |format|
format.html { redirect_to request_for_comments_url, alert: t('subscriptions.subscription_not_existent') }
format.json { render json: {message: t('subscriptions.subscription_not_existent')}, status: :not_found }
end
else
authorize!
rfc = @subscription.try(:request_for_comment)
if @subscription.destroy
respond_to do |format|
format.html { redirect_to request_for_comment_url(rfc), notice: t('subscriptions.successfully_unsubscribed') }
format.json { render json: {message: t('subscriptions.successfully_unsubscribed')}, status: :ok}
end
else
respond_to do |format|
format.html { redirect_to request_for_comment_url(rfc), :flash => { :danger => t('shared.message_failure') } }
format.json { render json: {message: t('shared.message_failure')}, status: :internal_server_error}
end
end
end
end
def set_subscription
@subscription = Subscription.find(params[:id])
authorize!
end
private :set_subscription
def subscription_params
current_user_id = current_user.try(:id)
current_user_class_name = current_user.try(:class).try(:name)
params[:subscription].permit(:request_for_comment_id, :subscription_type).merge(user_id: current_user_id, user_type: current_user_class_name)
end
private :subscription_params
end

View File

@ -21,6 +21,15 @@ class UserMailer < ActionMailer::Base
mail(subject: t('mailers.user_mailer.got_new_comment.subject', commenting_user_displayname: @commenting_user_displayname), to: request_for_comment.user.email) mail(subject: t('mailers.user_mailer.got_new_comment.subject', commenting_user_displayname: @commenting_user_displayname), to: request_for_comment.user.email)
end end
def got_new_comment_for_subscription(comment, subscription, from_user)
@receiver_displayname = subscription.user.displayname
@author_displayname = from_user.displayname
@comment_text = comment.text
@rfc_link = request_for_comment_url(subscription.request_for_comment)
@unsubscribe_link = unsubscribe_subscription_url(subscription)
mail(subject: t('mailers.user_mailer.got_new_comment_for_subscription.subject', author_displayname: @author_displayname), to: subscription.user.email)
end
def send_thank_you_note(request_for_comments, receiver) def send_thank_you_note(request_for_comments, receiver)
@receiver_displayname = receiver.displayname @receiver_displayname = receiver.displayname
@author = request_for_comments.user.displayname @author = request_for_comments.user.displayname

View File

@ -1,7 +1,7 @@
class Comment < ActiveRecord::Base class Comment < ActiveRecord::Base
# inherit the creation module: encapsulates that this is a polymorphic user, offers some aliases and makes sure that all necessary attributes are set. # inherit the creation module: encapsulates that this is a polymorphic user, offers some aliases and makes sure that all necessary attributes are set.
include Creation include Creation
attr_accessor :username, :date, :updated attr_accessor :username, :date, :updated, :editable
belongs_to :file, class_name: 'CodeOcean::File' belongs_to :file, class_name: 'CodeOcean::File'
belongs_to :user, polymorphic: true belongs_to :user, polymorphic: true

View File

@ -5,6 +5,7 @@ class RequestForComment < ActiveRecord::Base
belongs_to :file, class_name: 'CodeOcean::File' belongs_to :file, class_name: 'CodeOcean::File'
has_many :comments, through: :submission has_many :comments, through: :submission
has_many :subscriptions
scope :unsolved, -> { where(solved: [false, nil]) } scope :unsolved, -> { where(solved: [false, nil]) }
@ -37,6 +38,14 @@ class RequestForComment < ActiveRecord::Base
submission.files.map { |file| file.comments.size}.sum submission.files.map { |file| file.comments.size}.sum
end end
def commenters
commenters = []
comments.distinct.to_a.each {|comment|
commenters.append comment.user
}
commenters.uniq {|user| user.id}
end
def to_s def to_s
"RFC-" + self.id.to_s "RFC-" + self.id.to_s
end end

View File

@ -0,0 +1,4 @@
class Subscription < ActiveRecord::Base
belongs_to :user, polymorphic: true
belongs_to :request_for_comment
end

View File

@ -12,14 +12,10 @@ class CommentPolicy < ApplicationPolicy
everyone everyone
end end
[:new?, :destroy?, :update?].each do |action| [:new?, :destroy?, :update?, :edit?].each do |action|
define_method(action) { admin? || author? } define_method(action) { admin? || author? }
end end
def edit?
admin?
end
def index? def index?
everyone everyone
end end

View File

@ -0,0 +1,18 @@
class SubscriptionPolicy < ApplicationPolicy
def create?
everyone
end
def destroy?
author? || admin?
end
def show_error?
everyone
end
def author?
@user == @record.user
end
private :author?
end

View File

@ -1,4 +1,4 @@
json.array!(@comments) do |comment| json.array!(@comments) do |comment|
json.extract! comment, :id, :user_id, :file_id, :row, :column, :text, :username, :date, :updated json.extract! comment, :id, :user_id, :file_id, :row, :column, :text, :username, :date, :updated, :editable
json.url comment_url(comment, format: :json) json.url comment_url(comment, format: :json)
end end

View File

@ -1,9 +1,11 @@
h5 =t('exercises.implement.comment.addyours')
textarea.form-control(style='resize:none;')
#otherComments #otherComments
h5 =t('exercises.implement.comment.others') h5 =t('exercises.implement.comment.others')
pre#otherCommentsTextfield .container
p = '' label
button#addCommentButton.btn.btn-block.btn-primary(type='button') =t('exercises.implement.comment.addCommentButton') input#subscribe type='checkbox' title=t('request_for_comments.subscribe_to_author') data-subscription=Subscription.where(user: current_user, request_for_comment_id: @request_for_comment.id).try(:first).try(:id)
button#removeAllButton.btn.btn-block.btn-warning(type='button') =t('exercises.implement.comment.removeAllOnLine') = t('request_for_comments.subscribe_to_author')
#myComment
h5 =t('exercises.implement.comment.addyours')
textarea.form-control
button#addCommentButton.btn.btn-block.btn-primary(type='button') =t('exercises.implement.comment.addCommentButton')

View File

@ -81,8 +81,13 @@ also, all settings from the rails model needed for the editor configuration in t
<script type="text/javascript"> <script type="text/javascript">
$('.modal-content').draggable({
handle: '.modal-header'
}).resizable({
autoHide: true
});
var solvedButton = $('#mark-as-solved-button'); var solvedButton = $('#mark-as-solved-button');
var commentOnExerciseButton = $('#comment-exercise-button');
var addCommentExerciseButton = $('#addCommentExerciseButton'); var addCommentExerciseButton = $('#addCommentExerciseButton');
var thankYouContainer = $('#thank-you-container'); var thankYouContainer = $('#thank-you-container');
@ -138,15 +143,12 @@ also, all settings from the rails model needed for the editor configuration in t
currentEditor.getSession().setMode($(editor).data('mode')); currentEditor.getSession().setMode($(editor).data('mode'));
currentEditor.getSession().setOption("useWorker", false); currentEditor.getSession().setOption("useWorker", false);
currentEditor.commentVisualsByLine = {};
setAnnotations(currentEditor, $(editor).data('file-id')); setAnnotations(currentEditor, $(editor).data('file-id'));
currentEditor.on("guttermousedown", handleSidebarClick); currentEditor.on("guttermousedown", handleSidebarClick);
currentEditor.on("guttermousemove", showPopover);
}); });
function cleanupPopovers() {
// remove all possible popovers
$('.editor > .ace_gutter > .ace_gutter-layer > .ace_gutter-cell').popover('destroy');
}
function preprocess(commentText) { function preprocess(commentText) {
// sanitize comments to deal with XSS attacks: // sanitize comments to deal with XSS attacks:
commentText = $('div.sanitizer').text(commentText).html(); commentText = $('div.sanitizer').text(commentText).html();
@ -154,6 +156,51 @@ also, all settings from the rails model needed for the editor configuration in t
return commentText.replace(/\n/g, '<br>'); return commentText.replace(/\n/g, '<br>');
} }
function generateCommentHtmlContent(comments) {
var htmlContent = '';
comments.forEach(function(comment, index) {
var commentText = preprocess(comment.text);
if (index !== 0) {
htmlContent += '<div class="comment-divider"></div>'
}
htmlContent += '\
<div class="comment" data-comment-id=' + comment.id + '> \
<div class="comment-header"> \
<div class="comment-username">' + preprocess(comment.username) + '</div> \
<div class="comment-date">' + comment.date + '</div> \
<div class="comment-updated' + (comment.updated ? '' : ' hidden') + '"> \
<i class="fa fa-pencil" aria-hidden="true"></i> \
<%= t('request_for_comments.comment_edited') %> \
</div> \
</div> \
<div class="comment-content">' + commentText + '</div> \
<textarea class="comment-editor">' + commentText + '</textarea> \
<div class="comment-actions' + (comment.editable ? '' : ' hidden') + '"> \
<button class="action-edit btn btn-xs btn-warning"><%= t('shared.edit') %></button> \
<button class="action-delete btn btn-xs btn-danger"><%= t('shared.destroy') %></button> \
</div> \
</div>';
});
return htmlContent;
}
function buildPopover(comments, where) {
// only display the newest three comments in preview
var maxComments = 3;
var htmlContent = generateCommentHtmlContent(comments.reverse().slice(0, maxComments));
if (comments.length > maxComments) {
// add a hint that there are more comments than shown here
htmlContent += '<div class="popover-footer"><%= t('request_for_comments.click_for_more_comments') %></div>'
.replace('${numComments}', String(comments.length - maxComments));
}
where.popover({
content: htmlContent,
html: true, // necessary to style comments. XSS is not possible due to comment pre-processing (sanitizing)
trigger: 'manual', // can only be triggered by $(where).popover('show' | 'hide')
container: 'body'
});
}
function setAnnotations(editor, fileid) { function setAnnotations(editor, fileid) {
var session = editor.getSession(); var session = editor.getSession();
@ -167,73 +214,11 @@ also, all settings from the rails model needed for the editor configuration in t
}); });
jqrequest.done(function(response){ jqrequest.done(function(response){
// comments need to be sorted to cluster them per line
var comments = response.slice().sort(function (a, b) {
return a.row - b.row;
});
while (comments.length > 0) {
// new cluster of comments
var cluster = [];
var clusterRow = comments[0].row;
// now collect all comments on this line
while (comments.length > 0 && comments[0].row === clusterRow) {
cluster.push(comments.shift());
}
// sort the comments by creation date
cluster = cluster.sort(function (a, b) {
return a.id - b.id;
});
// build the markup for the current line's popover
var popupContent = '';
cluster.forEach(function(comment, index) {
if (index !== 0) {
popupContent += '<div class="popover-divider"></div>'
}
popupContent += '<p class="comment">';
popupContent += '<div class="popover-header">' +
'<div class="popover-username">' + preprocess(comment.username) + '</div>' +
'<div class="popover-date">' + comment.date + '</div>';
if (comment.updated) {
popupContent += '<div class="popover-updated">' +
'<i class="fa fa-pencil" aria-hidden="true"></i>' +
'<%= t('request_for_comments.comment_edited') %>' +
'</div>'
}
popupContent += '</div>';
popupContent += '<div class="popover-comment">' + preprocess(comment.text) + '</div>';
popupContent += '</p>';
});
// attach the popover to the ace sidebar (where the comment icon is displayed)
var icon = $('*[data-file-id="' + fileid + '"]') // the editor for this file
.find('.ace_gutter > .ace_gutter-layer') // the sidebar
.find('div:nth-child(' + (clusterRow + 1) + ')'); // the correct line
icon.popover({
content: popupContent,
html: true, // necessary to style comments. XSS is not possible due to comment pre-processing (sanitizing)
trigger: 'hover',
container: 'body'
});
}
$.each(response, function(index, comment) { $.each(response, function(index, comment) {
comment.className = 'code-ocean_comment'; comment.className = 'code-ocean_comment';
// if we have tabs or carriage returns in the comment, just add the name and leave it as it is. otherwise: format!
if(comment.text.includes("\n") || comment.text.includes("\t")){
comment.text = comment.username + ": " + comment.text;
} else {
comment.text = comment.username + ": " + stringDivider(comment.text, 80, "\n");
}
}); });
session.setAnnotations(response); session.setAnnotations(response);
}) });
}
function hasCommentsInRow(editor, row){
return editor.getSession().getAnnotations().some(function(element) {
return element.row === row;
})
} }
function getCommentsForRow(editor, row){ function getCommentsForRow(editor, row){
@ -242,23 +227,36 @@ also, all settings from the rails model needed for the editor configuration in t
}) })
} }
function deleteComment(file_id, row, editor) { function deleteComment(commentId, editor, file_id, callback) {
cleanupPopovers();
var jqxhr = $.ajax({ var jqxhr = $.ajax({
type: 'DELETE', type: 'DELETE',
url: "/comments", url: "/comments/" + commentId
data: {
row: row,
file_id: file_id }
}); });
jqxhr.done(function (response) { jqxhr.done(function () {
setAnnotations(editor, file_id); setAnnotations(editor, file_id);
callback();
});
jqxhr.fail(ajaxError);
}
function updateComment(commentId, text, editor, file_id, callback) {
var jqxhr = $.ajax({
type: 'PATCH',
url: "/comments/" + commentId,
data: {
comment: {
text: text
}
}
});
jqxhr.done(function () {
setAnnotations(editor, file_id);
callback();
}); });
jqxhr.fail(ajaxError); jqxhr.fail(ajaxError);
} }
function createComment(file_id, row, editor, commenttext){ function createComment(file_id, row, editor, commenttext){
cleanupPopovers();
var jqxhr = $.ajax({ var jqxhr = $.ajax({
data: { data: {
comment: { comment: {
@ -273,71 +271,173 @@ also, all settings from the rails model needed for the editor configuration in t
method: 'POST', method: 'POST',
url: "/comments" url: "/comments"
}); });
jqxhr.done(function(response){ jqxhr.done(function(){
setAnnotations(editor, file_id); setAnnotations(editor, file_id);
}); });
jqxhr.fail(ajaxError); jqxhr.fail(ajaxError);
} }
function createCommentOnExercise(file_id, row, editor, commenttext){ function subscribeToRFC(subscriptionType, checkbox){
checkbox.attr("disabled", true);
var jqxhr = $.ajax({ var jqxhr = $.ajax({
data: { data: {
comment: { subscription: {
file_id: file_id, request_for_comment_id: $('h4#exercise_caption').data('rfc-id'),
row: row, subscription_type: subscriptionType
column: 0,
text: commenttext,
request_id: $('h4#exercise_caption').data('rfc-id')
} }
}, },
dataType: 'json', dataType: 'json',
method: 'POST', method: 'POST',
url: "/comments" url: "/subscriptions.json"
}); });
jqxhr.done(function(response){ jqxhr.done(function(subscription) {
setAnnotations(editor, file_id); checkbox.data('subscription', subscription.id);
checkbox.attr("disabled", false);
});
jqxhr.fail(function(response) {
checkbox.prop('checked', false);
checkbox.attr("disabled", false);
ajaxError(response);
}); });
jqxhr.fail(ajaxError);
} }
function unsubscribeFromRFC(checkbox) {
checkbox.attr("disabled", true);
var subscriptionId = checkbox.data('subscription');
var jqxhr = $.ajax({
url: '/subscriptions/' + subscriptionId + '/unsubscribe.json'
});
jqxhr.done(function(response) {
checkbox.prop('checked', false);
checkbox.data('subscription', null);
checkbox.attr("disabled", false);
$.flash.success({text: response.message});
});
jqxhr.fail(function(response) {
checkbox.prop('checked', true);
checkbox.attr("disabled", false);
ajaxError(response);
});
}
var lastRow = null;
var lastTarget = null;
function showPopover(e) {
var target = e.domEvent.target;
var row = e.getDocumentPosition().row;
if (target.className.indexOf('ace_gutter-cell') === -1 || lastRow === row) {
return;
}
if (lastTarget === target) {
// sometimes the row gets updated before the DOM event target, so we need to wait for it to change
return;
}
lastRow = row;
var editor = e.editor;
var comments = getCommentsForRow(editor, row);
buildPopover(comments, $(target));
lastTarget = target;
$(target).popover('show');
$(target).on('mouseleave', function () {
$(this).off('mouseleave');
$(this).popover('destroy');
});
}
$('.ace_gutter').on('mouseleave', function () {
lastRow = null;
lastTarget = null;
});
function handleSidebarClick(e) { function handleSidebarClick(e) {
var target = e.domEvent.target; var target = e.domEvent.target;
var editor = e.editor; if (target.className.indexOf('ace_gutter-cell') === -1) return;
if (target.className.indexOf("ace_gutter-cell") == -1) return; var editor = e.editor;
var fileid = $(editor.container).data('file-id');
var row = e.getDocumentPosition().row; var row = e.getDocumentPosition().row;
e.stop(); e.stop();
$('.modal-title').text('<%= t('request_for_comments.modal_title') %>'.replace('${line}', row + 1));
var commentModal = $('#comment-modal'); var commentModal = $('#comment-modal');
if (hasCommentsInRow(editor, row)) { var otherComments = commentModal.find('#otherComments');
var rowComments = getCommentsForRow(editor, row); var htmlContent = generateCommentHtmlContent(getCommentsForRow(editor, row));
var comments = _.pluck(rowComments, 'text').join('\n'); if (htmlContent) {
commentModal.find('#otherComments').show(); otherComments.show();
commentModal.find('#otherCommentsTextfield').text(comments); var container = otherComments.find('.container');
container.html(htmlContent);
var deleteButtons = container.find('.action-delete');
deleteButtons.on('click', function (event) {
var button = $(event.target);
var parent = $(button).parent().parent();
var commentId = parent.data('comment-id');
deleteComment(commentId, editor, fileid, function () {
parent.html('<div class="comment-removed"><%= t('comments.deleted') %></div>');
});
});
var editButtons = container.find('.action-edit');
editButtons.on('click', function (event) {
var button = $(event.target);
var parent = $(button).parent().parent();
var commentId = parent.data('comment-id');
var currentlyEditing = button.data('editing');
var deleteButton = parent.find('.action-delete');
var commentContent = parent.find('.comment-content');
var commentEditor = parent.find('textarea.comment-editor');
var commentUpdated = parent.find('.comment-updated');
if (currentlyEditing) {
updateComment(commentId, commentEditor.val(), editor, fileid, function () {
button.text('<%= t('shared.edit') %>');
button.data('editing', false);
commentContent.text(commentEditor.val());
deleteButton.show();
commentContent.show();
commentEditor.hide();
commentUpdated.removeClass('hidden');
});
} else { } else {
commentModal.find('#otherComments').hide(); button.text('<%= t('comments.save_update') %>');
button.data('editing', true);
deleteButton.hide();
commentContent.hide();
commentEditor.show();
}
});
} else {
otherComments.hide();
} }
commentModal.find('#addCommentButton').off('click'); var subscribeCheckbox = commentModal.find('#subscribe');
commentModal.find('#removeAllButton').off('click'); subscribeCheckbox.prop('checked', subscribeCheckbox.data('subscription'));
subscribeCheckbox.off('change');
commentModal.find('#addCommentButton').on('click', function(e){ subscribeCheckbox.on('change', function() {
var commenttext = commentModal.find('textarea').val(); if (this.checked) {
var file_id = $(editor.container).data('file-id'); subscribeToRFC('author', $(this));
} else {
if (commenttext !== "") { unsubscribeFromRFC($(this));
createComment(file_id, row, editor, commenttext);
commentModal.find('textarea').val('') ;
commentModal.modal('hide');
} }
}); });
commentModal.find('#removeAllButton').on('click', function(e){ var addCommentButton = commentModal.find('#addCommentButton');
var file_id = $(editor.container).data('file-id'); addCommentButton.off('click');
deleteComment(file_id, row, editor); addCommentButton.on('click', function(){
var commentTextarea = commentModal.find('#myComment > textarea');
var commenttext = commentTextarea.val();
if (commenttext !== "") {
createComment(fileid, row, editor, commenttext);
commentTextarea.val('') ;
commentModal.modal('hide'); commentModal.modal('hide');
}
}); });
commentModal.modal('show'); commentModal.modal('show');
@ -351,19 +451,4 @@ also, all settings from the rails model needed for the editor configuration in t
}); });
} }
function stringDivider(str, width, spaceReplacer) {
if (str.length>width) {
var p=width;
for (;p>0 && str[p]!=' ';p--) {
}
if (p>0) {
var left = str.substring(0, p);
var right = str.substring(p+1);
return left + spaceReplacer + stringDivider(right, width, spaceReplacer);
}
}
return str;
}
</script> </script>

View File

@ -0,0 +1,7 @@
== t('mailers.user_mailer.got_new_comment_for_subscription.body',
receiver_displayname: @receiver_displayname, link_to_comment: link_to(@rfc_link, @rfc_link),
unsubscribe_link: link_to(@unsubscribe_link, @unsubscribe_link),
author_displayname: @author_displayname,
comment_text: @comment_text,
link_my_comments: link_to(t('request_for_comments.index.get_my_comment_requests'), my_request_for_comments_url),
link_all_comments: link_to(t('request_for_comments.index.all'), request_for_comments_url) )

View File

@ -159,6 +159,9 @@ de:
user_exercise_feedback: user_exercise_feedback:
one: Feedback one: Feedback
other: Feedback other: Feedback
comment:
one: Kommentar
other: Kommentare
errors: errors:
messages: messages:
together: 'muss zusammen mit %{attribute} definiert werden' together: 'muss zusammen mit %{attribute} definiert werden'
@ -449,13 +452,48 @@ de:
<br> <br>
This mail was automatically sent by CodeOcean. <br> This mail was automatically sent by CodeOcean. <br>
subject: "%{author} sagt Danke!" subject: "%{author} sagt Danke!"
got_new_comment_for_subscription:
body: |
English version below <br>
_________________________<br>
<br>
Hallo %{receiver_displayname}, <br>
<br>
es gibt einen neuen Kommentar von %{author_displayname} zu einer Kommentaranfrage auf CodeOcean, die Sie abonniert haben. <br>
<br>
%{author_displayname} schreibt: %{comment_text}<br>
<br>
Sie finden die Kommentaranfrage hier: %{link_to_comment} <br>
<br>
Falls Sie beim Klick auf diesen Link eine Fehlermeldung erhalten, dass Sie nicht berechtigt wären diese Aktion auszuführen, öffnen Sie bitte eine beliebige Programmieraufgabe aus einem Kurs heraus und klicken den Link danach noch einmal.<br>
<br>
Wenn Sie keine weiteren Benachrichtigungen zu dieser Anfrage erhalten möchten, klicken Sie bitte hier: %{unsubscribe_link}
<br>
Diese Mail wurde automatisch von CodeOcean verschickt.<br>
<br>
_________________________<br>
<br>
Dear %{receiver_displayname}, <br>
<br>
you received a new comment from %{author_displayname} to a request for comments on CodeOcean that you have subscribed to. <br>
<br>
%{author_displayname} wrote: %{comment_text} <br>
<br>
You can find the request for comments here: %{link_to_comment} <br>
<br>
If you receive an error that you are not authorized to perform this action when clicking the link, please log-in through any course exercise beforehand and click the link again. <br>
<br>
If you don't want to be notified about further comments, please click here: %{unsubscribe_link}
<br>
This mail was automatically sent by CodeOcean. <br>
subject: "%{author_displayname} hat einen neuen Kommentar in einer Diskussion veröffentlicht, die Sie abonniert haben."
request_for_comments: request_for_comments:
click_here: Zum Kommentieren auf die Seitenleiste klicken! click_here: Zum Kommentieren auf die Seitenleiste klicken!
comments: Kommentare comments: Kommentare
howto: | howto: |
Um Kommentare zu einer Programmzeile hinzuzufügen, kann einfach auf die jeweilige Zeilennummer auf der linken Seite geklickt werden. <br> Um Kommentare zu einer Programmzeile hinzuzufügen, kann einfach auf die jeweilige Zeilennummer auf der linken Seite geklickt werden. <br>
Es öffnet sich ein Textfeld, in dem der Kommentar eingetragen werden kann. <br> Es öffnet sich ein Textfeld, in dem der Kommentar eingetragen werden kann. <br>
Mit "Kommentieren" wird der Kommentar dann gesichert und taucht als Sprechblase neben der Zeile auf. Mit "Kommentar abschicken" wird der Kommentar dann gesichert und taucht als Sprechblase neben der Zeile auf.
howto_title: 'Anleitung:' howto_title: 'Anleitung:'
index: index:
get_my_comment_requests: Meine Kommentaranfragen get_my_comment_requests: Meine Kommentaranfragen
@ -473,6 +511,9 @@ de:
send_thank_you_note: "Senden" send_thank_you_note: "Senden"
cancel_thank_you_note: "Nichts senden" cancel_thank_you_note: "Nichts senden"
comment_edited: "bearbeitet" comment_edited: "bearbeitet"
modal_title: "Einen Kommentar in Zeile ${line} hinzufügen"
click_for_more_comments: "Klicken um ${numComments} weitere Kommentare zu sehen..."
subscribe_to_author: "Bei neuen Kommentaren des Autors per E-Mail benachrichtigt werden"
sessions: sessions:
create: create:
failure: Fehlerhafte E-Mail oder Passwort. failure: Fehlerhafte E-Mail oder Passwort.
@ -587,4 +628,10 @@ de:
estimated_time_20_to_30: "zwischen 20 und 30 Minuten" estimated_time_20_to_30: "zwischen 20 und 30 Minuten"
estimated_time_more_30: "mehr als 30 Minuten" estimated_time_more_30: "mehr als 30 Minuten"
working_time: "Geschätze Bearbeitungszeit für diese Aufgabe:" working_time: "Geschätze Bearbeitungszeit für diese Aufgabe:"
comments:
deleted: "Gelöscht"
save_update: "Speichern"
subscriptions:
successfully_unsubscribed: "Ihr Abonnement für weitere Kommentare auf dieser Kommentaranfrage wurde erfolgreich beendet."
subscription_not_existent: "Das Abonnement, von dem Sie sich abmelden wollen, existiert nicht."

View File

@ -159,6 +159,9 @@ en:
user_exercise_feedback: user_exercise_feedback:
one: Feedback one: Feedback
other: Feedback other: Feedback
comment:
one: Comment
other: Comments
errors: errors:
messages: messages:
together: 'has to be set along with %{attribute}' together: 'has to be set along with %{attribute}'
@ -449,6 +452,41 @@ en:
<br> <br>
This mail was automatically sent by CodeOcean. <br> This mail was automatically sent by CodeOcean. <br>
subject: "%{author} says thank you!" subject: "%{author} says thank you!"
got_new_comment_for_subscription:
body: |
English version below <br>
_________________________<br>
<br>
Hallo %{receiver_displayname}, <br>
<br>
es gibt einen neuen Kommentar von %{author_displayname} zu einer Kommentaranfrage auf CodeOcean, die Sie abonniert haben. <br>
<br>
%{author_displayname} schreibt: %{comment_text}<br>
<br>
Sie finden die Kommentaranfrage hier: %{link_to_comment} <br>
<br>
Falls Sie beim Klick auf diesen Link eine Fehlermeldung erhalten, dass Sie nicht berechtigt wären diese Aktion auszuführen, öffnen Sie bitte eine beliebige Programmieraufgabe aus einem Kurs heraus und klicken den Link danach noch einmal.<br>
<br>
Wenn Sie keine weiteren Benachrichtigungen zu dieser Anfrage erhalten möchten, klicken Sie bitte hier: %{unsubscribe_link}
<br>
Diese Mail wurde automatisch von CodeOcean verschickt.<br>
<br>
_________________________<br>
<br>
Dear %{receiver_displayname}, <br>
<br>
you received a new comment from %{author_displayname} to a request for comments on CodeOcean that you have subscribed to. <br>
<br>
%{author_displayname} wrote: %{comment_text} <br>
<br>
You can find the request for comments here: %{link_to_comment} <br>
<br>
If you receive an error that you are not authorized to perform this action when clicking the link, please log-in through any course exercise beforehand and click the link again. <br>
<br>
If you don't want to be notified about further comments, please click here: %{unsubscribe_link}
<br>
This mail was automatically sent by CodeOcean. <br>
subject: "%{author_displayname} has posted a new comment to a discussion you subscribed to on CodeOcean."
request_for_comments: request_for_comments:
click_here: Click on this sidebar to comment! click_here: Click on this sidebar to comment!
comments: Comments comments: Comments
@ -473,6 +511,9 @@ en:
send_thank_you_note: "Send" send_thank_you_note: "Send"
cancel_thank_you_note: "Don't send" cancel_thank_you_note: "Don't send"
comment_edited: "edited" comment_edited: "edited"
modal_title: "Add a comment to line ${line}"
click_for_more_comments: "Click to view ${numComments} more comments..."
subscribe_to_author: "Receive E-Mail notifications for new comments of the original author"
sessions: sessions:
create: create:
failure: Invalid email or password. failure: Invalid email or password.
@ -587,3 +628,9 @@ en:
estimated_time_20_to_30: "between 20 and 30 minutes" estimated_time_20_to_30: "between 20 and 30 minutes"
estimated_time_more_30: "more than 30 minutes" estimated_time_more_30: "more than 30 minutes"
working_time: "Estimated time working on this exercise:" working_time: "Estimated time working on this exercise:"
comments:
deleted: "Deleted"
save_update: "Save"
subscriptions:
successfully_unsubscribed: "You successfully unsubscribed from this Request for Comment"
subscription_not_existent: "The subscription you want to unsubscribe from does not exist."

View File

@ -14,17 +14,19 @@ Rails.application.routes.draw do
post :set_thank_you_note post :set_thank_you_note
end end
end end
resources :comments, except: [:destroy] do resources :comments
collection do
delete :destroy
end
end
get '/my_request_for_comments', as: 'my_request_for_comments', to: 'request_for_comments#get_my_comment_requests' get '/my_request_for_comments', as: 'my_request_for_comments', to: 'request_for_comments#get_my_comment_requests'
get '/my_rfc_activity', as: 'my_rfc_activity', to: 'request_for_comments#get_rfcs_with_my_comments' get '/my_rfc_activity', as: 'my_rfc_activity', to: 'request_for_comments#get_rfcs_with_my_comments'
delete '/comment_by_id', to: 'comments#destroy_by_id' delete '/comment_by_id', to: 'comments#destroy_by_id'
put '/comments', to: 'comments#update' put '/comments', to: 'comments#update'
resources :subscriptions do
member do
get :unsubscribe, to: 'subscriptions#destroy'
end
end
root to: 'application#welcome' root to: 'application#welcome'
namespace :admin do namespace :admin do

View File

@ -0,0 +1,11 @@
class CreateSubscriptions < ActiveRecord::Migration
def change
create_table :subscriptions do |t|
t.belongs_to :user, polymorphic: true
t.references :request_for_comment
t.string :type
t.timestamps null: false
end
end
end

View File

@ -0,0 +1,5 @@
class RenameSubscriptionType < ActiveRecord::Migration
def change
rename_column :subscriptions, :type, :subscription_type
end
end

View File

@ -11,7 +11,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20170608141612) do ActiveRecord::Schema.define(version: 20170913054203) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -281,6 +281,15 @@ ActiveRecord::Schema.define(version: 20170608141612) do
add_index "submissions", ["exercise_id"], name: "index_submissions_on_exercise_id", using: :btree add_index "submissions", ["exercise_id"], name: "index_submissions_on_exercise_id", using: :btree
add_index "submissions", ["user_id"], name: "index_submissions_on_user_id", using: :btree add_index "submissions", ["user_id"], name: "index_submissions_on_user_id", using: :btree
create_table "subscriptions", force: :cascade do |t|
t.integer "user_id"
t.string "user_type"
t.integer "request_for_comment_id"
t.string "subscription_type"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "tags", force: :cascade do |t| create_table "tags", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.datetime "created_at" t.datetime "created_at"

View File

@ -0,0 +1,7 @@
require 'test_helper'
class SubscriptionControllerTest < ActionController::TestCase
# test "the truth" do
# assert true
# end
end

View File

@ -0,0 +1,7 @@
FactoryGirl.define do
factory :subscription do
user nil
request_for_comments nil
type ""
end
end

View File

@ -0,0 +1,7 @@
require 'test_helper'
class SubscriptionTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,6 @@
/*! jQuery UI - v1.12.1 - 2017-09-05
* http://jqueryui.com
* Includes: draggable.css, core.css, resizable.css, selectable.css, sortable.css
* Copyright jQuery Foundation and other contributors; Licensed MIT */
.ui-draggable-handle{-ms-touch-action:none;touch-action:none}.ui-helper-hidden{display:none}.ui-helper-hidden-accessible{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.ui-helper-reset{margin:0;padding:0;border:0;outline:0;line-height:1.3;text-decoration:none;font-size:100%;list-style:none}.ui-helper-clearfix:before,.ui-helper-clearfix:after{content:"";display:table;border-collapse:collapse}.ui-helper-clearfix:after{clear:both}.ui-helper-zfix{width:100%;height:100%;top:0;left:0;position:absolute;opacity:0;filter:Alpha(Opacity=0)}.ui-front{z-index:100}.ui-state-disabled{cursor:default!important;pointer-events:none}.ui-icon{display:inline-block;vertical-align:middle;margin-top:-.25em;position:relative;text-indent:-99999px;overflow:hidden;background-repeat:no-repeat}.ui-widget-icon-block{left:50%;margin-left:-8px;display:block}.ui-widget-overlay{position:fixed;top:0;left:0;width:100%;height:100%}.ui-resizable{position:relative}.ui-resizable-handle{position:absolute;font-size:0.1px;display:block;-ms-touch-action:none;touch-action:none}.ui-resizable-disabled .ui-resizable-handle,.ui-resizable-autohide .ui-resizable-handle{display:none}.ui-resizable-n{cursor:n-resize;height:7px;width:100%;top:-5px;left:0}.ui-resizable-s{cursor:s-resize;height:7px;width:100%;bottom:-5px;left:0}.ui-resizable-e{cursor:e-resize;width:7px;right:-5px;top:0;height:100%}.ui-resizable-w{cursor:w-resize;width:7px;left:-5px;top:0;height:100%}.ui-resizable-se{cursor:se-resize;width:12px;height:12px;right:1px;bottom:1px}.ui-resizable-sw{cursor:sw-resize;width:9px;height:9px;left:-5px;bottom:-5px}.ui-resizable-nw{cursor:nw-resize;width:9px;height:9px;left:-5px;top:-5px}.ui-resizable-ne{cursor:ne-resize;width:9px;height:9px;right:-5px;top:-5px}.ui-selectable{-ms-touch-action:none;touch-action:none}.ui-selectable-helper{position:absolute;z-index:100;border:1px dotted black}.ui-sortable-handle{-ms-touch-action:none;touch-action:none}

View File

@ -0,0 +1,5 @@
/*! jQuery UI - v1.12.1 - 2017-09-05
* http://jqueryui.com
* Copyright jQuery Foundation and other contributors; Licensed MIT */
.ui-draggable-handle{-ms-touch-action:none;touch-action:none}.ui-helper-hidden{display:none}.ui-helper-hidden-accessible{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.ui-helper-reset{margin:0;padding:0;border:0;outline:0;line-height:1.3;text-decoration:none;font-size:100%;list-style:none}.ui-helper-clearfix:before,.ui-helper-clearfix:after{content:"";display:table;border-collapse:collapse}.ui-helper-clearfix:after{clear:both}.ui-helper-zfix{width:100%;height:100%;top:0;left:0;position:absolute;opacity:0;filter:Alpha(Opacity=0)}.ui-front{z-index:100}.ui-state-disabled{cursor:default!important;pointer-events:none}.ui-icon{display:inline-block;vertical-align:middle;margin-top:-.25em;position:relative;text-indent:-99999px;overflow:hidden;background-repeat:no-repeat}.ui-widget-icon-block{left:50%;margin-left:-8px;display:block}.ui-widget-overlay{position:fixed;top:0;left:0;width:100%;height:100%}.ui-resizable{position:relative}.ui-resizable-handle{position:absolute;font-size:0.1px;display:block;-ms-touch-action:none;touch-action:none}.ui-resizable-disabled .ui-resizable-handle,.ui-resizable-autohide .ui-resizable-handle{display:none}.ui-resizable-n{cursor:n-resize;height:7px;width:100%;top:-5px;left:0}.ui-resizable-s{cursor:s-resize;height:7px;width:100%;bottom:-5px;left:0}.ui-resizable-e{cursor:e-resize;width:7px;right:-5px;top:0;height:100%}.ui-resizable-w{cursor:w-resize;width:7px;left:-5px;top:0;height:100%}.ui-resizable-se{cursor:se-resize;width:12px;height:12px;right:1px;bottom:1px}.ui-resizable-sw{cursor:sw-resize;width:9px;height:9px;left:-5px;bottom:-5px}.ui-resizable-nw{cursor:nw-resize;width:9px;height:9px;left:-5px;top:-5px}.ui-resizable-ne{cursor:ne-resize;width:9px;height:9px;right:-5px;top:-5px}.ui-selectable{-ms-touch-action:none;touch-action:none}.ui-selectable-helper{position:absolute;z-index:100;border:1px dotted black}.ui-sortable-handle{-ms-touch-action:none;touch-action:none}