Commit 47f78a2c7614b0a32110e909c2244f67310d030e
Exists in
master
and in
4 other branches
Merge pull request #4440 from jackbot/editable-notes
Editable notes
Showing
11 changed files
with
332 additions
and
34 deletions
Show diff stats
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 < 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 | |
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 | + | |
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 'spec_helper' |
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 "On a merge request", 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 | ... | ... |
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 | ... | ... |