Feature #8691 » mid_air_collision_patch-20110131-trunk_r8743.patch
app/models/issue.rb (working copy) | ||
---|---|---|
693 | 693 |
rescue ActiveRecord::StaleObjectError |
694 | 694 |
attachments[:files].each(&:destroy) |
695 | 695 |
errors.add :base, l(:notice_locking_conflict) |
696 |
raise ActiveRecord::Rollback
|
|
696 |
raise ActiveRecord::StaleObjectError
|
|
697 | 697 |
end |
698 | 698 |
end |
699 | 699 |
end |
app/controllers/issues_controller.rb (working copy) | ||
---|---|---|
107 | 107 |
def show |
108 | 108 |
@journals = @issue.journals.find(:all, :include => [:user, :details], :order => "#{Journal.table_name}.created_on ASC") |
109 | 109 |
@journals.each_with_index {|j,i| j.indice = i+1} |
110 |
@latest_journal = @journals.last |
|
110 | 111 |
@journals.reverse! if User.current.wants_comments_in_reverse_order? |
111 | 112 | |
112 | 113 |
if User.current.allowed_to?(:view_changesets, @project) |
... | ... | |
184 | 185 |
end |
185 | 186 | |
186 | 187 |
def update |
188 |
if params[:conflict_resolution].present? |
|
189 |
case params[:conflict_resolution] |
|
190 |
when 'none' |
|
191 |
redirect_to :controller => 'issues', :action => 'show', :id => @issue |
|
192 |
return |
|
193 |
when 'notes' |
|
194 |
# update notes only |
|
195 |
params.delete(:attachments) |
|
196 |
params.delete(:issue) |
|
197 |
params.delete(:time_entry) |
|
198 |
when 'all' |
|
199 |
; |
|
200 |
end |
|
201 |
end |
|
202 |
|
|
187 | 203 |
update_issue_from_params |
188 | 204 | |
189 |
if @issue.save_issue_with_child_records(params, @time_entry) |
|
190 |
render_attachment_warning_if_needed(@issue) |
|
191 |
flash[:notice] = l(:notice_successful_update) unless @issue.current_journal.new_record? |
|
192 | ||
193 |
respond_to do |format| |
|
194 |
format.html { redirect_back_or_default({:action => 'show', :id => @issue}) } |
|
195 |
format.api { head :ok } |
|
205 |
begin |
|
206 |
if @issue.save_issue_with_child_records(params, @time_entry) |
|
207 |
render_attachment_warning_if_needed(@issue) |
|
208 |
flash[:notice] = l(:notice_successful_update) unless @issue.current_journal.new_record? |
|
209 |
|
|
210 |
respond_to do |format| |
|
211 |
format.html { redirect_back_or_default({:action => 'show', :id => @issue}) } |
|
212 |
format.api { head :ok } |
|
213 |
end |
|
214 |
else |
|
215 |
render_attachment_warning_if_needed(@issue) |
|
216 |
flash[:notice] = l(:notice_successful_update) unless @issue.current_journal.new_record? |
|
217 |
@journal = @issue.current_journal |
|
218 |
|
|
219 |
respond_to do |format| |
|
220 |
format.html { render :action => 'edit' } |
|
221 |
format.api { render_validation_errors(@issue) } |
|
222 |
end |
|
196 | 223 |
end |
197 |
else |
|
224 |
rescue ActiveRecord::StaleObjectError |
|
225 |
# mid-air conflict |
|
226 |
@conflict_detected = true |
|
227 |
|
|
228 |
@issue.reload |
|
229 |
params[:issue].delete(:lock_version) if params[:issue] |
|
230 |
update_issue_from_params |
|
231 |
|
|
232 |
@journals = @issue.journals.find(:all, :include => [:user, :details], :order => "#{Journal.table_name}.created_on ASC") |
|
233 |
@journals.each_with_index {|j,i| j.indice = i+1} |
|
234 |
@latest_journal = @journals.last |
|
235 |
if params[:latest_journal].present? |
|
236 |
latest_journal_id = params[:latest_journal].to_i |
|
237 |
@journals = @journals.select{|journal| latest_journal_id < journal.id } |
|
238 |
end |
|
239 |
@journals.reverse! if User.current.wants_comments_in_reverse_order? |
|
240 | ||
198 | 241 |
render_attachment_warning_if_needed(@issue) |
199 | 242 |
flash[:notice] = l(:notice_successful_update) unless @issue.current_journal.new_record? |
200 | 243 |
@journal = @issue.current_journal |
201 | ||
244 |
|
|
202 | 245 |
respond_to do |format| |
203 |
format.html { render :action => 'edit' }
|
|
246 |
format.html { render :action => 'conflict' }
|
|
204 | 247 |
format.api { render_validation_errors(@issue) } |
205 | 248 |
end |
206 | 249 |
end |
app/views/issues/_edit.html.erb (working copy) | ||
---|---|---|
1 | 1 |
<% labelled_form_for @issue, :html => {:id => 'issue-form', :multipart => true} do |f| %> |
2 | 2 |
<%= error_messages_for 'issue', 'time_entry' %> |
3 | 3 |
<div class="box"> |
4 |
<% if @conflict_detected %> |
|
5 |
<fieldset><legend><%= l(:label_conflict_resolution) %></legend> |
|
6 |
<label> |
|
7 |
<%= radio_button_tag 'conflict_resolution', 'none' %> |
|
8 |
<%= l(:label_conflict_resolution_none, |
|
9 |
:id => (link_to "##{@issue.id}", :controller => 'issues', :action => 'show', :id => @issue)) %> |
|
10 |
</label> |
|
11 |
<br /> |
|
12 |
<% if @notes.present? %> |
|
13 |
<label><%= radio_button_tag 'conflict_resolution', 'notes', true %> <%= l(:label_conflict_resolution_notes) %></label> |
|
14 |
<br /> |
|
15 |
<% end %> |
|
16 |
<label><%= radio_button_tag 'conflict_resolution', 'all', @notes.blank? %> <%= l(:label_conflict_resolution_all) %></label> |
|
17 |
</fieldset> |
|
18 |
<% end %> |
|
19 |
<% if @latest_journal %> |
|
20 |
<%= hidden_field_tag 'latest_journal', @latest_journal.id %> |
|
21 |
<% end %> |
|
22 | ||
4 | 23 |
<% if @edit_allowed || !@allowed_statuses.empty? %> |
5 |
<fieldset class="tabular"><legend><%= l(:label_change_properties) %></legend> |
|
24 |
<fieldset class="tabular conflict_resolution_all"><legend><%= l(:label_change_properties) %></legend>
|
|
6 | 25 |
<div id="all_attributes"> |
7 | 26 |
<%= render :partial => 'form', :locals => {:f => f} %> |
8 | 27 |
</div> |
9 | 28 |
</fieldset> |
10 | 29 |
<% end %> |
11 | 30 |
<% if User.current.allowed_to?(:log_time, @project) %> |
12 |
<fieldset class="tabular"><legend><%= l(:button_log_time) %></legend> |
|
31 |
<fieldset class="tabular conflict_resolution_all"><legend><%= l(:button_log_time) %></legend>
|
|
13 | 32 |
<% labelled_fields_for :time_entry, @time_entry do |time_entry| %> |
14 | 33 |
<div class="splitcontentleft"> |
15 | 34 |
<p><%= time_entry.text_field :hours, :size => 6, :label => :label_spent_time %> <%= l(:field_hours) %></p> |
... | ... | |
25 | 44 |
</fieldset> |
26 | 45 |
<% end %> |
27 | 46 | |
28 |
<fieldset><legend><%= l(:field_notes) %></legend> |
|
47 |
<fieldset class="conflict_resolution_all conflict_resolution_notes"><legend><%= l(:field_notes) %></legend>
|
|
29 | 48 |
<%= text_area_tag 'notes', @notes, :cols => 60, :rows => 10, :class => 'wiki-edit' %> |
30 | 49 |
<%= wikitoolbar_for 'notes' %> |
31 | 50 |
<%= call_hook(:view_issues_edit_notes_bottom, { :issue => @issue, :notes => @notes, :form => f }) %> |
32 | 51 | |
33 |
<p><%=l(:label_attachment_plural)%><br /><%= render :partial => 'attachments/form' %></p> |
|
52 |
<p class="conflict_resolution_all"><%=l(:label_attachment_plural)%><br /><%= render :partial => 'attachments/form' %></p>
|
|
34 | 53 |
</fieldset> |
35 | 54 |
</div> |
36 | 55 | |
37 | 56 |
<%= f.hidden_field :lock_version %> |
38 |
<%= submit_tag l(:button_submit) %> |
|
57 |
<%= submit_tag l(:button_submit), :class => 'conflict_resolution_notes conflict_resolution_all' %> |
|
58 |
<% if @conflict_detected %> |
|
59 |
<%= submit_tag l(:button_conflict_resolution_none, :id => "##{@issue.id}"), :class => 'conflict_resolution_none' %> |
|
60 |
<% end %> |
|
39 | 61 |
<%= link_to_remote l(:label_preview), |
40 | 62 |
{ :url => preview_issue_path(:project_id => @project, :id => @issue), |
41 | 63 |
:method => 'post', |
42 | 64 |
:update => 'preview', |
43 | 65 |
:with => 'Form.serialize("issue-form")', |
44 | 66 |
:complete => "Element.scrollTo('preview')" |
45 |
}, :accesskey => accesskey(:preview) %> |
|
67 |
}, :accesskey => accesskey(:preview), :class => 'conflict_resolution_notes conflict_resolution_all' %>
|
|
46 | 68 |
<% end %> |
47 | 69 | |
48 |
<div id="preview" class="wiki"></div> |
|
70 |
<div id="preview" class="wiki conflict_resolution_notes conflict_resolution_all"></div> |
|
71 | ||
72 |
<% if @conflict_detected %> |
|
73 |
<%= javascript_tag 'setupShowHideByRadio("conflict_resolution_", [ "none", "notes", "all" ])' %> |
|
74 |
<% end %> |
app/views/issues/conflict.html.erb (working copy) | ||
---|---|---|
1 |
<h2><%=h "#{@issue.tracker.name} ##{@issue.id}" %></h2> |
|
2 | ||
3 |
<% if @journals.present? %> |
|
4 |
<div id="history"> |
|
5 |
<h3><%=l(:label_conflict_history)%></h3> |
|
6 |
<%= render :partial => 'history', :locals => { :issue => @issue, :journals => @journals } %> |
|
7 |
</div> |
|
8 |
<% end %> |
|
9 | ||
10 |
<%= render :partial => 'edit' %> |
|
11 |
<% content_for :header_tags do %> |
|
12 |
<%= robot_exclusion_tag %> |
|
13 |
<% end %> |
public/javascripts/application.js (working copy) | ||
---|---|---|
515 | 515 |
} |
516 | 516 | |
517 | 517 |
Event.observe(window, 'load', hideOnLoad); |
518 | ||
519 |
/* |
|
520 |
* Setup observers of radio buttons to show/hide associated elements |
|
521 |
* automatically. |
|
522 |
* |
|
523 |
* id_base: |
|
524 |
* A base name of radio button ids to observe. |
|
525 |
* id_suffixes: |
|
526 |
* An array of radio button id suffixes to observe. |
|
527 |
* id_base + id_suffixes[i] is an id. |
|
528 |
* css_class_base: |
|
529 |
* A base name of css classes to show/hide. |
|
530 |
* css_class_base + css_class_suffixes[i] is a class. |
|
531 |
* When a radio button is clicked, |
|
532 |
* its corresponding css class is shown, and the others are hidden. |
|
533 |
* This parameter is an option, and defaults to id_base. |
|
534 |
* css_class_suffixes: |
|
535 |
* An array of css class suffixes to show/hide. |
|
536 |
* This parameter is an option, and defaults to id_suffixes. |
|
537 |
*/ |
|
538 |
function setupShowHideByRadio( |
|
539 |
id_base, id_suffixes, css_class_base, css_class_suffixes) { |
|
540 |
if (css_class_base === undefined) { |
|
541 |
css_class_base = id_base; |
|
542 |
} |
|
543 |
if (css_class_suffixes === undefined) { |
|
544 |
css_class_suffixes = id_suffixes; |
|
545 |
} |
|
546 |
function f() { |
|
547 |
/* find a checked radio button */ |
|
548 |
selected_index = -1; |
|
549 |
id_suffixes.each(function(id_suffix, index){ |
|
550 |
radio = $(id_base + id_suffix); |
|
551 |
if (radio && radio.checked) { |
|
552 |
selected_index = index; |
|
553 |
throw $break; |
|
554 |
} |
|
555 |
}); |
|
556 |
/* hide unselected */ |
|
557 |
css_class_suffixes.each(function(css_class_suffix, index){ |
|
558 |
if (index != selected_index) { |
|
559 |
$$('.' + css_class_base + css_class_suffix).each( |
|
560 |
function(ele) { ele.hide(); }); |
|
561 |
} |
|
562 |
}) |
|
563 |
/* show selected */ |
|
564 |
if (0 <= selected_index) { |
|
565 |
$$('.' + css_class_base + css_class_suffixes[selected_index]).each( |
|
566 |
function(ele) { ele.show(); }); |
|
567 |
} |
|
568 |
} |
|
569 |
/* observe radio buttons */ |
|
570 |
id_suffixes.each(function(id_suffix, index){ |
|
571 |
radio = $(id_base + id_suffix) |
|
572 |
if (radio) { |
|
573 |
Event.observe(radio, 'change', f); |
|
574 |
} |
|
575 |
}) |
|
576 |
f(); |
|
577 |
} |
test/functional/issues_controller_transaction_test.rb (working copy) | ||
---|---|---|
53 | 53 |
end |
54 | 54 | |
55 | 55 |
def test_put_update_stale_issue |
56 |
assert_put_update_stale_issue('') |
|
57 |
assert_no_tag :tag => 'input', :attributes => { :id => 'conflict_resolution_notes' } |
|
58 |
assert_tag :tag => 'input', :attributes => { :id => 'conflict_resolution_all', :checked => 'checked' } |
|
59 |
end |
|
60 | ||
61 |
def test_put_update_stale_issue_with_notes |
|
62 |
assert_put_update_stale_issue('test notes') |
|
63 |
assert_tag :tag => 'input', :attributes => { :id => 'conflict_resolution_notes', :checked => 'checked' } |
|
64 |
assert_tag :tag => 'input', :attributes => { :id => 'conflict_resolution_all' } |
|
65 |
end |
|
66 | ||
67 |
def assert_put_update_stale_issue(notes) |
|
56 | 68 |
issue = Issue.find(2) |
57 | 69 |
@request.session[:user_id] = 2 |
58 | 70 | |
... | ... | |
65 | 77 |
:fixed_version_id => 4, |
66 | 78 |
:lock_version => (issue.lock_version - 1) |
67 | 79 |
}, |
68 |
:notes => '',
|
|
80 |
:notes => notes,
|
|
69 | 81 |
:attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain')}}, |
70 | 82 |
:time_entry => { :hours => '2.5', :comments => '', :activity_id => TimeEntryActivity.first.id } |
71 | 83 |
end |
... | ... | |
73 | 85 |
end |
74 | 86 | |
75 | 87 |
assert_response :success |
76 |
assert_template 'edit'
|
|
88 |
assert_template 'conflict'
|
|
77 | 89 |
assert_tag :tag => 'div', :attributes => { :id => 'errorExplanation' }, |
78 | 90 |
:content => /Data has been updated by another user/ |
91 |
assert_tag :tag => 'input', :attributes => { :id => 'issue_lock_version', :value => issue.lock_version } |
|
92 |
assert_tag :tag => 'legend', :content => I18n.t(:label_conflict_resolution) |
|
93 | ||
94 |
journals = issue.journals.find(:all, :include => [:user, :details], :order => "#{Journal.table_name}.created_on ASC") |
|
95 |
assert_tag :tag => 'input', :attributes => { :id => 'latest_journal', :value => journals.last.id } |
|
96 |
journals.each{|journal| |
|
97 |
assert_tag :tag => 'div', :attributes => { :id => "change-#{journal.id}" } |
|
98 |
} |
|
99 |
end |
|
100 | ||
101 |
def test_conflict_resolution_none |
|
102 |
issue = Issue.find(2) |
|
103 |
@request.session[:user_id] = 2 |
|
104 |
|
|
105 |
assert_no_difference 'Journal.count' do |
|
106 |
assert_no_difference 'TimeEntry.count' do |
|
107 |
assert_no_difference 'Attachment.count' do |
|
108 |
put_conflict_resolution(issue, 'none') |
|
109 |
end |
|
110 |
end |
|
111 |
end |
|
112 |
end |
|
113 | ||
114 |
def test_conflict_resolution_notes |
|
115 |
issue = Issue.find(2) |
|
116 |
@request.session[:user_id] = 2 |
|
117 |
prev_status = issue.status |
|
118 |
|
|
119 |
assert_difference 'Journal.count' do |
|
120 |
assert_no_difference 'TimeEntry.count' do |
|
121 |
assert_no_difference 'Attachment.count' do |
|
122 |
put_conflict_resolution(issue, 'notes') |
|
123 |
end |
|
124 |
end |
|
125 |
end |
|
126 | ||
127 |
issue.reload |
|
128 |
assert_equal prev_status, issue.status |
|
129 |
end |
|
130 | ||
131 |
def test_conflict_resolution_all |
|
132 |
issue = Issue.find(2) |
|
133 |
@request.session[:user_id] = 2 |
|
134 |
prev_status = issue.status |
|
135 |
|
|
136 |
assert_difference 'Journal.count' do |
|
137 |
assert_difference 'TimeEntry.count' do |
|
138 |
assert_difference 'Attachment.count' do |
|
139 |
put_conflict_resolution(issue, 'all') |
|
140 |
end |
|
141 |
end |
|
142 |
end |
|
143 | ||
144 |
issue.reload |
|
145 |
assert_not_equal prev_status, issue.status |
|
146 |
end |
|
147 | ||
148 |
def put_conflict_resolution(issue, conflict_resolution) |
|
149 |
set_tmp_attachments_directory |
|
150 |
put :update, |
|
151 |
:id => issue.id, |
|
152 |
:issue => { |
|
153 |
:status_id => 3, |
|
154 |
:fixed_version_id => 4, |
|
155 |
:lock_version => issue.lock_version, |
|
156 |
}, |
|
157 |
:notes => 'test notes', |
|
158 |
:attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain')}}, |
|
159 |
:time_entry => { :hours => '2.5', :comments => '', :activity_id => TimeEntryActivity.first.id }, |
|
160 |
:conflict_resolution => conflict_resolution |
|
161 |
|
|
162 |
assert_redirected_to :controller => 'issues', :action => 'show', :id => issue.id |
|
79 | 163 |
end |
80 | 164 | |
165 | ||
81 | 166 |
def test_index_should_rescue_invalid_sql_query |
82 | 167 |
Query.any_instance.stubs(:statement).returns("INVALID STATEMENT") |
83 | 168 |
config/locales/en.yml (working copy) | ||
---|---|---|
840 | 840 |
label_copy_attachments: Copy attachments |
841 | 841 |
label_item_position: %{position} of %{count} |
842 | 842 |
label_completed_versions: Completed versions |
843 |
label_conflict_resolution: Conflict Resolution |
|
844 |
label_conflict_resolution_none: "Throw away my changes, and show %{id}" |
|
845 |
label_conflict_resolution_notes: Submit only my new notes |
|
846 |
label_conflict_resolution_all: Edit again |
|
847 |
label_conflict_history: Another user's updates |
|
843 | 848 | |
844 | 849 |
button_login: Login |
845 | 850 |
button_submit: Submit |
... | ... | |
889 | 894 |
button_show: Show |
890 | 895 |
button_edit_section: Edit this section |
891 | 896 |
button_export: Export |
897 |
button_conflict_resolution_none: "Show %{id}" |
|
892 | 898 | |
893 | 899 |
status_active: active |
894 | 900 |
status_registered: registered |
config/locales/ja.yml (working copy) | ||
---|---|---|
837 | 837 |
label_git_report_last_commit: ファイルとディレクトリの最新コミットを表示する |
838 | 838 |
label_parent_revision: 親 |
839 | 839 |
label_child_revision: 子 |
840 |
label_conflict_resolution: 自分の更新データの反映方法 |
|
841 |
label_conflict_resolution_none: "自分の更新データを <strong>破棄</strong> し %{id} を表示する" |
|
842 |
label_conflict_resolution_notes: 自分の <strong>注記のみ</strong> を反映する |
|
843 |
label_conflict_resolution_all: 自分の更新データを修正する |
|
844 |
label_conflict_history: 別のユーザによる更新内容 |
|
840 | 845 | |
841 | 846 |
button_login: ログイン |
842 | 847 |
button_submit: 変更 |
... | ... | |
884 | 889 |
button_quote: 引用 |
885 | 890 |
button_duplicate: 複製 |
886 | 891 |
button_show: 表示 |
892 |
button_conflict_resolution_none: "%{id} を表示" |
|
887 | 893 | |
888 | 894 |
status_active: 有効 |
889 | 895 |
status_registered: 登録 |