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 chosen.jquery.min
//= require jquery-ui.min
//= require d3
//= require jquery.turbolinks
//= require jquery_ujs

View File

@ -22,45 +22,132 @@
display: none !important;
}
p.comment {
width: 400px;
.modal-content {
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%;
overflow: hidden;
padding-bottom: 10px;
margin: auto;
min-width: 200px;
.comment-header {
width: 100%;
overflow: hidden;
padding-bottom: 10px;
margin: auto;
.comment-username {
font-weight: bold;
width: 60%;
float: left;
}
.comment-date {
text-align: right;
color: #008cba;
margin-left: 60%;
font-size: x-small;
}
.comment-updated {
text-align: right;
margin-left: 60%;
font-size: x-small;
}
}
.comment-content {
word-wrap: break-word;
margin-bottom: 10px;
}
.comment-editor {
display: none;
width: 100%;
height: auto;
background-color: inherit;
}
.comment-actions {
display: none;
}
}
.popover-username {
font-weight: bold;
width: 60%;
float: left;
}
.popover-date {
text-align: right;
color: #008cba;
margin-left: 60%;
font-size: x-small;
}
.popover-updated {
text-align: right;
margin-left: 60%;
font-size: x-small;
}
.popover-comment {
word-wrap: break-word;
margin-bottom: 10px;
}
.popover-divider {
.comment-divider {
width: 100%;
height: 1px;
background-color: #008cba;
overflow: hidden;
margin-top: 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
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
# skip_after_action :verify_authorized
@ -21,6 +21,7 @@ class CommentsController < ApplicationController
comment.username = comment.user.displayname
comment.date = comment.created_at.strftime('%d.%m.%Y %k:%M')
comment.updated = (comment.created_at != comment.updated_at)
comment.editable = comment.user == current_user
}
else
@comments = []
@ -50,12 +51,14 @@ class CommentsController < ApplicationController
def create
@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|
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.json { render :show, status: :created, location: @comment }
else
@ -83,7 +86,8 @@ class CommentsController < ApplicationController
# DELETE /comments/1
# DELETE /comments/1.json
def destroy_by_id
def destroy
authorize!
@comment.destroy
respond_to do |format|
format.html { head :no_content, notice: 'Comment was successfully destroyed.' }
@ -91,30 +95,42 @@ class CommentsController < ApplicationController
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
# Use callbacks to share common setup or constraints between actions.
def set_comment
@comment = Comment.find(params[:id])
end
# Use callbacks to share common setup or constraints between actions.
def set_comment
@comment = Comment.find(params[:id])
end
def comment_params_without_request_id
comment_params.except :request_id
end
# Never trust parameters from the scary internet, only allow the white list through.
def comment_params
#params.require(:comment).permit(:user_id, :file_id, :row, :column, :text)
# 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)
# Never trust parameters from the scary internet, only allow the white list through.
def comment_params
#params.require(:comment).permit(:user_id, :file_id, :row, :column, :text)
# 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)
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

View File

@ -68,11 +68,8 @@ class RequestForCommentsController < ApplicationController
def set_thank_you_note
authorize!
@request_for_comment.thank_you_note = params[:note]
commenters = []
@request_for_comment.comments.distinct.to_a.each {|comment|
commenters.append comment.user
}
commenters = commenters.uniq {|user| user.id}
commenters = @request_for_comment.commenters
commenters.each {|commenter| UserMailer.send_thank_you_note(@request_for_comment, commenter).deliver_now}
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)
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)
@receiver_displayname = receiver.displayname
@author = request_for_comments.user.displayname

View File

@ -1,7 +1,7 @@
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.
include Creation
attr_accessor :username, :date, :updated
attr_accessor :username, :date, :updated, :editable
belongs_to :file, class_name: 'CodeOcean::File'
belongs_to :user, polymorphic: true

View File

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

View File

@ -1,9 +1,11 @@
h5 =t('exercises.implement.comment.addyours')
textarea.form-control(style='resize:none;')
#otherComments
h5 =t('exercises.implement.comment.others')
pre#otherCommentsTextfield
p = ''
button#addCommentButton.btn.btn-block.btn-primary(type='button') =t('exercises.implement.comment.addCommentButton')
button#removeAllButton.btn.btn-block.btn-warning(type='button') =t('exercises.implement.comment.removeAllOnLine')
.container
label
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)
= 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">
$('.modal-content').draggable({
handle: '.modal-header'
}).resizable({
autoHide: true
});
var solvedButton = $('#mark-as-solved-button');
var commentOnExerciseButton = $('#comment-exercise-button');
var addCommentExerciseButton = $('#addCommentExerciseButton');
var thankYouContainer = $('#thank-you-container');
@ -138,102 +143,82 @@ also, all settings from the rails model needed for the editor configuration in t
currentEditor.getSession().setMode($(editor).data('mode'));
currentEditor.getSession().setOption("useWorker", false);
currentEditor.commentVisualsByLine = {};
setAnnotations(currentEditor, $(editor).data('file-id'));
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) {
// sanitize comments to deal with XSS attacks:
commentText = $('div.sanitizer').text(commentText).html();
// display original line breaks:
return commentText.replace(/\n/g, '<br>');
}
function preprocess(commentText) {
// sanitize comments to deal with XSS attacks:
commentText = $('div.sanitizer').text(commentText).html();
// display original line breaks:
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) {
var session = editor.getSession();
var jqrequest = $.ajax({
dataType: 'json',
method: 'GET',
url: '/comments',
data: {
file_id: fileid
}
dataType: 'json',
method: 'GET',
url: '/comments',
data: {
file_id: fileid
}
});
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) {
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);
})
}
function hasCommentsInRow(editor, row){
return editor.getSession().getAnnotations().some(function(element) {
return element.row === 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) {
cleanupPopovers();
function deleteComment(commentId, editor, file_id, callback) {
var jqxhr = $.ajax({
type: 'DELETE',
url: "/comments",
data: {
row: row,
file_id: file_id }
url: "/comments/" + commentId
});
jqxhr.done(function (response) {
jqxhr.done(function () {
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);
}
function createComment(file_id, row, editor, commenttext){
cleanupPopovers();
var jqxhr = $.ajax({
data: {
comment: {
@ -273,71 +271,173 @@ also, all settings from the rails model needed for the editor configuration in t
method: 'POST',
url: "/comments"
});
jqxhr.done(function(response){
jqxhr.done(function(){
setAnnotations(editor, file_id);
});
jqxhr.fail(ajaxError);
}
function createCommentOnExercise(file_id, row, editor, commenttext){
var jqxhr = $.ajax({
data: {
comment: {
file_id: file_id,
row: row,
column: 0,
text: commenttext,
request_id: $('h4#exercise_caption').data('rfc-id')
}
},
dataType: 'json',
method: 'POST',
url: "/comments"
});
jqxhr.done(function(response){
setAnnotations(editor, file_id);
});
jqxhr.fail(ajaxError);
function subscribeToRFC(subscriptionType, checkbox){
checkbox.attr("disabled", true);
var jqxhr = $.ajax({
data: {
subscription: {
request_for_comment_id: $('h4#exercise_caption').data('rfc-id'),
subscription_type: subscriptionType
}
},
dataType: 'json',
method: 'POST',
url: "/subscriptions.json"
});
jqxhr.done(function(subscription) {
checkbox.data('subscription', subscription.id);
checkbox.attr("disabled", false);
});
jqxhr.fail(function(response) {
checkbox.prop('checked', false);
checkbox.attr("disabled", false);
ajaxError(response);
});
}
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) {
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;
e.stop();
$('.modal-title').text('<%= t('request_for_comments.modal_title') %>'.replace('${line}', row + 1));
var commentModal = $('#comment-modal');
if (hasCommentsInRow(editor, row)) {
var rowComments = getCommentsForRow(editor, row);
var comments = _.pluck(rowComments, 'text').join('\n');
commentModal.find('#otherComments').show();
commentModal.find('#otherCommentsTextfield').text(comments);
var otherComments = commentModal.find('#otherComments');
var htmlContent = generateCommentHtmlContent(getCommentsForRow(editor, row));
if (htmlContent) {
otherComments.show();
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 {
button.text('<%= t('comments.save_update') %>');
button.data('editing', true);
deleteButton.hide();
commentContent.hide();
commentEditor.show();
}
});
} else {
commentModal.find('#otherComments').hide();
otherComments.hide();
}
commentModal.find('#addCommentButton').off('click');
commentModal.find('#removeAllButton').off('click');
commentModal.find('#addCommentButton').on('click', function(e){
var commenttext = commentModal.find('textarea').val();
var file_id = $(editor.container).data('file-id');
if (commenttext !== "") {
createComment(file_id, row, editor, commenttext);
commentModal.find('textarea').val('') ;
commentModal.modal('hide');
var subscribeCheckbox = commentModal.find('#subscribe');
subscribeCheckbox.prop('checked', subscribeCheckbox.data('subscription'));
subscribeCheckbox.off('change');
subscribeCheckbox.on('change', function() {
if (this.checked) {
subscribeToRFC('author', $(this));
} else {
unsubscribeFromRFC($(this));
}
});
commentModal.find('#removeAllButton').on('click', function(e){
var file_id = $(editor.container).data('file-id');
deleteComment(file_id, row, editor);
commentModal.modal('hide');
var addCommentButton = commentModal.find('#addCommentButton');
addCommentButton.off('click');
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('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>

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:
one: Feedback
other: Feedback
comment:
one: Kommentar
other: Kommentare
errors:
messages:
together: 'muss zusammen mit %{attribute} definiert werden'
@ -449,13 +452,48 @@ de:
<br>
This mail was automatically sent by CodeOcean. <br>
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:
click_here: Zum Kommentieren auf die Seitenleiste klicken!
comments: Kommentare
howto: |
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>
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:'
index:
get_my_comment_requests: Meine Kommentaranfragen
@ -473,6 +511,9 @@ de:
send_thank_you_note: "Senden"
cancel_thank_you_note: "Nichts senden"
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:
create:
failure: Fehlerhafte E-Mail oder Passwort.
@ -587,4 +628,10 @@ de:
estimated_time_20_to_30: "zwischen 20 und 30 Minuten"
estimated_time_more_30: "mehr als 30 Minuten"
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:
one: Feedback
other: Feedback
comment:
one: Comment
other: Comments
errors:
messages:
together: 'has to be set along with %{attribute}'
@ -449,6 +452,41 @@ en:
<br>
This mail was automatically sent by CodeOcean. <br>
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:
click_here: Click on this sidebar to comment!
comments: Comments
@ -473,6 +511,9 @@ en:
send_thank_you_note: "Send"
cancel_thank_you_note: "Don't send"
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:
create:
failure: Invalid email or password.
@ -587,3 +628,9 @@ en:
estimated_time_20_to_30: "between 20 and 30 minutes"
estimated_time_more_30: "more than 30 minutes"
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
end
end
resources :comments, except: [:destroy] do
collection do
delete :destroy
end
end
resources :comments
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'
delete '/comment_by_id', to: 'comments#destroy_by_id'
put '/comments', to: 'comments#update'
resources :subscriptions do
member do
get :unsubscribe, to: 'subscriptions#destroy'
end
end
root to: 'application#welcome'
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.
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
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", ["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|
t.string "name", null: false
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}