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 46 ".js-note-delete",
47 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 69 // reset main target form after submit
50 70 $(document).on("ajax:complete",
51 71 ".js-main-target-form",
... ... @@ -53,12 +73,12 @@ var NoteList = {
53 73  
54 74  
55 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 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 117  
98 118 /**
99 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 123 chooseNoteAttachment: function() {
104 124 var form = $(this).closest("form");
... ... @@ -133,7 +153,7 @@ var NoteList = {
133 153  
134 154 /**
135 155 * Called in response to "cancel" on a diff note form.
136   - *
  156 + *
137 157 * Shows the reply button again.
138 158 * Removes the form and if necessary it's temporary row.
139 159 */
... ... @@ -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 253 * Called when clicking on the "reply" button for a diff line.
181 254 *
182 255 * Shows the note form below the notes.
... ... @@ -426,5 +499,65 @@ var NoteList = {
426 499 votes.find(".upvotes").text(votes.find(".upvotes").text().replace(/\d+/, upvotes));
427 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 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 38 end
39 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 67 def preview
42 68 render text: view_context.markdown(params[:note])
43 69 end
... ...
app/helpers/notes_helper.rb
... ... @@ -28,4 +28,11 @@ module NotesHelper
28 28 def loading_new_notes?
29 29 params[:loading_new].present?
30 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 38 end
... ...
app/views/dashboard/projects.html.haml
... ... @@ -20,22 +20,24 @@
20 20 = nav_tab :scope, 'joined' do
21 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 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 42 %ul.bordered-list
41 43 - @projects.each do |project|
... ...
app/views/notes/_note.html.haml
... ... @@ -6,13 +6,14 @@
6 6 Link here
7 7 &nbsp;
8 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 12 %i.icon-trash.cred
11 13 = image_tag gravatar_icon(note.author.email), class: "avatar s32", alt: ''
12 14 = link_to_member(@project, note.author, avatar: false)
13 15 %span.note-last-update
14   - = time_ago_in_words(note.updated_at)
15   - ago
  16 + = note_timestamp(note)
16 17  
17 18 - if note.upvote?
18 19 %span.vote.upvote.label.label-success
... ... @@ -25,13 +26,37 @@
25 26  
26 27  
27 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 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 63 \ No newline at end of file
... ...
config/routes.rb
... ... @@ -319,7 +319,10 @@ Gitlab::Application.routes.draw do
319 319 end
320 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 326 collection do
324 327 post :preview
325 328 end
... ...
spec/factories.rb
  1 +include ActionDispatch::TestProcess
  2 +
1 3 FactoryGirl.define do
2 4 sequence :sentence, aliases: [:title, :content] do
3 5 Faker::Lorem.sentence
... ... @@ -120,6 +122,7 @@ FactoryGirl.define do
120 122 factory :note_on_issue, traits: [:on_issue], aliases: [:votable_note]
121 123 factory :note_on_merge_request, traits: [:on_merge_request]
122 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 127 trait :on_commit do
125 128 project factory: :project_with_code
... ... @@ -141,6 +144,10 @@ FactoryGirl.define do
141 144 noteable_id 1
142 145 noteable_type "Issue"
143 146 end
  147 +
  148 + trait :with_attachment do
  149 + attachment { fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png") }
  150 + end
144 151 end
145 152  
146 153 factory :event do
... ...
spec/features/notes_on_merge_requests_spec.rb
... ... @@ -3,6 +3,7 @@ require &#39;spec_helper&#39;
3 3 describe "On a merge request", js: true do
4 4 let!(:project) { create(:project_with_code) }
5 5 let!(:merge_request) { create(:merge_request, project: project) }
  6 + let!(:note) { create(:note_on_merge_request_with_attachment, project: project) }
6 7  
7 8 before do
8 9 login_as :user
... ... @@ -72,6 +73,71 @@ describe &quot;On a merge request&quot;, js: true do
72 73 should_not have_css(".note")
73 74 end
74 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 141 end
76 142  
77 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 262 # project_snippet GET /:project_id/snippets/:id(.:format) snippets#show
263 263 # PUT /:project_id/snippets/:id(.:format) snippets#update
264 264 # DELETE /:project_id/snippets/:id(.:format) snippets#destroy
265   -describe Projects::SnippetsController, "routing" do
  265 +describe SnippetsController, "routing" do
266 266 it "to #raw" do
267 267 get("/gitlabhq/snippets/1/raw").should route_to('projects/snippets#raw', project_id: 'gitlabhq', id: '1')
268 268 end
... ...