Commit 6ddacaf9bfe02378e610ce277cd901097dcf6fdd

Authored by Dmitriy Zaporozhets
2 parents d8e697ac ac983319

Merge branch 'riyad-discussions'

Showing 73 changed files with 2253 additions and 859 deletions   Show diff stats
app/assets/images/comment_add.png

781 Bytes

app/assets/images/diff_note_add.png 0 → 100644

691 Bytes

app/assets/javascripts/behaviors/details_behavior.coffee 0 → 100644
... ... @@ -0,0 +1,5 @@
  1 +$ ->
  2 + $("body").on "click", ".js-details-target", ->
  3 + container = $(@).closest(".js-details-container")
  4 +
  5 + container.toggleClass("open")
... ...
app/assets/javascripts/behaviors/toggler_behavior.coffee 0 → 100644
... ... @@ -0,0 +1,5 @@
  1 +$ ->
  2 + $("body").on "click", ".js-toggler-target", ->
  3 + container = $(@).closest(".js-toggler-container")
  4 +
  5 + container.toggleClass("on")
... ...
app/assets/javascripts/extensions/array.js 0 → 100644
... ... @@ -0,0 +1,7 @@
  1 +Array.prototype.first = function() {
  2 + return this[0];
  3 +}
  4 +
  5 +Array.prototype.last = function() {
  6 + return this[this.length-1];
  7 +}
0 8 \ No newline at end of file
... ...
app/assets/javascripts/notes.js
... ... @@ -9,72 +9,315 @@ var NoteList = {
9 9 loading_more_disabled: false,
10 10 reversed: false,
11 11  
12   - init:
13   - function(tid, tt, path) {
14   - this.notes_path = path + ".js";
15   - this.target_id = tid;
16   - this.target_type = tt;
17   - this.reversed = $("#notes-list").is(".reversed");
18   - this.target_params = "target_type=" + this.target_type + "&target_id=" + this.target_id;
19   -
20   - // get initial set of notes
21   - this.getContent();
22   -
23   - $("#notes-list, #new-notes-list").on("ajax:success", ".delete-note", function() {
24   - $(this).closest('li').fadeOut(function() {
25   - $(this).remove();
26   - NoteList.updateVotes();
27   - });
  12 + init: function(tid, tt, path) {
  13 + NoteList.notes_path = path + ".js";
  14 + NoteList.target_id = tid;
  15 + NoteList.target_type = tt;
  16 + NoteList.reversed = $("#notes-list").is(".reversed");
  17 + NoteList.target_params = "target_type=" + NoteList.target_type + "&target_id=" + NoteList.target_id;
  18 +
  19 + NoteList.setupMainTargetNoteForm();
  20 +
  21 + if(NoteList.reversed) {
  22 + var form = $(".js-main-target-form");
  23 + form.find(".buttons, .note_options").hide();
  24 + var textarea = form.find(".js-note-text");
  25 + textarea.css("height", "40px");
  26 + textarea.on("focus", function(){
  27 + textarea.css("height", "80px");
  28 + form.find(".buttons, .note_options").show();
28 29 });
  30 + }
29 31  
30   - $(".note-form-holder").on("ajax:before", function(){
31   - $(".submit_note").disable();
32   - })
  32 + // get initial set of notes
  33 + NoteList.getContent();
33 34  
34   - $(".note-form-holder").on("ajax:complete", function(){
35   - $(".submit_note").enable();
36   - $('#preview-note').hide();
37   - $('#note_note').show();
38   - })
  35 + // add a new diff note
  36 + $(document).on("click",
  37 + ".js-add-diff-note-button",
  38 + NoteList.addDiffNote);
39 39  
40   - disableButtonIfEmptyField(".note-text", ".submit_note");
  40 + // reply to diff/discussion notes
  41 + $(document).on("click",
  42 + ".js-discussion-reply-button",
  43 + NoteList.replyToDiscussionNote);
41 44  
42   - $("#note_attachment").change(function(e){
43   - var val = $('.input-file').val();
44   - var filename = val.replace(/^.*[\\\/]/, '');
45   - $(".file_name").text(filename);
46   - });
  45 + // setup note preview
  46 + $(document).on("click",
  47 + ".js-note-preview-button",
  48 + NoteList.previewNote);
  49 +
  50 + // update the file name when an attachment is selected
  51 + $(document).on("change",
  52 + ".js-note-attachment-input",
  53 + NoteList.updateFormAttachment);
  54 +
  55 + // hide diff note form
  56 + $(document).on("click",
  57 + ".js-close-discussion-note-form",
  58 + NoteList.removeDiscussionNoteForm);
  59 +
  60 + // remove a note (in general)
  61 + $(document).on("click",
  62 + ".js-note-delete",
  63 + NoteList.removeNote);
  64 +
  65 + // reset main target form after submit
  66 + $(document).on("ajax:complete",
  67 + ".js-main-target-form",
  68 + NoteList.resetMainTargetForm);
  69 +
  70 +
  71 + $(document).on("click",
  72 + ".js-choose-note-attachment-button",
  73 + NoteList.chooseNoteAttachment);
  74 +
  75 + $(document).on("click",
  76 + ".js-show-outdated-discussion",
  77 + function(e) { $(this).next('.outdated-discussion').show(); e.preventDefault() });
  78 + },
  79 +
  80 +
  81 + /**
  82 + * When clicking on buttons
  83 + */
  84 +
  85 + /**
  86 + * Called when clicking on the "add a comment" button on the side of a diff line.
  87 + *
  88 + * Inserts a temporary row for the form below the line.
  89 + * Sets up the form and shows it.
  90 + */
  91 + addDiffNote: function(e) {
  92 + e.preventDefault();
  93 +
  94 + // find the form
  95 + var form = $(".js-new-note-form");
  96 + var row = $(this).closest("tr");
  97 + var nextRow = row.next();
  98 +
  99 + // does it already have notes?
  100 + if (nextRow.is(".notes_holder")) {
  101 + $.proxy(NoteList.replyToDiscussionNote,
  102 + nextRow.find(".js-discussion-reply-button")
  103 + ).call();
  104 + } else {
  105 + // add a notes row and insert the form
  106 + row.after('<tr class="notes_holder js-temp-notes-holder"><td class="notes_line" colspan="2"></td><td class="notes_content"></td></tr>');
  107 + form.clone().appendTo(row.next().find(".notes_content"));
  108 +
  109 + // show the form
  110 + NoteList.setupDiscussionNoteForm($(this), row.next().find("form"));
  111 + }
  112 + },
  113 +
  114 + /**
  115 + * Called when clicking the "Choose File" button.
  116 + *
  117 + * Opesn the file selection dialog.
  118 + */
  119 + chooseNoteAttachment: function() {
  120 + var form = $(this).closest("form");
47 121  
48   - if(this.reversed) {
49   - var textarea = $(".note-text");
50   - $('.note_advanced_opts').hide();
51   - textarea.css("height", "40px");
52   - textarea.on("focus", function(){
53   - $(this).css("height", "80px");
54   - $('.note_advanced_opts').show();
  122 + form.find(".js-note-attachment-input").click();
  123 + },
  124 +
  125 + /**
  126 + * Shows the note preview.
  127 + *
  128 + * Lets the server render GFM into Html and displays it.
  129 + *
  130 + * Note: uses the Toggler behavior to toggle preview/edit views/buttons
  131 + */
  132 + previewNote: function(e) {
  133 + e.preventDefault();
  134 +
  135 + var form = $(this).closest("form");
  136 + var preview = form.find('.js-note-preview');
  137 + var noteText = form.find('.js-note-text').val();
  138 +
  139 + if(noteText.trim().length === 0) {
  140 + preview.text('Nothing to preview.');
  141 + } else {
  142 + preview.text('Loading...');
  143 + $.post($(this).data('url'), {note: noteText})
  144 + .success(function(previewData) {
  145 + preview.html(previewData);
55 146 });
56   - }
  147 + }
  148 + },
  149 +
  150 + /**
  151 + * Called in response to "cancel" on a diff note form.
  152 + *
  153 + * Shows the reply button again.
  154 + * Removes the form and if necessary it's temporary row.
  155 + */
  156 + removeDiscussionNoteForm: function() {
  157 + var form = $(this).closest("form");
  158 + var row = form.closest("tr");
  159 +
  160 + // show the reply button (will only work for replys)
  161 + form.prev(".js-discussion-reply-button").show();
  162 +
  163 + if (row.is(".js-temp-notes-holder")) {
  164 + // remove temporary row for diff lines
  165 + row.remove();
  166 + } else {
  167 + // only remove the form
  168 + form.remove();
  169 + }
  170 + },
57 171  
58   - // Setup note preview
59   - $(document).on('click', '#preview-link', function(e) {
60   - $('#preview-note').text('Loading...');
  172 + /**
  173 + * Called in response to deleting a note of any kind.
  174 + *
  175 + * Removes the actual note from view.
  176 + * Removes the whole discussion if the last note is being removed.
  177 + */
  178 + removeNote: function() {
  179 + var note = $(this).closest(".note");
  180 + var notes = note.closest(".notes");
61 181  
62   - $(this).text($(this).text() === "Edit" ? "Preview" : "Edit");
  182 + // check if this is the last note for this line
  183 + if (notes.find(".note").length === 1) {
  184 + // for discussions
  185 + notes.closest(".discussion").remove();
63 186  
64   - var note_text = $('#note_note').val();
  187 + // for diff lines
  188 + notes.closest("tr").remove();
  189 + }
65 190  
66   - if(note_text.trim().length === 0) {
67   - $('#preview-note').text('Nothing to preview.');
68   - } else {
69   - $.post($(this).attr('href'), {note: note_text}).success(function(data) {
70   - $('#preview-note').html(data);
71   - });
72   - }
  191 + note.remove();
  192 + NoteList.updateVotes();
  193 + },
73 194  
74   - $('#preview-note, #note_note').toggle();
75   - e.preventDefault();
76   - });
77   - },
  195 + /**
  196 + * Called when clicking on the "reply" button for a diff line.
  197 + *
  198 + * Shows the note form below the notes.
  199 + */
  200 + replyToDiscussionNote: function() {
  201 + // find the form
  202 + var form = $(".js-new-note-form");
  203 +
  204 + // hide reply button
  205 + $(this).hide();
  206 + // insert the form after the button
  207 + form.clone().insertAfter($(this));
  208 +
  209 + // show the form
  210 + NoteList.setupDiscussionNoteForm($(this), $(this).next("form"));
  211 + },
  212 +
  213 +
  214 + /**
  215 + * Helper for inserting and setting up note forms.
  216 + */
  217 +
  218 +
  219 + /**
  220 + * Called in response to creating a note failing validation.
  221 + *
  222 + * Adds the rendered errors to the respective form.
  223 + * If "discussionId" is null or undefined, the main target form is assumed.
  224 + */
  225 + errorsOnForm: function(errorsHtml, discussionId) {
  226 + // find the form
  227 + if (discussionId) {
  228 + var form = $("form[rel='"+discussionId+"']");
  229 + } else {
  230 + var form = $(".js-main-target-form");
  231 + }
  232 +
  233 + form.find(".js-errors").remove();
  234 + form.prepend(errorsHtml);
  235 +
  236 + form.find(".js-note-text").focus();
  237 + },
  238 +
  239 +
  240 + /**
  241 + * Shows the diff/discussion form and does some setup on it.
  242 + *
  243 + * Sets some hidden fields in the form.
  244 + *
  245 + * Note: dataHolder must have the "discussionId", "lineCode", "noteableType"
  246 + * and "noteableId" data attributes set.
  247 + */
  248 + setupDiscussionNoteForm: function(dataHolder, form) {
  249 + // setup note target
  250 + form.attr("rel", dataHolder.data("discussionId"));
  251 + form.find("#note_commit_id").val(dataHolder.data("commitId"));
  252 + form.find("#note_line_code").val(dataHolder.data("lineCode"));
  253 + form.find("#note_noteable_type").val(dataHolder.data("noteableType"));
  254 + form.find("#note_noteable_id").val(dataHolder.data("noteableId"));
  255 +
  256 + NoteList.setupNoteForm(form);
  257 +
  258 + form.find(".js-note-text").focus();
  259 + },
  260 +
  261 + /**
  262 + * Shows the main form and does some setup on it.
  263 + *
  264 + * Sets some hidden fields in the form.
  265 + */
  266 + setupMainTargetNoteForm: function() {
  267 + // find the form
  268 + var form = $(".js-new-note-form");
  269 + // insert the form after the button
  270 + form.clone().replaceAll($(".js-main-target-form"));
  271 +
  272 + form = form.prev("form");
  273 +
  274 + // show the form
  275 + NoteList.setupNoteForm(form);
  276 +
  277 + // fix classes
  278 + form.removeClass("js-new-note-form");
  279 + form.addClass("js-main-target-form");
  280 +
  281 + // remove unnecessary fields and buttons
  282 + form.find("#note_line_code").remove();
  283 + form.find(".js-close-discussion-note-form").remove();
  284 + },
  285 +
  286 + /**
  287 + * General note form setup.
  288 + *
  289 + * * deactivates the submit button when text is empty
  290 + * * hides the preview button when text is empty
  291 + * * setup GFM auto complete
  292 + * * show the form
  293 + */
  294 + setupNoteForm: function(form) {
  295 + disableButtonIfEmptyField(form.find(".js-note-text"), form.find(".js-comment-button"));
  296 +
  297 + form.removeClass("js-new-note-form");
  298 +
  299 + // setup preview buttons
  300 + form.find(".js-note-edit-button, .js-note-preview-button")
  301 + .tooltip({ placement: 'left' });
  302 +
  303 + previewButton = form.find(".js-note-preview-button");
  304 + form.find(".js-note-text").on("input", function() {
  305 + if ($(this).val().trim() !== "") {
  306 + previewButton.removeClass("turn-off").addClass("turn-on");
  307 + } else {
  308 + previewButton.removeClass("turn-on").addClass("turn-off");
  309 + }
  310 + });
  311 +
  312 + // remove notify commit author checkbox for non-commit notes
  313 + if (form.find("#note_noteable_type").val() !== "Commit") {
  314 + form.find(".js-notify-commit-author").remove();
  315 + }
  316 +
  317 + GitLab.GfmAutoComplete.setup();
  318 +
  319 + form.show();
  320 + },
78 321  
79 322  
80 323 /**
... ... @@ -86,40 +329,39 @@ var NoteList = {
86 329 /**
87 330 * Gets an inital set of notes.
88 331 */
89   - getContent:
90   - function() {
91   - $.ajax({
92   - type: "GET",
93   - url: this.notes_path,
94   - data: this.target_params,
95   - complete: function(){ $('.notes-status').removeClass("loading")},
96   - beforeSend: function() { $('.notes-status').addClass("loading") },
97   - dataType: "script"});
98   - },
  332 + getContent: function() {
  333 + $.ajax({
  334 + url: NoteList.notes_path,
  335 + data: NoteList.target_params,
  336 + complete: function(){ $('.js-notes-busy').removeClass("loading")},
  337 + beforeSend: function() { $('.js-notes-busy').addClass("loading") },
  338 + dataType: "script"
  339 + });
  340 + },
99 341  
100 342 /**
101 343 * Called in response to getContent().
102 344 * Replaces the content of #notes-list with the given html.
103 345 */
104   - setContent:
105   - function(first_id, last_id, html) {
106   - this.top_id = first_id;
107   - this.bottom_id = last_id;
108   - $("#notes-list").html(html);
  346 + setContent: function(newNoteIds, html) {
  347 + NoteList.top_id = newNoteIds.first();
  348 + NoteList.bottom_id = newNoteIds.last();
  349 + $("#notes-list").html(html);
109 350  
  351 + // for the wall
  352 + if (NoteList.reversed) {
110 353 // init infinite scrolling
111   - this.initLoadMore();
  354 + NoteList.initLoadMore();
112 355  
113 356 // init getting new notes
114   - if (this.reversed) {
115   - this.initRefreshNew();
116   - }
117   - },
  357 + NoteList.initRefreshNew();
  358 + }
  359 + },
118 360  
119 361  
120 362 /**
121 363 * Handle loading more notes when scrolling to the bottom of the page.
122   - * The id of the last note in the list is in this.bottom_id.
  364 + * The id of the last note in the list is in NoteList.bottom_id.
123 365 *
124 366 * Set up refreshing only new notes after all notes have been loaded.
125 367 */
... ... @@ -128,65 +370,57 @@ var NoteList = {
128 370 /**
129 371 * Initializes loading more notes when scrolling to the bottom of the page.
130 372 */
131   - initLoadMore:
132   - function() {
133   - $(document).endlessScroll({
134   - bottomPixels: 400,
135   - fireDelay: 1000,
136   - fireOnce:true,
137   - ceaseFire: function() {
138   - return NoteList.loading_more_disabled;
139   - },
140   - callback: function(i) {
141   - NoteList.getMore();
142   - }
143   - });
144   - },
  373 + initLoadMore: function() {
  374 + $(document).endlessScroll({
  375 + bottomPixels: 400,
  376 + fireDelay: 1000,
  377 + fireOnce:true,
  378 + ceaseFire: function() {
  379 + return NoteList.loading_more_disabled;
  380 + },
  381 + callback: function(i) {
  382 + NoteList.getMore();
  383 + }
  384 + });
  385 + },
145 386  
146 387 /**
147 388 * Gets an additional set of notes.
148 389 */
149   - getMore:
150   - function() {
151   - // only load more notes if there are no "new" notes
152   - $('.loading').show();
153   - $.ajax({
154   - type: "GET",
155   - url: this.notes_path,
156   - data: this.target_params + "&loading_more=1&" + (this.reversed ? "before_id" : "after_id") + "=" + this.bottom_id,
157   - complete: function(){ $('.notes-status').removeClass("loading")},
158   - beforeSend: function() { $('.notes-status').addClass("loading") },
159   - dataType: "script"});
160   - },
  390 + getMore: function() {
  391 + // only load more notes if there are no "new" notes
  392 + $('.loading').show();
  393 + $.ajax({
  394 + url: NoteList.notes_path,
  395 + data: NoteList.target_params + "&loading_more=1&" + (NoteList.reversed ? "before_id" : "after_id") + "=" + NoteList.bottom_id,
  396 + complete: function(){ $('.js-notes-busy').removeClass("loading")},
  397 + beforeSend: function() { $('.js-notes-busy').addClass("loading") },
  398 + dataType: "script"
  399 + });
  400 + },
161 401  
162 402 /**
163 403 * Called in response to getMore().
164 404 * Append notes to #notes-list.
165 405 */
166   - appendMoreNotes:
167   - function(id, html) {
168   - if(id != this.bottom_id) {
169   - this.bottom_id = id;
170   - $("#notes-list").append(html);
171   - }
172   - },
  406 + appendMoreNotes: function(newNoteIds, html) {
  407 + var lastNewNoteId = newNoteIds.last();
  408 + if(lastNewNoteId != NoteList.bottom_id) {
  409 + NoteList.bottom_id = lastNewNoteId;
  410 + $("#notes-list").append(html);
  411 + }
  412 + },
173 413  
174 414 /**
175 415 * Called in response to getMore().
176 416 * Disables loading more notes when scrolling to the bottom of the page.
177   - * Initalizes refreshing new notes.
178 417 */
179   - finishedLoadingMore:
180   - function() {
181   - this.loading_more_disabled = true;
  418 + finishedLoadingMore: function() {
  419 + NoteList.loading_more_disabled = true;
182 420  
183   - // from now on only get new notes
184   - if (!this.reversed) {
185   - this.initRefreshNew();
186   - }
187   - // make sure we are up to date
188   - this.updateVotes();
189   - },
  421 + // make sure we are up to date
  422 + NoteList.updateVotes();
  423 + },
190 424  
191 425  
192 426 /**
... ... @@ -194,52 +428,118 @@ var NoteList = {
194 428 *
195 429 * New notes are all notes that are created after the site has been loaded.
196 430 * The "old" notes are in #notes-list the "new" ones will be in #new-notes-list.
197   - * The id of the last "old" note is in this.bottom_id.
  431 + * The id of the last "old" note is in NoteList.bottom_id.
198 432 */
199 433  
200 434  
201 435 /**
202 436 * Initializes getting new notes every n seconds.
  437 + *
  438 + * Note: only used on wall.
203 439 */
204   - initRefreshNew:
205   - function() {
206   - setInterval("NoteList.getNew()", 10000);
207   - },
  440 + initRefreshNew: function() {
  441 + setInterval("NoteList.getNew()", 10000);
  442 + },
208 443  
209 444 /**
210 445 * Gets the new set of notes.
  446 + *
  447 + * Note: only used on wall.
211 448 */
212   - getNew:
213   - function() {
214   - $.ajax({
215   - type: "GET",
216   - url: this.notes_path,
217   - data: this.target_params + "&loading_new=1&after_id=" + (this.reversed ? this.top_id : this.bottom_id),
218   - dataType: "script"});
219   - },
  449 + getNew: function() {
  450 + $.ajax({
  451 + url: NoteList.notes_path,
  452 + data: NoteList.target_params + "&loading_new=1&after_id=" + (NoteList.reversed ? NoteList.top_id : NoteList.bottom_id),
  453 + dataType: "script"
  454 + });
  455 + },
220 456  
221 457 /**
222 458 * Called in response to getNew().
223 459 * Replaces the content of #new-notes-list with the given html.
  460 + *
  461 + * Note: only used on wall.
224 462 */
225   - replaceNewNotes:
226   - function(html) {
227   - $("#new-notes-list").html(html);
228   - this.updateVotes();
229   - },
  463 + replaceNewNotes: function(newNoteIds, html) {
  464 + $("#new-notes-list").html(html);
  465 + NoteList.updateVotes();
  466 + },
230 467  
231 468 /**
232   - * Adds a single note to #new-notes-list.
  469 + * Adds a single common note to #notes-list.
233 470 */
234   - appendNewNote:
235   - function(id, html) {
236   - if (this.reversed) {
237   - $("#new-notes-list").prepend(html);
238   - } else {
239   - $("#new-notes-list").append(html);
240   - }
241   - this.updateVotes();
242   - },
  471 + appendNewNote: function(id, html) {
  472 + $("#notes-list").append(html);
  473 + NoteList.updateVotes();
  474 + },
  475 +
  476 + /**
  477 + * Adds a single discussion note to #notes-list.
  478 + *
  479 + * Also removes the corresponding form.
  480 + */
  481 + appendNewDiscussionNote: function(discussionId, diffRowHtml, noteHtml) {
  482 + var form = $("form[rel='"+discussionId+"']");
  483 + var row = form.closest("tr");
  484 +
  485 + // is this the first note of discussion?
  486 + if (row.is(".js-temp-notes-holder")) {
  487 + // insert the note and the reply button after the temp row
  488 + row.after(diffRowHtml);
  489 + // remove the note (will be added again below)
  490 + row.next().find(".note").remove();
  491 + }
  492 +
  493 + // append new note to all matching discussions
  494 + $(".notes[rel='"+discussionId+"']").append(noteHtml);
  495 +
  496 + // cleanup after successfully creating a diff/discussion note
  497 + $.proxy(NoteList.removeDiscussionNoteForm, form).call();
  498 + },
  499 +
  500 + /**
  501 + * Adds a single wall note to #new-notes-list.
  502 + *
  503 + * Note: only used on wall.
  504 + */
  505 + appendNewWallNote: function(id, html) {
  506 + $("#new-notes-list").prepend(html);
  507 + },
  508 +
  509 + /**
  510 + * Called in response the main target form has been successfully submitted.
  511 + *
  512 + * Removes any errors.
  513 + * Resets text and preview.
  514 + * Resets buttons.
  515 + */
  516 + resetMainTargetForm: function(){
  517 + var form = $(this);
  518 +
  519 + // remove validation errors
  520 + form.find(".js-errors").remove();
  521 +
  522 + // reset text and preview
  523 + var previewContainer = form.find(".js-toggler-container.note_text_and_preview");
  524 + if (previewContainer.is(".on")) {
  525 + previewContainer.removeClass("on");
  526 + }
  527 + form.find(".js-note-text").val("").trigger("input");
  528 + },
  529 +
  530 + /**
  531 + * Called after an attachment file has been selected.
  532 + *
  533 + * Updates the file name for the selected attachment.
  534 + */
  535 + updateFormAttachment: function() {
  536 + var form = $(this).closest("form");
  537 +
  538 + // get only the basename
  539 + var filename = $(this).val().replace(/^.*[\\\/]/, '');
  540 +
  541 + form.find(".js-attachment-filename").text(filename);
  542 + },
243 543  
244 544 /**
245 545 * Recalculates the votes and updates them (if they are displayed at all).
... ... @@ -249,67 +549,24 @@ var NoteList = {
249 549 * Might produce inaccurate results when not all notes have been loaded and a
250 550 * recalculation is triggered (e.g. when deleting a note).
251 551 */
252   - updateVotes:
253   - function() {
254   - var votes = $("#votes .votes");
255   - var notes = $("#notes-list, #new-notes-list").find(".note .vote");
256   -
257   - // only update if there is a vote display
258   - if (votes.size()) {
259   - var upvotes = notes.filter(".upvote").size();
260   - var downvotes = notes.filter(".downvote").size();
261   - var votesCount = upvotes + downvotes;
262   - var upvotesPercent = votesCount ? (100.0 / votesCount * upvotes) : 0;
263   - var downvotesPercent = votesCount ? (100.0 - upvotesPercent) : 0;
264   -
265   - // change vote bar lengths
266   - votes.find(".bar-success").css("width", upvotesPercent+"%");
267   - votes.find(".bar-danger").css("width", downvotesPercent+"%");
268   - // replace vote numbers
269   - votes.find(".upvotes").text(votes.find(".upvotes").text().replace(/\d+/, upvotes));
270   - votes.find(".downvotes").text(votes.find(".downvotes").text().replace(/\d+/, downvotes));
271   - }
  552 + updateVotes: function() {
  553 + var votes = $("#votes .votes");
  554 + var notes = $("#notes-list .note .vote");
  555 +
  556 + // only update if there is a vote display
  557 + if (votes.size()) {
  558 + var upvotes = notes.filter(".upvote").size();
  559 + var downvotes = notes.filter(".downvote").size();
  560 + var votesCount = upvotes + downvotes;
  561 + var upvotesPercent = votesCount ? (100.0 / votesCount * upvotes) : 0;
  562 + var downvotesPercent = votesCount ? (100.0 - upvotesPercent) : 0;
  563 +
  564 + // change vote bar lengths
  565 + votes.find(".bar-success").css("width", upvotesPercent+"%");
  566 + votes.find(".bar-danger").css("width", downvotesPercent+"%");
  567 + // replace vote numbers
  568 + votes.find(".upvotes").text(votes.find(".upvotes").text().replace(/\d+/, upvotes));
  569 + votes.find(".downvotes").text(votes.find(".downvotes").text().replace(/\d+/, downvotes));
272 570 }
  571 + }
273 572 };
274   -
275   -var PerLineNotes = {
276   - init:
277   - function() {
278   - /**
279   - * Called when clicking on the "add note" or "reply" button for a diff line.
280   - *
281   - * Shows the note form below the line.
282   - * Sets some hidden fields in the form.
283   - */
284   - $(".diff_file_content").on("click", ".line_note_link, .line_note_reply_link", function(e) {
285   - var form = $(".per_line_form");
286   - $(this).closest("tr").after(form);
287   - form.find("#note_line_code").val($(this).data("lineCode"));
288   - form.show();
289   - e.preventDefault();
290   - });
291   -
292   - disableButtonIfEmptyField(".line-note-text", ".submit_inline_note");
293   -
294   - /**
295   - * Called in response to successfully deleting a note on a diff line.
296   - *
297   - * Removes the actual note from view.
298   - * Removes the reply button if the last note for that line has been removed.
299   - */
300   - $(".diff_file_content").on("ajax:success", ".delete-note", function() {
301   - var trNote = $(this).closest("tr");
302   - trNote.fadeOut(function() {
303   - $(this).remove();
304   - });
305   -
306   - // check if this is the last note for this line
307   - // elements must really be removed for this to work reliably
308   - var trLine = trNote.prev();
309   - var trRpl = trNote.next();
310   - if (trLine.is(".line_holder") && trRpl.is(".reply")) {
311   - trRpl.fadeOut(function() { $(this).remove(); });
312   - }
313   - });
314   - }
315   -}
... ...
app/assets/stylesheets/application.scss
... ... @@ -46,3 +46,8 @@
46 46 @import "themes/ui_gray.scss";
47 47 @import "themes/ui_color.scss";
48 48  
  49 +/**
  50 + * Styles for JS behaviors.
  51 + */
  52 +@import "behaviors.scss";
  53 +
... ...
app/assets/stylesheets/behaviors.scss 0 → 100644
... ... @@ -0,0 +1,14 @@
  1 +// Details
  2 +//--------
  3 +.js-details-container .content { display: none; }
  4 +.js-details-container .content.hide { display: block; }
  5 +.js-details-container.open .content { display: block; }
  6 +.js-details-container.open .content.hide { display: none; }
  7 +
  8 +
  9 +// Toggler
  10 +//--------
  11 +.js-toggler-container .turn-on { display: inherit; }
  12 +.js-toggler-container .turn-off { display: none; }
  13 +.js-toggler-container.on .turn-on { display: none; }
  14 +.js-toggler-container.on .turn-off { display: inherit; }
... ...
app/assets/stylesheets/common.scss
... ... @@ -546,3 +546,9 @@ h1.http_status_code {
546 546 }
547 547 }
548 548 }
  549 +
  550 +img.emoji {
  551 + height: 20px;
  552 + vertical-align: middle;
  553 + width: 20px;
  554 +}
... ...
app/assets/stylesheets/sections/commits.scss
... ... @@ -119,12 +119,14 @@
119 119 }
120 120 }
121 121 }
122   - .old_line, .new_line {
123   - margin: 0px;
124   - padding: 0px;
125   - border: none;
126   - background: #EEE;
127   - color: #666;
  122 + .new_line,
  123 + .old_line,
  124 + .notes_line {
  125 + margin:0px;
  126 + padding:0px;
  127 + border:none;
  128 + background:#EEE;
  129 + color:#666;
128 130 padding: 0px 5px;
129 131 border-right: 1px solid #ccc;
130 132 text-align: right;
... ... @@ -134,6 +136,7 @@
134 136 moz-user-select: none;
135 137 -khtml-user-select: none;
136 138 user-select: none;
  139 +
137 140 a {
138 141 float: left;
139 142 width: 35px;
... ...
app/assets/stylesheets/sections/notes.scss
1 1 /**
2 2 * Notes
3   - *
4 3 */
5   -#notes-list,
6   -#new-notes-list {
  4 +ul.notes {
7 5 display: block;
8 6 list-style: none;
9 7 margin: 0px;
10 8 padding: 0px;
11   -}
12 9  
13   -.issue_notes,
14   -.wiki_notes {
15   - .note_content {
16   - float: left;
17   - width: 400px;
18   - }
19   -}
  10 + .discussion-header,
  11 + .note-header {
  12 + @extend .cgray;
  13 + padding-top: 5px;
  14 + padding-bottom: 15px;
20 15  
21   -/* Note textare */
22   -#note_note {
23   - height: 80px;
24   - width: 98%;
25   - font-size: 14px;
26   -}
  16 + .avatar {
  17 + float: left;
  18 + margin-right: 10px;
  19 + }
27 20  
28   -#new_note {
29   - .attach_holder {
30   - display: none;
  21 + .discussion-last-update,
  22 + .note-last-update {
  23 + font-style: italic;
  24 + }
  25 + .note-author {
  26 + color: $style_color;
  27 + font-weight: bold;
  28 + &:hover {
  29 + color: $primary_color;
  30 + }
  31 + }
31 32 }
32   -}
33 33  
34   -.preview_note {
35   - margin: 2px;
36   - border: 1px solid #ddd;
37   - padding: 10px;
38   - min-height: 60px;
39   - background: #f5f5f5;
40   -}
  34 + .discussion {
  35 + padding: 8px 0;
  36 + overflow: hidden;
  37 + display: block;
  38 + position:relative;
41 39  
42   -.note {
43   - padding: 8px 0;
44   - overflow: hidden;
45   - display: block;
46   - position: relative;
47   - img {float: left; margin-right: 10px;}
48   - img.emoji {float: none;margin: 0;}
49   - .note-author cite{font-style: italic;}
50   - p { color: $style_color; }
51   - .note-author { color: $style_color;}
  40 + .discussion-body {
  41 + margin-left: 50px;
52 42  
53   - .note-title { margin-left: 45px; padding-top: 5px;}
54   - .avatar {
55   - margin-top: 3px;
56   - }
  43 + .diff_file,
  44 + .discussion-hidden,
  45 + .notes {
  46 + @extend .borders;
  47 + background-color: #F9F9F9;
  48 + }
  49 + .diff_file .notes {
  50 + /* reset */
  51 + background: inherit;
  52 + border: none;
  53 + @include box-shadow(none);
57 54  
58   - .delete-note {
59   - display: none;
60   - position: absolute;
61   - right: 0;
62   - top: 0;
  55 + }
  56 + .discussion-hidden .note {
  57 + @extend .cgray;
  58 + padding: 8px;
  59 + text-align: center;
  60 + }
  61 + .notes .note {
  62 + border-color: #ddd;
  63 + padding: 8px;
  64 + }
  65 + .reply-btn {
  66 + margin-top: 8px;
  67 + }
  68 + }
63 69 }
64 70  
65   - &:hover {
66   - .delete-note { display: block; }
67   - }
68   -}
69   -#notes-list:not(.reversed) .note,
70   -#new-notes-list:not(.reversed) .note {
71   - border-bottom: 1px solid #eee;
72   -}
73   -#notes-list.reversed .note,
74   -#new-notes-list.reversed .note {
75   - border-top: 1px solid #eee;
76   -}
77   -
78   -/* mark vote notes */
79   -.voting_notes .note {
80   - padding: 8px 0;
81   -}
  71 + .note {
  72 + padding: 8px 0;
  73 + overflow: hidden;
  74 + display: block;
  75 + position:relative;
  76 + p { color: $style_color; }
82 77  
83   -.notes-status {
84   - margin: 18px;
85   -}
  78 + .avatar {
  79 + margin-top: 3px;
  80 + }
  81 + .attachment {
  82 + font-size: 14px;
  83 + margin-top: -20px;
86 84  
  85 + .icon-attachment {
  86 + @extend .icon-paper-clip;
  87 + font-size: 24px;
  88 + position: relative;
  89 + text-align: right;
  90 + top: 6px;
  91 + }
  92 + }
  93 + .note-body {
  94 + margin-left: 45px;
  95 + }
  96 + .note-header {
  97 + padding-bottom: 5px;
  98 + }
  99 + }
87 100  
88   -p.notify_controls input{
89   - margin: 5px;
  101 + // paint top or bottom borders depending on notes direction
  102 + &:not(.reversed) .note,
  103 + &:not(.reversed) .discussion {
  104 + border-bottom: 1px solid #eee;
  105 + }
  106 + &.reversed .note,
  107 + &.reversed .discussion {
  108 + border-top: 1px solid #eee;
  109 + }
90 110 }
91 111  
92   -p.notify_controls span{
93   - font-weight: 700;
94   -}
  112 +.diff_file .notes_holder {
  113 + font-family: $sansFontFamily;
  114 + font-size: 13px;
  115 + line-height: 18px;
95 116  
96   -tr.line_notes_row {
97   - border-bottom: 1px solid #DDD;
98   - border-left: 7px solid #2A79A3;
  117 + td {
  118 + border: 1px solid #ddd;
  119 + border-left: none;
99 120  
100   - &.reply {
101   - background: #eee;
102   - border-left: 7px solid #2A79A3;
103   - border-top: 1px solid #ddd;
104   - td {
105   - padding: 7px 10px;
  121 + &.notes_line {
  122 + text-align: center;
  123 + padding: 10px 0;
106 124 }
107   - a.line_note_reply_link {
108   - border: 1px solid #eaeaea;
109   - @include border-radius(4px);
110   - padding: 3px 10px;
111   - margin-left: 5px;
112   - color: white;
113   - background: #2A79A3;
114   - border-color: #2A79A3;
  125 + &.notes_content {
  126 + background-color: $white;
  127 + border-width: 1px 0;
  128 + padding-top: 0;
115 129 }
116 130 }
117   - ul {
118   - margin: 0;
119   - li {
120   - padding: 0;
121   - border: none;
122   - }
  131 +
  132 + .reply-btn {
  133 + margin-top: 8px;
123 134 }
124 135 }
125 136  
126   -.line_notes_row, .per_line_form { font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; }
127 137  
128   -.per_line_form {
129   - background: #f5f5f5;
130   - border-top: 1px solid #eee;
131   - form { margin: 0; }
132   - td {
133   - border-bottom: 1px solid #ddd;
  138 +
  139 +/**
  140 + * Actions for Discussions/Notes
  141 + */
  142 +
  143 +.discussion,
  144 +.note {
  145 + &.note:hover {
  146 + .note-actions { display: block; }
  147 + }
  148 + .discussion-header:hover {
  149 + .discussion-actions { display: block; }
134 150 }
135   - .note_actions {
136   - margin: 0;
137   - padding-top: 10px;
138 151  
139   - .buttons {
140   - float: left;
141   - width: 300px;
142   - }
143   - .options {
144   - .labels {
145   - float: left;
146   - padding-left: 10px;
147   - label {
148   - padding: 6px 0;
149   - margin: 0;
150   - width: 120px;
151   - }
  152 + .discussion-actions,
  153 + .note-actions {
  154 + display: none;
  155 + float: right;
  156 +
  157 + [class^="icon-"],
  158 + [class*="icon-"] {
  159 + font-size: 16px;
  160 + line-height: 16px;
  161 + vertical-align: middle;
  162 + }
  163 +
  164 + a {
  165 + @extend .cgray;
  166 +
  167 + &:hover {
  168 + color: $primary_color;
  169 + &.danger { @extend .cred; }
152 170 }
153 171 }
154 172 }
155 173 }
  174 +.diff_file .note .note-actions {
  175 + right: 0;
  176 + top: 0;
  177 +}
  178 +
  179 +
  180 +
  181 +/**
  182 + * Line note button on the side of diffs
  183 + */
156 184  
157   -td .line_note_link {
158   - position: absolute;
159   - margin-left:-70px;
160   - margin-top:-10px;
161   - z-index: 10;
162   - background: url("comment_add.png") no-repeat left 0;
163   - width: 32px;
164   - height: 32px;
  185 +.diff_file tr.line_holder {
  186 + .add-diff-note {
  187 + background: url("diff_note_add.png") no-repeat left 0;
  188 + height: 22px;
  189 + margin-left: -65px;
  190 + position: absolute;
  191 + width: 22px;
  192 + z-index: 10;
165 193  
166   - opacity: 0.0;
167   - filter: alpha(opacity=0);
  194 + // "hide" it by default
  195 + opacity: 0.0;
  196 + filter: alpha(opacity=0);
168 197  
169   - &:hover {
170   - opacity: 1.0;
171   - filter: alpha(opacity=100);
  198 + &:hover {
  199 + opacity: 1.0;
  200 + filter: alpha(opacity=100);
  201 + }
  202 + }
  203 +
  204 + // "show" the icon also if we just hover somwhere over the line
  205 + &:hover > td {
  206 + background: $hover !important;
  207 +
  208 + .add-diff-note {
  209 + opacity: 1.0;
  210 + filter: alpha(opacity=100);
  211 + }
172 212 }
173 213 }
174 214  
175   -.diff_file_content tr.line_holder:hover > td { background: $hover !important; }
176   -.diff_file_content tr.line_holder:hover > td .line_note_link {
177   - opacity: 1.0;
178   - filter: alpha(opacity=100);
  215 +
  216 +
  217 +/**
  218 + * Note Form
  219 + */
  220 +
  221 +.comment-btn,
  222 +.reply-btn {
  223 + @extend .save-btn;
179 224 }
  225 +.diff_file,
  226 +.discussion {
  227 + .new_note {
  228 + margin: 8px 5px 8px 0;
180 229  
181   -.new_note {
182   - .input-file {
183   - font: 500px monospace;
184   - opacity: 0;
185   - filter: alpha(opacity=0);
186   - position: absolute;
187   - z-index: 1;
188   - top: 0;
189   - right: 0;
190   - padding: 0;
191   - margin: 0;
  230 + .note_options {
  231 + // because of the smaller width and the extra "cancel" button
  232 + margin-top: 8px;
  233 + }
192 234 }
  235 +}
  236 +.new_note {
  237 + display: none;
193 238  
194   - .note_advanced_opts {
  239 + .buttons {
  240 + float: left;
  241 + margin-top: 8px;
  242 + }
  243 + .clearfix {
  244 + margin-bottom: 0;
  245 + }
  246 + .note_options {
195 247 h6 {
196   - line-height: 32px;
197   - padding-right: 15px;
  248 + @extend .left;
  249 + line-height: 20px;
  250 + padding-right: 16px;
  251 + padding-bottom: 16px;
  252 + }
  253 + label {
  254 + padding: 0;
198 255 }
199   - }
200 256  
201   - .attachments {
202   - position: relative;
203   - width: 350px;
204   - height: 50px;
205   - overflow: hidden;
206   - margin:0 0 5px !important;
  257 + .attachment {
  258 + @extend .right;
  259 + position: relative;
  260 + width: 350px;
  261 + height: 50px;
  262 + margin:0 0 5px !important;
207 263  
208   - .input_file {
209   - .file_upload {
210   - position: absolute;
211   - right: 14px;
212   - top: 7px;
  264 + // hide the actual file field
  265 + input {
  266 + display: none;
213 267 }
214 268  
215   - .file_name {
216   - line-height: 30px;
217   - width: 240px;
218   - height: 28px;
219   - overflow: hidden;
220   - }
221   - .input-file {
222   - width: 260px;
223   - height: 41px;
  269 + .choose-btn {
224 270 float: right;
225 271 }
226 272 }
  273 + .notify_options {
  274 + @extend .right;
  275 + }
227 276 }
  277 + .note_text_and_preview {
  278 + // makes the "absolute" position for links relative to this
  279 + position: relative;
  280 +
  281 + // preview/edit buttons
  282 + > a {
  283 + font-size: 24px;
  284 + padding: 4px;
  285 + position: absolute;
  286 + right: 10px;
  287 + }
  288 + .note_preview {
  289 + background: #f5f5f5;
  290 + border: 1px solid #ddd;
  291 + @include border-radius(4px);
  292 + min-height: 80px;
  293 + padding: 4px 6px;
  294 + }
  295 + .note_text {
  296 + border: 1px solid #DDD;
  297 + box-shadow: none;
  298 + font-size: 14px;
  299 + height: 80px;
  300 + width: 98.6%;
  301 + }
  302 + }
  303 +}
  304 +
  305 +/* loading indicator */
  306 +.notes-busy {
  307 + margin: 18px;
228 308 }
229 309  
230   -.note-text {
231   - border: 1px solid #DDD;
232   - box-shadow: none;
  310 +.note-image-attach {
  311 + @extend .span4;
  312 + @extend .thumbnail;
  313 + margin-left: 45px;
233 314 }
... ...
app/contexts/notes/create_context.rb
... ... @@ -3,8 +3,8 @@ module Notes
3 3 def execute
4 4 note = project.notes.new(params[:note])
5 5 note.author = current_user
6   - note.notify = true if params[:notify] == '1'
7   - note.notify_author = true if params[:notify_author] == '1'
  6 + note.notify = params[:notify].present?
  7 + note.notify_author = params[:notify_author].present?
8 8 note.save
9 9 note
10 10 end
... ...
app/contexts/notes/load_context.rb
... ... @@ -9,11 +9,11 @@ module Notes
9 9  
10 10 @notes = case target_type
11 11 when "commit"
12   - project.notes.for_commit_id(target_id).not_inline.fresh.limit(20)
  12 + project.notes.for_commit_id(target_id).not_inline.fresh
13 13 when "issue"
14   - project.issues.find(target_id).notes.inc_author.fresh.limit(20)
  14 + project.issues.find(target_id).notes.inc_author.fresh
15 15 when "merge_request"
16   - project.merge_requests.find(target_id).mr_and_commit_notes.inc_author.fresh.limit(20)
  16 + project.merge_requests.find(target_id).mr_and_commit_notes.inc_author.fresh
17 17 when "snippet"
18 18 project.snippets.find(target_id).notes.fresh
19 19 when "wall"
... ...
app/controllers/commit_controller.rb
... ... @@ -13,11 +13,17 @@ class CommitController &lt; ProjectResourceController
13 13 @commit = result[:commit]
14 14 git_not_found! unless @commit
15 15  
16   - @suppress_diff = result[:suppress_diff]
17   - @note = result[:note]
18   - @line_notes = result[:line_notes]
19   - @notes_count = result[:notes_count]
20   - @comments_allowed = true
  16 + @suppress_diff = result[:suppress_diff]
  17 +
  18 + @note = result[:note]
  19 + @line_notes = result[:line_notes]
  20 + @notes_count = result[:notes_count]
  21 + @target_type = :commit
  22 + @target_id = @commit.id
  23 +
  24 + @comments_allowed = @reply_allowed = true
  25 + @comments_target = { noteable_type: 'Commit',
  26 + commit_id: @commit.id }
21 27  
22 28 respond_to do |format|
23 29 format.html do
... ...
app/controllers/issues_controller.rb
... ... @@ -35,6 +35,8 @@ class IssuesController &lt; ProjectResourceController
35 35  
36 36 def show
37 37 @note = @project.notes.new(noteable: @issue)
  38 + @target_type = :issue
  39 + @target_id = @issue.id
38 40  
39 41 respond_to do |format|
40 42 format.html
... ...
app/controllers/merge_requests_controller.rb
... ... @@ -18,6 +18,9 @@ class MergeRequestsController &lt; ProjectResourceController
18 18 end
19 19  
20 20 def show
  21 + @target_type = :merge_request
  22 + @target_id = @merge_request.id
  23 +
21 24 respond_to do |format|
22 25 format.html
23 26 format.js
... ... @@ -31,7 +34,9 @@ class MergeRequestsController &lt; ProjectResourceController
31 34 @diffs = @merge_request.diffs
32 35 @commit = @merge_request.last_commit
33 36  
34   - @comments_allowed = true
  37 + @comments_allowed = @reply_allowed = true
  38 + @comments_target = { noteable_type: 'MergeRequest',
  39 + noteable_id: @merge_request.id }
35 40 @line_notes = @merge_request.notes.where("line_code is not null")
36 41 end
37 42  
... ...
app/controllers/notes_controller.rb
... ... @@ -6,10 +6,12 @@ class NotesController &lt; ProjectResourceController
6 6 respond_to :js
7 7  
8 8 def index
9   - notes
  9 + @notes = Notes::LoadContext.new(project, current_user, params).execute
  10 + @target_type = params[:target_type].camelize
  11 + @target_id = params[:target_id]
  12 +
10 13 if params[:target_type] == "merge_request"
11   - @mixed_targets = true
12   - @main_target_type = params[:target_type].camelize
  14 + @discussions = discussions_from_notes
13 15 end
14 16  
15 17 respond_with(@notes)
... ... @@ -17,6 +19,8 @@ class NotesController &lt; ProjectResourceController
17 19  
18 20 def create
19 21 @note = Notes::CreateContext.new(project, current_user, params).execute
  22 + @target_type = params[:target_type].camelize
  23 + @target_id = params[:target_id]
20 24  
21 25 respond_to do |format|
22 26 format.html {redirect_to :back}
... ... @@ -40,7 +44,34 @@ class NotesController &lt; ProjectResourceController
40 44  
41 45 protected
42 46  
43   - def notes
44   - @notes = Notes::LoadContext.new(project, current_user, params).execute
  47 + def discussion_notes_for(note)
  48 + @notes.select do |other_note|
  49 + note.discussion_id == other_note.discussion_id
  50 + end
  51 + end
  52 +
  53 + def discussions_from_notes
  54 + discussion_ids = []
  55 + discussions = []
  56 +
  57 + @notes.each do |note|
  58 + next if discussion_ids.include?(note.discussion_id)
  59 +
  60 + # don't group notes for the main target
  61 + if note_for_main_target?(note)
  62 + discussions << [note]
  63 + else
  64 + discussions << discussion_notes_for(note)
  65 + discussion_ids << note.discussion_id
  66 + end
  67 + end
  68 +
  69 + discussions
  70 + end
  71 +
  72 + # Helps to distinguish e.g. commit notes in mr notes list
  73 + def note_for_main_target?(note)
  74 + note.for_wall? ||
  75 + (@target_type.camelize == note.noteable_type && !note.for_diff_line?)
45 76 end
46 77 end
... ...
app/controllers/projects_controller.rb
... ... @@ -80,7 +80,10 @@ class ProjectsController &lt; ProjectResourceController
80 80  
81 81 def wall
82 82 return render_404 unless @project.wall_enabled
83   - @note = Note.new
  83 +
  84 + @target_type = :wall
  85 + @target_id = nil
  86 + @note = @project.notes.new
84 87  
85 88 respond_to do |format|
86 89 format.html
... ...
app/controllers/snippets_controller.rb
... ... @@ -50,6 +50,8 @@ class SnippetsController &lt; ProjectResourceController
50 50  
51 51 def show
52 52 @note = @project.notes.new(noteable: @snippet)
  53 + @target_type = :snippet
  54 + @target_id = @snippet.id
53 55 end
54 56  
55 57 def destroy
... ...
app/helpers/commits_helper.rb
... ... @@ -9,11 +9,13 @@ module CommitsHelper
9 9 end
10 10 end
11 11  
12   - def build_line_anchor(index, line_new, line_old)
13   - "#{index}_#{line_old}_#{line_new}"
  12 + def build_line_anchor(diff, line_new, line_old)
  13 + "#{hexdigest(diff.new_path)}_#{line_old}_#{line_new}"
14 14 end
15 15  
16   - def each_diff_line(diff_arr, index)
  16 + def each_diff_line(diff, index)
  17 + diff_arr = diff.diff.lines.to_a
  18 +
17 19 line_old = 1
18 20 line_new = 1
19 21 type = nil
... ... @@ -39,7 +41,7 @@ module CommitsHelper
39 41 next
40 42 else
41 43 type = identification_type(line)
42   - line_code = build_line_anchor(index, line_new, line_old)
  44 + line_code = build_line_anchor(diff, line_new, line_old)
43 45 yield(full_line, type, line_code, line_new, line_old)
44 46 end
45 47  
... ...
app/helpers/notes_helper.rb
1 1 module NotesHelper
2   - def loading_more_notes?
3   - params[:loading_more].present?
  2 + # Helps to distinguish e.g. commit notes in mr notes list
  3 + def note_for_main_target?(note)
  4 + note.for_wall? ||
  5 + (@target_type.camelize == note.noteable_type && !note.for_diff_line?)
4 6 end
5 7  
6   - def loading_new_notes?
7   - params[:loading_new].present?
  8 + def note_target_fields
  9 + hidden_field_tag(:target_type, @target_type) +
  10 + hidden_field_tag(:target_id, @target_id)
8 11 end
9 12  
10   - # Helps to distinguish e.g. commit notes in mr notes list
11   - def note_for_main_target?(note)
12   - !@mixed_targets || @main_target_type == note.noteable_type
  13 + def link_to_commit_diff_line_note(note)
  14 + if note.for_commit_diff_line?
  15 + link_to "#{note.diff_file_name}:L#{note.diff_new_line}", project_commit_path(@project, note.noteable, anchor: note.line_code)
  16 + end
13 17 end
14 18  
15   - def link_to_commit_diff_line_note(note)
16   - commit = note.noteable
17   - diff_index, diff_old_line, diff_new_line = note.line_code.split('_')
  19 + def link_to_merge_request_diff_line_note(note)
  20 + if note.for_merge_request_diff_line? and note.diff
  21 + link_to "#{note.diff_file_name}:L#{note.diff_new_line}", diffs_project_merge_request_path(note.project, note.noteable_id, anchor: note.line_code)
  22 + end
  23 + end
18 24  
19   - link_file = commit.diffs[diff_index.to_i].new_path
20   - link_line = diff_new_line
  25 + def loading_more_notes?
  26 + params[:loading_more].present?
  27 + end
21 28  
22   - link_to "#{link_file}:L#{link_line}", project_commit_path(@project, commit, anchor: note.line_code)
  29 + def loading_new_notes?
  30 + params[:loading_new].present?
23 31 end
24 32 end
... ...
app/mailers/notify.rb
... ... @@ -63,12 +63,12 @@ class Notify &lt; ActionMailer::Base
63 63 # Note
64 64 #
65 65  
66   - def note_commit_email(recipient_id, note_id)
  66 + def note_commit_email(commit_autor_email, note_id)
67 67 @note = Note.find(note_id)
68 68 @commit = @note.noteable
69 69 @commit = CommitDecorator.decorate(@commit)
70 70 @project = @note.project
71   - mail(to: recipient(recipient_id), subject: subject("note for commit #{@commit.short_id}", @commit.title))
  71 + mail(to: recipient(commit_autor_email), subject: subject("note for commit #{@commit.short_id}", @commit.title))
72 72 end
73 73  
74 74 def note_issue_email(recipient_id, note_id)
... ...
app/models/concerns/issuable.rb
... ... @@ -69,29 +69,33 @@ module Issuable
69 69 closed_changed? && !closed
70 70 end
71 71  
72   - # Return the number of +1 comments (upvotes)
73   - def upvotes
74   - notes.select(&:upvote?).size
  72 + #
  73 + # Votes
  74 + #
  75 +
  76 + # Return the number of -1 comments (downvotes)
  77 + def downvotes
  78 + notes.select(&:downvote?).size
75 79 end
76 80  
77   - def upvotes_in_percent
  81 + def downvotes_in_percent
78 82 if votes_count.zero?
79 83 0
80 84 else
81   - 100.0 / votes_count * upvotes
  85 + 100.0 - upvotes_in_percent
82 86 end
83 87 end
84 88  
85   - # Return the number of -1 comments (downvotes)
86   - def downvotes
87   - notes.select(&:downvote?).size
  89 + # Return the number of +1 comments (upvotes)
  90 + def upvotes
  91 + notes.select(&:upvote?).size
88 92 end
89 93  
90   - def downvotes_in_percent
  94 + def upvotes_in_percent
91 95 if votes_count.zero?
92 96 0
93 97 else
94   - 100.0 - upvotes_in_percent
  98 + 100.0 / votes_count * upvotes
95 99 end
96 100 end
97 101  
... ...
app/models/note.rb
... ... @@ -19,7 +19,6 @@ require &#39;carrierwave/orm/activerecord&#39;
19 19 require 'file_size_validator'
20 20  
21 21 class Note < ActiveRecord::Base
22   -
23 22 attr_accessible :note, :noteable, :noteable_id, :noteable_type, :project_id,
24 23 :attachment, :line_code, :commit_id
25 24  
... ... @@ -34,6 +33,7 @@ class Note &lt; ActiveRecord::Base
34 33 delegate :name, :email, to: :author, prefix: true
35 34  
36 35 validates :note, :project, presence: true
  36 + validates :line_code, format: { with: /\A[a-z0-9]+_\d+_\d+\Z/ }, allow_blank: true
37 37 validates :attachment, file_size: { maximum: 10.megabytes.to_i }
38 38  
39 39 validates :noteable_id, presence: true, if: ->(n) { n.noteable_type.present? && n.noteable_type != 'Commit' }
... ... @@ -60,12 +60,74 @@ class Note &lt; ActiveRecord::Base
60 60 }, without_protection: true)
61 61 end
62 62  
63   - def notify
64   - @notify ||= false
  63 + def commit_author
  64 + @commit_author ||=
  65 + project.users.find_by_email(noteable.author_email) ||
  66 + project.users.find_by_name(noteable.author_name)
  67 + rescue
  68 + nil
65 69 end
66 70  
67   - def notify_author
68   - @notify_author ||= false
  71 + def diff
  72 + if noteable.diffs.present?
  73 + noteable.diffs.select do |d|
  74 + if d.b_path
  75 + Digest::SHA1.hexdigest(d.b_path) == diff_file_index
  76 + end
  77 + end.first
  78 + end
  79 + end
  80 +
  81 + def diff_file_index
  82 + line_code.split('_')[0]
  83 + end
  84 +
  85 + def diff_file_name
  86 + diff.b_path
  87 + end
  88 +
  89 + def diff_new_line
  90 + line_code.split('_')[2].to_i
  91 + end
  92 +
  93 + def discussion_id
  94 + @discussion_id ||= [:discussion, noteable_type.try(:underscore), noteable_id, line_code].join("-").to_sym
  95 + end
  96 +
  97 + # Returns true if this is a downvote note,
  98 + # otherwise false is returned
  99 + def downvote?
  100 + votable? && (note.start_with?('-1') ||
  101 + note.start_with?(':-1:')
  102 + )
  103 + end
  104 +
  105 + def for_commit?
  106 + noteable_type == "Commit"
  107 + end
  108 +
  109 + def for_commit_diff_line?
  110 + for_commit? && for_diff_line?
  111 + end
  112 +
  113 + def for_diff_line?
  114 + line_code.present?
  115 + end
  116 +
  117 + def for_issue?
  118 + noteable_type == "Issue"
  119 + end
  120 +
  121 + def for_merge_request?
  122 + noteable_type == "MergeRequest"
  123 + end
  124 +
  125 + def for_merge_request_diff_line?
  126 + for_merge_request? && for_diff_line?
  127 + end
  128 +
  129 + def for_wall?
  130 + noteable_type.blank?
69 131 end
70 132  
71 133 # override to return commits, which are not active record
... ... @@ -81,50 +143,24 @@ class Note &lt; ActiveRecord::Base
81 143 nil
82 144 end
83 145  
84   - # Check if we can notify commit author
85   - # with email about our comment
86   - #
87   - # If commit author email exist in project
88   - # and commit author is not passed user we can
89   - # send email to him
90   - #
91   - # params:
92   - # user - current user
93   - #
94   - # return:
95   - # Boolean
96   - #
97   - def notify_only_author?(user)
98   - for_commit? && commit_author &&
99   - commit_author.email != user.email
100   - end
101   -
102   - def for_commit?
103   - noteable_type == "Commit"
104   - end
105   -
106   - def for_diff_line?
107   - line_code.present?
  146 + def notify
  147 + @notify ||= false
108 148 end
109 149  
110   - def commit_author
111   - @commit_author ||=
112   - project.users.find_by_email(noteable.author_email) ||
113   - project.users.find_by_name(noteable.author_name)
114   - rescue
115   - nil
  150 + def notify_author
  151 + @notify_author ||= false
116 152 end
117 153  
118 154 # Returns true if this is an upvote note,
119 155 # otherwise false is returned
120 156 def upvote?
121   - note.start_with?('+1') || note.start_with?(':+1:')
  157 + votable? && (note.start_with?('+1') ||
  158 + note.start_with?(':+1:')
  159 + )
122 160 end
123 161  
124   - # Returns true if this is a downvote note,
125   - # otherwise false is returned
126   - def downvote?
127   - note.start_with?('-1') || note.start_with?(':-1:')
  162 + def votable?
  163 + for_issue? || (for_merge_request? && !for_diff_line?)
128 164 end
129 165  
130 166 def noteable_type_name
... ...
app/observers/note_observer.rb
... ... @@ -11,7 +11,7 @@ class NoteObserver &lt; ActiveRecord::Observer
11 11 notify_team(note)
12 12 elsif note.notify_author
13 13 # Notify only author of resource
14   - Notify.delay.note_commit_email(note.commit_author.id, note.id)
  14 + Notify.delay.note_commit_email(note.noteable.author_email, note.id)
15 15 else
16 16 # Otherwise ignore it
17 17 nil
... ...
app/views/commit/show.html.haml
... ... @@ -7,12 +7,10 @@
7 7 %span.cred #{@commit.stats.deletions} deletions
8 8  
9 9 = render "commits/diffs", diffs: @commit.diffs
10   -= render "notes/notes_with_form", tid: @commit.id, tt: "commit"
11   -= render "notes/per_line_form"
  10 += render "notes/notes_with_form"
12 11  
13 12 :javascript
14 13 $(function(){
15   - PerLineNotes.init();
16 14 var w, h;
17 15 $('.diff_file').each(function(){
18 16 $('.image.diff_removed img', this).on('load', $.proxy(function(event){
... ...
app/views/commits/_diffs.html.haml
... ... @@ -38,10 +38,10 @@
38 38  
39 39 %br/
40 40 .diff_file_content
41   - -# Skipp all non non-supported blobs
  41 + -# Skip all non-supported blobs
42 42 - next unless file.respond_to?('text?')
43 43 - if file.text?
44   - = render "commits/text_file", diff: diff, index: i
  44 + = render "commits/text_diff", diff: diff, index: i
45 45 - elsif file.image?
46 46 - old_file = (@commit.prev_commit.tree / diff.old_path) if !@commit.prev_commit.nil?
47 47 - if diff.renamed_file || diff.new_file || diff.deleted_file
... ...
app/views/commits/_text_diff.html.haml 0 → 100644
... ... @@ -0,0 +1,23 @@
  1 +- too_big = diff.diff.lines.count > 1000
  2 +- if too_big
  3 + %a.supp_diff_link Diff suppressed. Click to show
  4 +
  5 +%table{class: "#{'hide' if too_big}"}
  6 + - each_diff_line(diff, index) do |line, type, line_code, line_new, line_old|
  7 + %tr.line_holder{ id: line_code }
  8 + - if type == "match"
  9 + %td.old_line= "..."
  10 + %td.new_line= "..."
  11 + %td.line_content.matched= line
  12 + - else
  13 + %td.old_line
  14 + = link_to raw(type == "new" ? "&nbsp;" : line_old), "##{line_code}", id: line_code
  15 + - if @comments_allowed
  16 + = render "notes/diff_note_link", line_code: line_code
  17 + %td.new_line= link_to raw(type == "old" ? "&nbsp;" : line_new) , "##{line_code}", id: line_code
  18 + %td.line_content{class: "noteable_line #{type} #{line_code}", "line_code" => line_code}= raw diff_line_content(line)
  19 +
  20 + - if @reply_allowed
  21 + - comments = @line_notes.select { |n| n.line_code == line_code }.sort_by(&:created_at)
  22 + - unless comments.empty?
  23 + = render "notes/diff_notes_with_reply", notes: comments
... ...
app/views/commits/_text_file.html.haml
... ... @@ -1,23 +0,0 @@
1   -- too_big = diff.diff.lines.count > 1000
2   -- if too_big
3   - %a.supp_diff_link Diff suppressed. Click to show
4   -
5   -%table{class: "#{'hide' if too_big}"}
6   - - each_diff_line(diff.diff.lines.to_a, index) do |line, type, line_code, line_new, line_old|
7   - %tr.line_holder{ id: line_code }
8   - - if type == "match"
9   - %td.old_line= "..."
10   - %td.new_line= "..."
11   - %td.line_content.matched= line
12   - - else
13   - %td.old_line
14   - = link_to raw(type == "new" ? "&nbsp;" : line_old), "##{line_code}", id: line_code
15   - - if @comments_allowed
16   - = render "notes/per_line_note_link", line_code: line_code
17   - %td.new_line= link_to raw(type == "old" ? "&nbsp;" : line_new) , "##{line_code}", id: line_code
18   - %td.line_content{class: "noteable_line #{type} #{line_code}", "line_code" => line_code}= raw diff_line_content(line)
19   -
20   - - if @comments_allowed
21   - - comments = @line_notes.select { |n| n.line_code == line_code }.sort_by(&:created_at)
22   - - unless comments.empty?
23   - = render "notes/per_line_notes_with_reply", notes: comments
app/views/issues/show.html.haml
... ... @@ -56,4 +56,4 @@
56 56 = markdown @issue.description
57 57  
58 58  
59   -.issue_notes.voting_notes#notes= render "notes/notes_with_form", tid: @issue.id, tt: "issue"
  59 +.voting_notes#notes= render "notes/notes_with_form"
... ...
app/views/merge_requests/_show.html.haml
... ... @@ -12,20 +12,18 @@
12 12 %li.notes-tab{data: {action: 'notes'}}
13 13 = link_to project_merge_request_path(@project, @merge_request) do
14 14 %i.icon-comment
15   - Comments
  15 + Discussion
16 16 %li.diffs-tab{data: {action: 'diffs'}}
17 17 = link_to diffs_project_merge_request_path(@project, @merge_request) do
18 18 %i.icon-list-alt
19 19 Diff
20 20  
21 21 .notes.tab-content.voting_notes#notes{ class: (controller.action_name == 'show') ? "" : "hide" }
22   - = render("notes/notes_with_form", tid: @merge_request.id, tt: "merge_request")
  22 + = render "notes/notes_with_form"
23 23 .diffs.tab-content
24 24 = render "merge_requests/show/diffs" if @diffs
25 25 .status
26 26  
27   - = render "notes/per_line_form"
28   -
29 27 :javascript
30 28 var merge_request;
31 29 $(function(){
... ...
app/views/merge_requests/diffs.html.haml
1 1 = render "show"
2   -
3   -:javascript
4   - $(function(){
5   - PerLineNotes.init();
6   - });
... ...
app/views/merge_requests/diffs.js.haml
1 1 :plain
2 2 merge_request.$(".diffs").html("#{escape_javascript(render(partial: "merge_requests/show/diffs"))}");
3   -
4   - PerLineNotes.init();
5   -
... ...
app/views/merge_requests/show.js.haml
1 1 :plain
2   - merge_request.$(".notes").html("#{escape_javascript(render "notes/notes_with_form", tid: @merge_request.id, tt: "merge_request")}");
  2 + merge_request.$(".notes").html("#{escape_javascript(render "notes/notes_with_form")}");
... ...
app/views/notes/_common_form.html.haml
... ... @@ -1,39 +0,0 @@
1   -.note-form-holder
2   - = form_for [@project, @note], remote: "true", multipart: true do |f|
3   - %h3.page_title Leave a comment
4   - -if @note.errors.any?
5   - .alert-message.block-message.error
6   - - @note.errors.full_messages.each do |msg|
7   - %div= msg
8   -
9   - = f.hidden_field :noteable_id
10   - = f.hidden_field :commit_id
11   - = f.hidden_field :noteable_type
12   - = f.text_area :note, size: 255, class: 'note-text js-gfm-input'
13   - #preview-note.preview_note.hide
14   - .hint
15   - .right Comments are parsed with #{link_to "GitLab Flavored Markdown", help_markdown_path, target: '_blank'}.
16   - .clearfix
17   -
18   - .row.note_advanced_opts
19   - .span3
20   - = f.submit 'Add Comment', class: "btn success submit_note grouped", id: "submit_note"
21   - = link_to 'Preview', preview_project_notes_path(@project), class: 'btn grouped', id: 'preview-link'
22   - .span4.notify_opts
23   - %h6.left Notify via email:
24   - = label_tag :notify do
25   - = check_box_tag :notify, 1, @note.noteable_type != "Commit"
26   - %span Project team
27   -
28   - - if @note.notify_only_author?(current_user)
29   - = label_tag :notify_author do
30   - = check_box_tag :notify_author, 1 , @note.noteable_type == "Commit"
31   - %span Commit author
32   - .span5.attachments
33   - %h6.left Attachment:
34   - %span.file_name File name...
35   -
36   - .input.input_file
37   - %a.file_upload.btn.small Upload File
38   - = f.file_field :attachment, class: "input-file"
39   - %span.hint Any file less than 10 MB
app/views/notes/_create_common_note.js.haml
... ... @@ -1,13 +0,0 @@
1   -- if note.valid?
2   - :plain
3   - $(".note-form-holder .error").remove();
4   - $('.note-form-holder textarea').val("");
5   - $('.note-form-holder #preview-link').text('Preview');
6   - $('.note-form-holder #preview-note').hide();
7   - $('.note-form-holder').show();
8   - NoteList.appendNewNote(#{note.id}, "#{escape_javascript(render "notes/note", note: note)}");
9   -
10   -- else
11   - :plain
12   - $(".note-form-holder").replaceWith("#{escape_javascript(render 'notes/common_form')}");
13   - GitLab.GfmAutoComplete.setup();
app/views/notes/_create_per_line_note.js.haml
... ... @@ -1,19 +0,0 @@
1   -- if note.valid?
2   - :plain
3   - // hide and reset the form
4   - $(".per_line_form").hide();
5   - $('.line-note-form-holder textarea').val("");
6   -
7   - // find the reply button for this line
8   - // (might not be there if this is the first note)
9   - var trRpl = $("a.line_note_reply_link[data-line-code='#{note.line_code}']").closest("tr");
10   - if (trRpl.size() == 0) {
11   - // find the commented line ...
12   - var trEl = $(".#{note.line_code}").parent();
13   - // ... and insert the note and the reply button after it
14   - trEl.after("#{escape_javascript(render "notes/per_line_reply_button", line_code: note.line_code)}");
15   - trEl.after("#{escape_javascript(render "notes/per_line_note", note: note)}");
16   - } else {
17   - // instert new note before reply button
18   - trRpl.before("#{escape_javascript(render "notes/per_line_note", note: note)}");
19   - }
app/views/notes/_diff_note_link.html.haml 0 → 100644
... ... @@ -0,0 +1,10 @@
  1 +- note = @project.notes.new(@comments_target.merge({ line_code: line_code }))
  2 += link_to "",
  3 + "javascript:;",
  4 + class: "add-diff-note js-add-diff-note-button",
  5 + data: { noteable_type: note.noteable_type,
  6 + noteable_id: note.noteable_id,
  7 + commit_id: note.commit_id,
  8 + line_code: note.line_code,
  9 + discussion_id: note.discussion_id },
  10 + title: "Add a comment to this line"
... ...
app/views/notes/_diff_notes_with_reply.html.haml 0 → 100644
... ... @@ -0,0 +1,11 @@
  1 +- note = notes.first # example note
  2 +%tr.notes_holder
  3 + %td.notes_line{ colspan: 2 }
  4 + %span.btn.disabled
  5 + %i.icon-comment
  6 + = notes.count
  7 + %td.notes_content
  8 + %ul.notes{ rel: note.discussion_id }
  9 + = render notes
  10 +
  11 + = render "notes/discussion_reply_button", note: note
... ...
app/views/notes/_discussion.html.haml 0 → 100644
... ... @@ -0,0 +1,63 @@
  1 +- note = discussion_notes.first
  2 +.discussion.js-details-container.js-toggler-container.open{ class: note.discussion_id }
  3 + .discussion-header
  4 + .discussion-actions
  5 + = link_to "javascript:;", class: "js-details-target turn-on js-toggler-target" do
  6 + %i.icon-eye-close
  7 + Hide discussion
  8 + = link_to "javascript:;", class: "js-details-target turn-off js-toggler-target" do
  9 + %i.icon-eye-open
  10 + Show discussion
  11 + = image_tag gravatar_icon(note.author.email), class: "avatar s32"
  12 + %div
  13 + = link_to note.author_name, project_team_member_path(@project, @project.team_member_by_id(note.author)), class: "note-author"
  14 + - if note.for_merge_request?
  15 + - if note.diff
  16 + started a discussion on this merge request diff
  17 + = link_to_merge_request_diff_line_note(note)
  18 + - else
  19 + started
  20 + %strong
  21 + %i.icon-remove
  22 + outdated
  23 + discussion on this merge request diff
  24 + - elsif note.for_commit?
  25 + started a discussion on commit
  26 + #{link_to note.noteable.short_id, project_commit_path(@project, note.noteable)}
  27 + = link_to_commit_diff_line_note(note) if note.for_diff_line?
  28 + - else
  29 + %cite.cgray started a discussion
  30 + %div
  31 + - last_note = discussion_notes.last
  32 + last updated by
  33 + = link_to last_note.author_name, project_team_member_path(@project, @project.team_member_by_id(last_note.author)), class: "note-author"
  34 + %span.discussion-last-update
  35 + = time_ago_in_words(last_note.updated_at)
  36 + ago
  37 + .discussion-body
  38 + - if note.for_diff_line?
  39 + - if note.diff
  40 + .content
  41 + .diff_file= render "notes/discussion_diff", discussion_notes: discussion_notes, note: note
  42 + - else
  43 + = link_to 'show outdated discussion', '#', class: 'js-show-outdated-discussion'
  44 + %div.hide.outdated-discussion
  45 + .content
  46 + .notes{ rel: discussion_notes.first.discussion_id }
  47 + = render discussion_notes
  48 +
  49 +
  50 + - else
  51 + .content
  52 + .notes{ rel: discussion_notes.first.discussion_id }
  53 + = render discussion_notes
  54 + = render "notes/discussion_reply_button", note: discussion_notes.first
  55 +
  56 + -# will be shown when the other one is hidden
  57 + .discussion-hidden.content.hide
  58 + .note
  59 + %em Hidden discussion.
  60 + = link_to "javascript:;", class: "js-details-target js-toggler-target" do
  61 + %i.icon-eye-open
  62 + Show
  63 +
... ...
app/views/notes/_discussion_diff.html.haml 0 → 100644
... ... @@ -0,0 +1,25 @@
  1 +- diff = note.diff
  2 +.diff_file_header
  3 + - if diff.deleted_file
  4 + %span= diff.old_path
  5 + - else
  6 + %span= diff.new_path
  7 + - if diff.a_mode && diff.b_mode && diff.a_mode != diff.b_mode
  8 + %span.file-mode= "#{diff.a_mode} → #{diff.b_mode}"
  9 + %br/
  10 +.diff_file_content
  11 + %table
  12 + - each_diff_line(diff, note.diff_file_index) do |line, type, line_code, line_new, line_old|
  13 + %tr.line_holder{ id: line_code }
  14 + - if type == "match"
  15 + %td.old_line= "..."
  16 + %td.new_line= "..."
  17 + %td.line_content.matched= line
  18 + - else
  19 + %td.old_line= raw(type == "new" ? "&nbsp;" : line_old)
  20 + %td.new_line= raw(type == "old" ? "&nbsp;" : line_new)
  21 + %td.line_content{class: "noteable_line #{type} #{line_code}", "line_code" => line_code}= raw "#{line} &nbsp;"
  22 +
  23 + - if line_code == note.line_code
  24 + = render "notes/diff_notes_with_reply", notes: discussion_notes
  25 + - break # cut off diff after notes
... ...
app/views/notes/_discussion_reply_button.html.haml 0 → 100644
... ... @@ -0,0 +1,10 @@
  1 += link_to "javascript:;",
  2 + class: "btn reply-btn js-discussion-reply-button",
  3 + data: { noteable_type: note.noteable_type,
  4 + noteable_id: note.noteable_id,
  5 + commit_id: note.commit_id,
  6 + line_code: note.line_code,
  7 + discussion_id: note.discussion_id },
  8 + title: "Add a reply" do
  9 + %i.icon-comment
  10 + Reply
... ...
app/views/notes/_form.html.haml 0 → 100644
... ... @@ -0,0 +1,44 @@
  1 += form_for [@project, @note], remote: true, html: { multipart: true, id: nil, class: "new_note js-new-note-form" } do |f|
  2 +
  3 + = note_target_fields
  4 + = f.hidden_field :commit_id
  5 + = f.hidden_field :line_code
  6 + = f.hidden_field :noteable_id
  7 + = f.hidden_field :noteable_type
  8 +
  9 + .note_text_and_preview.js-toggler-container
  10 + %a.js-note-preview-button.js-toggler-target.turn-off{ href: "javascript:;", title: "Preview", data: {url: preview_project_notes_path(@project)} }
  11 + %i.icon-eye-open
  12 + %a.js-note-edit-button.js-toggler-target.turn-off{ href: "javascript:;", title: "Edit" }
  13 + %i.icon-edit
  14 +
  15 + = f.text_area :note, size: 255, class: 'note_text js-note-text js-gfm-input turn-on'
  16 + .note_preview.js-note-preview.turn-off
  17 +
  18 + .buttons
  19 + = f.submit 'Add Comment', class: "btn comment-btn grouped js-comment-button"
  20 + %a.btn.grouped.js-close-discussion-note-form Cancel
  21 + .hint
  22 + .right Comments are parsed with #{link_to "GitLab Flavored Markdown", help_markdown_path, target: '_blank'}.
  23 + .clearfix
  24 +
  25 + .note_options
  26 + .attachment
  27 + %h6 Attachment:
  28 + .file_name.js-attachment-filename File name...
  29 + %a.choose-btn.btn.small.js-choose-note-attachment-button Choose File ...
  30 + .hint Any file up to 10 MB
  31 +
  32 + = f.file_field :attachment, class: "js-note-attachment-input"
  33 +
  34 + .notify_options
  35 + %h6 Notify via email:
  36 + = label_tag :notify do
  37 + = check_box_tag :notify, 1, !@note.for_commit?
  38 + Project team
  39 +
  40 + .js-notify-commit-author
  41 + = label_tag :notify_author do
  42 + = check_box_tag :notify_author, 1 , @note.for_commit?
  43 + Commit author
  44 + .clearfix
... ...
app/views/notes/_form_errors.html.haml 0 → 100644
... ... @@ -0,0 +1,3 @@
  1 +.error_message.js-errors
  2 + - note.errors.full_messages.each do |msg|
  3 + %div= msg
... ...
app/views/notes/_note.html.haml
1   -%li{id: dom_id(note), class: "note"}
2   - = image_tag gravatar_icon(note.author.email), class: "avatar s32"
3   - %div.note-author
4   - %strong= note.author_name
5   - = link_to "##{dom_id(note)}", name: dom_id(note) do
6   - %cite.cgray
7   - = time_ago_in_words(note.updated_at)
8   - ago
  1 +%li{ id: dom_id(note), class: dom_class(note), data: { discussion: note.discussion_id } }
  2 + .note-header
  3 + .note-actions
  4 + = link_to "##{dom_id(note)}", name: dom_id(note) do
  5 + %i.icon-link
  6 + Link here
  7 + &nbsp;
  8 + - if(note.author_id == current_user.id) || can?(current_user, :admin_note, @project)
  9 + = link_to project_note_path(@project, note), title: "Remove comment", method: :delete, confirm: 'Are you sure you want to remove comment?', remote: true, class: "danger js-note-delete" do
  10 + %i.icon-trash.cred
  11 + = image_tag gravatar_icon(note.author.email), class: "avatar s32"
  12 + = link_to note.author_name, project_team_member_path(@project, @project.team_member_by_id(note.author)), class: "note-author"
  13 + %span.note-last-update
  14 + = time_ago_in_words(note.updated_at)
  15 + ago
9 16  
10   - - unless note_for_main_target?(note)
11   - - if note.for_commit?
12   - %span.cgray
13   - on #{link_to note.noteable.short_id, project_commit_path(@project, note.noteable)}
14   - = link_to_commit_diff_line_note(note) if note.for_diff_line?
  17 + - if note.upvote?
  18 + %span.vote.upvote.label.label-success
  19 + %i.icon-thumbs-up
  20 + \+1
  21 + - if note.downvote?
  22 + %span.vote.downvote.label.label-error
  23 + %i.icon-thumbs-down
  24 + \-1
15 25  
16   - -# only show vote if it's a note for the main target
17   - - if note_for_main_target?(note)
18   - - if note.upvote?
19   - %span.vote.upvote.label.label-success
20   - %i.icon-thumbs-up
21   - \+1
22   - - if note.downvote?
23   - %span.vote.downvote.label.label-error
24   - %i.icon-thumbs-down
25   - \-1
26 26  
27   - -# remove button
28   - - if(note.author_id == current_user.id) || can?(current_user, :admin_note, @project)
29   - = link_to [@project, note], confirm: 'Are you sure?', method: :delete, remote: true, class: "cred delete-note btn very_small" do
30   - %i.icon-trash
31   - Remove
32   -
33   - %div.note-title
  27 + .note-body
34 28 = preserve do
35 29 = markdown(note.note)
36   - - if note.attachment.url
37   - - if note.attachment.image?
38   - = image_tag note.attachment.url, class: 'thumbnail span4'
39   - .right
40   - %div.file
41   - = link_to note.attachment_identifier, note.attachment.url, target: "_blank"
  30 + - if note.attachment.url
  31 + - if note.attachment.image?
  32 + = image_tag note.attachment.url, class: 'note-image-attach'
  33 + .attachment.right
  34 + = link_to note.attachment.url, target: "_blank" do
  35 + %i.icon-attachment
  36 + = note.attachment_identifier
42 37 .clear
... ...
app/views/notes/_notes.html.haml
1   -- @notes.each do |note|
2   - - next unless note.author
3   - = render "note", note: note
4   -
  1 +- if @discussions.present?
  2 + - @discussions.each do |discussion_notes|
  3 + - note = discussion_notes.first
  4 + - if note_for_main_target?(note)
  5 + = render discussion_notes
  6 + - else
  7 + = render 'discussion', discussion_notes: discussion_notes
  8 +- else
  9 + - @notes.each do |note|
  10 + - next unless note.author
  11 + = render 'note', note: note
... ...
app/views/notes/_notes_with_form.html.haml
1   -%ul#notes-list
2   -%ul#new-notes-list
3   -.notes-status
  1 +%ul#notes-list.notes
  2 +.js-notes-busy
4 3  
  4 +.js-main-target-form
5 5 - if can? current_user, :write_note, @project
6   - = render "notes/common_form"
  6 + = render "notes/form"
7 7  
8 8 :javascript
9 9 $(function(){
10   - NoteList.init("#{tid}", "#{tt}", "#{project_notes_path(@project)}");
  10 + NoteList.init("#{@target_id}", "#{@target_type}", "#{project_notes_path(@project)}");
11 11 });
... ...
app/views/notes/_per_line_form.html.haml
... ... @@ -1,40 +0,0 @@
1   -%table{style: "display:none;"}
2   - %tr.per_line_form
3   - %td{colspan: 3 }
4   - .line-note-form-holder
5   - = form_for [@project, @note], remote: "true", multipart: true do |f|
6   - %h3.page_title Leave a note
7   - %div.span10
8   - -if @note.errors.any?
9   - .alert-message.block-message.error
10   - - @note.errors.full_messages.each do |msg|
11   - %div= msg
12   -
13   - = f.hidden_field :noteable_id
14   - = f.hidden_field :commit_id
15   - = f.hidden_field :noteable_type
16   - = f.hidden_field :line_code
17   - = f.text_area :note, size: 255, class: 'line-note-text js-gfm-input'
18   - .note_actions
19   - .buttons
20   - = f.submit 'Add note', class: "btn save-btn submit_note submit_inline_note", id: "submit_note"
21   - = link_to "Cancel", "#", class: "btn hide-button"
22   - .options
23   - %h6.left Notify via email:
24   - .labels
25   - = label_tag :notify do
26   - = check_box_tag :notify, 1, @note.noteable_type != "Commit"
27   - %span Project team
28   -
29   - - if @note.notify_only_author?(current_user)
30   - = label_tag :notify_author do
31   - = check_box_tag :notify_author, 1 , @note.noteable_type == "Commit"
32   - %span Commit author
33   -
34   -:javascript
35   - $(function(){
36   - $(".per_line_form .hide-button").bind("click", function(){
37   - $('.per_line_form').hide();
38   - return false;
39   - });
40   - });
app/views/notes/_per_line_note.html.haml
... ... @@ -1,5 +0,0 @@
1   -%tr.line_notes_row
2   - %td{colspan: 3}
3   - %ul
4   - = render "notes/note", note: note
5   -
app/views/notes/_per_line_note_link.html.haml
... ... @@ -1 +0,0 @@
1   -= link_to "", "#", class: "line_note_link", data: { line_code: line_code }, title: "Add note for this line"
app/views/notes/_per_line_notes_with_reply.html.haml
... ... @@ -1,3 +0,0 @@
1   -- notes.each do |note|
2   - = render "notes/per_line_note", note: note
3   -= render "notes/per_line_reply_button", line_code: notes.first.line_code
app/views/notes/_per_line_reply_button.html.haml
... ... @@ -1,4 +0,0 @@
1   -%tr.line_notes_row.reply
2   - %td{colspan: 3}
3   - %i.icon-comment
4   - = link_to "Reply", "#", class: "line_note_reply_link", data: { line_code: line_code }, title: "Add note for this line"
app/views/notes/_reversed_notes_with_form.html.haml
  1 +.js-main-target-form
1 2 - if can? current_user, :write_note, @project
2   - = render "notes/common_form"
  3 + = render "notes/form"
3 4  
4   -%ul.reversed#new-notes-list
5   -%ul.reversed#notes-list
6   -.notes-status
  5 +%ul#new-notes-list.reversed.notes
  6 +%ul#notes-list.reversed.notes
  7 +.notes-busy.js-notes-busy
7 8  
8 9 :javascript
9 10 $(function(){
10   - NoteList.init("#{tid}", "#{tt}", "#{project_notes_path(@project)}");
  11 + NoteList.init("#{@target_id}", "#{@target_type}", "#{project_notes_path(@project)}");
11 12 });
... ...
app/views/notes/create.js.haml
1   -- if @note.line_code
2   - = render "create_per_line_note", note: @note
3   -- else
4   - = render "create_common_note", note: @note
  1 +- if @note.valid?
  2 + var noteHtml = "#{escape_javascript(render "notes/note", note: @note)}";
  3 +
  4 + - if note_for_main_target?(@note)
  5 + - if @note.for_wall?
  6 + NoteList.appendNewWallNote(#{@note.id}, noteHtml);
  7 + - else
  8 + NoteList.appendNewNote(#{@note.id}, noteHtml);
  9 + - else
  10 + :plain
  11 + var firstDiscussionNoteHtml = "#{escape_javascript(render "notes/diff_notes_with_reply", notes: [@note])}";
  12 + NoteList.appendNewDiscussionNote("#{@note.discussion_id}",
  13 + firstDiscussionNoteHtml,
  14 + noteHtml);
5 15  
6   --# Enable submit button
7   -:plain
8   - $("#submit_note").removeAttr("disabled");
  16 +- else
  17 + var errorsHtml = "#{escape_javascript(render 'notes/form_errors', note: @note)}";
  18 + - if note_for_main_target?(@note)
  19 + NoteList.errorsOnForm(errorsHtml);
  20 + - else
  21 + NoteList.errorsOnForm(errorsHtml, "#{@note.discussion_id}");
9 22 \ No newline at end of file
... ...
app/views/notes/index.js.haml
1 1 - unless @notes.blank?
  2 + var notesHtml = "#{escape_javascript(render 'notes/notes')}";
  3 + - new_note_ids = @notes.map(&:id)
2 4 - if loading_more_notes?
3   - :plain
4   - NoteList.appendMoreNotes(#{@notes.last.id}, "#{escape_javascript(render 'notes/notes')}");
  5 + NoteList.appendMoreNotes(#{new_note_ids}, notesHtml);
5 6  
6 7 - elsif loading_new_notes?
7   - :plain
8   - NoteList.replaceNewNotes("#{escape_javascript(render 'notes/notes')}");
  8 + NoteList.replaceNewNotes(#{new_note_ids}, notesHtml);
9 9  
10 10 - else
11   - :plain
12   - NoteList.setContent(#{@notes.first.id}, #{@notes.last.id}, "#{escape_javascript(render 'notes/notes')}");
  11 + NoteList.setContent(#{new_note_ids}, notesHtml);
13 12  
14 13 - else
15 14 - if loading_more_notes?
16   - :plain
17   - NoteList.finishedLoadingMore();
  15 + NoteList.finishedLoadingMore();
... ...
app/views/projects/wall.html.haml
1 1 %div.wall_page
2   - = render "notes/reversed_notes_with_form", tid: nil, tt: "wall"
  2 + = render "notes/reversed_notes_with_form"
... ...
app/views/snippets/show.html.haml
... ... @@ -8,4 +8,4 @@
8 8  
9 9 %br
10 10 %div= render 'blob'
11   -%div#notes= render "notes/notes_with_form", tid: @snippet.id, tt: "snippet"
  11 +%div#notes= render "notes/notes_with_form"
... ...
features/project/commits/commit_comments.feature
1   -Feature: Project Comment commit
  1 +Feature: Comments on commits
2 2 Background:
3 3 Given I sign in as a user
4 4 And I own project "Shop"
5   - Given I visit project commit page
  5 + And I visit project commit page
6 6  
7 7 @javascript
8   - Scenario: I comment commit
  8 + Scenario: I can comment on a commit
9 9 Given I leave a comment like "XML attached"
10   - Then I should see comment "XML attached"
  10 + Then I should see a comment saying "XML attached"
  11 +
  12 + @javascript
  13 + Scenario: I can't cancel the main form
  14 + Then I should not see the cancel comment button
  15 +
  16 + @javascript
  17 + Scenario: I can't preview without text
  18 + Given I haven't written any comment text
  19 + Then I should not see the comment preview button
  20 +
  21 + @javascript
  22 + Scenario: I can preview with text
  23 + Given I write a comment like "Nice"
  24 + Then I should see the comment preview button
  25 +
  26 + @javascript
  27 + Scenario: I preview a comment
  28 + Given I preview a comment text like "Bug fixed :smile:"
  29 + Then I should see the comment preview
  30 + And I should not see the comment text field
  31 +
  32 + @javascript
  33 + Scenario: I can edit after preview
  34 + Given I preview a comment text like "Bug fixed :smile:"
  35 + Then I should see the comment edit button
  36 +
  37 + @javascript
  38 + Scenario: I have a reset form after posting from preview
  39 + Given I preview a comment text like "Bug fixed :smile:"
  40 + And I submit the comment
  41 + Then I should see an empty comment text field
  42 + And I should not see the comment preview
  43 +
  44 + @javascript
  45 + Scenario: I can delete a comment
  46 + Given I leave a comment like "XML attached"
  47 + And I delete a comment
  48 + Then I should not see a comment saying "XML attached"
... ...
features/project/commits/commit_diff_comments.feature 0 → 100644
... ... @@ -0,0 +1,91 @@
  1 +Feature: Comments on commit diffs
  2 + Background:
  3 + Given I sign in as a user
  4 + And I own project "Shop"
  5 + And I visit project commit page
  6 +
  7 + @javascript
  8 + Scenario: I can access add diff comment buttons
  9 + Then I should see add a diff comment button
  10 +
  11 + @javascript
  12 + Scenario: I can comment on a commit diff
  13 + Given I leave a diff comment like "Typo, please fix"
  14 + Then I should see a diff comment saying "Typo, please fix"
  15 +
  16 + @javascript
  17 + Scenario: I get a temporary form for the first comment on a diff line
  18 + Given I open a diff comment form
  19 + Then I should see a temporary diff comment form
  20 +
  21 + @javascript
  22 + Scenario: I have a cancel button on the diff form
  23 + Given I open a diff comment form
  24 + Then I should see the cancel comment button
  25 +
  26 + @javascript
  27 + Scenario: I can cancel a diff form
  28 + Given I open a diff comment form
  29 + And I cancel the diff comment
  30 + Then I should not see the diff comment form
  31 +
  32 + @javascript
  33 + Scenario: I can't open a second form for a diff line
  34 + Given I open a diff comment form
  35 + And I open a diff comment form
  36 + Then I should only see one diff form
  37 +
  38 + @javascript
  39 + Scenario: I can have multiple forms
  40 + Given I open a diff comment form
  41 + And I write a diff comment like ":-1: I don't like this"
  42 + And I open another diff comment form
  43 + Then I should see a diff comment form with ":-1: I don't like this"
  44 + And I should see an empty diff comment form
  45 +
  46 + @javascript
  47 + Scenario: I can preview multiple forms separately
  48 + Given I preview a diff comment text like "Should fix it :smile:"
  49 + And I preview another diff comment text like "DRY this up"
  50 + Then I should see two separate previews
  51 +
  52 + @javascript
  53 + Scenario: I have a reply button in discussions
  54 + Given I leave a diff comment like "Typo, please fix"
  55 + Then I should see a discussion reply button
  56 +
  57 + @javascript
  58 + Scenario: I can't preview without text
  59 + Given I open a diff comment form
  60 + And I haven't written any diff comment text
  61 + Then I should not see the diff comment preview button
  62 +
  63 + @javascript
  64 + Scenario: I can preview with text
  65 + Given I open a diff comment form
  66 + And I write a diff comment like ":-1: I don't like this"
  67 + Then I should see the diff comment preview button
  68 +
  69 + @javascript
  70 + Scenario: I preview a diff comment
  71 + Given I preview a diff comment text like "Should fix it :smile:"
  72 + Then I should see the diff comment preview
  73 + And I should not see the diff comment text field
  74 +
  75 + @javascript
  76 + Scenario: I can edit after preview
  77 + Given I preview a diff comment text like "Should fix it :smile:"
  78 + Then I should see the diff comment edit button
  79 +
  80 + @javascript
  81 + Scenario: The form gets removed after posting
  82 + Given I preview a diff comment text like "Should fix it :smile:"
  83 + And I submit the diff comment
  84 + Then I should not see the diff comment form
  85 + And I should see a discussion reply button
  86 +
  87 + @javascript
  88 + Scenario: I can delete a discussion comment
  89 + Given I leave a diff comment like "Typo, please fix"
  90 + And I delete a diff comment
  91 + Then I should not see a diff comment saying "Typo, please fix"
... ...
features/project/merge_requests.feature
... ... @@ -35,8 +35,34 @@ Feature: Project Merge Requests
35 35 Then I should see merge request "Wiki Feature"
36 36  
37 37 @javascript
38   - Scenario: I comment merge request
  38 + Scenario: I comment on a merge request
39 39 Given I visit merge request page "Bug NS-04"
40 40 And I leave a comment like "XML attached"
41 41 Then I should see comment "XML attached"
42 42  
  43 + @javascript
  44 + Scenario: I comment on a merge request diff
  45 + Given project "Shop" have "Bug NS-05" open merge request with diffs inside
  46 + And I visit merge request page "Bug NS-05"
  47 + And I switch to the diff tab
  48 + And I leave a comment like "Line is wrong" on line 185 of the first file
  49 + And I switch to the merge request's comments tab
  50 + Then I should see a discussion has started on line 185
  51 +
  52 + @javascript
  53 + Scenario: I comment on a line of a commit in merge request
  54 + Given project "Shop" have "Bug NS-05" open merge request with diffs inside
  55 + And I visit merge request page "Bug NS-05"
  56 + And I click on the first commit in the merge request
  57 + And I leave a comment like "Line is wrong" on line 185 of the first file
  58 + And I switch to the merge request's comments tab
  59 + Then I should see a discussion has started on commit bcf03b5de6c:L185
  60 +
  61 + @javascript
  62 + Scenario: I comment on a commit in merge request
  63 + Given project "Shop" have "Bug NS-05" open merge request with diffs inside
  64 + And I visit merge request page "Bug NS-05"
  65 + And I click on the first commit in the merge request
  66 + And I leave a comment on the diff page
  67 + And I switch to the merge request's comments tab
  68 + Then I should see a discussion has started on commit bcf03b5de6c
... ...
features/steps/project/comments_on_commit_diffs.rb 0 → 100644
... ... @@ -0,0 +1,6 @@
  1 +class CommentsOnCommitDiffs < Spinach::FeatureSteps
  2 + include SharedAuthentication
  3 + include SharedDiffNote
  4 + include SharedPaths
  5 + include SharedProject
  6 +end
... ...
features/steps/project/comments_on_commits.rb 0 → 100644
... ... @@ -0,0 +1,6 @@
  1 +class CommentsOnCommits < Spinach::FeatureSteps
  2 + include SharedAuthentication
  3 + include SharedNote
  4 + include SharedPaths
  5 + include SharedProject
  6 +end
... ...
features/steps/project/project_comment_commit.rb
... ... @@ -1,6 +0,0 @@
1   -class ProjectCommentCommit < Spinach::FeatureSteps
2   - include SharedAuthentication
3   - include SharedProject
4   - include SharedNote
5   - include SharedPaths
6   -end
features/steps/project/project_merge_requests.rb
... ... @@ -4,50 +4,55 @@ class ProjectMergeRequests &lt; Spinach::FeatureSteps
4 4 include SharedNote
5 5 include SharedPaths
6 6  
7   - Then 'I should see "Bug NS-04" in merge requests' do
8   - page.should have_content "Bug NS-04"
  7 + Given 'I click link "New Merge Request"' do
  8 + click_link "New Merge Request"
9 9 end
10 10  
11   - And 'I should not see "Feature NS-03" in merge requests' do
12   - page.should_not have_content "Feature NS-03"
  11 + Given 'I click link "Bug NS-04"' do
  12 + click_link "Bug NS-04"
  13 + end
  14 +
  15 + Given 'I click link "All"' do
  16 + click_link "All"
13 17 end
14 18  
15 19 Given 'I click link "Closed"' do
16 20 click_link "Closed"
17 21 end
18 22  
19   - Then 'I should see "Feature NS-03" in merge requests' do
20   - page.should have_content "Feature NS-03"
  23 + Then 'I should see merge request "Wiki Feature"' do
  24 + page.should have_content "Wiki Feature"
21 25 end
22 26  
23   - And 'I should not see "Bug NS-04" in merge requests' do
24   - page.should_not have_content "Bug NS-04"
  27 + Then 'I should see closed merge request "Bug NS-04"' do
  28 + mr = MergeRequest.find_by_title("Bug NS-04")
  29 + mr.closed.should be_true
  30 + page.should have_content "Closed by"
25 31 end
26 32  
27   - Given 'I click link "All"' do
28   - click_link "All"
  33 + Then 'I should see merge request "Bug NS-04"' do
  34 + page.should have_content "Bug NS-04"
29 35 end
30 36  
31   - Given 'I click link "Bug NS-04"' do
32   - click_link "Bug NS-04"
  37 + Then 'I should see "Bug NS-04" in merge requests' do
  38 + page.should have_content "Bug NS-04"
33 39 end
34 40  
35   - Then 'I should see merge request "Bug NS-04"' do
36   - page.should have_content "Bug NS-04"
  41 + Then 'I should see "Feature NS-03" in merge requests' do
  42 + page.should have_content "Feature NS-03"
37 43 end
38 44  
39   - And 'I click link "Close"' do
40   - click_link "Close"
  45 + And 'I should not see "Feature NS-03" in merge requests' do
  46 + page.should_not have_content "Feature NS-03"
41 47 end
42 48  
43   - Then 'I should see closed merge request "Bug NS-04"' do
44   - mr = MergeRequest.find_by_title("Bug NS-04")
45   - mr.closed.should be_true
46   - page.should have_content "Closed by"
  49 +
  50 + And 'I should not see "Bug NS-04" in merge requests' do
  51 + page.should_not have_content "Bug NS-04"
47 52 end
48 53  
49   - Given 'I click link "New Merge Request"' do
50   - click_link "New Merge Request"
  54 + And 'I click link "Close"' do
  55 + click_link "Close"
51 56 end
52 57  
53 58 And 'I submit new merge request "Wiki Feature"' do
... ... @@ -57,24 +62,91 @@ class ProjectMergeRequests &lt; Spinach::FeatureSteps
57 62 click_button "Submit merge request"
58 63 end
59 64  
60   - Then 'I should see merge request "Wiki Feature"' do
61   - page.should have_content "Wiki Feature"
62   - end
63   -
64 65 And 'project "Shop" have "Bug NS-04" open merge request' do
65 66 project = Project.find_by_name("Shop")
66 67 create(:merge_request,
67   - :title => "Bug NS-04",
68   - :project => project,
69   - :author => project.users.first)
  68 + title: "Bug NS-04",
  69 + project: project,
  70 + author: project.users.first)
  71 + end
  72 +
  73 + And 'project "Shop" have "Bug NS-05" open merge request with diffs inside' do
  74 + project = Project.find_by_name("Shop")
  75 + create(:merge_request_with_diffs,
  76 + title: "Bug NS-05",
  77 + project: project,
  78 + author: project.users.first)
70 79 end
71 80  
72 81 And 'project "Shop" have "Feature NS-03" closed merge request' do
73 82 project = Project.find_by_name("Shop")
74 83 create(:merge_request,
75   - :title => "Feature NS-03",
76   - :project => project,
77   - :author => project.users.first,
78   - :closed => true)
  84 + title: "Feature NS-03",
  85 + project: project,
  86 + author: project.users.first,
  87 + closed: true)
  88 + end
  89 +
  90 + And 'I switch to the diff tab' do
  91 + mr = MergeRequest.find_by_title("Bug NS-05")
  92 + visit diffs_project_merge_request_path(mr.project, mr)
  93 + end
  94 +
  95 + And 'I switch to the merge request\'s comments tab' do
  96 + mr = MergeRequest.find_by_title("Bug NS-05")
  97 + visit project_merge_request_path(mr.project, mr)
  98 + end
  99 +
  100 + And 'I click on the first commit in the merge request' do
  101 + mr = MergeRequest.find_by_title("Bug NS-05")
  102 + click_link mr.commits.first.short_id(8)
  103 + end
  104 +
  105 + And 'I leave a comment on the diff page' do
  106 + within(:xpath, "//div[@class='note-form-holder']") do
  107 + fill_in "note_note", with: "One comment to rule them all"
  108 + click_button "Add Comment"
  109 + end
  110 + end
  111 +
  112 + And 'I leave a comment like "Line is wrong" on line 185 of the first file' do
  113 + save_and_open_page
  114 + within(:xpath, "//div[@class='diff_file'][1]") do
  115 + click_link "add-diff-line-note-0_185_185"
  116 + end
  117 +
  118 + within(:xpath, "//div[@class='line-note-form-holder']") do
  119 + fill_in "note_note", with: "Line is wrong"
  120 + click_button "Add Comment"
  121 + end
  122 + end
  123 +
  124 + Then 'I should see a discussion has started on line 185' do
  125 + mr = MergeRequest.find_by_title("Bug NS-05")
  126 + first_commit = mr.commits.first
  127 + first_diff = mr.diffs.first
  128 + page.should have_content "#{current_user.name} started a discussion on this merge request diff"
  129 + page.should have_content "#{first_diff.b_path}:L185"
  130 + page.should have_content "Line is wrong"
  131 + end
  132 +
  133 + Then 'I should see a discussion has started on commit bcf03b5de6c:L185' do
  134 + mr = MergeRequest.find_by_title("Bug NS-05")
  135 + first_commit = mr.commits.first
  136 + first_diff = mr.diffs.first
  137 + page.should have_content "#{current_user.name} started a discussion on commit"
  138 + page.should have_content first_commit.short_id(8)
  139 + page.should have_content "#{first_diff.b_path}:L185"
  140 + page.should have_content "Line is wrong"
  141 + end
  142 +
  143 + Then 'I should see a discussion has started on commit bcf03b5de6c' do
  144 + mr = MergeRequest.find_by_title("Bug NS-05")
  145 + first_commit = mr.st_commits.first
  146 + first_diff = mr.diffs.first
  147 + page.should have_content "#{current_user.name} started a discussion on commit"
  148 + page.should have_content first_commit.short_id(8)
  149 + page.should have_content "One comment to rule them all"
  150 + page.should_not have_content "#{first_diff.b_path}:L185"
79 151 end
80 152 end
... ...
features/steps/shared/diff_note.rb 0 → 100644
... ... @@ -0,0 +1,158 @@
  1 +module SharedDiffNote
  2 + include Spinach::DSL
  3 +
  4 + Given 'I cancel the diff comment' do
  5 + within(".diff_file") do
  6 + find(".js-close-discussion-note-form").trigger("click")
  7 + end
  8 + end
  9 +
  10 + Given 'I delete a diff comment' do
  11 + within(".diff_file") do
  12 + first(".js-note-delete").trigger("click")
  13 + end
  14 + end
  15 +
  16 + Given 'I haven\'t written any diff comment text' do
  17 + within(".diff_file") do
  18 + fill_in "note[note]", with: ""
  19 + end
  20 + end
  21 +
  22 + Given 'I leave a diff comment like "Typo, please fix"' do
  23 + find("#586fb7c4e1add2d4d24e27566ed7064680098646_29_14.line_holder .js-add-diff-note-button").trigger("click")
  24 + within(".diff_file") do
  25 + fill_in "note[note]", with: "Typo, please fix"
  26 + #click_button("Add Comment")
  27 + find(".js-comment-button").trigger("click")
  28 + end
  29 + end
  30 +
  31 + Given 'I preview a diff comment text like "Should fix it :smile:"' do
  32 + find("#586fb7c4e1add2d4d24e27566ed7064680098646_29_14.line_holder .js-add-diff-note-button").trigger("click")
  33 + within(".diff_file") do
  34 + fill_in "note[note]", with: "Should fix it :smile:"
  35 + find(".js-note-preview-button").trigger("click")
  36 + end
  37 + end
  38 +
  39 + Given 'I preview another diff comment text like "DRY this up"' do
  40 + find("#586fb7c4e1add2d4d24e27566ed7064680098646_57_41.line_holder .js-add-diff-note-button").trigger("click")
  41 + within(".diff_file") do
  42 + fill_in "note[note]", with: "DRY this up"
  43 + find(".js-note-preview-button").trigger("click")
  44 + end
  45 + end
  46 +
  47 + Given 'I open a diff comment form' do
  48 + find("#586fb7c4e1add2d4d24e27566ed7064680098646_29_14.line_holder .js-add-diff-note-button").trigger("click")
  49 + end
  50 +
  51 + Given 'I open another diff comment form' do
  52 + find("#586fb7c4e1add2d4d24e27566ed7064680098646_57_41.line_holder .js-add-diff-note-button").trigger("click")
  53 + end
  54 +
  55 + Given 'I write a diff comment like ":-1: I don\'t like this"' do
  56 + within(".diff_file") do
  57 + fill_in "note[note]", with: ":-1: I don\'t like this"
  58 + end
  59 + end
  60 +
  61 + Given 'I submit the diff comment' do
  62 + within(".diff_file") do
  63 + click_button("Add Comment")
  64 + end
  65 + end
  66 +
  67 +
  68 +
  69 + Then 'I should not see the diff comment form' do
  70 + within(".diff_file") do
  71 + page.should_not have_css("form.new_note")
  72 + end
  73 + end
  74 +
  75 + Then 'I should not see the diff comment preview button' do
  76 + within(".diff_file") do
  77 + page.should have_css(".js-note-preview-button", visible: false)
  78 + end
  79 + end
  80 +
  81 + Then 'I should not see the diff comment text field' do
  82 + within(".diff_file") do
  83 + page.should have_css(".js-note-text", visible: false)
  84 + end
  85 + end
  86 +
  87 + Then 'I should only see one diff form' do
  88 + within(".diff_file") do
  89 + page.should have_css("form.new_note", count: 1)
  90 + end
  91 + end
  92 +
  93 + Then 'I should see a diff comment form with ":-1: I don\'t like this"' do
  94 + within(".diff_file") do
  95 + page.should have_field("note[note]", with: ":-1: I don\'t like this")
  96 + end
  97 + end
  98 +
  99 + Then 'I should see a diff comment saying "Typo, please fix"' do
  100 + within(".diff_file .note") do
  101 + page.should have_content("Typo, please fix")
  102 + end
  103 + end
  104 +
  105 + Then 'I should see a discussion reply button' do
  106 + within(".diff_file") do
  107 + page.should have_link("Reply")
  108 + end
  109 + end
  110 +
  111 + Then 'I should see a temporary diff comment form' do
  112 + within(".diff_file") do
  113 + page.should have_css(".js-temp-notes-holder form.new_note")
  114 + end
  115 + end
  116 +
  117 + Then 'I should see add a diff comment button' do
  118 + page.should have_css(".js-add-diff-note-button", visible: false)
  119 + end
  120 +
  121 + Then 'I should see an empty diff comment form' do
  122 + within(".diff_file") do
  123 + page.should have_field("note[note]", with: "")
  124 + end
  125 + end
  126 +
  127 + Then 'I should see the cancel comment button' do
  128 + within(".diff_file form") do
  129 + page.should have_css(".js-close-discussion-note-form", text: "Cancel")
  130 + end
  131 + end
  132 +
  133 + Then 'I should see the diff comment preview' do
  134 + within(".diff_file form") do
  135 + page.should have_css(".js-note-preview", visible: false)
  136 + end
  137 + end
  138 +
  139 + Then 'I should see the diff comment edit button' do
  140 + within(".diff_file") do
  141 + page.should have_css(".js-note-edit-button", visible: true)
  142 + end
  143 + end
  144 +
  145 + Then 'I should see the diff comment preview button' do
  146 + within(".diff_file") do
  147 + page.should have_css(".js-note-preview-button", visible: true)
  148 + end
  149 + end
  150 +
  151 + Then 'I should see two separate previews' do
  152 + within(".diff_file") do
  153 + page.should have_css(".js-note-preview", visible: true, count: 2)
  154 + page.should have_content("Should fix it")
  155 + page.should have_content("DRY this up")
  156 + end
  157 + end
  158 +end
... ...
features/steps/shared/note.rb
1 1 module SharedNote
2 2 include Spinach::DSL
3 3  
  4 + Given 'I delete a comment' do
  5 + first(".js-note-delete").trigger("click")
  6 + end
  7 +
  8 + Given 'I haven\'t written any comment text' do
  9 + within(".js-main-target-form") do
  10 + fill_in "note[note]", with: ""
  11 + end
  12 + end
  13 +
4 14 Given 'I leave a comment like "XML attached"' do
5   - fill_in "note_note", :with => "XML attached"
6   - click_button "Add Comment"
  15 + within(".js-main-target-form") do
  16 + fill_in "note[note]", with: "XML attached"
  17 + click_button "Add Comment"
  18 + end
7 19 end
8 20  
9   - Then 'I should see comment "XML attached"' do
10   - page.should have_content "XML attached"
  21 + Given 'I preview a comment text like "Bug fixed :smile:"' do
  22 + within(".js-main-target-form") do
  23 + fill_in "note[note]", with: "Bug fixed :smile:"
  24 + find(".js-note-preview-button").trigger("click")
  25 + end
11 26 end
12 27  
  28 + Given 'I submit the comment' do
  29 + within(".js-main-target-form") do
  30 + click_button "Add Comment"
  31 + end
  32 + end
  33 +
  34 + Given 'I write a comment like "Nice"' do
  35 + within(".js-main-target-form") do
  36 + fill_in "note[note]", with: "Nice"
  37 + end
  38 + end
  39 +
  40 +
  41 +
  42 + Then 'I should not see a comment saying "XML attached"' do
  43 + page.should_not have_css(".note")
  44 + end
  45 +
  46 + Then 'I should not see the cancel comment button' do
  47 + within(".js-main-target-form") do
  48 + should_not have_link("Cancel")
  49 + end
  50 + end
  51 +
  52 + Then 'I should not see the comment preview' do
  53 + within(".js-main-target-form") do
  54 + page.should have_css(".js-note-preview", visible: false)
  55 + end
  56 + end
  57 +
  58 + Then 'I should not see the comment preview button' do
  59 + within(".js-main-target-form") do
  60 + page.should have_css(".js-note-preview-button", visible: false)
  61 + end
  62 + end
  63 +
  64 + Then 'I should not see the comment text field' do
  65 + within(".js-main-target-form") do
  66 + page.should have_css(".js-note-text", visible: false)
  67 + end
  68 + end
  69 +
  70 + Then 'I should see a comment saying "XML attached"' do
  71 + within(".note") do
  72 + page.should have_content("XML attached")
  73 + end
  74 + end
  75 +
  76 + Then 'I should see an empty comment text field' do
  77 + within(".js-main-target-form") do
  78 + page.should have_field("note[note]", with: "")
  79 + end
  80 + end
  81 +
  82 + Then 'I should see the comment edit button' do
  83 + within(".js-main-target-form") do
  84 + page.should have_css(".js-note-edit-button", visible: true)
  85 + end
  86 + end
  87 +
  88 + Then 'I should see the comment preview' do
  89 + within(".js-main-target-form") do
  90 + page.should have_css(".js-note-preview", visible: true)
  91 + end
  92 + end
  93 +
  94 + Then 'I should see the comment preview button' do
  95 + within(".js-main-target-form") do
  96 + page.should have_css(".js-note-preview-button", visible: true)
  97 + end
  98 + end
  99 +
  100 +
  101 +
  102 + # Wall
  103 +
13 104 Given 'I write new comment "my special test message"' do
14   - fill_in "note_note", :with => "my special test message"
15   - click_button "Add Comment"
  105 + within(".js-main-target-form") do
  106 + fill_in "note[note]", with: "my special test message"
  107 + click_button "Add Comment"
  108 + end
16 109 end
17 110  
18 111 Then 'I should see project wall note "my special test message"' do
... ...
features/steps/shared/paths.rb
... ... @@ -224,6 +224,11 @@ module SharedPaths
224 224 visit project_merge_request_path(mr.project, mr)
225 225 end
226 226  
  227 + Given 'I visit merge request page "Bug NS-05"' do
  228 + mr = MergeRequest.find_by_title("Bug NS-05")
  229 + visit project_merge_request_path(mr.project, mr)
  230 + end
  231 +
227 232 And 'I visit project "Shop" merge requests page' do
228 233 visit project_merge_requests_path(Project.find_by_name("Shop"))
229 234 end
... ...
lib/gitlab/markdown.rb
... ... @@ -25,22 +25,6 @@ module Gitlab
25 25 # >> gfm(":trollface:")
26 26 # => "<img alt=\":trollface:\" class=\"emoji\" src=\"/images/trollface.png" title=\":trollface:\" />
27 27 module Markdown
28   - REFERENCE_PATTERN = %r{
29   - (?<prefix>\W)? # Prefix
30   - ( # Reference
31   - @(?<user>[a-zA-Z][a-zA-Z0-9_\-\.]*) # User name
32   - |\#(?<issue>\d+) # Issue ID
33   - |!(?<merge_request>\d+) # MR ID
34   - |\$(?<snippet>\d+) # Snippet ID
35   - |(?<commit>[\h]{6,40}) # Commit ID
36   - )
37   - (?<suffix>\W)? # Suffix
38   - }x.freeze
39   -
40   - TYPES = [:user, :issue, :merge_request, :snippet, :commit].freeze
41   -
42   - EMOJI_PATTERN = %r{(:(\S+):)}.freeze
43   -
44 28 attr_reader :html_options
45 29  
46 30 # Public: Parse the provided text with GitLab-Flavored Markdown
... ... @@ -96,6 +80,20 @@ module Gitlab
96 80 text
97 81 end
98 82  
  83 + REFERENCE_PATTERN = %r{
  84 + (?<prefix>\W)? # Prefix
  85 + ( # Reference
  86 + @(?<user>[a-zA-Z][a-zA-Z0-9_\-\.]*) # User name
  87 + |\#(?<issue>\d+) # Issue ID
  88 + |!(?<merge_request>\d+) # MR ID
  89 + |\$(?<snippet>\d+) # Snippet ID
  90 + |(?<commit>[\h]{6,40}) # Commit ID
  91 + )
  92 + (?<suffix>\W)? # Suffix
  93 + }x.freeze
  94 +
  95 + TYPES = [:user, :issue, :merge_request, :snippet, :commit].freeze
  96 +
99 97 def parse_references(text)
100 98 # parse reference links
101 99 text.gsub!(REFERENCE_PATTERN) do |match|
... ... @@ -115,11 +113,13 @@ module Gitlab
115 113 end
116 114 end
117 115  
  116 + EMOJI_PATTERN = %r{(:(\S+):)}.freeze
  117 +
118 118 def parse_emoji(text)
119 119 # parse emoji
120 120 text.gsub!(EMOJI_PATTERN) do |match|
121 121 if valid_emoji?($2)
122   - image_tag("emoji/#{$2}.png", size: "20x20", class: 'emoji', title: $1, alt: $1)
  122 + image_tag("emoji/#{$2}.png", class: 'emoji', title: $1, alt: $1, size: "20x20")
123 123 else
124 124 match
125 125 end
... ...
spec/factories.rb
... ... @@ -73,8 +73,8 @@ FactoryGirl.define do
73 73  
74 74 # pick 3 commits "at random" (from bcf03b5d~3 to bcf03b5d)
75 75 trait :with_diffs do
76   - target_branch "bcf03b5d~3"
77   - source_branch "bcf03b5d"
  76 + target_branch "master" # pretend bcf03b5d~3
  77 + source_branch "stable" # pretend bcf03b5d
78 78 st_commits do
79 79 [Commit.new(project.repo.commit('bcf03b5d')),
80 80 Commit.new(project.repo.commit('bcf03b5d~1')),
... ... @@ -92,6 +92,32 @@ FactoryGirl.define do
92 92 factory :note do
93 93 project
94 94 note "Note"
  95 + author
  96 +
  97 + factory :note_on_commit, traits: [:on_commit]
  98 + factory :note_on_commit_diff, traits: [:on_commit, :on_diff]
  99 + factory :note_on_issue, traits: [:on_issue], aliases: [:votable_note]
  100 + factory :note_on_merge_request, traits: [:on_merge_request]
  101 + factory :note_on_merge_request_diff, traits: [:on_merge_request, :on_diff]
  102 +
  103 + trait :on_commit do
  104 + commit_id "bcf03b5de6c33f3869ef70d68cf06e679d1d7f9a"
  105 + noteable_type "Commit"
  106 + end
  107 +
  108 + trait :on_diff do
  109 + line_code "0_184_184"
  110 + end
  111 +
  112 + trait :on_merge_request do
  113 + noteable_id 1
  114 + noteable_type "MergeRequest"
  115 + end
  116 +
  117 + trait :on_issue do
  118 + noteable_id 1
  119 + noteable_type "Issue"
  120 + end
95 121 end
96 122  
97 123 factory :event do
... ...
spec/lib/votes_spec.rb
1 1 require 'spec_helper'
2 2  
3 3 describe Issue do
4   - let(:issue) { create(:issue) }
  4 + it { should include_module(Votes) }
  5 +end
  6 +
  7 +describe MergeRequest do
  8 + let(:merge_request) { FactoryGirl.create(:merge_request_with_diffs) }
  9 +
  10 + it { should include_module(Votes) }
5 11  
6 12 describe "#upvotes" do
7 13 it "with no notes has a 0/0 score" do
8   - issue.upvotes.should == 0
  14 + merge_request.upvotes.should == 0
9 15 end
10 16  
11 17 it "should recognize non-+1 notes" do
12   - issue.notes << create(:note, note: "No +1 here")
13   - issue.should have(1).note
14   - issue.notes.first.upvote?.should be_false
15   - issue.upvotes.should == 0
  18 + merge_request.notes << create(:note, note: "No +1 here")
  19 + merge_request.should have(1).note
  20 + merge_request.notes.first.upvote?.should be_false
  21 + merge_request.upvotes.should == 0
16 22 end
17 23  
18 24 it "should recognize a single +1 note" do
19   - issue.notes << create(:note, note: "+1 This is awesome")
20   - issue.upvotes.should == 1
  25 + merge_request.notes << create(:note, note: "+1 This is awesome")
  26 + merge_request.upvotes.should == 1
21 27 end
22 28  
23 29 it "should recognize multiple +1 notes" do
24   - issue.notes << create(:note, note: "+1 This is awesome")
25   - issue.notes << create(:note, note: "+1 I want this")
26   - issue.upvotes.should == 2
  30 + merge_request.notes << create(:note, note: "+1 This is awesome")
  31 + merge_request.notes << create(:note, note: "+1 I want this")
  32 + merge_request.upvotes.should == 2
27 33 end
28 34 end
29 35  
30 36 describe "#downvotes" do
31 37 it "with no notes has a 0/0 score" do
32   - issue.downvotes.should == 0
  38 + merge_request.downvotes.should == 0
33 39 end
34 40  
35 41 it "should recognize non--1 notes" do
36   - issue.notes << create(:note, note: "Almost got a -1")
37   - issue.should have(1).note
38   - issue.notes.first.downvote?.should be_false
39   - issue.downvotes.should == 0
  42 + merge_request.notes << create(:note, note: "Almost got a -1")
  43 + merge_request.should have(1).note
  44 + merge_request.notes.first.downvote?.should be_false
  45 + merge_request.downvotes.should == 0
40 46 end
41 47  
42 48 it "should recognize a single -1 note" do
43   - issue.notes << create(:note, note: "-1 This is bad")
44   - issue.downvotes.should == 1
  49 + merge_request.notes << create(:note, note: "-1 This is bad")
  50 + merge_request.downvotes.should == 1
45 51 end
46 52  
47 53 it "should recognize multiple -1 notes" do
48   - issue.notes << create(:note, note: "-1 This is bad")
49   - issue.notes << create(:note, note: "-1 Away with this")
50   - issue.downvotes.should == 2
  54 + merge_request.notes << create(:note, note: "-1 This is bad")
  55 + merge_request.notes << create(:note, note: "-1 Away with this")
  56 + merge_request.downvotes.should == 2
51 57 end
52 58 end
53 59  
54 60 describe "#votes_count" do
55 61 it "with no notes has a 0/0 score" do
56   - issue.votes_count.should == 0
  62 + merge_request.votes_count.should == 0
57 63 end
58 64  
59 65 it "should recognize non notes" do
60   - issue.notes << create(:note, note: "No +1 here")
61   - issue.should have(1).note
62   - issue.votes_count.should == 0
  66 + merge_request.notes << create(:note, note: "No +1 here")
  67 + merge_request.should have(1).note
  68 + merge_request.votes_count.should == 0
63 69 end
64 70  
65 71 it "should recognize a single +1 note" do
66   - issue.notes << create(:note, note: "+1 This is awesome")
67   - issue.votes_count.should == 1
  72 + merge_request.notes << create(:note, note: "+1 This is awesome")
  73 + merge_request.votes_count.should == 1
68 74 end
69 75  
70 76 it "should recognize a single -1 note" do
71   - issue.notes << create(:note, note: "-1 This is bad")
72   - issue.votes_count.should == 1
  77 + merge_request.notes << create(:note, note: "-1 This is bad")
  78 + merge_request.votes_count.should == 1
73 79 end
74 80  
75 81 it "should recognize multiple notes" do
76   - issue.notes << create(:note, note: "+1 This is awesome")
77   - issue.notes << create(:note, note: "-1 This is bad")
78   - issue.notes << create(:note, note: "+1 I want this")
79   - issue.votes_count.should == 3
  82 + merge_request.notes << create(:note, note: "+1 This is awesome")
  83 + merge_request.notes << create(:note, note: "-1 This is bad")
  84 + merge_request.notes << create(:note, note: "+1 I want this")
  85 + merge_request.votes_count.should == 3
80 86 end
81 87 end
82 88  
83 89 describe "#upvotes_in_percent" do
84 90 it "with no notes has a 0% score" do
85   - issue.upvotes_in_percent.should == 0
  91 + merge_request.upvotes_in_percent.should == 0
86 92 end
87 93  
88 94 it "should count a single 1 note as 100%" do
89   - issue.notes << create(:note, note: "+1 This is awesome")
90   - issue.upvotes_in_percent.should == 100
  95 + merge_request.notes << create(:note, note: "+1 This is awesome")
  96 + merge_request.upvotes_in_percent.should == 100
91 97 end
92 98  
93 99 it "should count multiple +1 notes as 100%" do
94   - issue.notes << create(:note, note: "+1 This is awesome")
95   - issue.notes << create(:note, note: "+1 I want this")
96   - issue.upvotes_in_percent.should == 100
  100 + merge_request.notes << create(:note, note: "+1 This is awesome")
  101 + merge_request.notes << create(:note, note: "+1 I want this")
  102 + merge_request.upvotes_in_percent.should == 100
97 103 end
98 104  
99 105 it "should count fractions for multiple +1 and -1 notes correctly" do
100   - issue.notes << create(:note, note: "+1 This is awesome")
101   - issue.notes << create(:note, note: "+1 I want this")
102   - issue.notes << create(:note, note: "-1 This is bad")
103   - issue.notes << create(:note, note: "+1 me too")
104   - issue.upvotes_in_percent.should == 75
  106 + merge_request.notes << create(:note, note: "+1 This is awesome")
  107 + merge_request.notes << create(:note, note: "+1 I want this")
  108 + merge_request.notes << create(:note, note: "-1 This is bad")
  109 + merge_request.notes << create(:note, note: "+1 me too")
  110 + merge_request.upvotes_in_percent.should == 75
105 111 end
106 112 end
107 113  
108 114 describe "#downvotes_in_percent" do
109 115 it "with no notes has a 0% score" do
110   - issue.downvotes_in_percent.should == 0
  116 + merge_request.downvotes_in_percent.should == 0
111 117 end
112 118  
113 119 it "should count a single -1 note as 100%" do
114   - issue.notes << create(:note, note: "-1 This is bad")
115   - issue.downvotes_in_percent.should == 100
  120 + merge_request.notes << create(:note, note: "-1 This is bad")
  121 + merge_request.downvotes_in_percent.should == 100
116 122 end
117 123  
118 124 it "should count multiple -1 notes as 100%" do
119   - issue.notes << create(:note, note: "-1 This is bad")
120   - issue.notes << create(:note, note: "-1 Away with this")
121   - issue.downvotes_in_percent.should == 100
  125 + merge_request.notes << create(:note, note: "-1 This is bad")
  126 + merge_request.notes << create(:note, note: "-1 Away with this")
  127 + merge_request.downvotes_in_percent.should == 100
122 128 end
123 129  
124 130 it "should count fractions for multiple +1 and -1 notes correctly" do
125   - issue.notes << create(:note, note: "+1 This is awesome")
126   - issue.notes << create(:note, note: "+1 I want this")
127   - issue.notes << create(:note, note: "-1 This is bad")
128   - issue.notes << create(:note, note: "+1 me too")
129   - issue.downvotes_in_percent.should == 25
  131 + merge_request.notes << create(:note, note: "+1 This is awesome")
  132 + merge_request.notes << create(:note, note: "+1 I want this")
  133 + merge_request.notes << create(:note, note: "-1 This is bad")
  134 + merge_request.notes << create(:note, note: "+1 me too")
  135 + merge_request.downvotes_in_percent.should == 25
130 136 end
131 137 end
132 138 end
... ...
spec/models/note_spec.rb
... ... @@ -38,34 +38,34 @@ describe Note do
38 38 let(:project) { create(:project) }
39 39  
40 40 it "recognizes a neutral note" do
41   - note = create(:note, note: "This is not a +1 note")
  41 + note = create(:votable_note, note: "This is not a +1 note")
42 42 note.should_not be_upvote
43 43 note.should_not be_downvote
44 44 end
45 45  
46 46 it "recognizes a neutral emoji note" do
47   - note = build(:note, note: "I would :+1: this, but I don't want to")
  47 + note = build(:votable_note, note: "I would :+1: this, but I don't want to")
48 48 note.should_not be_upvote
49 49 note.should_not be_downvote
50 50 end
51 51  
52 52 it "recognizes a +1 note" do
53   - note = create(:note, note: "+1 for this")
  53 + note = create(:votable_note, note: "+1 for this")
54 54 note.should be_upvote
55 55 end
56 56  
57 57 it "recognizes a +1 emoji as a vote" do
58   - note = build(:note, note: ":+1: for this")
  58 + note = build(:votable_note, note: ":+1: for this")
59 59 note.should be_upvote
60 60 end
61 61  
62 62 it "recognizes a -1 note" do
63   - note = create(:note, note: "-1 for this")
  63 + note = create(:votable_note, note: "-1 for this")
64 64 note.should be_downvote
65 65 end
66 66  
67 67 it "recognizes a -1 emoji as a vote" do
68   - note = build(:note, note: ":-1: for this")
  68 + note = build(:votable_note, note: ":-1: for this")
69 69 note.should be_downvote
70 70 end
71 71 end
... ... @@ -74,43 +74,72 @@ describe Note do
74 74 let(:commit) { project.repository.commit }
75 75  
76 76 describe "Commit notes" do
77   - before do
78   - @note = create(:note,
79   - commit_id: commit.id,
80   - noteable_type: "Commit")
81   - end
  77 + let!(:note) { create(:note_on_commit, note: "+1 from me") }
  78 + let!(:commit) { note.noteable }
82 79  
83 80 it "should be accessible through #noteable" do
84   - @note.commit_id.should == commit.id
85   - @note.noteable.should be_a(Commit)
86   - @note.noteable.should == commit
  81 + note.commit_id.should == commit.id
  82 + note.noteable.should be_a(Commit)
  83 + note.noteable.should == commit
87 84 end
88 85  
89 86 it "should save a valid note" do
90   - @note.commit_id.should == commit.id
91   - @note.noteable == commit
  87 + note.commit_id.should == commit.id
  88 + note.noteable == commit
92 89 end
93 90  
94 91 it "should be recognized by #for_commit?" do
95   - @note.should be_for_commit
  92 + note.should be_for_commit
96 93 end
97   - end
98 94  
99   - describe "Pre-line commit notes" do
100   - before do
101   - @note = create(:note,
102   - commit_id: commit.id,
103   - noteable_type: "Commit",
104   - line_code: "0_16_1")
  95 + it "should not be votable" do
  96 + note.should_not be_votable
105 97 end
  98 + end
  99 +
  100 + describe "Commit diff line notes" do
  101 + let!(:note) { create(:note_on_commit_line, note: "+1 from me") }
  102 + let!(:commit) { note.noteable }
106 103  
107 104 it "should save a valid note" do
108   - @note.commit_id.should == commit.id
109   - @note.noteable.id.should == commit.id
  105 + note.commit_id.should == commit.id
  106 + note.noteable.id.should == commit.id
110 107 end
111 108  
112 109 it "should be recognized by #for_diff_line?" do
113   - @note.should be_for_diff_line
  110 + note.should be_for_diff_line
  111 + end
  112 +
  113 + it "should be recognized by #for_commit_diff_line?" do
  114 + note.should be_for_commit_diff_line
  115 + end
  116 +
  117 + it "should not be votable" do
  118 + note.should_not be_votable
  119 + end
  120 + end
  121 +
  122 + describe "Issue notes" do
  123 + let!(:note) { create(:note_on_issue, note: "+1 from me") }
  124 +
  125 + it "should not be votable" do
  126 + note.should be_votable
  127 + end
  128 + end
  129 +
  130 + describe "Merge request notes" do
  131 + let!(:note) { create(:note_on_merge_request, note: "+1 from me") }
  132 +
  133 + it "should not be votable" do
  134 + note.should be_votable
  135 + end
  136 + end
  137 +
  138 + describe "Merge request diff line notes" do
  139 + let!(:note) { create(:note_on_merge_request_line, note: "+1 from me") }
  140 +
  141 + it "should not be votable" do
  142 + note.should_not be_votable
114 143 end
115 144 end
116 145  
... ...
spec/requests/notes_on_merge_requests_spec.rb 0 → 100644
... ... @@ -0,0 +1,233 @@
  1 +require 'spec_helper'
  2 +
  3 +describe "On a merge request", js: true do
  4 + let!(:project) { create(:project) }
  5 + let!(:merge_request) { create(:merge_request, project: project) }
  6 +
  7 + before do
  8 + login_as :user
  9 + project.team << [@user, :master]
  10 +
  11 + visit project_merge_request_path(project, merge_request)
  12 + end
  13 +
  14 + subject { page }
  15 +
  16 + describe "the note form" do
  17 + # main target form creation
  18 + it { should have_css(".js-main-target-form", visible: true, count: 1) }
  19 +
  20 + # button initalization
  21 + it { within(".js-main-target-form") { should have_button("Add Comment") } }
  22 + it { within(".js-main-target-form") { should_not have_link("Cancel") } }
  23 +
  24 + # notifiactions
  25 + it { within(".js-main-target-form") { should have_checked_field("Project team") } }
  26 + it { within(".js-main-target-form") { should_not have_checked_field("Commit author") } }
  27 + it { within(".js-main-target-form") { should_not have_unchecked_field("Commit author") } }
  28 +
  29 + describe "without text" do
  30 + it { within(".js-main-target-form") { should have_css(".js-note-preview-button", visible: false) } }
  31 + end
  32 +
  33 + describe "with text" do
  34 + before do
  35 + within(".js-main-target-form") do
  36 + fill_in "note[note]", with: "This is awesome"
  37 + end
  38 + end
  39 +
  40 + it { within(".js-main-target-form") { should_not have_css(".js-comment-button[disabled]") } }
  41 +
  42 + it { within(".js-main-target-form") { should have_css(".js-note-preview-button", visible: true) } }
  43 + end
  44 +
  45 + describe "with preview" do
  46 + before do
  47 + within(".js-main-target-form") do
  48 + fill_in "note[note]", with: "This is awesome"
  49 + find(".js-note-preview-button").trigger("click")
  50 + end
  51 + end
  52 +
  53 + it { within(".js-main-target-form") { should have_css(".js-note-preview", text: "This is awesome", visible: true) } }
  54 +
  55 + it { within(".js-main-target-form") { should have_css(".js-note-preview-button", visible: false) } }
  56 + it { within(".js-main-target-form") { should have_css(".js-note-edit-button", visible: true) } }
  57 + end
  58 + end
  59 +
  60 + describe "when posting a note" do
  61 + before do
  62 + within(".js-main-target-form") do
  63 + fill_in "note[note]", with: "This is awsome!"
  64 + find(".js-note-preview-button").trigger("click")
  65 + click_button "Add Comment"
  66 + end
  67 + end
  68 +
  69 + # note added
  70 + it { within(".js-main-target-form") { should have_content("This is awsome!") } }
  71 +
  72 + # reset form
  73 + it { within(".js-main-target-form") { should have_no_field("note[note]", with: "This is awesome!") } }
  74 +
  75 + # return from preview
  76 + it { within(".js-main-target-form") { should have_css(".js-note-preview", visible: false) } }
  77 + it { within(".js-main-target-form") { should have_css(".js-note-text", visible: true) } }
  78 +
  79 +
  80 + it "should be removable" do
  81 + find(".js-note-delete").trigger("click")
  82 +
  83 + should_not have_css(".note")
  84 + end
  85 + end
  86 +end
  87 +
  88 +
  89 +
  90 +describe "On a merge request diff", js: true, focus: true do
  91 + let!(:project) { create(:project) }
  92 + let!(:merge_request) { create(:merge_request_with_diffs, project: project) }
  93 +
  94 + before do
  95 + login_as :user
  96 + project.team << [@user, :master]
  97 +
  98 + visit diffs_project_merge_request_path(project, merge_request)
  99 +
  100 + click_link("Diff")
  101 + end
  102 +
  103 + subject { page }
  104 +
  105 + describe "when adding a note" do
  106 + before do
  107 + find("#4735dfc552ad7bf15ca468adc3cad9d05b624490_185_185.line_holder .js-add-diff-note-button").trigger("click")
  108 + end
  109 +
  110 + describe "the notes holder" do
  111 + it { should have_css("#4735dfc552ad7bf15ca468adc3cad9d05b624490_185_185.line_holder + .js-temp-notes-holder") }
  112 +
  113 + it { within(".js-temp-notes-holder") { should have_css(".new_note") } }
  114 + end
  115 +
  116 + describe "the note form" do
  117 + # set up hidden fields correctly
  118 + it { within(".js-temp-notes-holder") { find("#note_noteable_type").value.should == "MergeRequest" } }
  119 + it { within(".js-temp-notes-holder") { find("#note_noteable_id").value.should == "" } }
  120 + it { within(".js-temp-notes-holder") { find("#note_commit_id").value.should == "bcf03b5de6c33f3869ef70d68cf06e679d1d7f9a" } }
  121 + it { within(".js-temp-notes-holder") { find("#note_line_code").value.should == "4735dfc552ad7bf15ca468adc3cad9d05b624490_185_185" } }
  122 +
  123 + # buttons
  124 + it { should have_button("Add Comment") }
  125 + it { should have_css(".js-close-discussion-note-form", text: "Cancel") }
  126 +
  127 + # notification options
  128 + it { should have_unchecked_field("Project team") }
  129 + it { should have_checked_field("Commit author") }
  130 +
  131 + it "shouldn't add a second form for same row" do
  132 + find("#4735dfc552ad7bf15ca468adc3cad9d05b624490_185_185.line_holder .js-add-diff-note-button").trigger("click")
  133 +
  134 + should have_css("#4735dfc552ad7bf15ca468adc3cad9d05b624490_185_185.line_holder + .js-temp-notes-holder form", count: 1)
  135 + end
  136 +
  137 + it "should be removed when canceled" do
  138 + find(".js-close-discussion-note-form").trigger("click")
  139 +
  140 + should have_no_css(".js-temp-notes-holder")
  141 + end
  142 + end
  143 + end
  144 +
  145 + describe "with muliple note forms" do
  146 + before do
  147 + find("#4735dfc552ad7bf15ca468adc3cad9d05b624490_185_185.line_holder .js-add-diff-note-button").trigger("click")
  148 + find("#342e16cbbd482ac2047dc679b2749d248cc1428f_18_17.line_holder .js-add-diff-note-button").trigger("click")
  149 + end
  150 +
  151 + # has two line forms
  152 + it { should have_css(".js-temp-notes-holder", count: 2) }
  153 +
  154 + describe "previewing them separately" do
  155 + before do
  156 + # add two separate texts and trigger previews on both
  157 + within("#4735dfc552ad7bf15ca468adc3cad9d05b624490_185_185.line_holder + .js-temp-notes-holder") do
  158 + fill_in "note[note]", with: "One comment on line 185"
  159 + find(".js-note-preview-button").trigger("click")
  160 + end
  161 + within("#342e16cbbd482ac2047dc679b2749d248cc1428f_18_17.line_holder + .js-temp-notes-holder") do
  162 + fill_in "note[note]", with: "Another comment on line 17"
  163 + find(".js-note-preview-button").trigger("click")
  164 + end
  165 + end
  166 +
  167 + # check if previews were rendered separately
  168 + it { within("#4735dfc552ad7bf15ca468adc3cad9d05b624490_185_185.line_holder + .js-temp-notes-holder") { should have_css(".js-note-preview", text: "One comment on line 185") } }
  169 + it { within("#342e16cbbd482ac2047dc679b2749d248cc1428f_18_17.line_holder + .js-temp-notes-holder") { should have_css(".js-note-preview", text: "Another comment on line 17") } }
  170 + end
  171 +
  172 + describe "posting a note" do
  173 + before do
  174 + within("#342e16cbbd482ac2047dc679b2749d248cc1428f_18_17.line_holder + .js-temp-notes-holder") do
  175 + fill_in "note[note]", with: "Another comment on line 17"
  176 + click_button("Add Comment")
  177 + end
  178 + end
  179 +
  180 + # removed form after submit
  181 + it { should have_no_css("#342e16cbbd482ac2047dc679b2749d248cc1428f_18_17.line_holder + .js-temp-notes-holder") }
  182 +
  183 + # added discussion
  184 + it { should have_content("Another comment on line 17") }
  185 + it { should have_css("#342e16cbbd482ac2047dc679b2749d248cc1428f_18_17.line_holder + .notes_holder") }
  186 + it { should have_css("#342e16cbbd482ac2047dc679b2749d248cc1428f_18_17.line_holder + .notes_holder .note", count: 1) }
  187 + it { should have_link("Reply") }
  188 +
  189 + it "should remove last note of a discussion" do
  190 + within("#342e16cbbd482ac2047dc679b2749d248cc1428f_18_17.line_holder + .notes_holder") do
  191 + find(".js-note-delete").trigger("click")
  192 + end
  193 +
  194 + # removed whole discussion
  195 + should_not have_css(".note_holder")
  196 + should have_css("#342e16cbbd482ac2047dc679b2749d248cc1428f_18_17.line_holder + #342e16cbbd482ac2047dc679b2749d248cc1428f_18_18.line_holder")
  197 + end
  198 + end
  199 + end
  200 +
  201 + describe "when replying to a note" do
  202 + before do
  203 + # create first note
  204 + find("#4735dfc552ad7bf15ca468adc3cad9d05b624490_184_184.line_holder .js-add-diff-note-button").trigger("click")
  205 + within("#4735dfc552ad7bf15ca468adc3cad9d05b624490_184_184.line_holder + .js-temp-notes-holder") do
  206 + fill_in "note[note]", with: "One comment on line 184"
  207 + click_button("Add Comment")
  208 + end
  209 + # create second note
  210 + within("#4735dfc552ad7bf15ca468adc3cad9d05b624490_184_184.line_holder + .notes_holder") do
  211 + find(".js-discussion-reply-button").trigger("click")
  212 + fill_in "note[note]", with: "An additional comment in reply"
  213 + click_button("Add Comment")
  214 + end
  215 + end
  216 +
  217 + # inserted note
  218 + it { should have_content("An additional comment in reply") }
  219 + it { within("#4735dfc552ad7bf15ca468adc3cad9d05b624490_184_184.line_holder + .notes_holder") { should have_css(".note", count: 2) } }
  220 +
  221 + # removed form after reply
  222 + it { within("#4735dfc552ad7bf15ca468adc3cad9d05b624490_184_184.line_holder + .notes_holder") { should have_no_css("form") } }
  223 + it { within("#4735dfc552ad7bf15ca468adc3cad9d05b624490_184_184.line_holder + .notes_holder") { should have_link("Reply") } }
  224 + end
  225 +end
  226 +
  227 +
  228 +
  229 +describe "On merge request discussion", js: true do
  230 + describe "with merge request diff note"
  231 + describe "with commit note"
  232 + describe "with commit diff note"
  233 +end
... ...
spec/requests/notes_on_wall_spec.rb 0 → 100644
... ... @@ -0,0 +1,85 @@
  1 +require 'spec_helper'
  2 +
  3 +describe "On the project wall", js: true do
  4 + let!(:project) { create(:project) }
  5 + let!(:commit) { project.commit("bcf03b5de6c33f3869ef70d68cf06e679d1d7f9a") }
  6 +
  7 + before do
  8 + login_as :user
  9 + project.team << [@user, :master]
  10 + visit wall_project_path(project)
  11 + end
  12 +
  13 + subject { page }
  14 +
  15 + describe "the note form" do
  16 + # main target form creation
  17 + it { should have_css(".js-main-target-form", visible: true, count: 1) }
  18 +
  19 + # button initalization
  20 + it { within(".js-main-target-form") { should have_button("Add Comment") } }
  21 + it { within(".js-main-target-form") { should_not have_link("Cancel") } }
  22 +
  23 + # notifiactions
  24 + it { within(".js-main-target-form") { should have_checked_field("Project team") } }
  25 + it { within(".js-main-target-form") { should_not have_checked_field("Commit author") } }
  26 + it { within(".js-main-target-form") { should_not have_unchecked_field("Commit author") } }
  27 +
  28 + describe "without text" do
  29 + it { within(".js-main-target-form") { should have_css(".js-note-preview-button", visible: false) } }
  30 + end
  31 +
  32 + describe "with text" do
  33 + before do
  34 + within(".js-main-target-form") do
  35 + fill_in "note[note]", with: "This is awesome"
  36 + end
  37 + end
  38 +
  39 + it { within(".js-main-target-form") { should_not have_css(".js-comment-button[disabled]") } }
  40 +
  41 + it { within(".js-main-target-form") { should have_css(".js-note-preview-button", visible: true) } }
  42 + end
  43 +
  44 + describe "with preview" do
  45 + before do
  46 + within(".js-main-target-form") do
  47 + fill_in "note[note]", with: "This is awesome"
  48 + find(".js-note-preview-button").trigger("click")
  49 + end
  50 + end
  51 +
  52 + it { within(".js-main-target-form") { should have_css(".js-note-preview", text: "This is awesome", visible: true) } }
  53 +
  54 + it { within(".js-main-target-form") { should have_css(".js-note-preview-button", visible: false) } }
  55 + it { within(".js-main-target-form") { should have_css(".js-note-edit-button", visible: true) } }
  56 + end
  57 + end
  58 +
  59 + describe "when posting a note" do
  60 + before do
  61 + within(".js-main-target-form") do
  62 + fill_in "note[note]", with: "This is awsome!"
  63 + find(".js-note-preview-button").trigger("click")
  64 + click_button "Add Comment"
  65 + end
  66 + end
  67 +
  68 + # note added
  69 + it { within(".js-main-target-form") { should have_content("This is awsome!") } }
  70 +
  71 + # reset form
  72 + it { within(".js-main-target-form") { should have_no_field("note[note]", with: "This is awesome!") } }
  73 +
  74 + # return from preview
  75 + it { within(".js-main-target-form") { should have_css(".js-note-preview", visible: false) } }
  76 + it { within(".js-main-target-form") { should have_css(".js-note-text", visible: true) } }
  77 +
  78 +
  79 + it "should be removable" do
  80 + find(".js-note-delete").trigger("click")
  81 +
  82 + should_not have_css(".note")
  83 + end
  84 + end
  85 +end
... ...