Merge remote-tracking branch 'upstream/dev' into fix/self_link_schema

Conflicts:
	app/models/work_package.rb
	lib/api/v3/work_packages/schema/work_package_schema.rb
	spec/lib/api/v3/work_packages/schema/work_package_schema_spec.rb
	spec/lib/api/v3/work_packages/work_package_schema_representer_spec.rb
pull/2655/head
Jan Sandbrink 10 years ago
commit 7e3f2da2e9
  1. 2
      app/assets/javascripts/application.js.erb
  2. 129
      app/assets/javascripts/custom-fields.js
  3. 12
      app/assets/javascripts/work_packages.js.erb
  4. 2
      app/assets/stylesheets/content/_forms.md
  5. 6
      app/assets/stylesheets/content/_forms.sass
  6. 6
      app/assets/stylesheets/content/_wiki.sass
  7. 6
      app/assets/stylesheets/layout/_work_package.sass
  8. 6
      app/controllers/versions_controller.rb
  9. 73
      app/helpers/work_packages_helper.rb
  10. 2
      app/models/custom_value.rb
  11. 12
      app/models/custom_value/bool_strategy.rb
  12. 4
      app/models/custom_value/format_strategy.rb
  13. 8
      app/models/work_package/validations.rb
  14. 6
      app/views/attachments/_nested_form.html.erb
  15. 142
      app/views/custom_fields/_form.html.erb
  16. 2
      app/views/messages/_form.html.erb
  17. 30
      app/views/versions/create.js.erb
  18. 37
      app/views/work_packages/_form.html.erb
  19. 2
      app/views/work_packages/new.html.erb
  20. 4
      app/views/work_packages/show.html.erb
  21. 4
      config/locales/de.yml
  22. 6
      config/locales/en.yml
  23. 37
      doc/apiv3-documentation.apib
  24. 4
      features/custom_fields/create_bool.feature
  25. 11
      frontend/app/ui_components/inplace-editor-dispatcher.js
  26. 22
      frontend/tests/unit/tests/ui_components/inplace-editor-dispatcher-test.js
  27. 26
      lib/api/decorators/single.rb
  28. 14
      lib/api/errors/unwritable_property.rb
  29. 9
      lib/api/root.rb
  30. 3
      lib/api/v3/projects/projects_api.rb
  31. 1
      lib/api/v3/root.rb
  32. 38
      lib/api/v3/types/type_collection_representer.rb
  33. 57
      lib/api/v3/types/type_representer.rb
  34. 60
      lib/api/v3/types/types_api.rb
  35. 50
      lib/api/v3/types/types_by_project_api.rb
  36. 12
      lib/api/v3/utilities/path_helper.rb
  37. 2
      lib/api/v3/versions/projects_by_version_api.rb
  38. 4
      lib/api/v3/versions/versions_api.rb
  39. 6
      lib/api/v3/versions/versions_by_project_api.rb
  40. 25
      lib/api/v3/work_packages/form/form_api.rb
  41. 5
      lib/api/v3/work_packages/form/work_package_attribute_links_representer.rb
  42. 20
      lib/api/v3/work_packages/form/work_package_payload_representer.rb
  43. 30
      lib/api/v3/work_packages/schema/work_package_schema.rb
  44. 45
      lib/api/v3/work_packages/schema/work_package_schema_representer.rb
  45. 24
      lib/api/v3/work_packages/work_package_contract.rb
  46. 66
      lib/api/v3/work_packages/work_package_representer.rb
  47. 44
      lib/api/v3/work_packages/work_packages_api.rb
  48. 3
      lib/open_project/form_tag_helper.rb
  49. 19
      lib/plugins/acts_as_customizable/lib/acts_as_customizable.rb
  50. 9
      lib/tabular_form_builder.rb
  51. 11
      spec/controllers/versions_controller_spec.rb
  52. 20
      spec/factories/type_factory.rb
  53. 1
      spec/features/attachments/attachments_spec.rb
  54. 4
      spec/features/work_packages/new_work_package_spec.rb
  55. 106
      spec/lib/api/v3/types/type_representer_spec.rb
  56. 26
      spec/lib/api/v3/utilities/path_helper_spec.rb
  57. 26
      spec/lib/api/v3/work_packages/form/work_package_payload_representer_spec.rb
  58. 97
      spec/lib/api/v3/work_packages/schema/work_package_schema_spec.rb
  59. 159
      spec/lib/api/v3/work_packages/work_package_contract_spec.rb
  60. 9
      spec/lib/api/v3/work_packages/work_package_representer_spec.rb
  61. 183
      spec/lib/api/v3/work_packages/work_package_schema_representer_spec.rb
  62. 25
      spec/lib/open_project/form_tag_helper_spec.rb
  63. 22
      spec/lib/tabular_form_builder_spec.rb
  64. 29
      spec/models/custom_value/bool_strategy_spec.rb
  65. 60
      spec/models/custom_value/format_strategy_spec.rb
  66. 23
      spec/models/work_package/work_package_validations_spec.rb
  67. 3
      spec/models/work_package_spec.rb
  68. 10
      spec/requests/api/v3/project_resource_spec.rb
  69. 113
      spec/requests/api/v3/types/type_resource_spec.rb
  70. 95
      spec/requests/api/v3/types/types_by_project_resource_spec.rb
  71. 66
      spec/requests/api/v3/work_package_resource_spec.rb
  72. 42
      spec/requests/api/v3/work_packages/form/work_package_form_resource_spec.rb

@ -43,6 +43,8 @@
//= require ./bundles/openproject-core-app //= require ./bundles/openproject-core-app
//= require_tree ./bundles //= require_tree ./bundles
//= require custom-fields
//source: http://stackoverflow.com/questions/8120065/jquery-and-prototype-dont-work-together-with-array-prototype-reverse //source: http://stackoverflow.com/questions/8120065/jquery-and-prototype-dont-work-together-with-array-prototype-reverse
if (typeof []._reverse == 'undefined') { if (typeof []._reverse == 'undefined') {
jQuery.fn.reverse = Array.prototype.reverse; jQuery.fn.reverse = Array.prototype.reverse;

@ -0,0 +1,129 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See doc/COPYRIGHT.rdoc for more details.
//++
(function($) {
/*
* @see /app/views/custom_fields/_form.html.erb
*/
$(function() {
var customFieldForm = $('#custom_field_form');
if (customFieldForm.length === 0) {
return;
}
// collect the nodes involved
var format = $('#custom_field_field_format'),
lengthField = $('#custom_field_length'),
regexpField = $('#custom_field_regexp'),
possibleValues = $('#custom_field_possible_values_attributes'),
defaultValueFields = $('#custom_field_default_value_attributes'),
spanDefaultTextMulti = $('#default_value_text_multi'),
spanDefaultTextSingle = $('#default_value_text_single'),
spanDefaultBool = $('#default_value_bool');
var deactivate = function(element) {
element.hide().find('input, textarea').not('.destroy_flag').attr('disabled', true);
},
activate = function(element) {
element.show().find('input, textarea').not('.destroy_flag').removeAttr('disabled');
},
toggleVisibility = function(method, args) {
var fields = Array.prototype.slice.call(args);
$.each(fields, function(idx, field) {
field.closest('.form--field, .form--grouping')[method]();
});
},
hide = function() { toggleVisibility('hide', arguments); },
show = function() { toggleVisibility('show', arguments); },
toggleFormat = function() {
var searchable = $('#searchable_container'),
unsearchable = function() { searchable.attr('checked', false).hide(); };
// defaults (reset these fields before doing anything else)
$.each([spanDefaultBool, spanDefaultTextSingle], function(idx, element) {
deactivate(element);
});
show(defaultValueFields);
activate(spanDefaultTextMulti);
switch (format.val()) {
case 'list':
hide(lengthField, regexpField);
show(searchable);
activate(possibleValues);
break;
case 'bool':
activate(spanDefaultBool);
deactivate(spanDefaultTextMulti);
deactivate(possibleValues);
hide(lengthField, regexpField, searchable);
unsearchable();
break;
case 'date':
activate(spanDefaultTextSingle);
deactivate(spanDefaultTextMulti);
deactivate(possibleValues);
hide(lengthField, regexpField);
unsearchable();
break;
case 'float':
case 'int':
activate(spanDefaultTextSingle);
deactivate(spanDefaultTextMulti);
deactivate(possibleValues);
show(lengthField, regexpField);
unsearchable();
break;
case 'user':
case 'version':
deactivate(defaultValueFields);
deactivate(possibleValues);
hide(lengthField, regexpField, defaultValueFields);
unsearchable();
break;
default:
show(lengthField, regexpField, searchable);
deactivate(possibleValues);
break;
}
};
// assign the switch format function to the select field
format.on('change', toggleFormat).trigger('change');
});
$(function() {
var localeSelectors = $('.locale_selector');
localeSelectors.change(function () {
var lang = $(this).val(),
span = $(this).closest('.translation');
span.attr('lang', lang);
}).trigger('change');
});
}(jQuery));

@ -35,16 +35,25 @@ var WorkPackage = WorkPackage || {};
var init; var init;
// TODO: remove this, once the issue edit view is completely angular based // TODO: remove this, once the issue edit view is completely angular based
var bodyInjector = function() { return angular.element('body').injector(); };
var manuallyCompile = function(button) { var manuallyCompile = function(button) {
if (!window.angular || button.length < 1) { if (!window.angular || button.length < 1) {
return; return;
} }
angular.element('body').injector().invoke(['$compile', function($compile) { bodyInjector().invoke(['$compile', function($compile) {
var scope = angular.element(button).scope(); var scope = angular.element(button).scope();
$compile(button)(scope); $compile(button)(scope);
}]) }])
}; };
var initializeAtWho = function(section) {
var textareas = section.find('textarea');
bodyInjector().invoke(['AutoCompleteHelper', function(AutoCompleteHelper) {
AutoCompleteHelper.enableTextareaAutoCompletion(textareas);
}]);
}
init = function () { init = function () {
$.ajaxAppend({ $.ajaxAppend({
@ -68,6 +77,7 @@ var WorkPackage = WorkPackage || {};
// TODO: see last todo // TODO: see last todo
var previewButton = update.find(".preview.button") var previewButton = update.find(".preview.button")
manuallyCompile(previewButton); manuallyCompile(previewButton);
initializeAtWho(update);
} }
}); });

@ -586,7 +586,7 @@
</span> </span>
</div> </div>
</fieldset> </fieldset>
```
# Forms: Text fields # Forms: Text fields

@ -44,6 +44,7 @@ $form--field-types: (text-field, text-area, select, check-box, range-field, sear
vertical-align: middle vertical-align: middle
text-overflow: ellipsis text-overflow: ellipsis
overflow: hidden overflow: hidden
white-space: nowrap
// A general CSS class to be applied to forms using the above defined form style. // A general CSS class to be applied to forms using the above defined form style.
// We can't define this on form itself as this would break a lot of existing forms. // We can't define this on form itself as this would break a lot of existing forms.
@ -238,6 +239,11 @@ fieldset.form--fieldset
@include grid-order(1) @include grid-order(1)
@include grid-size(shrink) @include grid-size(shrink)
// FIXME: this will break anything in regards to flex layouting within the container,
// e.g. using form--field-inline-action inside -trailing-label
.form--label + span.form--field-container
display: block
.form--label .form--label
@include grid-content(2) @include grid-content(2)
@include label-style @include label-style

@ -190,5 +190,11 @@ h1:hover, h2:hover, h3:hover
background: url(image-path('wiki_styles/note_small.png')) 5px 4px no-repeat #F5FFFA background: url(image-path('wiki_styles/note_small.png')) 5px 4px no-repeat #F5FFFA
border: 1px solid #C7CFCA border: 1px solid #C7CFCA
.quick_info .label
background: none
color: #000
font-weight: bold
font-size: $wiki-default-font-size
.wiki-content .wiki-content
width: 700px width: 700px

@ -147,3 +147,9 @@
line-height: $user-avatar-mini-width line-height: $user-avatar-mini-width
.avatar-mini .avatar-mini
float: left float: left
#timelog, #attachments
padding-left: 0
padding-right: 0
margin-right: 0 !important

@ -93,11 +93,7 @@ class VersionsController < ApplicationController
redirect_to controller: '/projects', action: 'settings', tab: 'versions', id: @project redirect_to controller: '/projects', action: 'settings', tab: 'versions', id: @project
end end
format.js do format.js do
# IE doesn't support the replace_html rjs method for select box options render locals: { versions: @project.shared_versions.open, version: @version }
render(:update) {|page|
page.replace 'work_package_fixed_version_id',
content_tag('select', '<option></option>'.html_safe + version_options_for_select(@project.shared_versions.open, @version).html_safe, id: 'work_package_fixed_version_id', name: 'work_package[fixed_version_id]')
}
end end
end end
else else

@ -308,15 +308,6 @@ module WorkPackagesHelper
].flatten.compact ].flatten.compact
end end
def work_package_form_top_attributes(form, work_package, locals = {})
[
work_package_form_type_attribute(form, work_package, locals),
work_package_form_subject_attribute(form, work_package, locals),
work_package_form_parent_attribute(form, work_package, locals),
work_package_form_description_attribute(form, work_package, locals)
].compact
end
def work_package_show_attribute_list(work_package) def work_package_show_attribute_list(work_package)
main_attributes = work_package_show_main_attributes(work_package) main_attributes = work_package_show_main_attributes(work_package)
custom_field_attributes = work_package_show_custom_fields(work_package) custom_field_attributes = work_package_show_custom_fields(work_package)
@ -448,66 +439,24 @@ module WorkPackagesHelper
end end
end end
def work_package_form_type_attribute(form, work_package, locals = {}) def work_package_form_type_selectable_types(project)
selectable_types = locals[:project].types.map { |t| [((t.is_standard) ? '' : t.name), t.id] } project.types.map { |t| [((t.is_standard) ? '' : t.name), t.id] }
field = work_package_form_field do
form.select :type_id, selectable_types
end
url = work_package.new_record? ?
new_type_project_work_packages_path(locals[:project]) :
new_type_work_package_path(work_package)
field += observe_field :work_package_type_id, url: url,
update: :attributes,
method: :get,
with: "Form.serialize('work_package-form')"
WorkPackageAttribute.new(:type, field)
end end
def work_package_form_subject_attribute(form, _work_package, _locals = {}) def work_package_form_type_observable_url(work_package, project)
field = work_package_form_field do if work_package.new_record?
form.text_field(:subject, required: true) new_type_project_work_packages_path(project)
else
new_type_work_package_path(work_package)
end end
WorkPackageAttribute.new :subject, field
end end
def work_package_form_parent_attribute(form, work_package, locals = {}) def user_can_manage_subtasks?(project)
return unless User.current.allowed_to?(:manage_subtasks, locals[:project]) User.current.allowed_to?(:manage_subtasks, project)
parent_field = work_package_form_field do
form.text_field :parent_id,
size: 10,
title: l(:description_autocomplete),
class: 'short'
end
parent_field += '<div id="parent_issue_candidates" class="autocomplete"></div>'.html_safe
autocomplete_path = work_packages_auto_complete_path(id: work_package,
project_id: locals[:project],
escape: false)
parent_field += javascript_tag "observeWorkPackageParentField('#{autocomplete_path}')"
WorkPackageAttribute.new(:parent_issue, parent_field)
end end
def work_package_form_description_attribute(form, work_package, _locals = {}) def work_package_form_parent_autocomplete_path(work_package, project)
field = work_package_form_field classes: '-vertical' do work_packages_auto_complete_path(id: work_package, project_id: project, escape: false)
form.text_area :description,
cols: 60,
rows: (work_package.description.blank? ? 10 : [[10, work_package.description.length / 50].max, 100].min),
accesskey: accesskey(:edit),
class: 'wiki-edit',
:'ng-non-bindable' => '',
:'data-wp_autocomplete_url' => work_packages_auto_complete_path(project_id: work_package.project, format: :json)
end
WorkPackageAttribute.new(:description, field)
end end
def work_package_form_status_attribute(form, work_package, locals = {}) def work_package_form_status_attribute(form, work_package, locals = {})

@ -81,7 +81,7 @@ class CustomValue < ActiveRecord::Base
protected protected
def validate_presence_of_required_value def validate_presence_of_required_value
errors.add(:value, :blank) if custom_field.is_required? && value.blank? errors.add(:value, :blank) if custom_field.is_required? && !strategy.value_present?
end end
def validate_format_of_value def validate_format_of_value

@ -28,10 +28,16 @@
#++ #++
class CustomValue::BoolStrategy < CustomValue::FormatStrategy class CustomValue::BoolStrategy < CustomValue::FormatStrategy
def value_present?
# can't use :blank? safely, because false.blank? == true
# can't use :present? safely, because false.present? == false
!value.nil? && value != ''
end
def typed_value def typed_value
unless value.blank? return nil unless value_present?
value == '1'
end value == '1'
end end
def validate_type_of_value def validate_type_of_value

@ -35,6 +35,10 @@ class CustomValue::FormatStrategy
@custom_value = custom_value @custom_value = custom_value
end end
def value_present?
!value.blank?
end
# Returns the value of the CustomValue in a typed fashion (i.e. not as the string # Returns the value of the CustomValue in a typed fashion (i.e. not as the string
# that is used for representation in the database) # that is used for representation in the database)
def typed_value def typed_value

@ -60,6 +60,8 @@ module WorkPackage::Validations
validate :validate_category validate :validate_category
validate :validate_children validate :validate_children
validate :validate_estimated_hours
end end
def validate_start_date_before_soonest_start_date def validate_start_date_before_soonest_start_date
@ -129,6 +131,12 @@ module WorkPackage::Validations
end end
end end
def validate_estimated_hours
if !estimated_hours.nil? && estimated_hours < 0
errors.add :estimated_hours, :only_values_greater_or_equal_zeroes_allowed
end
end
private private
def status_changed? def status_changed?

@ -39,11 +39,13 @@ an attachments_attributes= and not every model supports this we build a nested f
end end
%> %>
<fieldset id="attachments" class="header_collapsible collapsible collapsed"> <% open ||= false %>
<fieldset id="attachments" class="header_collapsible collapsible<%= open ? '' : ' collapsed' %>">
<legend title="<%=l(:description_attachment_toggle)%>", onclick="toggleFieldset(this);"> <legend title="<%=l(:description_attachment_toggle)%>", onclick="toggleFieldset(this);">
<a href="javascript:"><%=l(:label_attachment_plural)%></a> <a href="javascript:"><%=l(:label_attachment_plural)%></a>
</legend> </legend>
<div style="display: none;"> <div <%= 'style="display: none;"' unless open %>>
<div id="attachments_fields"> <div id="attachments_fields">
<div class="grid-block" id="attachment_template"> <div class="grid-block" id="attachment_template">
<div class="form--field"> <div class="form--field">

@ -27,112 +27,8 @@ See doc/COPYRIGHT.rdoc for more details.
++#%> ++#%>
<%= error_messages_for 'custom_field' %> <%= error_messages_for 'custom_field' %>
<script type="text/javascript">
//<![CDATA[
function toggle_custom_field_format() {
var format = $("custom_field_field_format");
var p_length = $("custom_field_min_length");
var p_regexp = $("custom_field_regexp");
var p_values = $("custom_field_possible_values_attributes");
var p_searchable = $("custom_field_searchable");
var p_default_value = $("custom_field_default_value_attributes");
var span_default_text_multi = $("default_value_text_multi");
var span_default_text_single = $("default_value_text_single");
var span_default_bool = $("default_value_bool");
var hide_and_disable = function(element) {
element.hide().
select('input, textbox').each(function (element) {
if(!element.match('.destroy_flag')) {
element.writeAttribute('disabled', 'disabled');
}
});
}
var show_and_enable = function(element) {
element.show().
select('input, textbox').each(function (element) {
if(!element.match('.destroy_flag')) {
element.removeAttribute('disabled');
}
});
}
hide_and_disable(span_default_bool); <section class="form--section" id="custom_field_form">
hide_and_disable(span_default_text_single);
show_and_enable(span_default_text_multi);
Element.show(p_default_value);
switch (format.value) {
case "list":
Element.hide(p_length.parentNode);
Element.hide(p_regexp.parentNode);
if (p_searchable) Element.show(p_searchable.parentNode);
show_and_enable(p_values);
break;
case "bool":
show_and_enable(span_default_bool);
hide_and_disable(span_default_text_multi);
Element.hide(p_length.parentNode);
Element.hide(p_regexp.parentNode);
if (p_searchable) Element.hide(p_searchable.parentNode);
hide_and_disable(p_values);
break;
case "date":
hide_and_disable(span_default_text_multi);
show_and_enable(span_default_text_single);
Element.hide(p_length.parentNode);
Element.hide(p_regexp.parentNode);
if (p_searchable) Element.hide(p_searchable.parentNode);
hide_and_disable(p_values);
break;
case "float":
case "int":
hide_and_disable(span_default_text_multi);
show_and_enable(span_default_text_single);
Element.show(p_length.parentNode);
Element.show(p_regexp.parentNode);
if (p_searchable) Element.hide(p_searchable.parentNode);
hide_and_disable(p_values);
break;
case "user":
case "version":
Element.hide(p_length.parentNode);
Element.hide(p_regexp.parentNode);
if (p_searchable) Element.hide(p_searchable.parentNode);
hide_and_disable(p_values);
Element.hide(p_default_value);
break;
default:
Element.show(p_length.parentNode);
Element.show(p_regexp.parentNode);
if (p_searchable) Element.show(p_searchable.parentNode);
hide_and_disable(p_values);
break;
}
}
function initLocaleChangeListener() {
jQuery(".locale_selector").each(function (index) {
var localeSelector = jQuery(this);
localeSelector.change(function () {
localeSelector.children("option:selected").each(function () {
//alert(jQuery(this).attr('value'));
var span = jQuery(jQuery(this).parents("span.translation")[0])
if (typeof span.attr('lang') !== 'undefined') {
span.removeAttr('lang');
}
span.attr('lang', jQuery(this).attr('value'));
});
});
localeSelector.change();
});
}
//]]>
</script>
<section class="form--section">
<div class="form--field -required" id="custom_field_name_attributes"> <div class="form--field -required" id="custom_field_name_attributes">
<%= f.text_field :name, <%= f.text_field :name,
:multi_locale => true %> :multi_locale => true %>
@ -141,23 +37,23 @@ See doc/COPYRIGHT.rdoc for more details.
<%= f.select :field_format, <%= f.select :field_format,
custom_field_formats_for_select(@custom_field), custom_field_formats_for_select(@custom_field),
{}, {},
:onchange => "toggle_custom_field_format();", disabled: !@custom_field.new_record? %>
:disabled => !@custom_field.new_record? %>
</div> </div>
<div class="form--field"> <div class="form--grouping" id="custom_field_length">
<label class="form--label" for="custom_field_min_length"><%=l(:label_min_max_length)%></label> <div class="form--grouping-label">
<span class="form--field-container"> <%= l(:label_min_max_length) %> <br>
<%= f.text_field :min_length, <small>(<%= l(:text_min_max_length_info ) %>)</small>
:size => 5, </div>
:no_label => true %> - <div class="form--grouping-row">
<%= f.text_field :max_length, <div class="form--field">
:size => 5, <%= f.text_field :min_length %>
:no_label => true %> </div>
</span> <div class="form--field">
<span class="form--field-instructions"> <%= f.text_field :max_length %>
<%=l(:text_min_max_length_info)%> </div>
</span> </div>
</div> </div>
<div class="form--field"> <div class="form--field">
<%= f.text_field :regexp, <%= f.text_field :regexp,
:size => 50 %> :size => 50 %>
@ -219,7 +115,7 @@ See doc/COPYRIGHT.rdoc for more details.
<div class="form--field"><%= f.check_box :is_required %></div> <div class="form--field"><%= f.check_box :is_required %></div>
<div class="form--field"><%= f.check_box :is_for_all %></div> <div class="form--field"><%= f.check_box :is_for_all %></div>
<div class="form--field"><%= f.check_box :is_filter %></div> <div class="form--field"><%= f.check_box :is_filter %></div>
<div class="form--field"><%= f.check_box :searchable %></div> <div class="form--field" id="searchable_container"><%= f.check_box :searchable %></div>
<% when "UserCustomField" %> <% when "UserCustomField" %>
<div class="form--field"><%= f.check_box :is_required %></div> <div class="form--field"><%= f.check_box :is_required %></div>
<div class="form--field"><%= f.check_box :visible %></div> <div class="form--field"><%= f.check_box :visible %></div>
@ -227,7 +123,7 @@ See doc/COPYRIGHT.rdoc for more details.
<% when "ProjectCustomField" %> <% when "ProjectCustomField" %>
<div class="form--field"><%= f.check_box :is_required %></div> <div class="form--field"><%= f.check_box :is_required %></div>
<div class="form--field"><%= f.check_box :visible %></div> <div class="form--field"><%= f.check_box :visible %></div>
<div class="form--field"><%= f.check_box :searchable %></div> <div class="form--field" id="searchable_container"><%= f.check_box :searchable %></div>
<% when "TimeEntryCustomField" %> <% when "TimeEntryCustomField" %>
<div class="form--field"><%= f.check_box :is_required %></div> <div class="form--field"><%= f.check_box :is_required %></div>
<% else %> <% else %>
@ -236,5 +132,3 @@ See doc/COPYRIGHT.rdoc for more details.
<%= call_hook(:"view_custom_fields_form_#{@custom_field.type.to_s.underscore}", :custom_field => @custom_field, :form => f) %> <%= call_hook(:"view_custom_fields_form_#{@custom_field.type.to_s.underscore}", :custom_field => @custom_field, :form => f) %>
</section> </section>
<%= javascript_tag "toggle_custom_field_format();" %>
<%= javascript_tag "initLocaleChangeListener();" %>

@ -43,7 +43,7 @@ See doc/COPYRIGHT.rdoc for more details.
</div> </div>
<% end %> <% end %>
<div class="form--field"> <div class="form--field">
<%= f.text_area :content, label: l(:description_message_content), data: { wp_autocomplete_url: work_packages_auto_complete_path(project_id: @project, format: :json), :'ng-non-bindable' => '' } %> <%= f.text_area :content, label: l(:description_message_content), class: 'wiki-edit', data: {:'ng-non-bindable' => '' }, 'data-wp_autocomplete_url' => work_packages_auto_complete_path(project_id: @project, format: :json) %>
<%= wikitoolbar_for 'message_content' %> <%= wikitoolbar_for 'message_content' %>
</div> </div>
<%= render :partial => 'attachments/form' %> <%= render :partial => 'attachments/form' %>

@ -0,0 +1,30 @@
<%#-- copyright
OpenProject is a project management system.
Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2013 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See doc/COPYRIGHT.rdoc for more details.
++#%>
jQuery('#work_package_fixed_version_id').empty().append("<%= escape_javascript(version_options_for_select(versions, version)) %>");

@ -29,12 +29,37 @@ See doc/COPYRIGHT.rdoc for more details.
<%= call_hook(:view_work_packages_form_details_top, { :issue => work_package, :form => f }) %> <%= call_hook(:view_work_packages_form_details_top, { :issue => work_package, :form => f }) %>
<% work_package_form_top_attributes(f, work_package, <div class="grid-block">
:priorities => priorities, <div class="form--column">
:project => project).each do |attribute| %> <div class="form--field">
<%= attribute.field %> <%= f.select :type_id, work_package_form_type_selectable_types(project) %>
<%= observe_field :work_package_type_id, url: work_package_form_type_observable_url(work_package, project),
update: :attributes,
method: :get,
with: "Form.serialize('work_package-form')" %>
</div>
</div>
<% if user_can_manage_subtasks?(project) %>
<div class="form--column">
<div class="form--field">
<%= f.text_field :parent_id, title: l(:description_autocomplete) %>
</div>
</div>
<% end %>
</div>
<% if user_can_manage_subtasks?(project) %>
<div id="parent_issue_candidates" class="autocomplete"></div>
<script>
observeWorkPackageParentField('<%= work_package_form_parent_autocomplete_path(work_package, project) %>')
</script>
<% end %> <% end %>
<div class="form--field -vertical">
<%= f.text_field :subject, required: true %>
</div>
<div class="form--field -vertical">
<%= f.text_area :description, accesskey: accesskey(:edit), class: 'wiki-edit',:'ng-non-bindable' => '',
:'data-wp_autocomplete_url' => work_packages_auto_complete_path(project_id: work_package.project, format: :json) %>
</div>
<div id="attributes" class="attributes"> <div id="attributes" class="attributes">
<%= render :partial => 'attributes', :locals => { :f => f, <%= render :partial => 'attributes', :locals => { :f => f,
@ -74,7 +99,7 @@ See doc/COPYRIGHT.rdoc for more details.
<% if work_package.new_record? %> <% if work_package.new_record? %>
<hr class="form--separator -invisible"> <hr class="form--separator -invisible">
<%= render :partial => 'attachments/nested_form' %> <%= render :partial => 'attachments/nested_form', locals: { open: true } %>
<% end %> <% end %>
<%= call_hook(:view_work_packages_form_details_bottom, { :issue => work_package, :form => f }) %> <%= call_hook(:view_work_packages_form_details_bottom, { :issue => work_package, :form => f }) %>

@ -36,7 +36,7 @@ See doc/COPYRIGHT.rdoc for more details.
:url => project_work_packages_path(project), :url => project_work_packages_path(project),
:html => { :multipart => true, :html => { :multipart => true,
:id => 'work_package-form', :id => 'work_package-form',
:class => 'new-work_package-form form -bordered' } do |f| %> :class => 'new-work_package-form form' } do |f| %>
<%= error_messages_for :object => work_package %> <%= error_messages_for :object => work_package %>

@ -52,7 +52,7 @@ See doc/COPYRIGHT.rdoc for more details.
<%= render partial: 'show_attributes', locals: { work_package: work_package } %> <%= render partial: 'show_attributes', locals: { work_package: work_package } %>
<% if work_package.description? %> <% if work_package.description? %>
<hr /> <hr>
<div class="description work_package_part"> <div class="description work_package_part">
<div class="contextual"> <div class="contextual">
<%= link_to_if_authorized(l(:button_quote), { controller: :work_packages, action: :quoted, id: work_package }, :class => 'quote-link icon icon-quote') %> <%= link_to_if_authorized(l(:button_quote), { controller: :work_packages, action: :quoted, id: work_package }, :class => 'quote-link icon icon-quote') %>
@ -62,6 +62,7 @@ See doc/COPYRIGHT.rdoc for more details.
<%= format_text work_package, :description, :attachments => work_package.attachments %> <%= format_text work_package, :description, :attachments => work_package.attachments %>
</div> </div>
</div> </div>
<hr>
<% end %> <% end %>
<% if work_package.attachments.any? -%> <% if work_package.attachments.any? -%>
@ -70,7 +71,6 @@ See doc/COPYRIGHT.rdoc for more details.
<%= call_hook(:view_work_packages_show_description_bottom, :issue => work_package) %> <%= call_hook(:view_work_packages_show_description_bottom, :issue => work_package) %>
<%= render :partial => 'subwork_packages_paragraph', :locals => { :work_package => work_package, <%= render :partial => 'subwork_packages_paragraph', :locals => { :work_package => work_package,
:ancestors => ancestors, :ancestors => ancestors,
:descendants => descendants } %> :descendants => descendants } %>

@ -237,6 +237,8 @@ de:
only_same_project_categories_allowed: only_same_project_categories_allowed:
"Ein Arbeitpaket und seine Kategorie müssen im selben Projekt sein." "Ein Arbeitpaket und seine Kategorie müssen im selben Projekt sein."
does_not_exist: "Die angegebene Kategorie existiert nicht." does_not_exist: "Die angegebene Kategorie existiert nicht."
estimated_hours:
only_values_greater_or_equal_zeroes_allowed: "Der geschätzte Aufwand muss >= 0 sein."
project_association: project_association:
identical_projects: "kann nicht von einem Projekt auf sich selbst erstellt werden" identical_projects: "kann nicht von einem Projekt auf sich selbst erstellt werden"
project_association_not_allowed: "erlaubt keine Projekt-Abhängigkeiten" project_association_not_allowed: "erlaubt keine Projekt-Abhängigkeiten"
@ -1698,5 +1700,7 @@ de:
validation: validation:
invalid_user_assigned_to_work_package: invalid_user_assigned_to_work_package:
"Der gewählte Nutzer darf dem Arbeitspaket nicht als '%{property}' zugewiesen werden." "Der gewählte Nutzer darf dem Arbeitspaket nicht als '%{property}' zugewiesen werden."
estimated_hours: "Der geschätzte Aufwand kann für Eltern-Arbeitspakete nicht gesetzt werden."
done_ratio: "Der Fortschritt kann nicht gesetzt werden falls es sich um ein Eltern-Arbeitspaket handelt, er durch den Status definiert wird oder wenn er komplett deaktiviert wurde."
resources: resources:
schema: 'Schema' schema: 'Schema'

@ -239,6 +239,8 @@ en:
only_same_project_categories_allowed: only_same_project_categories_allowed:
"The category of a work package must be within the same project as the work package." "The category of a work package must be within the same project as the work package."
does_not_exist: "The specified category does not exist." does_not_exist: "The specified category does not exist."
estimated_hours:
only_values_greater_or_equal_zeroes_allowed: "The estimated hours must be >= 0."
user: user:
attributes: attributes:
password: password:
@ -1677,7 +1679,7 @@ en:
invalid_resource: "For property '%{property}' a link like '%{expected}' is expected, but got '%{actual}'." invalid_resource: "For property '%{property}' a link like '%{expected}' is expected, but got '%{actual}'."
invalid_user_status_transition: "The current user account status does not allow this operation." invalid_user_status_transition: "The current user account status does not allow this operation."
missing_content_type: "not specified" missing_content_type: "not specified"
multiple_errors: "Multiple fields violated their constraints." multiple_errors: "Multiple field constraints have been violated."
parse_error: "The request body was neither empty, nor did it contain a single JSON object." parse_error: "The request body was neither empty, nor did it contain a single JSON object."
writing_read_only_attributes: "You must not write a read-only attribute." writing_read_only_attributes: "You must not write a read-only attribute."
invalid_format: "Invalid format for property '%{property}': Expected format like '%{expected_format}', but got '%{actual}'." invalid_format: "Invalid format for property '%{property}': Expected format like '%{expected_format}', but got '%{actual}'."
@ -1688,5 +1690,7 @@ en:
validation: validation:
invalid_user_assigned_to_work_package: invalid_user_assigned_to_work_package:
"The chosen user is not allowed to be '%{property}' for this work package." "The chosen user is not allowed to be '%{property}' for this work package."
estimated_hours: "Estimated hours cannot be set on parent work packages."
done_ratio: "Done ratio cannot be set on parent work packages, when it is inferred by status or when it is disabled."
resources: resources:
schema: 'Schema' schema: 'Schema'

@ -2576,21 +2576,21 @@ Note that due to sharing this might be more than the versions *defined* by that
## Properties: ## Properties:
| Property | Description | Type | Constraints | Supported operations | Condition | | Property | Description | Type | Constraints | Supported operations | Condition |
| :--------------: | ------------------------------------------------------ | ----------- | ---------------------------------------------------------------------------- | -------------------- | -------------------------------- | | :--------------: | ------------------------------------------------------ | ----------- | ------------------------------------------------------------------------------------------------------ | -------------------- | -------------------------------- |
| id | Work package id | Integer | x > 0 | READ | | | id | Work package id | Integer | x > 0 | READ | |
| lockVersion | The version of the item as used for optimistic locking | Integer | | READ | | | lockVersion | The version of the item as used for optimistic locking | Integer | | READ | |
| subject | Work package subject | String | not null; 1 <= length <= 255 | READ / WRITE | | | subject | Work package subject | String | not null; 1 <= length <= 255 | READ / WRITE | |
| type | Name of the work package's type | String | not null | READ | | | type | Name of the work package's type | String | not null | READ | |
| description | The work package description | Formattable | | READ / WRITE | | | description | The work package description | Formattable | | READ / WRITE | |
| parentId | Parent work package id | Integer | Must be an id of an existing and visible (for the current user) work package | READ / WRITE | | | parentId | Parent work package id | Integer | Must be an id of an existing and visible (for the current user) work package | READ / WRITE | |
| startDate | | Date | must be equal or greater than the soonest possible start date | READ / WRITE | | | startDate | | Date | Cannot be set for parent work packages; must be equal or greater than the earliest possible start date | READ / WRITE | |
| dueDate | | Date | must be greater than or equal to the start date | READ / WRITE | | | dueDate | | Date | Cannot be set for parent work packages; must be greater than or equal to the start date | READ / WRITE | |
| estimatedTime | | Duration | | READ | | | estimatedTime | Time a work package likely needs to be completed | Duration | Cannot be set for parent work packages | READ / WRITE | |
| spentTime | | Duration | | READ | **Permission** view time entries | | spentTime | | Duration | | READ | **Permission** view time entries |
| percentageDone | | Integer | 0 <= x <= 100 | READ | | | percentageDone | Amount of total completion for a work package | Integer | 0 <= x <= 100; Cannot be set for parent work packages | READ / WRITE | |
| createdAt | Time of creation | DateTime | | READ | | | createdAt | Time of creation | DateTime | | READ | |
| updatedAt | Time of the most recent change to the work package | DateTime | | READ | | | updatedAt | Time of the most recent change to the work package | DateTime | | READ | |
*Note that the properties listed here only cover the built-in properties of the OpenProject Core. *Note that the properties listed here only cover the built-in properties of the OpenProject Core.
Using plug-ins and custom fields a work package might contain various additional properties. Using plug-ins and custom fields a work package might contain various additional properties.
@ -2601,6 +2601,13 @@ all properties of the linking work package, including properties added by plug-i
they can occur as properties or as linked properties. A client has to consult the schema to resolve they can occur as properties or as linked properties. A client has to consult the schema to resolve
the human readable name of custom fields.* the human readable name of custom fields.*
*Properties that cannot be set directly on parent work packages are inferred from their children instead:*
* `startDate` is the earliest start date from its children
* `dueDate` is the latest due date from its children
* `estimatedTime` is the sum of estimated times from its children
* `percentageDone` is the weighted average of the sum of its children percentages done. The weight is given by the average of its children estimatedHours. However, if the percentage done is given by a work package's status, then only the status matters and no value is inferred.
## WorkPackage [/api/v3/work_packages/{id}{?notify}] ## WorkPackage [/api/v3/work_packages/{id}{?notify}]
+ Model + Model

@ -45,8 +45,8 @@ Feature: Localized boolean custom fields can be created
Scenario: Available fields Scenario: Available fields
When I select "Boolean" from "custom_field_field_format" When I select "Boolean" from "custom_field_field_format"
Then there should be the following localizations: Then there should be the following localizations:
| locale | name | default_value | possible_values | | locale | name | default_value |
| en | | 0 | | | en | | 0 |
And there should be a "custom_field_type_ids_1" field visible And there should be a "custom_field_type_ids_1" field visible
And I should see "Bug" And I should see "Bug"
And there should be a "custom_field_type_ids_2" field visible And there should be a "custom_field_type_ids_2" field visible

@ -213,9 +213,9 @@ module.exports = function($sce, $http, $timeout, AutoCompleteHelper, TextileServ
$scope.hasEmptyOption = true; $scope.hasEmptyOption = true;
} }
if ($scope.embedded) { if ($scope.embedded) {
$scope.readValue = getReadAttributeValue($scope); $scope.readValue = this._getReadAttributeValue($scope);
if ($scope.isEditable) { if ($scope.isEditable) {
setEmbeddedOptions($scope); this._setEmbeddedOptions($scope);
} }
} else { } else {
$scope.readValue = $scope.entity.embedded[$scope.attribute]; $scope.readValue = $scope.entity.embedded[$scope.attribute];
@ -230,8 +230,13 @@ module.exports = function($sce, $http, $timeout, AutoCompleteHelper, TextileServ
} }
}; };
// when you need to expose inner functions like that for test
// it's a sign that it should be in a service
this._setEmbeddedOptions = setEmbeddedOptions;
this._getReadAttributeValue = getReadAttributeValue;
this.dispatchHook = function($scope, action, data) { this.dispatchHook = function($scope, action, data) {
var actionFunction = hooks[$scope.type][action] || hooks._fallback[action] || angular.noop; var actionFunction = hooks[$scope.type][action] || hooks._fallback[action] || angular.noop;
return actionFunction($scope, data); return actionFunction.call(this, $scope, data);
}; };
}; };

@ -38,6 +38,28 @@ describe('inplaceEditor Dispatcher', function() {
dispatcher = InplaceEditorDispatcher; dispatcher = InplaceEditorDispatcher;
})); }));
describe('setReadValue', function() {
context('when type is select2', function() {
beforeEach(function() {
scope.attribute = 'status.name';
scope.type = 'select2';
scope.embedded = true;
});
context('when element is editable', function() {
beforeEach(function() {
scope.isEditable = false;
sinon.spy(dispatcher, '_setEmbeddedOptions');
sinon.stub(dispatcher, '_getReadAttributeValue');
dispatcher.dispatchHook(scope, 'setReadValue', {});
});
it('sets not the embedded options eagerly', function() {
expect(dispatcher._setEmbeddedOptions).to.not.have.been.called;
});
});
});
});
describe('formattable', function() { describe('formattable', function() {
var text = 'Raw text for my formattable!'; var text = 'Raw text for my formattable!';
var data = { var data = {

@ -65,18 +65,36 @@ module API
path: property, path: property,
association: property, association: property,
title_getter: -> (*) { represented.send(association).name }, title_getter: -> (*) { represented.send(association).name },
show_if: -> (*) { true }) show_if: -> (*) { true },
embed_as: nil)
link property do link property do
next unless instance_eval(&show_if) next unless instance_eval(&show_if)
value = represented.send(association) value = represented.send(association)
link_object = { href: (api_v3_paths.send(path, value.id) if value) } if value
link_object[:title] = instance_eval(&title_getter) if value {
href: api_v3_paths.send(path, value.id),
title: instance_eval(&title_getter)
}
else
{ href: nil }
end
end
link_object if embed_as
embed_property property,
association: association,
decorator: embed_as
end end
end end
def self.embed_property(property, association: property, decorator:)
property association,
as: property.to_s.camelize(:lower),
embedded: true,
decorator: decorator
end
protected protected
def current_user def current_user

@ -36,13 +36,25 @@ module API
fail ArgumentError, 'UnwritableProperty error must contain at least one invalid attribute!' if attributes.empty? fail ArgumentError, 'UnwritableProperty error must contain at least one invalid attribute!' if attributes.empty?
if attributes.length == 1 if attributes.length == 1
message = I18n.t('api_v3.errors.writing_read_only_attributes') message = if attributes.length == 1
begin
I18n.t("api_v3.errors.validation.#{attributes.first}", raise: true)
rescue I18n::MissingTranslationData
I18n.t('api_v3.errors.writing_read_only_attributes')
end
else
I18n.t('api_v3.errors.multiple_errors')
end
else else
message = I18n.t('api_v3.errors.multiple_errors') message = I18n.t('api_v3.errors.multiple_errors')
end end
super 422, message super 422, message
evaluate_attributes(attributes, invalid_attributes)
end
def evaluate_attributes(attributes, invalid_attributes)
if attributes.length > 1 if attributes.length > 1
invalid_attributes.each do |attribute| invalid_attributes.each do |attribute|
@errors << UnwritableProperty.new(attribute) @errors << UnwritableProperty.new(attribute)

@ -104,14 +104,19 @@ module API
# checks whether the user has # checks whether the user has
# any of the provided permission in any of the provided # any of the provided permission in any of the provided
# projects # projects
def authorize_any(permissions, projects, user: current_user) def authorize_any(permissions, projects: nil, global: false, user: current_user)
raise ArgumentError if projects.nil? && !global
projects = Array(projects) projects = Array(projects)
authorized = permissions.any? do |permission| authorized = permissions.any? do |permission|
allowed_condition = Project.allowed_to_condition(user, permission) allowed_condition = Project.allowed_to_condition(user, permission)
allowed_projects = Project.where(allowed_condition) allowed_projects = Project.where(allowed_condition)
!(allowed_projects & projects).empty? if global
allowed_projects.any?
else
!(allowed_projects & projects).empty?
end
end end
raise API::Errors::Unauthorized unless authorized raise API::Errors::Unauthorized unless authorized

@ -51,7 +51,8 @@ module API
mount API::V3::Projects::AvailableAssigneesAPI mount API::V3::Projects::AvailableAssigneesAPI
mount API::V3::Projects::AvailableResponsiblesAPI mount API::V3::Projects::AvailableResponsiblesAPI
mount API::V3::Categories::CategoriesByProjectAPI mount API::V3::Categories::CategoriesByProjectAPI
mount API::V3::Versions::ProjectsVersionsAPI mount API::V3::Versions::VersionsByProjectAPI
mount API::V3::Types::TypesByProjectAPI
end end
end end
end end

@ -45,6 +45,7 @@ module API
mount ::API::V3::Render::RenderAPI mount ::API::V3::Render::RenderAPI
mount ::API::V3::Statuses::StatusesAPI mount ::API::V3::Statuses::StatusesAPI
mount ::API::V3::StringObjects::StringObjectsAPI mount ::API::V3::StringObjects::StringObjectsAPI
mount ::API::V3::Types::TypesAPI
mount ::API::V3::Users::UsersAPI mount ::API::V3::Users::UsersAPI
mount ::API::V3::Versions::VersionsAPI mount ::API::V3::Versions::VersionsAPI
mount ::API::V3::WorkPackages::WorkPackagesAPI mount ::API::V3::WorkPackages::WorkPackagesAPI

@ -0,0 +1,38 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License status 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either status 2
# of the License, or (at your option) any later status.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
module API
module V3
module Types
class TypeCollectionRepresenter < ::API::Decorators::Collection
element_decorator ::API::V3::Types::TypeRepresenter
end
end
end
end

@ -0,0 +1,57 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
module API
module V3
module Types
class TypeRepresenter < ::API::Decorators::Single
self_link
property :id
property :name
property :color,
getter: -> (*) { color.hexcode if color },
render_nil: true
property :position
property :is_default
property :is_milestone
property :created_at,
exec_context: :decorator,
getter: -> (*) { datetime_formatter.format_datetime(represented.created_at) }
property :updated_at,
exec_context: :decorator,
getter: -> (*) { datetime_formatter.format_datetime(represented.updated_at) }
def _type
'Type'
end
end
end
end
end

@ -0,0 +1,60 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
module API
module V3
module Types
class TypesAPI < Grape::API
resources :types do
before do
authorize_any([:view_work_packages, :manage_types], global: true)
end
get do
types = Type.all
TypeCollectionRepresenter.new(types,
types.count,
api_v3_paths.types)
end
namespace ':id' do
before do
type = Type.find(params[:id])
@representer = ::API::V3::Types::TypeRepresenter.new(type)
end
get do
@representer
end
end
end
end
end
end
end

@ -0,0 +1,50 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2014 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
module API
module V3
module Types
class TypesByProjectAPI < Grape::API
resources :types do
before do
authorize_any [:view_work_packages, :manage_types], projects: @project
end
get do
types = @project.types
TypeCollectionRepresenter.new(types,
types.count,
api_v3_paths.types_by_project(@project.id),
context: { current_user: current_user })
end
end
end
end
end
end

@ -116,6 +116,18 @@ module API
"#{root}/string_objects/#{::ERB::Util::url_encode(value)}" "#{root}/string_objects/#{::ERB::Util::url_encode(value)}"
end end
def self.types
"#{root}/types"
end
def self.types_by_project(project_id)
"#{project(project_id)}/types"
end
def self.type(id)
"#{types}/#{id}"
end
def self.users def self.users
"#{root}/users" "#{root}/users"
end end

@ -30,7 +30,7 @@
module API module API
module V3 module V3
module Versions module Versions
class VersionsProjectsAPI < Grape::API class ProjectsByVersionAPI < Grape::API
resources :projects do resources :projects do
before do before do
@projects = @version.projects.visible(current_user).all @projects = @version.projects.visible(current_user).all

@ -47,7 +47,7 @@ module API
permissions = [:view_work_packages, :manage_versions] permissions = [:view_work_packages, :manage_versions]
authorize_any(permissions, projects, user: current_user) authorize_any(permissions, projects: projects, user: current_user)
end end
def context def context
@ -59,7 +59,7 @@ module API
VersionRepresenter.new(@version, context) VersionRepresenter.new(@version, context)
end end
mount API::V3::Versions::VersionsProjectsAPI mount API::V3::Versions::ProjectsByVersionAPI
end end
end end
end end

@ -30,18 +30,18 @@
module API module API
module V3 module V3
module Versions module Versions
class ProjectsVersionsAPI < Grape::API class VersionsByProjectAPI < Grape::API
resources :versions do resources :versions do
before do before do
@versions = @project.shared_versions.all @versions = @project.shared_versions.all
authorize_any [:view_work_packages, :manage_versions], @project authorize_any [:view_work_packages, :manage_versions], projects: @project
end end
get do get do
VersionCollectionRepresenter.new(@versions, VersionCollectionRepresenter.new(@versions,
@versions.count, @versions.count,
api_v3_paths.versions(@project.identifier), api_v3_paths.versions(@project.id),
context: { current_user: current_user }) context: { current_user: current_user })
end end
end end

@ -31,25 +31,18 @@ module API
module WorkPackages module WorkPackages
module Form module Form
class FormAPI < Grape::API class FormAPI < Grape::API
helpers do post '/form' do
def process_form_request write_work_package_attributes
write_work_package_attributes write_request_valid?
write_request_valid?
error = ::API::Errors::ErrorBase.create(@representer.represented.errors) error = ::API::Errors::ErrorBase.create(@work_package.errors)
if error.is_a? ::API::Errors::Validation if error.is_a? ::API::Errors::Validation
status 200 status 200
FormRepresenter.new(@representer.represented, current_user: current_user) FormRepresenter.new(@work_package, current_user: current_user)
else else
fail error fail error
end
end end
end
post '/form' do
process_form_request
end end
end end
end end

@ -72,6 +72,11 @@ module API
} }
end end
linked_property(property_name: :type,
namespace: :types,
method: :type_id,
path: :type)
linked_property(property_name: :status, linked_property(property_name: :status,
namespace: :statuses, namespace: :statuses,
method: :status_id, method: :status_id,

@ -74,6 +74,26 @@ module API
property :lock_version property :lock_version
property :subject, render_nil: true property :subject, render_nil: true
property :done_ratio,
as: :percentageDone,
getter: -> (*) { done_ratio if Setting.work_package_done_ratio != 'disabled' },
render_nil: false
property :estimated_hours,
as: :estimatedTime,
exec_context: :decorator,
getter: -> (*) {
datetime_formatter.format_duration_from_hours(represented.estimated_hours,
allow_nil: true)
},
setter: -> (value, *) {
represented.estimated_hours = datetime_formatter.parse_duration_to_hours(
value,
'estimated_hours',
allow_nil: true)
},
render_nil: true
property :description, property :description,
exec_context: :decorator, exec_context: :decorator,
getter: -> (*) { getter: -> (*) {

@ -50,14 +50,21 @@ module API
status_origin = @work_package status_origin = @work_package
# do not allow to skip statuses without intermediate saving # do not allow to skip statuses without intermediately saving the work package
# we therefore take the original status of the work_package, while preserving all
# other changes to it (e.g. type, assignee, etc.)
if @work_package.persisted? && @work_package.status_id_changed? if @work_package.persisted? && @work_package.status_id_changed?
status_origin = @work_package.class.find(@work_package.id) status_origin = @work_package.clone
status_origin.status = Status.find_by_id(@work_package.status_id_was)
end end
status_origin.new_statuses_allowed_to(user) status_origin.new_statuses_allowed_to(user)
end end
def assignable_types
project.try(:types)
end
def assignable_versions def assignable_versions
@work_package.try(:assignable_versions) @work_package.try(:assignable_versions)
end end
@ -71,8 +78,27 @@ module API
end end
def available_custom_fields def available_custom_fields
# we might have received a (currently) invalid work package
return [] if @project.nil? || @type.nil?
project.all_work_package_custom_fields & type.custom_fields.all project.all_work_package_custom_fields & type.custom_fields.all
end end
def percentage_done_writable?
if Setting.work_package_done_ratio == 'status' ||
Setting.work_package_done_ratio == 'disabled'
return false
end
nil_or_leaf?(@work_package)
end
def estimated_time_writable?
nil_or_leaf?(@work_package)
end
def nil_or_leaf?(work_package)
work_package.nil? || work_package.leaf?
end
end end
end end
end end

@ -84,19 +84,31 @@ module API
type: 'Date', type: 'Date',
required: false required: false
schema :estimated_time, property :estimated_time,
type: 'Duration', exec_context: :decorator,
required: false, getter: -> (*) do
writable: false representer = ::API::Decorators::PropertySchemaRepresenter
.new(type: 'Duration',
name: WorkPackage.human_attribute_name(:estimated_time))
representer.writable = represented.estimated_time_writable?
representer.required = false
representer
end
schema :spent_time, schema :spent_time,
type: 'Duration', type: 'Duration',
writable: false writable: false
schema :percentage_done, property :percentage_done,
type: 'Integer', exec_context: :decorator,
name_source: :done_ratio, getter: -> (*) do
writable: false representer = ::API::Decorators::PropertySchemaRepresenter
.new(type: 'Integer',
name: WorkPackage.human_attribute_name(:done_ratio))
representer.writable = represented.percentage_done_writable?
representer
end,
if: -> (*) { Setting.work_package_done_ratio != 'disabled' }
schema :created_at, schema :created_at,
type: 'DateTime', type: 'DateTime',
@ -114,10 +126,6 @@ module API
type: 'Project', type: 'Project',
writable: false writable: false
schema :type,
type: 'Type',
writable: false
schema_with_allowed_link :assignee, schema_with_allowed_link :assignee,
type: 'User', type: 'User',
required: false, required: false,
@ -132,6 +140,19 @@ module API
api_v3_paths.available_responsibles(represented.project.id) api_v3_paths.available_responsibles(represented.project.id)
} }
schema_with_allowed_collection :type,
type: 'Type',
values_callback: -> (*) {
represented.assignable_types
},
value_representer: Types::TypeRepresenter,
link_factory: -> (type) {
{
href: api_v3_paths.type(type.id),
title: type.name
}
}
schema_with_allowed_collection :status, schema_with_allowed_collection :status,
type: 'Status', type: 'Status',
values_callback: -> (*) { values_callback: -> (*) {

@ -43,11 +43,14 @@ module API
start_date start_date
due_date due_date
status_id status_id
type_id
assigned_to_id assigned_to_id
responsible_id responsible_id
priority_id priority_id
category_id category_id
fixed_version_id fixed_version_id
done_ratio
estimated_hours
) )
end end
@ -65,6 +68,8 @@ module API
validate :readonly_attributes_unchanged validate :readonly_attributes_unchanged
validate :assignee_visible validate :assignee_visible
validate :responsible_visible validate :responsible_visible
validate :estimated_hours_valid
validate :done_ratio_valid
extend Reform::Form::ActiveModel::ModelValidations extend Reform::Form::ActiveModel::ModelValidations
copy_validations_from WorkPackage copy_validations_from WorkPackage
@ -109,6 +114,25 @@ module API
people_visible :responsible, 'responsible_id', model.project.possible_responsible_members people_visible :responsible, 'responsible_id', model.project.possible_responsible_members
end end
def estimated_hours_valid
if !model.leaf? && model.changed.include?('estimated_hours')
errors.add :error_readonly, 'estimated_hours'
end
end
def done_ratio_valid
if model.changed.include?('done_ratio')
# TODO Allow multiple errors as soon as they have separate messages
if !model.leaf?
errors.add :error_readonly, 'done_ratio'
elsif Setting.work_package_done_ratio == 'status'
errors.add :error_readonly, 'done_ratio'
elsif Setting.work_package_done_ratio == 'disabled'
errors.add :error_readonly, 'done_ratio'
end
end
end
def people_visible(attribute, id_attribute, list) def people_visible(attribute, id_attribute, list)
id = model[id_attribute] id = model[id_attribute]

@ -102,11 +102,15 @@ module API
} if current_user_allowed_to(:move_work_packages) } if current_user_allowed_to(:move_work_packages)
end end
linked_property :status linked_property :type, embed_as: ::API::V3::Types::TypeRepresenter
linked_property :status, embed_as: ::API::V3::Statuses::StatusRepresenter
linked_property :author, path: :user linked_property :author, path: :user, embed_as: ::API::V3::Users::UserRepresenter
linked_property :responsible, path: :user linked_property :responsible, path: :user, embed_as: ::API::V3::Users::UserRepresenter
linked_property :assignee, path: :user, association: :assigned_to linked_property :assignee,
path: :user,
association: :assigned_to,
embed_as: ::API::V3::Users::UserRepresenter
link :availableWatchers do link :availableWatchers do
{ {
@ -190,7 +194,9 @@ module API
} if current_user_allowed_to(:view_time_entries) } if current_user_allowed_to(:view_time_entries)
end end
linked_property :category linked_property :category, embed_as: ::API::V3::Categories::CategoryRepresenter
linked_property :priority, embed_as: ::API::V3::Priorities::PriorityRepresenter
linked_property :project, embed_as: ::API::V3::Projects::ProjectRepresenter
linked_property :version, linked_property :version,
association: :fixed_version, association: :fixed_version,
@ -198,10 +204,6 @@ module API
represented.fixed_version.to_s_for_project(represented.project) represented.fixed_version.to_s_for_project(represented.project)
} }
linked_property :project
linked_property :priority
links :children do links :children do
visible_children.map do |child| visible_children.map do |child|
{ href: "#{root_path}api/v3/work_packages/#{child.id}", title: child.subject } { href: "#{root_path}api/v3/work_packages/#{child.id}", title: child.subject }
@ -211,7 +213,6 @@ module API
property :id, render_nil: true property :id, render_nil: true
property :lock_version property :lock_version
property :subject, render_nil: true property :subject, render_nil: true
property :type, getter: -> (*) { type.try(:name) }, render_nil: true
property :description, property :description,
exec_context: :decorator, exec_context: :decorator,
getter: -> (*) { getter: -> (*) {
@ -247,11 +248,11 @@ module API
end, end,
writeable: false, writeable: false,
if: -> (_) { current_user_allowed_to(:view_time_entries) } if: -> (_) { current_user_allowed_to(:view_time_entries) }
property :percentage_done, property :done_ratio,
as: :percentageDone,
render_nil: true, render_nil: true,
exec_context: :decorator, writeable: false,
setter: -> (value, *) { self.done_ratio = value }, if: -> (*) { Setting.work_package_done_ratio != 'disabled' }
writeable: false
property :parent_id, writeable: true property :parent_id, writeable: true
property :created_at, property :created_at,
exec_context: :decorator, exec_context: :decorator,
@ -260,41 +261,12 @@ module API
exec_context: :decorator, exec_context: :decorator,
getter: -> (*) { datetime_formatter.format_datetime(represented.updated_at) } getter: -> (*) { datetime_formatter.format_datetime(represented.updated_at) }
property :status,
embedded: true,
class: ::Status,
decorator: ::API::V3::Statuses::StatusRepresenter
property :author,
embedded: true,
class: ::User,
decorator: ::API::V3::Users::UserRepresenter
property :responsible,
embedded: true,
class: ::User,
decorator: ::API::V3::Users::UserRepresenter
property :assigned_to,
as: :assignee,
embedded: true,
class: ::User,
decorator: ::API::V3::Users::UserRepresenter
property :category,
embedded: true,
class: ::Category,
decorator: ::API::V3::Categories::CategoryRepresenter
property :priority,
embedded: true,
class: ::IssuePriority,
decorator: ::API::V3::Priorities::PriorityRepresenter
property :activities, embedded: true, exec_context: :decorator property :activities, embedded: true, exec_context: :decorator
property :version, property :version,
embedded: true, embedded: true,
exec_context: :decorator exec_context: :decorator,
property :project, if: ->(*) { represented.fixed_version.present? }
embedded: true,
class: ::Project,
decorator: ::API::V3::Projects::ProjectRepresenter
property :watchers, property :watchers,
embedded: true, embedded: true,
exec_context: :decorator, exec_context: :decorator,
@ -353,10 +325,6 @@ module API
@visible_children ||= represented.children.select(&:visible?) @visible_children ||= represented.children.select(&:visible?)
end end
def percentage_done
represented.done_ratio unless Setting.work_package_done_ratio == 'disabled'
end
private private
def version_policy def version_policy

@ -38,29 +38,44 @@ module API
helpers do helpers do
attr_reader :work_package attr_reader :work_package
def work_package_representer
WorkPackages::WorkPackageRepresenter.create(@work_package,
current_user: current_user)
end
def write_work_package_attributes def write_work_package_attributes
if request_body if request_body
payload = ::API::V3::WorkPackages::Form::WorkPackagePayloadRepresenter.create(
@work_package,
enforce_lock_version_validation: true)
begin begin
payload.from_json(request_body.to_json) # we need to merge the JSON two times:
# In Pass 1 the representer only has custom fields for the current WP type
# After Pass 1 the correct type information is merged into the WP
# In Pass 2 the representer is created with the new type info and will be able
# to also parse custom fields successfully
merge_json_into_work_package!(request_body.to_json)
merge_json_into_work_package!(request_body.to_json)
rescue ::API::Errors::Form::InvalidResourceLink => e rescue ::API::Errors::Form::InvalidResourceLink => e
fail ::API::Errors::Validation.new(e.message) fail ::API::Errors::Validation.new(e.message)
end end
end end
end end
# merges the given JSON representation into @work_package
def merge_json_into_work_package!(json)
payload = ::API::V3::WorkPackages::Form::WorkPackagePayloadRepresenter.create(
@work_package,
enforce_lock_version_validation: true)
payload.from_json(json)
end
def request_body def request_body
env['api.request.body'] env['api.request.body']
end end
def write_request_valid? def write_request_valid?
contract = WorkPackageContract.new(@representer.represented, current_user) contract = WorkPackageContract.new(@work_package, current_user)
contract_valid = contract.validate contract_valid = contract.validate
represented_valid = @representer.represented.valid? represented_valid = @work_package.valid?
return true if contract_valid && represented_valid return true if contract_valid && represented_valid
@ -68,7 +83,7 @@ module API
# order to have them available at one place. # order to have them available at one place.
contract.errors.keys.each do |key| contract.errors.keys.each do |key|
contract.errors[key].each do |message| contract.errors[key].each do |message|
@representer.represented.errors.add(key, message) @work_package.errors.add(key, message)
end end
end end
@ -78,14 +93,12 @@ module API
before do before do
@work_package = WorkPackage.find(params[:id]) @work_package = WorkPackage.find(params[:id])
@representer = WorkPackages::WorkPackageRepresenter.create(work_package,
current_user: current_user)
end end
get do get do
authorize({ controller: :work_packages_api, action: :get }, authorize({ controller: :work_packages_api, action: :get },
context: @work_package.project) context: @work_package.project)
@representer work_package_representer
end end
patch do patch do
@ -93,15 +106,16 @@ module API
send_notifications = !(params.has_key?(:notify) && params[:notify] == 'false') send_notifications = !(params.has_key?(:notify) && params[:notify] == 'false')
update_service = UpdateWorkPackageService.new(current_user, update_service = UpdateWorkPackageService.new(current_user,
@representer.represented, @work_package,
nil, nil,
send_notifications) send_notifications)
if write_request_valid? && update_service.save if write_request_valid? && update_service.save
@representer.represented.reload @work_package.reload
@representer
work_package_representer
else else
fail ::API::Errors::ErrorBase.create(@representer.represented.errors.dup) fail ::API::Errors::ErrorBase.create(@work_package.errors.dup)
end end
end end

@ -59,6 +59,9 @@ module OpenProject
block_given? && content_or_options.is_a?(Hash) ? content_or_options : (options ||= {}), block_given? && content_or_options.is_a?(Hash) ? content_or_options : (options ||= {}),
'form--label' 'form--label'
) )
if block_given?
options[:title] = options[:title] || strip_tags(capture(&block))
end
label_tag(name, content_or_options, options, &block) label_tag(name, content_or_options, options, &block)
end end

@ -46,7 +46,6 @@ module Redmine
order: "#{CustomField.table_name}.position", order: "#{CustomField.table_name}.position",
dependent: :delete_all, dependent: :delete_all,
validate: false validate: false
before_validation { |customized| customized.custom_field_values if customized.new_record? }
validate :validate_custom_values validate :validate_custom_values
send :include, Redmine::Acts::Customizable::InstanceMethods send :include, Redmine::Acts::Customizable::InstanceMethods
# Save custom values when saving the customized object # Save custom values when saving the customized object
@ -83,12 +82,19 @@ module Redmine
@custom_field_values_changed = true @custom_field_values_changed = true
values = values.stringify_keys values = values.stringify_keys
custom_field_values.each do |custom_value| custom_field_values.each do |custom_value|
custom_value.value = values[custom_value.custom_field_id.to_s] if values.has_key?(custom_value.custom_field_id.to_s) if values.has_key?(custom_value.custom_field_id.to_s)
custom_value.value = values[custom_value.custom_field_id.to_s]
end
end if values.is_a?(Hash) end if values.is_a?(Hash)
end end
def custom_field_values def custom_field_values
@custom_field_values ||= available_custom_fields.map { |x| custom_values.detect { |v| v.custom_field == x } || custom_values.build(customized: self, custom_field: x, value: nil) } @custom_field_values ||= available_custom_fields.map do |custom_field|
existing_cv = custom_values.detect { |v| v.custom_field == custom_field }
existing_cv || custom_values.build(customized: self,
custom_field: custom_field,
value: nil)
end
end end
def visible_custom_field_values def visible_custom_field_values
@ -114,14 +120,13 @@ module Redmine
def reset_custom_values! def reset_custom_values!
@custom_field_values = nil @custom_field_values = nil
@custom_field_values_changed = true @custom_field_values_changed = true
values = custom_values.inject({}) { |h, v| h[v.custom_field_id] = v.value; h }
custom_values.each { |cv| cv.destroy unless custom_field_values.include?(cv) } custom_values.each { |cv| cv.destroy unless custom_field_values.include?(cv) }
end end
def validate_custom_values def validate_custom_values
custom_values.reject(&:marked_for_destruction?).select(&:invalid?).each do |custom_value| custom_field_values.reject(&:marked_for_destruction?).select(&:invalid?).each do |cv|
custom_value.errors.each do |_, message| cv.errors.each do |_, message|
errors.add(custom_value.custom_field.accessor_name.to_sym, message) errors.add(cv.custom_field.accessor_name.to_sym, message)
end end
end end
end end

@ -55,6 +55,7 @@ class TabularFormBuilder < ActionView::Helpers::FormBuilder
def label(method, text = nil, options = {}, &block) def label(method, text = nil, options = {}, &block)
options[:class] = Array(options[:class]) + %w(form--label) options[:class] = Array(options[:class]) + %w(form--label)
options[:title] = options[:title] || title_from_context(method)
super super
end end
@ -291,4 +292,12 @@ class TabularFormBuilder < ActionView::Helpers::FormBuilder
def sanitized_object_name def sanitized_object_name
object_name.to_s.gsub(/\]\[|[^-a-zA-Z0-9:.]/, '_').sub(/_$/, '') object_name.to_s.gsub(/\]\[|[^-a-zA-Z0-9:.]/, '_').sub(/_$/, '')
end end
def title_from_context(method)
if object.class.respond_to? :human_attribute_name
object.class.human_attribute_name method
else
method.to_s.camelize
end
end
end end

@ -145,6 +145,8 @@ describe VersionsController, type: :controller do
end end
context 'from issue form' do context 'from issue form' do
render_views
before do before do
allow(User).to receive(:current).and_return(user) allow(User).to receive(:current).and_return(user)
post :create, project_id: project.id, version: { name: 'test_add_version_from_issue_form' }, format: :js post :create, project_id: project.id, version: { name: 'test_add_version_from_issue_form' }, format: :js
@ -159,12 +161,9 @@ describe VersionsController, type: :controller do
it 'returns updated select box with new version' do it 'returns updated select box with new version' do
version = Version.find_by_name('test_add_version_from_issue_form') version = Version.find_by_name('test_add_version_from_issue_form')
select_substring = "select id=\\\"work_package_fixed_version_id\\\" name=\\\"work_package[fixed_version_id]\\\"" expect(response.body).to include(
# selected option tag for the new version "option value=\\\"#{version.id}\\\" selected=\\\"selected\\\""
option_substring = "option value=\\\"#{version.id}\\\" selected=\\\"selected\\\"" )
expect(response.body.include?(select_substring)).to be_truthy
expect(response.body.include?(option_substring)).to be_truthy
end end
it 'escapes potentially harmful html' do it 'escapes potentially harmful html' do

@ -30,17 +30,29 @@ FactoryGirl.define do
factory :type do factory :type do
sequence(:position) { |p| p } sequence(:position) { |p| p }
name { |a| "Type No. #{a.position}" } name { |a| "Type No. #{a.position}" }
created_at { Time.now }
updated_at { Time.now }
factory :type_with_workflow, class: Type do
callback(:after_build) do |t|
t.workflows = [FactoryGirl.build(:workflow_with_default_status)]
end
end
end end
factory :type_standard, class: Type do factory :type_standard, class: Type do
name 'None' name 'None'
is_standard true is_standard true
is_default true is_default true
created_at { Time.now }
updated_at { Time.now }
end end
factory :type_bug, class: Type do factory :type_bug, class: Type do
name 'Bug' name 'Bug'
position 1 position 1
created_at { Time.now }
updated_at { Time.now }
# reuse existing type with the given name # reuse existing type with the given name
# this prevents a validation error (name has to be unique) # this prevents a validation error (name has to be unique)
@ -62,12 +74,4 @@ FactoryGirl.define do
position 4 position 4
end end
end end
factory :type_with_workflow, class: Type do
sequence(:name) { |n| "Type #{n}" }
sequence(:position) { |n| n }
callback(:after_build) do |t|
t.workflows = [FactoryGirl.build(:workflow_with_default_status)]
end
end
end end

@ -47,7 +47,6 @@ describe 'attachments', type: :feature do
fill_in 'Subject', with: 'attachment test' fill_in 'Subject', with: 'attachment test'
# open attachment fieldset and attach file # open attachment fieldset and attach file
find('#attachments legend').click()
attach_file 'attachments[1][file]', file.path attach_file 'attachments[1][file]', file.path
click_button 'Create' click_button 'Create'

@ -46,8 +46,8 @@ describe 'New work package', type: :feature do
work_packages_page.visit_new work_packages_page.visit_new
input = page.find('#work_package_start_date') # Fill in the date, a sa simple click does not seem to trigger the datepicker here
input.click fill_in 'Start date', with: DateTime.now.strftime('%Y-%m-%d')
expect(page).to have_selector(datepicker_selector) expect(page).to have_selector(datepicker_selector)
end end

@ -0,0 +1,106 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe ::API::V3::Types::TypeRepresenter do
let(:type) { FactoryGirl.build_stubbed(:type, color: FactoryGirl.build(:color)) }
let(:representer) { described_class.new(type) }
include API::V3::Utilities::PathHelper
context 'generation' do
subject { representer.to_json }
describe 'links' do
it_behaves_like 'has a titled link' do
let(:link) { 'self' }
let(:href) { api_v3_paths.type(type.id) }
let(:title) { type.name }
end
end
it 'indicates its id' do
is_expected.to be_json_eql(type.id.to_json).at_path('id')
end
it 'indicates its name' do
is_expected.to be_json_eql(type.name.to_json).at_path('name')
end
it 'indicates its color' do
is_expected.to be_json_eql(type.color.hexcode.to_json).at_path('color')
end
context 'no color set' do
let(:type) { FactoryGirl.build(:type, color: nil) }
it 'indicates a missing color' do
is_expected.to be_json_eql(nil.to_json).at_path('color')
end
end
it 'indicates its position' do
is_expected.to be_json_eql(type.position.to_json).at_path('position')
end
it 'indicates that it is not the default type' do
is_expected.to be_json_eql(false.to_json).at_path('isDefault')
end
context 'as default type' do
let(:type) { FactoryGirl.build(:type, is_default: true) }
it 'indicates that it is the default type' do
is_expected.to be_json_eql(true.to_json).at_path('isDefault')
end
end
it 'indicates that it is not a milestone' do
is_expected.to be_json_eql(false.to_json).at_path('isMilestone')
end
context 'as milestone' do
let(:type) { FactoryGirl.build(:type, is_milestone: true) }
it 'indicates that it is a milestone' do
is_expected.to be_json_eql(true.to_json).at_path('isMilestone')
end
end
it_behaves_like 'has UTC ISO 8601 date and time' do
let(:date) { type.created_at }
let(:json_path) { 'createdAt' }
end
it_behaves_like 'has UTC ISO 8601 date and time' do
let(:date) { type.updated_at }
let(:json_path) { 'updatedAt' }
end
end
end

@ -254,6 +254,32 @@ describe ::API::V3::Utilities::PathHelper do
end end
end end
describe 'types paths' do
describe '#types' do
subject { helper.types }
it_behaves_like 'api v3 path'
it { is_expected.to eql('/api/v3/types') }
end
describe '#types_by_project' do
subject { helper.types_by_project 12 }
it_behaves_like 'api v3 path'
it { is_expected.to eql('/api/v3/projects/12/types') }
end
describe '#type' do
subject { helper.type 1 }
it_behaves_like 'api v3 path'
it { is_expected.to eql('/api/v3/types/1') }
end
end
describe '#user' do describe '#user' do
subject { helper.user 1 } subject { helper.user 1 }

@ -60,6 +60,32 @@ describe ::API::V3::WorkPackages::Form::WorkPackagePayloadRepresenter do
it { is_expected.to be_json_eql(work_package.lock_version.to_json).at_path('lockVersion') } it { is_expected.to be_json_eql(work_package.lock_version.to_json).at_path('lockVersion') }
end end
describe 'estimated hours' do
it { is_expected.to have_json_path('estimatedTime') }
it do
is_expected.to be_json_eql(work_package.estimated_hours.to_json)
.at_path('estimatedTime')
end
context 'not set' do
it { is_expected.to have_json_type(NilClass).at_path('estimatedTime') }
end
context 'set' do
let(:work_package) { FactoryGirl.build(:work_package, estimated_hours: 0) }
it { is_expected.to have_json_type(String).at_path('estimatedTime') }
end
end
describe 'percentage done' do
it { is_expected.to have_json_path('percentageDone') }
it { is_expected.to have_json_type(Integer).at_path('percentageDone') }
it do
is_expected.to be_json_eql(work_package.done_ratio.to_json).at_path('percentageDone')
end
end
describe 'startDate' do describe 'startDate' do
it_behaves_like 'has ISO 8601 date only' do it_behaves_like 'has ISO 8601 date only' do
let(:date) { work_package.start_date } let(:date) { work_package.start_date }

@ -49,11 +49,11 @@ describe ::API::V3::WorkPackages::Schema::WorkPackageSchema do
end end
describe '#assignable_statuses_for' do describe '#assignable_statuses_for' do
let(:user) { double } let(:user) { double('current user') }
let(:status_result) { double } let(:status_result) { double('status result') }
before do before do
allow(work_package).to receive(:is_persisted?).and_return(false) allow(work_package).to receive(:persisted?).and_return(false)
allow(work_package).to receive(:status_id_changed?).and_return(false) allow(work_package).to receive(:status_id_changed?).and_return(false)
end end
@ -64,18 +64,31 @@ describe ::API::V3::WorkPackages::Schema::WorkPackageSchema do
end end
context 'changed work package' do context 'changed work package' do
let(:work_package) { FactoryGirl.create(:work_package) } let(:work_package) {
let(:stored_wp) { FactoryGirl.build(:work_package, id: work_package.id) } double('original work package',
id: double,
clone: cloned_wp,
status: double('wrong status'),
persisted?: true).as_null_object
}
let(:cloned_wp) {
double('cloned work package',
new_statuses_allowed_to: status_result)
}
let(:stored_status) {
double('good status')
}
before do before do
allow(work_package).to receive(:persisted?).and_return(true)
allow(work_package).to receive(:status_id_changed?).and_return(true) allow(work_package).to receive(:status_id_changed?).and_return(true)
allow(WorkPackage).to receive(:find).with(work_package.id).and_return(stored_wp) allow(Status).to receive(:find_by_id)
.with(work_package.status_id_was).and_return(stored_status)
end end
it 'calls through to the stored work package' do it 'calls through to the cloned work package' do
expect(work_package).to_not receive(:new_statuses_allowed_to) expect(cloned_wp).to receive(:status=).with(stored_status)
expect(stored_wp).to receive(:new_statuses_allowed_to).with(user) expect(cloned_wp).to receive(:new_statuses_allowed_to).with(user)
.and_return(status_result)
expect(subject.assignable_statuses_for(user)).to eql(status_result) expect(subject.assignable_statuses_for(user)).to eql(status_result)
end end
end end
@ -96,6 +109,29 @@ describe ::API::V3::WorkPackages::Schema::WorkPackageSchema do
it 'is expected to return custom fields available in project AND type' do it 'is expected to return custom fields available in project AND type' do
expect(subject.available_custom_fields).to eql([cf2]) expect(subject.available_custom_fields).to eql([cf2])
end end
context 'type missing' do
let(:type) { nil }
it 'returns an empty list' do
expect(subject.available_custom_fields).to eql([])
end
end
context 'project missing' do
let(:project) { nil }
it 'returns an empty list' do
expect(subject.available_custom_fields).to eql([])
end
end
end
describe '#assignable_types' do
let(:result) { double }
it 'calls through to the project' do
expect(project).to receive(:types).and_return(result)
expect(subject.assignable_types).to eql(result)
end
end end
describe '#assignable_versions' do describe '#assignable_versions' do
@ -135,6 +171,41 @@ describe ::API::V3::WorkPackages::Schema::WorkPackageSchema do
expect(subject.assignable_categories).to match_array([category]) expect(subject.assignable_categories).to match_array([category])
end end
end end
describe 'utility methods' do
context 'leaf' do
let(:work_package) { FactoryGirl.create(:work_package) }
it 'detects leaf' do
expect(subject.nil_or_leaf? work_package).to be true
end
end
context 'parent' do
let(:child) { FactoryGirl.build(:work_package, project: project, type: type) }
let(:parent) do
FactoryGirl.build(:work_package, project: project, type: type, children: [child])
end
it 'detects parent' do
expect(subject.nil_or_leaf? parent).to be false
end
end
context 'percentage done' do
it 'is not writable when inferred by status' do
allow(Setting).to receive(:work_package_done_ratio).and_return('status')
expect(subject.percentage_done_writable?).to be false
end
it 'is not writable when disabled' do
allow(Setting).to receive(:work_package_done_ratio).and_return('disabled')
expect(subject.percentage_done_writable?).to be false
end
end
end
end end
context 'created from project and type' do context 'created from project and type' do
@ -156,5 +227,11 @@ describe ::API::V3::WorkPackages::Schema::WorkPackageSchema do
it 'does not know assignable versions' do it 'does not know assignable versions' do
expect(subject.assignable_versions).to eql(nil) expect(subject.assignable_versions).to eql(nil)
end end
describe 'leaf or nil' do
it 'evaluates nil work package as nil' do
expect(subject.nil_or_leaf? nil).to be true
end
end
end end
end end

@ -0,0 +1,159 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe ::API::V3::WorkPackages::WorkPackageContract do
let(:work_package) do
FactoryGirl.create(:work_package,
done_ratio: 50,
estimated_hours: 6.0,
project: project)
end
let(:member) { FactoryGirl.create(:user, member_in_project: project, member_through_role: role) }
let (:project) { FactoryGirl.create(:project) }
let(:current_user) { member }
let(:permissions) {
[
:view_work_packages,
:view_work_package_watchers,
:edit_work_packages,
:add_work_package_watchers,
:delete_work_package_watchers,
:manage_work_package_relations,
:add_work_package_notes
]
}
let(:role) { FactoryGirl.create :role, permissions: permissions }
let(:changed_values) { [] }
subject(:contract) { described_class.new(work_package, current_user) }
before do
allow(work_package).to receive(:changed).and_return(changed_values)
end
describe 'estimated hours' do
context 'is no parent' do
before do
contract.validate
end
context 'has not changed' do
it('is valid') { expect(contract.errors.empty?).to be true }
end
context 'has changed' do
let(:changed_values) { ['estimated_hours'] }
it('is valid') { expect(contract.errors.empty?).to be true }
end
end
context 'is a parent' do
before do
child
work_package.reload
contract.validate
end
let(:child) do
FactoryGirl.create(:work_package, parent_id: work_package.id, project: project)
end
context 'has not changed' do
it('is valid') { expect(contract.errors.empty?).to be true }
end
context 'has changed' do
let(:changed_values) { ['estimated_hours'] }
it('is invalid') do
expect(contract.errors[:error_readonly]).to match_array(changed_values)
end
end
end
end
describe 'percentage done' do
context 'has not changed' do
before do
contract.validate
end
it('is valid') { expect(contract.errors.empty?).to be true }
end
context 'has changed' do
before do
contract.validate
end
let(:changed_values) { ['done_ratio'] }
it('is valid') { expect(contract.errors.empty?).to be true }
context 'is parent' do
before do
child
work_package.reload
contract.validate
end
let(:child) do
FactoryGirl.create(:work_package, parent_id: work_package.id, project: project)
end
it('is invalid') do
expect(contract.errors[:error_readonly]).to match_array(changed_values)
end
end
context 'done ratio inferred by status' do
before do
allow(Setting).to receive(:work_package_done_ratio).and_return('status')
contract.validate
end
it('is invalid') do
expect(contract.errors[:error_readonly]).to match_array(changed_values)
end
end
context 'done ratio disabled' do
before do
allow(Setting).to receive(:work_package_done_ratio).and_return('disabled')
contract.validate
end
it('is invalid') do
expect(contract.errors[:error_readonly]).to match_array(changed_values)
end
end
end
end
end

@ -125,7 +125,6 @@ describe ::API::V3::WorkPackages::WorkPackageRepresenter do
end end
it { is_expected.to have_json_path('subject') } it { is_expected.to have_json_path('subject') }
it { is_expected.to have_json_path('type') }
describe 'lock version' do describe 'lock version' do
it { is_expected.to have_json_path('lockVersion') } it { is_expected.to have_json_path('lockVersion') }
@ -272,6 +271,14 @@ describe ::API::V3::WorkPackages::WorkPackageRepresenter do
end end
end end
describe 'type' do
it_behaves_like 'has a titled link' do
let(:link) { 'type' }
let(:href) { "/api/v3/types/#{work_package.type_id}" }
let(:title) { work_package.type.name }
end
end
describe 'author' do describe 'author' do
it_behaves_like 'has a titled link' do it_behaves_like 'has a titled link' do
let(:link) { 'author' } let(:link) { 'author' }

@ -49,6 +49,38 @@ describe ::API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter do
context 'generation' do context 'generation' do
subject(:generated) { representer.to_json } subject(:generated) { representer.to_json }
shared_examples_for 'has a collection of allowed values' do
let(:embedded) { true }
context 'when no values are allowed' do
before { allow(schema).to receive(allowed_values_method).and_return([]) }
it_behaves_like 'links to and embeds allowed values directly' do
let(:path) { json_path }
let(:hrefs) { [] }
end
end
context 'when values are allowed' do
let(:values) { FactoryGirl.build_stubbed_list(factory, 3) }
before { allow(schema).to receive(allowed_values_method).and_return(values) }
it_behaves_like 'links to and embeds allowed values directly' do
let(:path) { json_path }
let(:hrefs) { values.map { |value| "/api/v3/#{href_path}/#{value.id}" } }
end
end
context 'when not embedded' do
let(:embedded) { false }
it_behaves_like 'does not link to allowed values' do
let(:path) { json_path }
end
end
end
describe '_type' do describe '_type' do
it 'is indicated as Schema' do it 'is indicated as Schema' do
is_expected.to be_json_eql('Schema'.to_json).at_path('_type') is_expected.to be_json_eql('Schema'.to_json).at_path('_type')
@ -129,7 +161,7 @@ describe ::API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter do
let(:type) { 'Duration' } let(:type) { 'Duration' }
let(:name) { I18n.t('attributes.estimated_time') } let(:name) { I18n.t('attributes.estimated_time') }
let(:required) { false } let(:required) { false }
let(:writable) { false } let(:writable) { schema.estimated_time_writable? }
end end
end end
@ -149,7 +181,13 @@ describe ::API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter do
let(:type) { 'Integer' } let(:type) { 'Integer' }
let(:name) { I18n.t('activerecord.attributes.work_package.done_ratio') } let(:name) { I18n.t('activerecord.attributes.work_package.done_ratio') }
let(:required) { true } let(:required) { true }
let(:writable) { false } let(:writable) { schema.percentage_done_writable? }
end
it 'is hidden when disabled' do
allow(Setting).to receive(:work_package_done_ratio).and_return('disabled')
is_expected.to_not have_json_path('percentageDone')
end end
end end
@ -199,13 +237,18 @@ describe ::API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter do
let(:type) { 'Type' } let(:type) { 'Type' }
let(:name) { I18n.t('activerecord.attributes.work_package.type') } let(:name) { I18n.t('activerecord.attributes.work_package.type') }
let(:required) { true } let(:required) { true }
let(:writable) { false } let(:writable) { true }
end
it_behaves_like 'has a collection of allowed values' do
let(:json_path) { 'type' }
let(:href_path) { 'types' }
let(:factory) { :type }
let(:allowed_values_method) { :assignable_types }
end end
end end
describe 'status' do describe 'status' do
let(:embedded) { true }
it_behaves_like 'has basic schema properties' do it_behaves_like 'has basic schema properties' do
let(:path) { 'status' } let(:path) { 'status' }
let(:type) { 'Status' } let(:type) { 'Status' }
@ -214,38 +257,15 @@ describe ::API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter do
let(:writable) { true } let(:writable) { true }
end end
context 'w/o allowed statuses' do it_behaves_like 'has a collection of allowed values' do
before { allow(schema).to receive(:new_statuses_allowed_to).and_return([]) } let(:json_path) { 'status' }
let(:href_path) { 'statuses' }
it_behaves_like 'links to and embeds allowed values directly' do let(:factory) { :status }
let(:path) { 'status' } let(:allowed_values_method) { :assignable_statuses_for }
let(:hrefs) { [] }
end
end
context 'with allowed statuses' do
let(:statuses) { FactoryGirl.build_list(:status, 3) }
before { allow(schema).to receive(:assignable_statuses_for).and_return(statuses) }
it_behaves_like 'links to and embeds allowed values directly' do
let(:path) { 'status' }
let(:hrefs) { statuses.map { |status| "/api/v3/statuses/#{status.id}" } }
end
end
context 'when not embedded' do
let(:embedded) { false }
it_behaves_like 'does not link to allowed values' do
let(:path) { 'status' }
end
end end
end end
describe 'categories' do describe 'categories' do
let(:embedded) { true }
it_behaves_like 'has basic schema properties' do it_behaves_like 'has basic schema properties' do
let(:path) { 'category' } let(:path) { 'category' }
let(:type) { 'Category' } let(:type) { 'Category' }
@ -254,38 +274,15 @@ describe ::API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter do
let(:writable) { true } let(:writable) { true }
end end
context 'w/o allowed categories' do it_behaves_like 'has a collection of allowed values' do
before { allow(schema).to receive(:assignable_categories).and_return([]) } let(:json_path) { 'category' }
let(:href_path) { 'categories' }
it_behaves_like 'links to allowed values directly' do let(:factory) { :category }
let(:path) { 'category' } let(:allowed_values_method) { :assignable_categories }
let(:hrefs) { [] }
end
end
context 'with allowed categories' do
let(:categories) { FactoryGirl.build_stubbed_list(:category, 3) }
before { allow(schema).to receive(:assignable_categories).and_return(categories) }
it_behaves_like 'links to allowed values directly' do
let(:path) { 'category' }
let(:hrefs) { categories.map { |category| "/api/v3/categories/#{category.id}" } }
end
end
context 'when not embedded' do
let(:embedded) { false }
it_behaves_like 'does not link to allowed values' do
let(:path) { 'category' }
end
end end
end end
describe 'versions' do describe 'versions' do
let(:embedded) { true }
it_behaves_like 'has basic schema properties' do it_behaves_like 'has basic schema properties' do
let(:path) { 'version' } let(:path) { 'version' }
let(:type) { 'Version' } let(:type) { 'Version' }
@ -294,38 +291,15 @@ describe ::API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter do
let(:writable) { true } let(:writable) { true }
end end
context 'w/o allowed versions' do it_behaves_like 'has a collection of allowed values' do
before { allow(schema).to receive(:assignable_versions).and_return([]) } let(:json_path) { 'version' }
let(:href_path) { 'versions' }
it_behaves_like 'links to and embeds allowed values directly' do let(:factory) { :version }
let(:path) { 'version' } let(:allowed_values_method) { :assignable_versions }
let(:hrefs) { [] }
end
end
context 'with allowed versions' do
let(:versions) { FactoryGirl.build_stubbed_list(:version, 3) }
before { allow(schema).to receive(:assignable_versions).and_return(versions) }
it_behaves_like 'links to and embeds allowed values directly' do
let(:path) { 'version' }
let(:hrefs) { versions.map { |version| "/api/v3/versions/#{version.id}" } }
end
end
context 'when not embedded' do
let(:embedded) { false }
it_behaves_like 'does not link to allowed values' do
let(:path) { 'version' }
end
end end
end end
describe 'priorities' do describe 'priorities' do
let(:embedded) { true }
it_behaves_like 'has basic schema properties' do it_behaves_like 'has basic schema properties' do
let(:path) { 'priority' } let(:path) { 'priority' }
let(:type) { 'Priority' } let(:type) { 'Priority' }
@ -334,32 +308,11 @@ describe ::API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter do
let(:writable) { true } let(:writable) { true }
end end
context 'w/o allowed priorities' do it_behaves_like 'has a collection of allowed values' do
before { allow(schema).to receive(:assignable_priorities).and_return([]) } let(:json_path) { 'priority' }
let(:href_path) { 'priorities' }
it_behaves_like 'links to and embeds allowed values directly' do let(:factory) { :priority }
let(:path) { 'priority' } let(:allowed_values_method) { :assignable_priorities }
let(:hrefs) { [] }
end
end
context 'with allowed priorities' do
let(:priorities) { FactoryGirl.build_stubbed_list(:priority, 3) }
before { allow(schema).to receive(:assignable_priorities).and_return(priorities) }
it_behaves_like 'links to and embeds allowed values directly' do
let(:path) { 'priority' }
let(:hrefs) { priorities.map { |priority| "/api/v3/priorities/#{priority.id}" } }
end
end
context 'when not embedded' do
let(:embedded) { false }
it_behaves_like 'does not link to allowed values' do
let(:path) { 'priority' }
end
end end
end end

@ -93,7 +93,30 @@ describe OpenProject::FormTagHelper, type: :helper do
it 'should output element' do it 'should output element' do
expect(output).to be_html_eql(%{ expect(output).to be_html_eql(%{
<label class="form--label" for="field">Label content</label> <label class="form--label" for="field" title="Label content">Label content</label>
})
end
it 'should use the title from the options if given' do
label = helper.styled_label_tag 'field', 'Lautrec', title: 'Carim'
expect(label).to be_html_eql(%{
<label for="field" class="form--label" title="Carim">Lautrec</label>
})
end
it 'should prefer the title given in the options over the content' do
label = helper.styled_label_tag('field', nil, title: 'Carim') { 'Lordvessel' }
expect(label).to be_html_eql(%{
<label for="field" class="form--label" title="Carim">Lordvessel</label>
})
end
it 'should strip any given inline HTML from the title tag' do
label = helper.styled_label_tag('field') do
helper.content_tag :span, 'Sif'
end
expect(label).to be_html_eql(%{
<label for="field" class="form--label" title="Sif"><span>Sif</span></label>
}) })
end end
end end

@ -504,15 +504,31 @@ JJ Abrams</textarea>
subject(:output) { builder.label :name } subject(:output) { builder.label :name }
it 'should output element' do it 'should output element' do
expect(output).to be_html_eql %{<label class="form--label" for="user_name">Name</label>} expect(output).to be_html_eql %{
<label class="form--label"
for="user_name"
title="Name">
Name
</label>
}
end end
describe 'with existing attributes' do describe 'with existing attributes' do
subject(:output) { builder.label :name, 'Fear', class: 'sharknado' } subject(:output) { builder.label :name, 'Fear', class: 'sharknado', title: 'Fear' }
it 'should keep associated classes' do it 'should keep associated classes' do
expect(output).to be_html_eql %{ expect(output).to be_html_eql %{
<label class="sharknado form--label" for="user_name">Fear</label> <label class="sharknado form--label" for="user_name" title="Fear">Fear</label>
}
end
end
describe 'when using it without ActiveModel' do
let(:resource) { OpenStruct.new name: 'Deadpool' }
it 'should fall back to the method name' do
expect(output).to be_html_eql %{
<label class="form--label" for="user_name" title="Name">Name</label>
} }
end end
end end

@ -34,6 +34,35 @@ describe CustomValue::BoolStrategy do
value: value) value: value)
} }
describe '#value_present?' do
subject { described_class.new(custom_value).value_present? }
context 'value is nil' do
let(:value) { nil }
it { is_expected.to eql(false) }
end
context 'value is empty string' do
let(:value) { '' }
it { is_expected.to eql(false) }
end
context 'value is present string' do
let(:value) { '1' }
it { is_expected.to eql(true) }
end
context 'value is true' do
let(:value) { true }
it { is_expected.to eql(true) }
end
context 'value is false' do
let(:value) { false }
it { is_expected.to eql(true) }
end
end
describe '#typed_value' do describe '#typed_value' do
subject { described_class.new(custom_value).typed_value } subject { described_class.new(custom_value).typed_value }

@ -0,0 +1,60 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe CustomValue::FormatStrategy do
let(:custom_value) {
double('CustomValue',
value: value)
}
describe '#value_present?' do
subject { described_class.new(custom_value).value_present? }
context 'value is nil' do
let(:value) { nil }
it { is_expected.to eql(false) }
end
context 'value is empty string' do
let(:value) { '' }
it { is_expected.to eql(false) }
end
context 'value is present string' do
let(:value) { 'foo' }
it { is_expected.to eql(true) }
end
context 'value is present integer' do
let(:value) { 42 }
it { is_expected.to eql(true) }
end
end
end

@ -244,5 +244,28 @@ describe WorkPackage, type: :model do
expect(invalid_work_package.errors_on(:category).size).to eq(1) expect(invalid_work_package.errors_on(:category).size).to eq(1)
end end
end end
describe 'validations of estimated hours' do
wp_regular = FactoryGirl.build(:work_package, estimated_hours: 1)
wp_zero_hours = FactoryGirl.build(:work_package, estimated_hours: 0)
wp_nil = FactoryGirl.build(:work_package, estimated_hours: nil)
wp_invalid = FactoryGirl.build(:work_package, estimated_hours: -1)
it 'should not raise for values > 0' do
expect(wp_regular).to be_valid
end
it 'should not raise for zero hours' do
expect(wp_zero_hours).to be_valid
end
it 'should not raise for nil' do
expect(wp_nil).to be_valid
end
it 'should raise for values < 0' do
expect(wp_invalid).not_to be_valid
end
end
end end
end end

@ -1591,9 +1591,6 @@ describe WorkPackage, type: :model do
work_package.save! work_package.save!
work_package.reload work_package.reload
# is it fine?
expect(work_package).to be_valid
# now give the work_package another required custom field, but don't assign a value # now give the work_package another required custom field, but don't assign a value
work_package.project.work_package_custom_fields << cf2 work_package.project.work_package_custom_fields << cf2
work_package.type.custom_fields << cf2 work_package.type.custom_fields << cf2

@ -37,10 +37,10 @@ describe 'API v3 Project resource' do
let(:role) { FactoryGirl.create(:role) } let(:role) { FactoryGirl.create(:role) }
describe '#get' do describe '#get' do
let(:get_path) { "/api/v3/projects/#{project.id}" }
subject(:response) { last_response } subject(:response) { last_response }
context 'logged in user' do context 'logged in user' do
let(:get_path) { "/api/v3/projects/#{project.id}" }
before do before do
allow(User).to receive(:current).and_return current_user allow(User).to receive(:current).and_return current_user
member = FactoryGirl.build(:member, user: current_user, project: project) member = FactoryGirl.build(:member, user: current_user, project: project)
@ -77,5 +77,13 @@ describe 'API v3 Project resource' do
end end
end end
end end
context 'not logged in user' do
before do
get get_path
end
it_behaves_like 'not found'
end
end end
end end

@ -0,0 +1,113 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
require 'rack/test'
describe 'API v3 Type resource' do
include Rack::Test::Methods
let(:role) { FactoryGirl.create(:role, permissions: [:view_work_packages]) }
let(:project) { FactoryGirl.create(:project, no_types: true, is_public: false) }
let(:current_user) do
FactoryGirl.create(:user,
member_in_project: project,
member_through_role: role)
end
let!(:types) { FactoryGirl.create_list(:type, 4) }
describe 'types' do
describe '#get' do
let(:get_path) { '/api/v3/types' }
subject(:response) { last_response }
context 'logged in user' do
before do
allow(User).to receive(:current).and_return current_user
get get_path
end
it_behaves_like 'API V3 collection response', 4, 4, 'Type'
end
context 'not logged in user' do
before do
get get_path
end
it_behaves_like 'error response',
403,
'MissingPermission',
I18n.t('api_v3.errors.code_403')
end
end
end
describe 'types/:id' do
describe '#get' do
let(:status) { types.first }
let(:get_path) { "/api/v3/types/#{status.id}" }
subject(:response) { last_response }
context 'logged in user' do
before do
allow(User).to receive(:current).and_return(current_user)
get get_path
end
context 'valid type id' do
it { expect(response.status).to eq(200) }
end
context 'invalid type id' do
let(:get_path) { '/api/v3/types/bogus' }
it_behaves_like 'not found' do
let(:id) { 'bogus' }
let(:type) { 'Type' }
end
end
end
context 'not logged in user' do
before do
get get_path
end
it_behaves_like 'error response',
403,
'MissingPermission',
I18n.t('api_v3.errors.code_403')
end
end
end
end

@ -0,0 +1,95 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
require 'rack/test'
describe '/api/v3/projects/:id/types' do
include Rack::Test::Methods
let(:role) { FactoryGirl.create(:role, permissions: [:view_work_packages]) }
let(:project) { FactoryGirl.create(:project, no_types: true, is_public: false) }
let(:requested_project) { project }
let(:current_user) do
FactoryGirl.create(:user,
member_in_project: project,
member_through_role: role)
end
let!(:irrelevant_types) { FactoryGirl.create_list(:type, 4) }
let!(:expected_types) { FactoryGirl.create_list(:type, 4) }
describe '#get' do
let(:get_path) { "/api/v3/projects/#{requested_project.id}/types" }
subject(:response) { last_response }
before do
project.types << expected_types
end
context 'logged in user' do
before do
allow(User).to receive(:current).and_return current_user
get get_path
end
it_behaves_like 'API V3 collection response', 4, 4, 'Type'
it 'only contains expected types' do
actual_types = JSON.parse(subject.body)['_embedded']['elements']
actual_type_ids = actual_types.map { |hash| hash['id'] }
expected_type_ids = expected_types.map(&:id)
expect(actual_type_ids).to match_array expected_type_ids
end
# N.B. this test depends on order, while this is not strictly neccessary
it 'only contains expected types' do
(0..3).each do |i|
expected_id = expected_types[i].id.to_json
expect(subject.body).to be_json_eql(expected_id).at_path("_embedded/elements/#{i}/id")
end
end
context 'in a foreign project' do
let(:requested_project) { FactoryGirl.create(:project, is_public: false) }
it_behaves_like 'not found'
end
end
context 'not logged in user' do
before do
get get_path
end
it_behaves_like 'not found'
end
end
end

@ -198,7 +198,6 @@ h4. things we like
end end
end end
# disabled the its below because the implementation was temporarily disabled
describe '#patch' do describe '#patch' do
let(:patch_path) { "/api/v3/work_packages/#{work_package.id}" } let(:patch_path) { "/api/v3/work_packages/#{work_package.id}" }
let(:valid_params) do let(:valid_params) do
@ -472,6 +471,71 @@ h4. things we like
end end
end end
context 'type' do
let(:target_type) { FactoryGirl.create(:type) }
let(:type_link) { "/api/v3/types/#{target_type.id}" }
let(:type_parameter) { { _links: { type: { href: type_link } } } }
let(:params) { valid_params.merge(type_parameter) }
before { allow(User).to receive(:current).and_return current_user }
context 'valid type' do
before do
project.types << target_type
end
include_context 'patch request'
it { expect(response.status).to eq(200) }
it 'should respond with updated work package type' do
expect(subject.body).to be_json_eql(target_type.name.to_json)
.at_path('_embedded/type/name')
end
it_behaves_like 'lock version updated'
end
context 'valid type changing custom fields' do
let(:custom_field) { FactoryGirl.create(:work_package_custom_field) }
before do
project.types << target_type
project.work_package_custom_fields << custom_field
target_type.custom_fields << custom_field
end
include_context 'patch request'
it 'responds with the new custom field added' do
expect(subject.body).to have_json_path("customField#{custom_field.id}")
end
end
context 'invalid type' do
include_context 'patch request'
it_behaves_like 'constraint violation' do
let(:message) { "Type #{I18n.t('activerecord.errors.messages.inclusion')}" }
end
end
context 'wrong resource' do
let(:type_link) { "/api/v3/users/#{current_user.id}" }
include_context 'patch request'
it_behaves_like 'constraint violation' do
let(:message) {
I18n.t('api_v3.errors.invalid_resource',
property: 'type',
expected: '/api/v3/types/:id',
actual: type_link)
}
end
end
end
context 'assignee and responsible' do context 'assignee and responsible' do
let(:user) { FactoryGirl.create(:user, member_in_project: project) } let(:user) { FactoryGirl.create(:user, member_in_project: project) }
let(:params) { valid_params.merge(user_parameter) } let(:params) { valid_params.merge(user_parameter) }

@ -624,6 +624,48 @@ describe 'API v3 Work package form resource', type: :request do
end end
end end
describe 'type' do
let(:path) { '_embedded/payload/_links/type/href' }
let(:links_path) { '_embedded/schema/type/_links' }
let(:target_type) { FactoryGirl.create(:type) }
let(:other_type) { work_package.type }
let(:type_link) { "/api/v3/types/#{target_type.id}" }
let(:other_type_link) { "/api/v3/types/#{other_type.id}" }
let(:type_parameter) { { _links: { type: { href: type_link } } } }
let(:params) { valid_params.merge(type_parameter) }
before do
project.types << target_type # make sure we have a valid transition
end
describe 'allowed values' do
before do
other_type
end
include_context 'post request'
it 'should list the types' do
expect(subject.body).to be_json_eql(type_link.to_json)
.at_path("#{links_path}/allowedValues/1/href")
expect(subject.body).to be_json_eql(other_type_link.to_json)
.at_path("#{links_path}/allowedValues/0/href")
end
end
context 'valid type' do
include_context 'post request'
it_behaves_like 'valid payload'
it_behaves_like 'having no errors'
it 'should respond with updated work package type' do
expect(subject.body).to be_json_eql(type_link.to_json).at_path(path)
end
end
end
describe 'multiple errors' do describe 'multiple errors' do
let(:user_link) { '/api/v3/users/42' } let(:user_link) { '/api/v3/users/42' }
let(:status_link) { '/api/v3/statuses/-1' } let(:status_link) { '/api/v3/statuses/-1' }

Loading…
Cancel
Save