Commit 47f78a2c7614b0a32110e909c2244f67310d030e

Authored by Dmitriy Zaporozhets
2 parents 315d4cc8 6fa06893

Merge pull request #4440 from jackbot/editable-notes

Editable notes
app/assets/javascripts/notes.js
@@ -46,6 +46,26 @@ var NoteList = { @@ -46,6 +46,26 @@ var NoteList = {
46 ".js-note-delete", 46 ".js-note-delete",
47 NoteList.removeNote); 47 NoteList.removeNote);
48 48
  49 + // show the edit note form
  50 + $(document).on("click",
  51 + ".js-note-edit",
  52 + NoteList.showEditNoteForm);
  53 +
  54 + // cancel note editing
  55 + $(document).on("click",
  56 + ".note-edit-cancel",
  57 + NoteList.cancelNoteEdit);
  58 +
  59 + // delete note attachment
  60 + $(document).on("click",
  61 + ".js-note-attachment-delete",
  62 + NoteList.deleteNoteAttachment);
  63 +
  64 + // update the note after editing
  65 + $(document).on("ajax:complete",
  66 + "form.edit_note",
  67 + NoteList.updateNote);
  68 +
49 // reset main target form after submit 69 // reset main target form after submit
50 $(document).on("ajax:complete", 70 $(document).on("ajax:complete",
51 ".js-main-target-form", 71 ".js-main-target-form",
@@ -53,12 +73,12 @@ var NoteList = { @@ -53,12 +73,12 @@ var NoteList = {
53 73
54 74
55 $(document).on("click", 75 $(document).on("click",
56 - ".js-choose-note-attachment-button",  
57 - NoteList.chooseNoteAttachment); 76 + ".js-choose-note-attachment-button",
  77 + NoteList.chooseNoteAttachment);
58 78
59 $(document).on("click", 79 $(document).on("click",
60 - ".js-show-outdated-discussion",  
61 - function(e) { $(this).next('.outdated-discussion').show(); e.preventDefault() }); 80 + ".js-show-outdated-discussion",
  81 + function(e) { $(this).next('.outdated-discussion').show(); e.preventDefault() });
62 }, 82 },
63 83
64 84
@@ -97,8 +117,8 @@ var NoteList = { @@ -97,8 +117,8 @@ var NoteList = {
97 117
98 /** 118 /**
99 * Called when clicking the "Choose File" button. 119 * Called when clicking the "Choose File" button.
100 - *  
101 - * Opesn the file selection dialog. 120 + *
  121 + * Opens the file selection dialog.
102 */ 122 */
103 chooseNoteAttachment: function() { 123 chooseNoteAttachment: function() {
104 var form = $(this).closest("form"); 124 var form = $(this).closest("form");
@@ -133,7 +153,7 @@ var NoteList = { @@ -133,7 +153,7 @@ var NoteList = {
133 153
134 /** 154 /**
135 * Called in response to "cancel" on a diff note form. 155 * Called in response to "cancel" on a diff note form.
136 - * 156 + *
137 * Shows the reply button again. 157 * Shows the reply button again.
138 * Removes the form and if necessary it's temporary row. 158 * Removes the form and if necessary it's temporary row.
139 */ 159 */
@@ -177,6 +197,59 @@ var NoteList = { @@ -177,6 +197,59 @@ var NoteList = {
177 }, 197 },
178 198
179 /** 199 /**
  200 + * Called in response to clicking the edit note link
  201 + *
  202 + * Replaces the note text with the note edit form
  203 + * Adds a hidden div with the original content of the note to fill the edit note form with
  204 + * if the user cancels
  205 + */
  206 + showEditNoteForm: function(e) {
  207 + e.preventDefault();
  208 + var note = $(this).closest(".note");
  209 + note.find(".note-text").hide();
  210 +
  211 + // Show the attachment delete link
  212 + note.find(".js-note-attachment-delete").show();
  213 +
  214 + var form = note.find(".note-edit-form");
  215 + form.show();
  216 +
  217 +
  218 + var textarea = form.find("textarea");
  219 + var p = $("<p></p>").text(textarea.val());
  220 + var hidden_div = $('<div class="note-original-content"></div>').append(p);
  221 + form.append(hidden_div);
  222 + hidden_div.hide();
  223 + textarea.focus();
  224 + },
  225 +
  226 + /**
  227 + * Called in response to clicking the cancel button when editing a note
  228 + *
  229 + * Resets and hides the note editing form
  230 + */
  231 + cancelNoteEdit: function(e) {
  232 + e.preventDefault();
  233 + var note = $(this).closest(".note");
  234 + NoteList.resetNoteEditing(note);
  235 + },
  236 +
  237 +
  238 + /**
  239 + * Called in response to clicking the delete attachment link
  240 + *
  241 + * Removes the attachment wrapper view, including image tag if it exists
  242 + * Resets the note editing form
  243 + */
  244 + deleteNoteAttachment: function() {
  245 + var note = $(this).closest(".note");
  246 + note.find(".note-attachment").remove();
  247 + NoteList.resetNoteEditing(note);
  248 + NoteList.rewriteTimestamp(note.find(".note-last-update"));
  249 + },
  250 +
  251 +
  252 + /**
180 * Called when clicking on the "reply" button for a diff line. 253 * Called when clicking on the "reply" button for a diff line.
181 * 254 *
182 * Shows the note form below the notes. 255 * Shows the note form below the notes.
@@ -426,5 +499,65 @@ var NoteList = { @@ -426,5 +499,65 @@ var NoteList = {
426 votes.find(".upvotes").text(votes.find(".upvotes").text().replace(/\d+/, upvotes)); 499 votes.find(".upvotes").text(votes.find(".upvotes").text().replace(/\d+/, upvotes));
427 votes.find(".downvotes").text(votes.find(".downvotes").text().replace(/\d+/, downvotes)); 500 votes.find(".downvotes").text(votes.find(".downvotes").text().replace(/\d+/, downvotes));
428 } 501 }
  502 + },
  503 +
  504 + /**
  505 + * Called in response to the edit note form being submitted
  506 + *
  507 + * Updates the current note field.
  508 + * Hides the edit note form
  509 + */
  510 + updateNote: function(e, xhr, settings) {
  511 + response = JSON.parse(xhr.responseText);
  512 + if (response.success) {
  513 + var note_li = $("#note_" + response.id);
  514 + var note_text = note_li.find(".note-text");
  515 + note_text.html(response.note).show();
  516 +
  517 + var note_form = note_li.find(".note-edit-form");
  518 + note_form.hide();
  519 + note_form.find(".btn-save").enableButton();
  520 +
  521 + // Update the "Edited at xxx label" on the note to show it's just been updated
  522 + NoteList.rewriteTimestamp(note_li.find(".note-last-update"));
  523 + }
  524 + },
  525 +
  526 + /**
  527 + * Called in response to the 'cancel note' link clicked, or after deleting a note attachment
  528 + *
  529 + * Hides the edit note form and shows the note
  530 + * Resets the edit note form textarea with the original content of the note
  531 + */
  532 + resetNoteEditing: function(note) {
  533 + note.find(".note-text").show();
  534 +
  535 + // Hide the attachment delete link
  536 + note.find(".js-note-attachment-delete").hide();
  537 +
  538 + // Put the original content of the note back into the edit form textarea
  539 + var form = note.find(".note-edit-form");
  540 + var original_content = form.find(".note-original-content");
  541 + form.find("textarea").val(original_content.text());
  542 + original_content.remove();
  543 +
  544 + note.find(".note-edit-form").hide();
  545 + },
  546 +
  547 + /**
  548 + * Utility function to generate new timestamp text for a note
  549 + *
  550 + */
  551 + rewriteTimestamp: function(element) {
  552 + // Strip all newlines from the existing timestamp
  553 + var ts = element.text().replace(/\n/g, ' ').trim();
  554 +
  555 + // If the timestamp already has '(Edited xxx ago)' text, remove it
  556 + ts = ts.replace(new RegExp("\\(Edited [A-Za-z0-9 ]+\\)$", "gi"), "");
  557 +
  558 + // Append "(Edited just now)"
  559 + ts = (ts + " <small>(Edited just now)</small>");
  560 +
  561 + element.html(ts);
429 } 562 }
430 }; 563 };
app/assets/stylesheets/sections/notes.scss
@@ -325,3 +325,32 @@ ul.notes { @@ -325,3 +325,32 @@ ul.notes {
325 float: left; 325 float: left;
326 } 326 }
327 } 327 }
  328 +
  329 +.note-edit-form {
  330 + display: none;
  331 +
  332 + .note_text {
  333 + border: 1px solid #DDD;
  334 + box-shadow: none;
  335 + font-size: 14px;
  336 + height: 80px;
  337 + width: 98.6%;
  338 + }
  339 +
  340 + .form-actions {
  341 + padding-left: 20px;
  342 +
  343 + .btn-save {
  344 + float: left;
  345 + }
  346 +
  347 + .note-form-option {
  348 + float: left;
  349 + padding: 2px 0 0 25px;
  350 + }
  351 + }
  352 +}
  353 +
  354 +.js-note-attachment-delete {
  355 + display: none;
  356 +}
app/controllers/notes_controller.rb
@@ -38,6 +38,32 @@ class NotesController &lt; ProjectResourceController @@ -38,6 +38,32 @@ class NotesController &lt; ProjectResourceController
38 end 38 end
39 end 39 end
40 40
  41 + def update
  42 + @note = @project.notes.find(params[:id])
  43 + return access_denied! unless can?(current_user, :admin_note, @note)
  44 +
  45 + @note.update_attributes(params[:note])
  46 +
  47 + respond_to do |format|
  48 + format.js do
  49 + render js: { success: @note.valid?, id: @note.id, note: view_context.markdown(@note.note) }.to_json
  50 + end
  51 + format.html do
  52 + redirect_to :back
  53 + end
  54 + end
  55 + end
  56 +
  57 + def delete_attachment
  58 + @note = @project.notes.find(params[:id])
  59 + @note.remove_attachment!
  60 + @note.update_attribute(:attachment, nil)
  61 +
  62 + respond_to do |format|
  63 + format.js { render nothing: true }
  64 + end
  65 + end
  66 +
41 def preview 67 def preview
42 render text: view_context.markdown(params[:note]) 68 render text: view_context.markdown(params[:note])
43 end 69 end
app/helpers/notes_helper.rb
@@ -28,4 +28,11 @@ module NotesHelper @@ -28,4 +28,11 @@ module NotesHelper
28 def loading_new_notes? 28 def loading_new_notes?
29 params[:loading_new].present? 29 params[:loading_new].present?
30 end 30 end
  31 +
  32 + def note_timestamp(note)
  33 + # Shows the created at time and the updated at time if different
  34 + ts = "#{time_ago_in_words(note.created_at)} ago"
  35 + ts << content_tag(:small, " (Edited #{time_ago_in_words(note.updated_at)} ago)") if note.updated_at != note.created_at
  36 + ts.html_safe
  37 + end
31 end 38 end
app/views/dashboard/projects.html.haml
@@ -20,22 +20,24 @@ @@ -20,22 +20,24 @@
20 = nav_tab :scope, 'joined' do 20 = nav_tab :scope, 'joined' do
21 = link_to "Joined", projects_dashboard_path(scope: 'joined') 21 = link_to "Joined", projects_dashboard_path(scope: 'joined')
22 22
23 - %p.light Filter by label:  
24 - %ul.bordered-list  
25 - - @labels.each do |label|  
26 - %li{ class: (label.name == params[:label]) ? 'active' : 'light' }  
27 - = link_to projects_dashboard_path(scope: params[:scope], label: label.name) do  
28 - %i.icon-tag  
29 - = label.name 23 + - if @labels.any?
  24 + %p.light Filter by label:
  25 + %ul.bordered-list
  26 + - @labels.each do |label|
  27 + %li{ class: (label.name == params[:label]) ? 'active' : 'light' }
  28 + = link_to projects_dashboard_path(scope: params[:scope], label: label.name) do
  29 + %i.icon-tag
  30 + = label.name
30 31
31 32
32 .span9 33 .span9
33 - = form_tag projects_dashboard_path, method: 'get' do  
34 - %fieldset.dashboard-search-filter  
35 - = hidden_field_tag "scope", params[:scope]  
36 - = search_field_tag "search", params[:search], { id: 'dashboard_projects_search', placeholder: 'Search', class: 'left input-xxlarge'}  
37 - = button_tag type: 'submit', class: 'btn' do  
38 - %i.icon-search 34 + - if @projects.any?
  35 + = form_tag projects_dashboard_path, method: 'get' do
  36 + %fieldset.dashboard-search-filter
  37 + = hidden_field_tag "scope", params[:scope]
  38 + = search_field_tag "search", params[:search], { id: 'dashboard_projects_search', placeholder: 'Search', class: 'left input-xxlarge'}
  39 + = button_tag type: 'submit', class: 'btn' do
  40 + %i.icon-search
39 41
40 %ul.bordered-list 42 %ul.bordered-list
41 - @projects.each do |project| 43 - @projects.each do |project|
app/views/notes/_note.html.haml
@@ -6,13 +6,14 @@ @@ -6,13 +6,14 @@
6 Link here 6 Link here
7 &nbsp; 7 &nbsp;
8 - if(note.author_id == current_user.id) || can?(current_user, :admin_note, @project) 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 9 + = link_to "#", title: "Edit comment", class: "js-note-edit" do
  10 + %i.icon-edit
  11 + = link_to project_note_path(@project, note), title: "Remove comment", method: :delete, confirm: 'Are you sure you want to remove this comment?', remote: true, class: "danger js-note-delete" do
10 %i.icon-trash.cred 12 %i.icon-trash.cred
11 = image_tag gravatar_icon(note.author.email), class: "avatar s32", alt: '' 13 = image_tag gravatar_icon(note.author.email), class: "avatar s32", alt: ''
12 = link_to_member(@project, note.author, avatar: false) 14 = link_to_member(@project, note.author, avatar: false)
13 %span.note-last-update 15 %span.note-last-update
14 - = time_ago_in_words(note.updated_at)  
15 - ago 16 + = note_timestamp(note)
16 17
17 - if note.upvote? 18 - if note.upvote?
18 %span.vote.upvote.label.label-success 19 %span.vote.upvote.label.label-success
@@ -25,13 +26,37 @@ @@ -25,13 +26,37 @@
25 26
26 27
27 .note-body 28 .note-body
28 - = preserve do  
29 - = markdown(note.note) 29 + .note-text
  30 + = preserve do
  31 + = markdown(note.note)
  32 +
  33 + .note-edit-form
  34 + = form_for note, url: project_note_path(@project, note), method: :put, remote: true do |f|
  35 + = f.text_area :note, class: 'note_text js-note-text js-gfm-input turn-on'
  36 +
  37 + .form-actions
  38 + = f.submit 'Save changes', class: "btn btn-primary btn-save"
  39 +
  40 + .note-form-option
  41 + %a.choose-btn.btn.btn-small.js-choose-note-attachment-button
  42 + %i.icon-paper-clip
  43 + %span Choose File ...
  44 + &nbsp;
  45 + %span.file_name.js-attachment-filename File name...
  46 + = f.file_field :attachment, class: "js-note-attachment-input hide"
  47 +
  48 + = link_to 'Cancel', "#", class: "btn btn-cancel note-edit-cancel"
  49 +
  50 +
30 - if note.attachment.url 51 - if note.attachment.url
31 - - if note.attachment.image?  
32 - = image_tag note.attachment.url, class: 'note-image-attach'  
33 - .attachment.pull-right  
34 - = link_to note.attachment.secure_url, target: "_blank" do  
35 - %i.icon-paper-clip  
36 - = note.attachment_identifier  
37 - .clear 52 + .note-attachment
  53 + - if note.attachment.image?
  54 + = image_tag note.attachment.url, class: 'note-image-attach'
  55 + .attachment.pull-right
  56 + = link_to note.attachment.secure_url, target: "_blank" do
  57 + %i.icon-paper-clip
  58 + = note.attachment_identifier
  59 + = link_to delete_attachment_project_note_path(@project, note),
  60 + title: "Delete this attachment", method: :delete, remote: true, confirm: 'Are you sure you want to remove the attachment?', class: "danger js-note-attachment-delete" do
  61 + %i.icon-trash.cred
  62 + .clear
38 \ No newline at end of file 63 \ No newline at end of file
config/routes.rb
@@ -319,7 +319,10 @@ Gitlab::Application.routes.draw do @@ -319,7 +319,10 @@ Gitlab::Application.routes.draw do
319 end 319 end
320 end 320 end
321 321
322 - resources :notes, only: [:index, :create, :destroy] do 322 + resources :notes, only: [:index, :create, :destroy, :update] do
  323 + member do
  324 + delete :delete_attachment
  325 + end
323 collection do 326 collection do
324 post :preview 327 post :preview
325 end 328 end
spec/factories.rb
  1 +include ActionDispatch::TestProcess
  2 +
1 FactoryGirl.define do 3 FactoryGirl.define do
2 sequence :sentence, aliases: [:title, :content] do 4 sequence :sentence, aliases: [:title, :content] do
3 Faker::Lorem.sentence 5 Faker::Lorem.sentence
@@ -120,6 +122,7 @@ FactoryGirl.define do @@ -120,6 +122,7 @@ FactoryGirl.define do
120 factory :note_on_issue, traits: [:on_issue], aliases: [:votable_note] 122 factory :note_on_issue, traits: [:on_issue], aliases: [:votable_note]
121 factory :note_on_merge_request, traits: [:on_merge_request] 123 factory :note_on_merge_request, traits: [:on_merge_request]
122 factory :note_on_merge_request_diff, traits: [:on_merge_request, :on_diff] 124 factory :note_on_merge_request_diff, traits: [:on_merge_request, :on_diff]
  125 + factory :note_on_merge_request_with_attachment, traits: [:on_merge_request, :with_attachment]
123 126
124 trait :on_commit do 127 trait :on_commit do
125 project factory: :project_with_code 128 project factory: :project_with_code
@@ -141,6 +144,10 @@ FactoryGirl.define do @@ -141,6 +144,10 @@ FactoryGirl.define do
141 noteable_id 1 144 noteable_id 1
142 noteable_type "Issue" 145 noteable_type "Issue"
143 end 146 end
  147 +
  148 + trait :with_attachment do
  149 + attachment { fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png") }
  150 + end
144 end 151 end
145 152
146 factory :event do 153 factory :event do
spec/features/notes_on_merge_requests_spec.rb
@@ -3,6 +3,7 @@ require &#39;spec_helper&#39; @@ -3,6 +3,7 @@ require &#39;spec_helper&#39;
3 describe "On a merge request", js: true do 3 describe "On a merge request", js: true do
4 let!(:project) { create(:project_with_code) } 4 let!(:project) { create(:project_with_code) }
5 let!(:merge_request) { create(:merge_request, project: project) } 5 let!(:merge_request) { create(:merge_request, project: project) }
  6 + let!(:note) { create(:note_on_merge_request_with_attachment, project: project) }
6 7
7 before do 8 before do
8 login_as :user 9 login_as :user
@@ -72,6 +73,71 @@ describe &quot;On a merge request&quot;, js: true do @@ -72,6 +73,71 @@ describe &quot;On a merge request&quot;, js: true do
72 should_not have_css(".note") 73 should_not have_css(".note")
73 end 74 end
74 end 75 end
  76 +
  77 + describe "when editing a note", js: true do
  78 + it "should contain the hidden edit form" do
  79 + within("#note_#{note.id}") { should have_css(".note-edit-form", visible: false) }
  80 + end
  81 +
  82 + describe "editing the note" do
  83 + before do
  84 + find('.note').hover
  85 + find(".js-note-edit").click
  86 + end
  87 +
  88 + it "should show the note edit form and hide the note body" do
  89 + within("#note_#{note.id}") do
  90 + find(".note-edit-form", visible: true).should be_visible
  91 + find(".note-text", visible: false).should_not be_visible
  92 + end
  93 + end
  94 +
  95 + it "should reset the edit note form textarea with the original content of the note if cancelled" do
  96 + find('.note').hover
  97 + find(".js-note-edit").click
  98 +
  99 + within(".note-edit-form") do
  100 + fill_in "note[note]", with: "Some new content"
  101 + find(".btn-cancel").click
  102 + find(".js-note-text", visible: false).text.should == note.note
  103 + end
  104 + end
  105 +
  106 + it "appends the edited at time to the note" do
  107 + find('.note').hover
  108 + find(".js-note-edit").click
  109 +
  110 + within(".note-edit-form") do
  111 + fill_in "note[note]", with: "Some new content"
  112 + find(".btn-save").click
  113 + end
  114 +
  115 + within("#note_#{note.id}") do
  116 + should have_css(".note-last-update small")
  117 + find(".note-last-update small").text.should match(/Edited just now/)
  118 + end
  119 + end
  120 + end
  121 +
  122 + describe "deleting an attachment" do
  123 + before do
  124 + find('.note').hover
  125 + find(".js-note-edit").click
  126 + end
  127 +
  128 + it "shows the delete link" do
  129 + within(".note-attachment") do
  130 + should have_css(".js-note-attachment-delete")
  131 + end
  132 + end
  133 +
  134 + it "removes the attachment div and resets the edit form" do
  135 + find(".js-note-attachment-delete").click
  136 + should_not have_css(".note-attachment")
  137 + find(".note-edit-form", visible: false).should_not be_visible
  138 + end
  139 + end
  140 + end
75 end 141 end
76 142
77 describe "On a merge request diff", js: true, focus: true do 143 describe "On a merge request diff", js: true, focus: true do
spec/fixtures/dk.png 0 → 100644

1.12 KB

spec/routing/project_routing_spec.rb
@@ -262,7 +262,7 @@ end @@ -262,7 +262,7 @@ end
262 # project_snippet GET /:project_id/snippets/:id(.:format) snippets#show 262 # project_snippet GET /:project_id/snippets/:id(.:format) snippets#show
263 # PUT /:project_id/snippets/:id(.:format) snippets#update 263 # PUT /:project_id/snippets/:id(.:format) snippets#update
264 # DELETE /:project_id/snippets/:id(.:format) snippets#destroy 264 # DELETE /:project_id/snippets/:id(.:format) snippets#destroy
265 -describe Projects::SnippetsController, "routing" do 265 +describe SnippetsController, "routing" do
266 it "to #raw" do 266 it "to #raw" do
267 get("/gitlabhq/snippets/1/raw").should route_to('projects/snippets#raw', project_id: 'gitlabhq', id: '1') 267 get("/gitlabhq/snippets/1/raw").should route_to('projects/snippets#raw', project_id: 'gitlabhq', id: '1')
268 end 268 end