diff --git a/app/models/custom_option.rb b/app/models/custom_option.rb index 64e3a2991d..d33921c60a 100644 --- a/app/models/custom_option.rb +++ b/app/models/custom_option.rb @@ -36,4 +36,10 @@ class CustomOption < ActiveRecord::Base belongs_to :custom_field validates :value, presence: true, length: { maximum: 255 } + + def to_s + value + end + + alias :name :to_s end diff --git a/app/models/queries/work_packages/filter/custom_field_filter.rb b/app/models/queries/work_packages/filter/custom_field_filter.rb index c501007335..dd7b62d342 100644 --- a/app/models/queries/work_packages/filter/custom_field_filter.rb +++ b/app/models/queries/work_packages/filter/custom_field_filter.rb @@ -1,4 +1,5 @@ #-- encoding: UTF-8 + #-- copyright # OpenProject is a project management system. # Copyright (C) 2012-2017 the OpenProject Foundation (OPF) @@ -119,7 +120,7 @@ class Queries::WorkPackages::Filter::CustomFieldFilter < when 'version' Version.find(values) when 'list' - value_objects_for_list + custom_field.custom_options.find(values) else super end @@ -182,16 +183,6 @@ class Queries::WorkPackages::Filter::CustomFieldFilter < .map(&:id).include? custom_field.id end - def value_objects_for_list - objects = allowed_values.select do |value| - values.include? value.last.to_s - end - - objects.map do |value| - Queries::StringObject.new(value.last, value.first) - end - end - def strategies strategies = Queries::Filters::STRATEGIES.dup strategies[:list_optional] = Queries::Filters::Strategies::CfListOptional @@ -200,17 +191,3 @@ class Queries::WorkPackages::Filter::CustomFieldFilter < strategies end end - -# -# This object is only used to transport the values tothe query filter instance -# representer which expects a class and deduces the path from the classes' name. -# -class Queries::StringObject - attr_accessor :id, - :name - - def initialize(id, name) - self.id = [name, id] - self.name = name - end -end diff --git a/app/models/query/results.rb b/app/models/query/results.rb index f35d66b4fe..8ef470ca72 100644 --- a/app/models/query/results.rb +++ b/app/models/query/results.rb @@ -1,4 +1,5 @@ #-- encoding: UTF-8 + #-- copyright # OpenProject is a project management system. # Copyright (C) 2012-2017 the OpenProject Foundation (OPF) @@ -56,22 +57,9 @@ class ::Query::Results @work_package_count_by_group ||= begin r = nil if query.grouped? - begin - # Rails will raise an (unexpected) RecordNotFound if there's only a nil group value - r = WorkPackage - .group(query.group_by_statement) - .visible - .includes(:status, :project) - .where(query.statement) - .references(:statuses, :projects) - .count - rescue ActiveRecord::RecordNotFound - r = { nil => work_package_count } - end - c = query.group_by_column - if c.is_a?(QueryCustomFieldColumn) - r = r.keys.inject({}) { |h, k| h[c.custom_field.cast_value(k)] = r[k]; h } - end + r = groups_grouped_by_column(query) + + r = transform_group_keys(query, r) end r end @@ -164,4 +152,52 @@ class ::Query::Results def custom_field_column?(name) name.to_s =~ /\Acf_\d+\z/ end + + def groups_grouped_by_column(query) + # Rails will raise an (unexpected) RecordNotFound if there's only a nil group value + WorkPackage + .group(query.group_by_statement) + .visible + .includes(:status, :project) + .where(query.statement) + .references(:statuses, :projects) + .count + rescue ActiveRecord::RecordNotFound + { nil => work_package_count } + end + + def transform_group_keys(query, groups) + column = query.group_by_column + + if column.is_a?(QueryCustomFieldColumn) && column.custom_field.list? + transform_list_group_by_keys(column.custom_field, groups) + elsif column.is_a?(QueryCustomFieldColumn) + transform_custom_field_keys(column.custom_field, groups) + else + groups + end + end + + def transform_list_group_by_keys(custom_field, groups) + options = custom_options_for_keys(custom_field, groups) + + groups.transform_keys do |key| + if custom_field.multi_value? + key.split('.').map do |subkey| + options[subkey].first + end + else + options[key] ? options[key].first : nil + end + end + end + + def custom_options_for_keys(custom_field, groups) + keys = groups.keys.map { |k| k.split('.') } + custom_field.custom_options.find(keys.flatten).group_by { |o| o.id.to_s } + end + + def transform_custom_field_keys(custom_field, groups) + groups.transform_keys { |key| custom_field.cast_value(key) } + end end diff --git a/docs/api/apiv3-documentation.apib b/docs/api/apiv3-documentation.apib index 8dfd7b725b..297027d474 100644 --- a/docs/api/apiv3-documentation.apib +++ b/docs/api/apiv3-documentation.apib @@ -10,6 +10,7 @@ FORMAT: 1A + diff --git a/docs/api/apiv3/endpoints/custom-options.apib b/docs/api/apiv3/endpoints/custom-options.apib new file mode 100644 index 0000000000..f03fdb572e --- /dev/null +++ b/docs/api/apiv3/endpoints/custom-options.apib @@ -0,0 +1,51 @@ +# Group Custom Objects + +## Linked Properties +| Link | Description | Type | Constraints | Supported operations | +|:-------------:|-------------------------- | ------------- | ----------- | -------------------- | +| self | This custom object | CustomObject | not null | READ | + +## Local Properties +| Property | Description | Type | Constraints | Supported operations | +|:----------------:| ---------------------------------------------- | -------- | ----------- | -------------------- | +| id | The identifier | Integer | | READ | +| value | The value defined for this custom object | String | | READ | + + +Custom objects are options of list custom fields. + +## Custom Object [/api/v3/custom_objects/{id}] + ++ Model + + Body + + { + "_links": { + "self": { "href": "/api/v3/custom_objects/1" } + }, + "_type": "CustomObject", + "value": "Foo" + } + +## View Custom Object [GET] + ++ Parameters + + id (required, integer, `1`) ... The custom object's identifier + ++ Response 200 (application/hal+json) + + [Custom Object][] + ++ Response 404 (application/hal+json) + + Returned if the custom object does not exist or the client does not have sufficient permissions to see it. + + **Required permission:** view work package in any project the custom object's custom field is active in. + + + Body + + { + "_type": "Error", + "errorIdentifier": "urn:openproject-org:api:v3:errors:NotFound", + "message": "The specified resource does not exist." + } diff --git a/doc/apiv3/endpoints/principals.apib b/docs/api/apiv3/endpoints/principals.apib similarity index 100% rename from doc/apiv3/endpoints/principals.apib rename to docs/api/apiv3/endpoints/principals.apib diff --git a/doc/apiv3/endpoints/roles.apib b/docs/api/apiv3/endpoints/roles.apib similarity index 100% rename from doc/apiv3/endpoints/roles.apib rename to docs/api/apiv3/endpoints/roles.apib diff --git a/frontend/app/components/work-packages/wp-display-attr/wp-display-attr.directive.ts b/frontend/app/components/work-packages/wp-display-attr/wp-display-attr.directive.ts index 77045ae1f4..d00202ef40 100644 --- a/frontend/app/components/work-packages/wp-display-attr/wp-display-attr.directive.ts +++ b/frontend/app/components/work-packages/wp-display-attr/wp-display-attr.directive.ts @@ -109,7 +109,8 @@ export class WorkPackageDisplayAttributeController { protected updateAttribute(wp:WorkPackageResourceInterface) { this.workPackage = wp; - if (this.schema[this.attribute] && (this.schema[this.attribute].type === '[]StringObject' || this.schema[this.attribute].type === '[]User')) { + if (this.schema[this.attribute] && (this.schema[this.attribute].type === '[]CustomOption' || + this.schema[this.attribute].type === '[]User')) { this.field = new MultipleLinesStringObjectsDisplayField(this.workPackage, this.attribute, this.schema[this.attribute]) } else { this.field = this.wpDisplayField.getField(this.workPackage, this.attribute, this.schema[this.attribute]) as DisplayField; diff --git a/frontend/app/components/wp-display/field-types/wp-display-multiple-lines-string-objects-field.directive.html b/frontend/app/components/wp-display/field-types/wp-display-multiple-lines-string-objects-field.directive.html index 6fc2829424..be0a1fabdf 100644 --- a/frontend/app/components/wp-display/field-types/wp-display-multiple-lines-string-objects-field.directive.html +++ b/frontend/app/components/wp-display/field-types/wp-display-multiple-lines-string-objects-field.directive.html @@ -6,4 +6,4 @@
-
- \ No newline at end of file + diff --git a/frontend/app/components/wp-display/field-types/wp-display-multiple-lines-string-objects-field.module.ts b/frontend/app/components/wp-display/field-types/wp-display-multiple-lines-string-objects-field.module.ts index 047a8f0960..b08267a6cf 100644 --- a/frontend/app/components/wp-display/field-types/wp-display-multiple-lines-string-objects-field.module.ts +++ b/frontend/app/components/wp-display/field-types/wp-display-multiple-lines-string-objects-field.module.ts @@ -26,8 +26,8 @@ // See doc/COPYRIGHT.rdoc for more details. // ++ -import {StringObjectsDisplayField} from "./wp-display-string-objects-field.module"; +import {ResourcesDisplayField} from "./wp-display-resources-field.module"; -export class MultipleLinesStringObjectsDisplayField extends StringObjectsDisplayField { +export class MultipleLinesStringObjectsDisplayField extends ResourcesDisplayField { public template: string = '/components/wp-display/field-types/wp-display-multiple-lines-string-objects-field.directive.html'; } diff --git a/frontend/app/components/wp-display/field-types/wp-display-resources-field-multiline.directive.html b/frontend/app/components/wp-display/field-types/wp-display-resources-field-multiline.directive.html new file mode 100644 index 0000000000..7d246cb95a --- /dev/null +++ b/frontend/app/components/wp-display/field-types/wp-display-resources-field-multiline.directive.html @@ -0,0 +1,10 @@ + +
+ {{ value }} +
+ +
+ - +
+
+ diff --git a/frontend/app/components/wp-display/field-types/wp-display-string-objects-field.directive.html b/frontend/app/components/wp-display/field-types/wp-display-resources-field.directive.html similarity index 100% rename from frontend/app/components/wp-display/field-types/wp-display-string-objects-field.directive.html rename to frontend/app/components/wp-display/field-types/wp-display-resources-field.directive.html diff --git a/frontend/app/components/wp-display/field-types/wp-display-string-objects-field.module.ts b/frontend/app/components/wp-display/field-types/wp-display-resources-field.module.ts similarity index 94% rename from frontend/app/components/wp-display/field-types/wp-display-string-objects-field.module.ts rename to frontend/app/components/wp-display/field-types/wp-display-resources-field.module.ts index 69a683c661..87d8acb624 100644 --- a/frontend/app/components/wp-display/field-types/wp-display-string-objects-field.module.ts +++ b/frontend/app/components/wp-display/field-types/wp-display-resources-field.module.ts @@ -28,8 +28,8 @@ import {DisplayField} from "../wp-display-field/wp-display-field.module"; -export class StringObjectsDisplayField extends DisplayField { - public template: string = '/components/wp-display/field-types/wp-display-string-objects-field.directive.html'; +export class ResourcesDisplayField extends DisplayField { + public template: string = '/components/wp-display/field-types/wp-display-resources-field.directive.html'; public get value() { if (this.schema) { diff --git a/frontend/app/components/wp-display/field-types/wp-display-string-object-field.module.ts b/frontend/app/components/wp-display/field-types/wp-display-string-object-field.module.ts deleted file mode 100644 index 88bc90a606..0000000000 --- a/frontend/app/components/wp-display/field-types/wp-display-string-object-field.module.ts +++ /dev/null @@ -1,40 +0,0 @@ -// -- 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. -// ++ - -import {DisplayField} from "../wp-display-field/wp-display-field.module"; - -export class StringObjectDisplayField extends DisplayField { - public get value() { - if(this.schema) { - return this.resource[this.name] && (this.resource[this.name].value || this.resource[this.name].name); - } - else { - return null; - } - } -} diff --git a/frontend/app/components/wp-display/wp-display-field/wp-display-field.config.ts b/frontend/app/components/wp-display/wp-display-field/wp-display-field.config.ts index 1e1c6f758a..f2e74d6d0a 100644 --- a/frontend/app/components/wp-display/wp-display-field/wp-display-field.config.ts +++ b/frontend/app/components/wp-display/wp-display-field/wp-display-field.config.ts @@ -29,8 +29,7 @@ import {WorkPackageDisplayFieldService} from './wp-display-field.service'; import {TextDisplayField} from '../field-types/wp-display-text-field.module'; import {ResourceDisplayField} from '../field-types/wp-display-resource-field.module'; -import {StringObjectDisplayField} from '../field-types/wp-display-string-object-field.module'; -import {StringObjectsDisplayField} from '../field-types/wp-display-string-objects-field.module'; +import {ResourcesDisplayField} from '../field-types/wp-display-resources-field.module'; import {FormattableDisplayField} from '../field-types/wp-display-formattable-field.module'; import {DurationDisplayField} from '../field-types/wp-display-duration-field.module'; import {DateDisplayField} from '../field-types/wp-display-date-field.module'; @@ -55,10 +54,10 @@ openprojectModule 'Status', 'Priority', 'Version', - 'Category']) - .addFieldType(StringObjectDisplayField, 'string_object', ['StringObject']) - .addFieldType(StringObjectsDisplayField, 'string_objects', ['[]StringObject']) - .addFieldType(StringObjectsDisplayField, 'users', ['[]User']) + 'Category', + 'CustomOption']) + .addFieldType(ResourcesDisplayField, 'resources', ['[]CustomOption', + '[]User']) .addFieldType(FormattableDisplayField, 'formattable', ['Formattable']) .addFieldType(DurationDisplayField, 'duration', ['Duration']) .addFieldType(DateDisplayField, 'date', ['Date']) diff --git a/frontend/app/components/wp-display/wp-display-field/wp-display-field.module.ts b/frontend/app/components/wp-display/wp-display-field/wp-display-field.module.ts index 96c544d490..0f6cffb0e4 100644 --- a/frontend/app/components/wp-display/wp-display-field/wp-display-field.module.ts +++ b/frontend/app/components/wp-display/wp-display-field/wp-display-field.module.ts @@ -36,6 +36,7 @@ export class DisplayField extends Field { public static $injector: ng.auto.IInjectorService; public template:string|null = null; public I18n: op.I18n; + public mode:string|null = null; public get value() { if (this.schema) { diff --git a/frontend/app/components/wp-edit/wp-edit-field/wp-edit-field.config.ts b/frontend/app/components/wp-edit/wp-edit-field/wp-edit-field.config.ts index e8f0e6043d..80df216675 100644 --- a/frontend/app/components/wp-edit/wp-edit-field/wp-edit-field.config.ts +++ b/frontend/app/components/wp-edit/wp-edit-field/wp-edit-field.config.ts @@ -39,13 +39,6 @@ import {DateEditField} from "../field-types/wp-edit-date-field.module"; import {WikiTextareaEditField} from "../field-types/wp-edit-wiki-textarea-field.module"; import {openprojectModule} from "../../../angular-modules"; -//TODO: Implement -class DateRangeEditField extends EditField { -} - - -//TODO: See file wp-field.service.js:getInplaceEditStrategy for more eventual classes - openprojectModule .run((wpEditField:WorkPackageEditFieldService) => { wpEditField.defaultType = 'text'; @@ -59,10 +52,10 @@ openprojectModule 'User', 'Version', 'Category', - 'StringObject', + 'CustomOption', 'Project']) .addFieldType(MultiSelectEditField, 'multi-select', [ - '[]StringObject', + '[]CustomOption', '[]User' ]) .addFieldType(FloatEditField, 'float', ['Float']) diff --git a/lib/api/decorators/aggregation_group.rb b/lib/api/decorators/aggregation_group.rb index e562cdc733..9f692dddc3 100644 --- a/lib/api/decorators/aggregation_group.rb +++ b/lib/api/decorators/aggregation_group.rb @@ -35,7 +35,7 @@ module API @sums = sums @query = query - group_key = set_links!(query, group_key) || group_key + group_key = set_links!(group_key) || group_key @link = ::API::V3::Utilities::ResourceLinkGenerator.make_link(group_key) @@ -92,35 +92,20 @@ module API :query ## - # Initializes the links collection for this group if the query is being grouped by - # a multi value custom field. In that case an updated group_key is returned too. + # Initializes the links collection for this group if the group has multiple keys # # @return [String] A new group key for the multi value custom field. - def set_links!(query, group_key) - if multi_value_custom_field? query - options = link_options query, group_key - - if options - @links = options.map do |opt| - { - href: ::API::V3::Utilities::ResourceLinkGenerator.make_link(opt.id.to_s), - title: opt.value - } - end - - options.map(&:value).join(", ") + def set_links!(group_key) + if group_key.is_a?(Array) + @links = group_key.map do |opt| + { + href: ::API::V3::Utilities::ResourceLinkGenerator.make_link(opt), + title: opt.to_s + } end - end - end - - def multi_value_custom_field?(query) - column = query.group_by_column - column.is_a?(QueryCustomFieldColumn) && column.custom_field.multi_value? - end - - def link_options(query, group_key) - query.group_by_column.custom_field.custom_options.where(id: group_key.to_s.split(".")) + group_key.map(&:name).sort.join(", ") + end end def value diff --git a/lib/api/v3/custom_options/custom_option_representer.rb b/lib/api/v3/custom_options/custom_option_representer.rb new file mode 100644 index 0000000000..fa3c2d8dd2 --- /dev/null +++ b/lib/api/v3/custom_options/custom_option_representer.rb @@ -0,0 +1,48 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2017 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-2017 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 CustomOptions + class CustomOptionRepresenter < ::API::Decorators::Single + self_link + + # TODO: add link to custom field once api for custom fields exists + + def _type + 'CustomOption' + end + + property :id + + property :value + end + end + end +end diff --git a/lib/api/v3/custom_options/custom_options_api.rb b/lib/api/v3/custom_options/custom_options_api.rb new file mode 100644 index 0000000000..3a5ecb8f4c --- /dev/null +++ b/lib/api/v3/custom_options/custom_options_api.rb @@ -0,0 +1,67 @@ +#-- encoding: UTF-8 + +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2017 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-2017 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 CustomOptions + class CustomOptionsAPI < ::API::OpenProjectAPI + resources :custom_options do + namespace ':id' do + params do + requires :id, type: Integer + end + + helpers do + def authorize_view_in_activated_project(custom_option) + allowed = Project + .allowed_to(current_user, :view_work_packages) + .joins(:work_package_custom_fields) + .where(custom_fields: { id: custom_option.custom_field_id }) + .exists? + + unless allowed + raise API::Errors::NotFound + end + end + end + + get do + co = CustomOption.find(params[:id]) + + authorize_view_in_activated_project(co) + + CustomOptionRepresenter.new(co, current_user: current_user) + end + end + end + end + end + end +end diff --git a/lib/api/v3/queries/schemas/string_object_filter_dependency_representer.rb b/lib/api/v3/queries/schemas/custom_option_filter_dependency_representer.rb similarity index 86% rename from lib/api/v3/queries/schemas/string_object_filter_dependency_representer.rb rename to lib/api/v3/queries/schemas/custom_option_filter_dependency_representer.rb index eb6893693e..f0c6b26d38 100644 --- a/lib/api/v3/queries/schemas/string_object_filter_dependency_representer.rb +++ b/lib/api/v3/queries/schemas/custom_option_filter_dependency_representer.rb @@ -1,4 +1,5 @@ #-- encoding: UTF-8 + #-- copyright # OpenProject is a project management system. # Copyright (C) 2012-2017 the OpenProject Foundation (OPF) @@ -31,7 +32,7 @@ module API module V3 module Queries module Schemas - class StringObjectFilterDependencyRepresenter < + class CustomOptionFilterDependencyRepresenter < FilterDependencyRepresenter schema_with_allowed_collection :values, @@ -41,13 +42,13 @@ module API required: true, visibility: false, values_callback: ->(*) { - represented.allowed_values + represented.custom_field.custom_options }, - value_representer: StringObjects::StringObjectRepresenter, + value_representer: CustomOptions::CustomOptionRepresenter, link_factory: ->(value) { { - href: api_v3_paths.string_object(value), - title: value + href: api_v3_paths.custom_option(value), + title: value.to_s } }, show_if: ->(*) { @@ -57,7 +58,7 @@ module API private def type - '[]StringObject' + '[]CustomOption' end end end diff --git a/lib/api/v3/queries/schemas/filter_dependency_representer_factory.rb b/lib/api/v3/queries/schemas/filter_dependency_representer_factory.rb index 564e40c6c7..532b3d0f78 100644 --- a/lib/api/v3/queries/schemas/filter_dependency_representer_factory.rb +++ b/lib/api/v3/queries/schemas/filter_dependency_representer_factory.rb @@ -84,7 +84,7 @@ module API case format when 'list' - 'API::V3::Queries::Schemas::StringObjectFilterDependencyRepresenter' + 'API::V3::Queries::Schemas::CustomOptionFilterDependencyRepresenter' when 'bool' 'API::V3::Queries::Schemas::BooleanFilterDependencyRepresenter' when 'user', 'version', 'float' diff --git a/lib/api/v3/root.rb b/lib/api/v3/root.rb index 4215942141..7531740a37 100644 --- a/lib/api/v3/root.rb +++ b/lib/api/v3/root.rb @@ -38,6 +38,7 @@ module API mount ::API::V3::Attachments::AttachmentsAPI mount ::API::V3::Categories::CategoriesAPI mount ::API::V3::Configuration::ConfigurationAPI + mount ::API::V3::CustomOptions::CustomOptionsAPI mount ::API::V3::Principals::PrincipalsAPI mount ::API::V3::Priorities::PrioritiesAPI mount ::API::V3::Projects::ProjectsAPI diff --git a/lib/api/v3/utilities/custom_field_injector.rb b/lib/api/v3/utilities/custom_field_injector.rb index 8de6487be4..04ed338047 100644 --- a/lib/api/v3/utilities/custom_field_injector.rb +++ b/lib/api/v3/utilities/custom_field_injector.rb @@ -40,7 +40,7 @@ module API 'bool' => 'Boolean', 'user' => 'User', 'version' => 'Version', - 'list' => 'StringObject' + 'list' => 'CustomOption' }.freeze LINK_FORMATS = ['list', 'user', 'version'].freeze @@ -48,19 +48,19 @@ module API PATH_METHOD_MAP = { 'user' => :user, 'version' => :version, - 'list' => :string_object + 'list' => :custom_option }.freeze NAMESPACE_MAP = { 'user' => 'users', 'version' => 'versions', - 'list' => 'string_objects' + 'list' => 'custom_options' }.freeze REPRESENTER_MAP = { 'user' => Users::UserRepresenter, 'version' => Versions::VersionRepresenter, - 'list' => StringObjects::StringObjectRepresenter + 'list' => CustomOptions::CustomOptionRepresenter }.freeze class << self @@ -182,14 +182,14 @@ module API @class.schema_with_allowed_collection property_name(custom_field.id), type: 'Version', - name_source: -> (*) { custom_field.name }, - values_callback: -> (*) { + name_source: ->(*) { custom_field.name }, + values_callback: ->(*) { customized .assignable_custom_field_values(custom_field) }, writable: true, value_representer: Versions::VersionRepresenter, - link_factory: -> (version) { + link_factory: ->(version) { { href: api_v3_paths.version(version.id), title: version.name @@ -216,16 +216,14 @@ module API end def inject_list_schema(custom_field, customized) - representer = StringObjects::StringObjectRepresenter - type = custom_field.multi_value ? "[]StringObject" : "StringObject" + representer = CustomOptions::CustomOptionRepresenter + type = custom_field.multi_value ? "[]CustomOption" : "CustomOption" name_source = ->(*) { custom_field.name } values_callback = ->(*) { customized.assignable_custom_field_values(custom_field) } link_factory = ->(value) do - # allow both single values and tuples for - # custom titles { - href: api_v3_paths.string_object(value), - title: Array(value).first + href: api_v3_paths.custom_option(value.id), + title: value.to_s } end diff --git a/lib/api/v3/utilities/custom_field_injector/link_value_getter.rb b/lib/api/v3/utilities/custom_field_injector/link_value_getter.rb index aab64fe2ab..1f4d86dee8 100644 --- a/lib/api/v3/utilities/custom_field_injector/link_value_getter.rb +++ b/lib/api/v3/utilities/custom_field_injector/link_value_getter.rb @@ -59,11 +59,10 @@ module API Array(represented.custom_value_for(custom_field)).flat_map do |custom_value| if custom_value && custom_value.value.present? title = link_value_title(custom_value) - params = link_value_params(title, custom_field, custom_value) [{ title: title, - href: api_v3_paths.send(path_method, params) + href: api_v3_paths.send(path_method, custom_value.value) }] else [] @@ -79,17 +78,8 @@ module API end end - def link_value_params(title, custom_field, custom_value) - if custom_field.list? - # list custom_fields values use string objects which support and need titles - [title, custom_value.value] - else - custom_value.value - end - end - ## - # While multi value custom fields are expected to simpl return an empty array + # While multi value custom fields are expected to simply return an empty array # if they have no value a normal single value custom field is expected by # the frontend to return a single element with a null href and title. def single_empty_value diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 4e82b524c9..bc3d627edc 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -107,6 +107,10 @@ module API "#{work_packages_by_project(project_id)}/form" end + def self.custom_option(id) + "#{root}/custom_options/#{id}" + end + def self.my_preferences "#{root}/my_preferences" end @@ -262,18 +266,9 @@ module API "#{statuses}/#{id}" end - ## - # Accepts either a single value or a [value, title] tuple (array) - # and returns an URL to a string object for it. def self.string_object(value) - val, title = Array(value).reverse.map { |v| ::ERB::Util::url_encode(v) } - path = "#{root}/string_objects?value=#{val}" - - if title - "#{path}&title=#{title}" - else - path - end + val = ::ERB::Util::url_encode(value) + "#{root}/string_objects?value=#{val}" end def self.types diff --git a/lib/api/v3/work_packages/schema/specific_work_package_schema.rb b/lib/api/v3/work_packages/schema/specific_work_package_schema.rb index 3e04d7c41a..b8da906f85 100644 --- a/lib/api/v3/work_packages/schema/specific_work_package_schema.rb +++ b/lib/api/v3/work_packages/schema/specific_work_package_schema.rb @@ -66,7 +66,7 @@ module API def assignable_custom_field_values(custom_field) case custom_field.field_format when 'list' - custom_field.custom_options.map { |co| [co.value, co.id] } + custom_field.possible_values when 'version' assignable_values(:version, nil) end diff --git a/spec/factories/custom_option_factory.rb b/spec/factories/custom_option_factory.rb new file mode 100644 index 0000000000..f92c72be36 --- /dev/null +++ b/spec/factories/custom_option_factory.rb @@ -0,0 +1,33 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2017 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-2017 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. +#++ + +FactoryGirl.define do + factory :custom_option do + sequence(:value) { |n| "Custom Option #{n}" } + end +end diff --git a/spec/features/work_packages/details/inplace_editor/custom_field_spec.rb b/spec/features/work_packages/details/inplace_editor/custom_field_spec.rb index c7981df7fc..b8ee1a14dc 100644 --- a/spec/features/work_packages/details/inplace_editor/custom_field_spec.rb +++ b/spec/features/work_packages/details/inplace_editor/custom_field_spec.rb @@ -86,7 +86,6 @@ describe 'custom field inplace editor', js: true do message: I18n.t('js.notice_successful_update'), field: field1 - field2.activate! expect_update 'Y', message: I18n.t('js.notice_successful_update'), @@ -96,11 +95,11 @@ describe 'custom field inplace editor', js: true do customField2: 'Y' field1.activate! - field1.expect_value("/api/v3/string_objects?value=#{custom_value('bar')}&title=bar") + field1.expect_value("/api/v3/custom_options/#{custom_value('bar')}") field1.cancel_by_escape field2.activate! - field2.expect_value("/api/v3/string_objects?value=#{custom_value('Y')}&title=Y") + field2.expect_value("/api/v3/custom_options/#{custom_value('Y')}") expect_update 'X', message: I18n.t('js.notice_successful_update'), field: field2 diff --git a/spec/lib/api/v3/custom_options/custom_option_representer_spec.rb b/spec/lib/api/v3/custom_options/custom_option_representer_spec.rb new file mode 100644 index 0000000000..354334f70b --- /dev/null +++ b/spec/lib/api/v3/custom_options/custom_option_representer_spec.rb @@ -0,0 +1,64 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2017 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-2017 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::CustomOptions::CustomOptionRepresenter do + include ::API::V3::Utilities::PathHelper + + let(:custom_option) { FactoryGirl.build_stubbed(:custom_option, custom_field: custom_field) } + let(:custom_field) { FactoryGirl.build_stubbed(:list_wp_custom_field) } + let(:user) { FactoryGirl.build_stubbed(:user) } + let(:representer) do + described_class.new(custom_option, current_user: user) + end + + subject { representer.to_json } + + describe 'generation' do + describe '_links' do + it_behaves_like 'has a titled link' do + let(:link) { 'self' } + let(:href) { api_v3_paths.custom_option custom_option.id } + let(:title) { custom_option.to_s } + end + end + + it 'has the type "CustomOption"' do + is_expected.to be_json_eql('CustomOption'.to_json).at_path('_type') + end + + it 'has an id' do + is_expected.to be_json_eql(custom_option.id.to_json).at_path('id') + end + + it 'has a value' do + is_expected.to be_json_eql(custom_option.to_s.to_json).at_path('value') + end + end +end diff --git a/spec/lib/api/v3/queries/schemas/string_object_filter_dependency_representer_spec.rb b/spec/lib/api/v3/queries/schemas/custom_option_filter_dependency_representer_spec.rb similarity index 92% rename from spec/lib/api/v3/queries/schemas/string_object_filter_dependency_representer_spec.rb rename to spec/lib/api/v3/queries/schemas/custom_option_filter_dependency_representer_spec.rb index db574c10a0..7005362f6e 100644 --- a/spec/lib/api/v3/queries/schemas/string_object_filter_dependency_representer_spec.rb +++ b/spec/lib/api/v3/queries/schemas/custom_option_filter_dependency_representer_spec.rb @@ -28,7 +28,7 @@ require 'spec_helper' -describe ::API::V3::Queries::Schemas::StringObjectFilterDependencyRepresenter do +describe ::API::V3::Queries::Schemas::CustomOptionFilterDependencyRepresenter do include ::API::V3::Utilities::PathHelper let(:project) { FactoryGirl.build_stubbed(:project) } @@ -52,10 +52,10 @@ describe ::API::V3::Queries::Schemas::StringObjectFilterDependencyRepresenter do context 'properties' do describe 'values' do let(:path) { 'values' } - let(:type) { '[]StringObject' } + let(:type) { '[]CustomOption' } let(:hrefs) do - filter.allowed_values.each_with_object([]) do |value, array| - array << api_v3_paths.string_object(value) + custom_field.custom_options.map do |value| + api_v3_paths.custom_option(value) end end diff --git a/spec/lib/api/v3/queries/schemas/filter_dependency_representer_factory_spec.rb b/spec/lib/api/v3/queries/schemas/filter_dependency_representer_factory_spec.rb index 7e2cedd2b6..07814a9e93 100644 --- a/spec/lib/api/v3/queries/schemas/filter_dependency_representer_factory_spec.rb +++ b/spec/lib/api/v3/queries/schemas/filter_dependency_representer_factory_spec.rb @@ -105,7 +105,7 @@ describe ::API::V3::Queries::Schemas::FilterDependencyRepresenterFactory do let(:custom_field) { FactoryGirl.build_stubbed(:list_wp_custom_field) } it 'is the string object dependency' do - is_expected.to be_a(::API::V3::Queries::Schemas::StringObjectFilterDependencyRepresenter) + is_expected.to be_a(::API::V3::Queries::Schemas::CustomOptionFilterDependencyRepresenter) end end diff --git a/spec/lib/api/v3/utilities/custom_field_injector_spec.rb b/spec/lib/api/v3/utilities/custom_field_injector_spec.rb index f5d07d687d..c9b1d43178 100644 --- a/spec/lib/api/v3/utilities/custom_field_injector_spec.rb +++ b/spec/lib/api/v3/utilities/custom_field_injector_spec.rb @@ -155,7 +155,7 @@ describe ::API::V3::Utilities::CustomFieldInjector do allow(schema) .to receive(:assignable_custom_field_values) .with(custom_field) - .and_return(custom_field.possible_values.map { |co| [co.value, co.id] }) + .and_return(custom_field.possible_values) end let(:custom_field) do @@ -170,7 +170,7 @@ describe ::API::V3::Utilities::CustomFieldInjector do it_behaves_like 'has basic schema properties' do let(:path) { cf_path } - let(:type) { 'StringObject' } + let(:type) { 'CustomOption' } let(:name) { custom_field.name } let(:required) { true } let(:writable) { true } @@ -180,7 +180,7 @@ describe ::API::V3::Utilities::CustomFieldInjector do let(:path) { cf_path } let(:hrefs) do custom_field.possible_values.map do |value| - api_v3_paths.string_object([value.value, value.id]) + api_v3_paths.custom_option(value.id) end end end @@ -297,18 +297,21 @@ describe ::API::V3::Utilities::CustomFieldInjector do end context 'list custom field' do - let(:value) { 'Foobar' } - let(:raw_value) { value } + let(:value) { FactoryGirl.build_stubbed(:custom_option) } + let(:typed_value) { value.value } + let(:raw_value) { value.id.to_s } let(:field_format) { 'list' } it_behaves_like 'has a titled link' do let(:link) { cf_path } - let(:href) { "/api/v3/string_objects?value=#{raw_value}&title=#{value}" } - let(:title) { value } + let(:href) { api_v3_paths.custom_option(value.id) } + let(:title) { value.value } end context 'value is nil' do let(:value) { nil } + let(:raw_value) { '' } + let(:typed_value) { '' } it_behaves_like 'has an empty link' do let(:link) { cf_path } diff --git a/spec/lib/api/v3/utilities/path_helper_spec.rb b/spec/lib/api/v3/utilities/path_helper_spec.rb index 93a4aa2e87..3d0ce3537b 100644 --- a/spec/lib/api/v3/utilities/path_helper_spec.rb +++ b/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -140,6 +140,12 @@ describe ::API::V3::Utilities::PathHelper do it_behaves_like 'api v3 path', '/configuration' end + describe '#custom_option' do + subject { helper.custom_option 42 } + + it_behaves_like 'api v3 path', '/custom_options/42' + end + describe '#create_work_package_form' do subject { helper.create_work_package_form } diff --git a/spec/lib/api/v3/work_packages/schema/specific_work_package_schema_spec.rb b/spec/lib/api/v3/work_packages/schema/specific_work_package_schema_spec.rb index b17efc964e..deee5724db 100644 --- a/spec/lib/api/v3/work_packages/schema/specific_work_package_schema_spec.rb +++ b/spec/lib/api/v3/work_packages/schema/specific_work_package_schema_spec.rb @@ -263,7 +263,7 @@ describe ::API::V3::WorkPackages::Schema::SpecificWorkPackageSchema do it "is a list custom fields' possible values" do expect(subject.assignable_custom_field_values(list_cf)) - .to eql list_cf.possible_values.map { |co| [co.value, co.id] } + .to eql list_cf.possible_values end it "is a version custom fields' project values" do diff --git a/spec/models/queries/work_packages/filter/custom_field_filter_spec.rb b/spec/models/queries/work_packages/filter/custom_field_filter_spec.rb index 10e823a711..cd350c3e55 100644 --- a/spec/models/queries/work_packages/filter/custom_field_filter_spec.rb +++ b/spec/models/queries/work_packages/filter/custom_field_filter_spec.rb @@ -412,20 +412,9 @@ describe Queries::WorkPackages::Filter::CustomFieldFilter, type: :model do end it 'returns an array with custom classes' do - expect(instance.value_objects.length) - .to eql(2) - - expect(instance.value_objects[0].id) - .to match_array([custom_field.custom_options.first.value, - custom_field.custom_options.first.id]) - expect(instance.value_objects[0].name) - .to eql(custom_field.custom_options.first.value) - - expect(instance.value_objects[1].id) - .to match_array([custom_field.custom_options.last.value, - custom_field.custom_options.last.id]) - expect(instance.value_objects[1].name) - .to eql(custom_field.custom_options.last.value) + expect(instance.value_objects) + .to match_array([custom_field.custom_options.last, + custom_field.custom_options.first]) end end end diff --git a/spec/requests/api/v3/custom_options/custom_options_resource_spec.rb b/spec/requests/api/v3/custom_options/custom_options_resource_spec.rb new file mode 100644 index 0000000000..0a4649421a --- /dev/null +++ b/spec/requests/api/v3/custom_options/custom_options_resource_spec.rb @@ -0,0 +1,119 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2017 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-2017 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 Custom Options resource' do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + let(:user) do + FactoryGirl.create(:user, + member_in_project: project, + member_through_role: role) + end + let(:project) { FactoryGirl.create(:project) } + let(:role) { FactoryGirl.create(:role, permissions: permissions) } + let(:permissions) { [:view_work_packages] } + let(:custom_field) do + cf = FactoryGirl.create(:list_wp_custom_field) + + project.work_package_custom_fields << cf + + cf + end + let(:custom_option) do + FactoryGirl.create(:custom_option, + custom_field: custom_field) + end + + subject(:response) { last_response } + + describe 'GET api/v3/custom_options/:id' do + let(:path) { api_v3_paths.custom_option custom_option.id } + + before do + allow(User) + .to receive(:current) + .and_return(user) + get path + end + + context 'when being allowed' do + it 'is successful' do + expect(subject.status) + .to eql(200) + end + + it 'returns the custom option' do + expect(response.body) + .to be_json_eql('CustomOption'.to_json) + .at_path('_type') + + expect(response.body) + .to be_json_eql(custom_option.id.to_json) + .at_path('id') + + expect(response.body) + .to be_json_eql(custom_option.value.to_json) + .at_path('value') + end + end + + context 'when lacking permission' do + let(:permissions) { [] } + + it 'is 404' do + expect(subject.status) + .to eql(404) + end + end + + context 'when custom option not in project' do + let(:custom_field) do + # not added to project + FactoryGirl.create(:list_wp_custom_field) + end + + it 'is 404' do + expect(subject.status) + .to eql(404) + end + end + + context 'when not existing' do + let(:path) { api_v3_paths.custom_option 0 } + + it 'is 404' do + expect(subject.status) + .to eql(404) + end + end + end +end diff --git a/spec/requests/api/v3/work_package_resource_spec.rb b/spec/requests/api/v3/work_package_resource_spec.rb index be2974b977..5e62011b5e 100644 --- a/spec/requests/api/v3/work_package_resource_spec.rb +++ b/spec/requests/api/v3/work_package_resource_spec.rb @@ -833,21 +833,19 @@ describe 'API v3 Work package resource', type: :request do end context 'list custom field' do - let(:custom_field) { - FactoryGirl.create(:work_package_custom_field, - field_format: 'list', - is_required: false, - possible_values: [target_value]) - } - let(:target_value) { 'Low No. of specialc#aracters!' } + let(:custom_field) do + FactoryGirl.create(:list_wp_custom_field) + end + + let(:target_value) { custom_field.possible_values.last } let(:value_link) do - api_v3_paths.string_object [target_value, custom_field.custom_options.first.id] + api_v3_paths.custom_option target_value.id end - let(:value_parameter) { + let(:value_parameter) do { _links: { custom_field.accessor_name.camelize(:lower) => { href: value_link } } } - } + end let(:params) { valid_params.merge(value_parameter) } before do diff --git a/spec_legacy/unit/query_spec.rb b/spec_legacy/unit/query_spec.rb index db9447942a..9b63a0d832 100644 --- a/spec_legacy/unit/query_spec.rb +++ b/spec_legacy/unit/query_spec.rb @@ -366,9 +366,12 @@ describe Query, type: :model do q = Query.new(name: '_', group_by: 'cf_1') count_by_group = q.results.work_package_count_by_group assert_kind_of Hash, count_by_group - assert_equal %w(NilClass String), count_by_group.keys.map { |k| k.class.name }.uniq.sort + expect(count_by_group.keys.map { |k| k.class.name }.uniq) + .to match_array(%w(CustomOption NilClass)) assert_equal %w(Fixnum), count_by_group.values.map { |k| k.class.name }.uniq - assert count_by_group.has_key?('1') + puts count_by_group + expect(count_by_group.any? { |k, v| k.is_a?(CustomOption) && k.id == 1 && v == 1 }) + .to be_truthy end it 'should issue count by date custom field group' do