mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
Merge pull request #5212 from opf/feature/attribute_groups
[24123] Work package group form configuration
This commit is contained in:
@@ -41,6 +41,7 @@
|
||||
@import content/tables
|
||||
@import content/tabular
|
||||
@import content/timelines
|
||||
@import content/types_form_configuration
|
||||
@import content/user
|
||||
@import content/preview
|
||||
@import content/modal
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
.type-form-conf-group,
|
||||
#type-form-conf-inactive-group
|
||||
border-radius: 2px
|
||||
padding: 0px 3px 1px 3px
|
||||
margin-bottom: 20px
|
||||
|
||||
.group-head
|
||||
@include varprop(color, font-color-on-primary-dark)
|
||||
padding: 7px 4px 8px 0px
|
||||
text-transform: uppercase
|
||||
.group-handle
|
||||
cursor: -webkit-grab
|
||||
cursor: grab
|
||||
@include varprop(color, font-color-on-primary-dark)
|
||||
font-size: 12px
|
||||
group-edit-in-place
|
||||
display: inline-block
|
||||
.delete-group:before
|
||||
vertical-align: bottom
|
||||
@include varprop(color, font-color-on-primary-dark)
|
||||
.attributes
|
||||
min-height: 29px
|
||||
|
||||
.type-form-conf-group
|
||||
@include varprop(background, primary-color)
|
||||
.group-name
|
||||
@include varprop(border-color, primary-color)
|
||||
border-width: 1px
|
||||
border-style: solid
|
||||
&:hover
|
||||
cursor: text
|
||||
@include varprop(border-color, inplace-edit--border-color)
|
||||
background: white
|
||||
color: #222222
|
||||
|
||||
&.-error
|
||||
@include varprop(background, content-form-error-color)
|
||||
.group-name
|
||||
@include varprop(border-color, content-form-error-color)
|
||||
.group-handle,
|
||||
.delete-group:before
|
||||
@include varprop(color, font-color-on-primary)
|
||||
|
||||
|
||||
#type-form-conf-inactive-group
|
||||
background: $gray-dark
|
||||
.visibility-check,
|
||||
.delete-group
|
||||
visibility: hidden
|
||||
.group-head
|
||||
display: block
|
||||
.advice
|
||||
text-transform: initial
|
||||
|
||||
.type-form-conf-attribute
|
||||
padding: 7px 7px 7px 0px
|
||||
margin-bottom: 2px
|
||||
|
||||
background: $gray-light
|
||||
|
||||
border-top-left-radius: 2px
|
||||
border-bottom-left-radius: 2px
|
||||
border-top-right-radius: 2px
|
||||
border-bottom-right-radius: 2px
|
||||
.attribute-handle
|
||||
cursor: -webkit-grab
|
||||
cursor: grab
|
||||
color: $body-font-color
|
||||
font-size: 12px
|
||||
.delete-group:before
|
||||
color: $body-font-color
|
||||
|
||||
.attribute-cf-label
|
||||
font-size: 0.8rem
|
||||
padding-left: 2px
|
||||
color: lighten($body-font-color, 10%)
|
||||
|
||||
#type-form-conf-group-template
|
||||
display: none
|
||||
|
||||
.group-head,
|
||||
.type-form-conf-attribute
|
||||
display: flex
|
||||
align-items: baseline
|
||||
justify-content: space-between
|
||||
.icon-toggle
|
||||
flex-basis: 15px
|
||||
.attribute-name,
|
||||
.group-name
|
||||
flex-basis: 60%
|
||||
.visibility-check
|
||||
flex-basis: 40%
|
||||
text-align: center
|
||||
+31
-4
@@ -227,6 +227,8 @@ body.controller-work_packages.action-show {
|
||||
width: initial;
|
||||
align-self: center;
|
||||
flex-grow: 1;
|
||||
/* Leave space for the back button */
|
||||
max-width: calc(100% - 100px);
|
||||
|
||||
|
||||
.wp-table--cell-span {
|
||||
@@ -241,14 +243,18 @@ body.controller-work_packages.action-show {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.work-packages--details--subject,
|
||||
.work-packages--subject-element,
|
||||
.work-packages--details--subject .wp-inline-edit--field {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
line-height: 34px;
|
||||
}
|
||||
|
||||
.work-packages--details--subject .wp-inline-edit--field {
|
||||
.work-packages--type-selector .wp-inline-edit--field {
|
||||
line-height: 34px;
|
||||
}
|
||||
|
||||
.work-packages--subject-element .wp-inline-edit--field {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -256,13 +262,13 @@ body.controller-work_packages.action-show {
|
||||
}
|
||||
|
||||
.work-packages--split-view {
|
||||
.work-packages--details--subject,
|
||||
.work-packages--subject-element,
|
||||
.work-packages--details--subject .wp-inline-edit--field {
|
||||
font-size: 1.125rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.work-packages--details--subject {
|
||||
.work-packages--subject-element {
|
||||
line-height: 24px;
|
||||
|
||||
.wp-inline-edit--field {
|
||||
@@ -273,6 +279,27 @@ body.controller-work_packages.action-show {
|
||||
}
|
||||
}
|
||||
|
||||
.work-packages--subject-type-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.work-packages--type-selector {
|
||||
.wp-table--cell-span {
|
||||
padding-right: 5px !important;
|
||||
}
|
||||
|
||||
.wp-edit-field.-active {
|
||||
margin-right: 80px !important;
|
||||
width: 95%
|
||||
}
|
||||
.inplace-edit--read-value--value-span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.inplace-edit--read-value--value-span:after {
|
||||
content: ':';
|
||||
}
|
||||
}
|
||||
|
||||
.edit-all-mode {
|
||||
.subject-header .work-packages--details--subject .inplace-edit--text-field {
|
||||
padding-left: 0.375rem;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
.wp-show--back-button
|
||||
width: 100px
|
||||
padding: 0 10px
|
||||
align-self: center
|
||||
|
||||
a
|
||||
margin: 0
|
||||
margin-top: 18px
|
||||
width: 75px
|
||||
|
||||
@@ -31,7 +31,6 @@ class CustomFieldsController < ApplicationController
|
||||
layout 'admin'
|
||||
|
||||
before_action :require_admin
|
||||
before_action :find_types, except: [:index, :destroy]
|
||||
before_action :find_custom_field, only: [:edit, :update, :destroy, :move, :delete_option]
|
||||
before_action :blank_translation_attributes_as_nil, only: [:create, :update]
|
||||
|
||||
@@ -69,12 +68,6 @@ class CustomFieldsController < ApplicationController
|
||||
end
|
||||
|
||||
if ok
|
||||
if @custom_field.is_a? WorkPackageCustomField
|
||||
@custom_field.types.each do |type|
|
||||
TypesHelper.update_type_attribute_visibility! type
|
||||
end
|
||||
end
|
||||
|
||||
flash[:notice] = t(:notice_successful_update)
|
||||
call_hook(:controller_custom_fields_edit_after_save, custom_field: @custom_field)
|
||||
redirect_to edit_custom_field_path(id: @custom_field.id)
|
||||
@@ -187,10 +180,6 @@ class CustomFieldsController < ApplicationController
|
||||
render_404
|
||||
end
|
||||
|
||||
def find_types
|
||||
@types = ::Type.order('position')
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def default_breadcrumb
|
||||
|
||||
@@ -52,7 +52,7 @@ class TypesController < ApplicationController
|
||||
|
||||
def create
|
||||
service = CreateTypeService.new
|
||||
result = service.call(attributes: permitted_params.type)
|
||||
result = service.call(permitted_params: permitted_params.type)
|
||||
@type = service.type
|
||||
|
||||
if result.success?
|
||||
@@ -61,8 +61,8 @@ class TypesController < ApplicationController
|
||||
@type = service.type
|
||||
@type.workflows.copy(copy_from)
|
||||
end
|
||||
flash[:notice] = l(:notice_successful_create)
|
||||
redirect_to action: 'index'
|
||||
flash[:notice] = t(:notice_successful_create)
|
||||
redirect_to edit_type_tab_path(id: @type.id, tab: 'settings')
|
||||
else
|
||||
@types = ::Type.order('position')
|
||||
@projects = Project.all
|
||||
@@ -90,8 +90,8 @@ class TypesController < ApplicationController
|
||||
params[:type].delete :name if @type.is_standard?
|
||||
|
||||
service = UpdateTypeService.new(type: @type)
|
||||
result = service.call(attributes: permitted_params.type)
|
||||
|
||||
result = service.call(permitted_params: permitted_params.type, unsafe_params: params[:type])
|
||||
if result.success?
|
||||
redirect_to(edit_type_tab_path(id: @type.id, tab: @tab),
|
||||
notice: t(:notice_successful_update))
|
||||
|
||||
+1
-120
@@ -49,131 +49,12 @@ module ::TypesHelper
|
||||
|
||||
module_function
|
||||
|
||||
##
|
||||
# Provides a map of all work package form attributes as seen when creating
|
||||
# or updating a work package. Through this map it can be checked whether or
|
||||
# not an attribute is required.
|
||||
#
|
||||
# E.g.
|
||||
#
|
||||
# ::TypesHelper.work_package_form_attributes['author'][:required] # => true
|
||||
#
|
||||
# @return [Hash{String => Hash}] Map from attribute names to options.
|
||||
def work_package_form_attributes(merge_date: false)
|
||||
rattrs = API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter.representable_attrs
|
||||
definitions = rattrs[:definitions]
|
||||
skip = ['_type', 'links', 'parent_id', 'parent', 'description']
|
||||
attributes = definitions.keys
|
||||
.reject { |key| skip.include? key }
|
||||
.map { |key| [key, definitions[key]] }.to_h
|
||||
|
||||
# within the form date is shown as a single entry including start and due
|
||||
if merge_date
|
||||
attributes['date'] = { required: false, has_default: false }
|
||||
attributes.delete 'due_date'
|
||||
attributes.delete 'start_date'
|
||||
end
|
||||
|
||||
WorkPackageCustomField.includes(:translations).all.each do |field|
|
||||
attributes["custom_field_#{field.id}"] = {
|
||||
required: field.is_required,
|
||||
has_default: field.default_value.present?,
|
||||
display_name: field.name
|
||||
}
|
||||
end
|
||||
|
||||
attributes
|
||||
end
|
||||
|
||||
def attr_i18n_key(name)
|
||||
if name == 'percentage_done'
|
||||
'done_ratio'
|
||||
else
|
||||
name
|
||||
end
|
||||
end
|
||||
|
||||
def attr_translate(name)
|
||||
if name == 'date'
|
||||
I18n.t('label_date')
|
||||
else
|
||||
key = attr_i18n_key(name)
|
||||
I18n.t("activerecord.attributes.work_package.#{key}", default: '')
|
||||
.presence || I18n.t("attributes.#{key}")
|
||||
end
|
||||
end
|
||||
|
||||
def translated_attribute_name(name, attr)
|
||||
if attr[:name_source]
|
||||
attr[:name_source].call
|
||||
else
|
||||
attr[:display_name] || attr_translate(name)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Calculates the visibility for all attributes of the given type.
|
||||
#
|
||||
# @param type [Type] Type for which to get the attribute visibilities.
|
||||
# @return [Hash{String => String}] A map from each attribute name to the attribute's visibility.
|
||||
def type_attribute_visibility(type)
|
||||
enabled_cfs = type.custom_field_ids.join("|")
|
||||
visibility = work_package_form_attributes
|
||||
.keys
|
||||
.select { |name| name !~ /^custom_field/ || name =~ /^custom_field_(#{enabled_cfs})$/ }
|
||||
.map { |name| [name, attr_visibility(name, type) || "default"] }
|
||||
.to_h
|
||||
end
|
||||
|
||||
##
|
||||
# Updates the given type's attribute visibility map.
|
||||
#
|
||||
# @param type [Type] The type to be updated
|
||||
# @return [Type] The updated type
|
||||
def update_type_attribute_visibility!(type)
|
||||
type.update! attribute_visibility: type_attribute_visibility(type)
|
||||
end
|
||||
|
||||
##
|
||||
# Checks visibility of a work package type's attribute.
|
||||
#
|
||||
# @param name [String] Name of the field of which to check the visibility.
|
||||
# @param type [Type] Work package type whose field visibility is checked.
|
||||
# @return [String] Either 'hidden', 'default' or 'visible'.
|
||||
def attr_visibility(name, type)
|
||||
if name =~ /^custom_field_/
|
||||
custom_field_visibility name, type
|
||||
elsif name == 'date' && !type.is_milestone
|
||||
non_milestone_date_field_visibility type
|
||||
else
|
||||
type.attribute_visibility[name]
|
||||
end
|
||||
end
|
||||
|
||||
#
|
||||
# Bases visibility of custom fields on `type.custom_field_ids`
|
||||
# if no visibility is defined yet. After the first update
|
||||
# attribute_visibility and custom_field_ids will be kept in sync
|
||||
# by the type service.
|
||||
def custom_field_visibility(name, type)
|
||||
id = name.split('_').last.to_i
|
||||
value = type.attribute_visibility[name]
|
||||
|
||||
if value.nil? || value == 'hidden'
|
||||
if type.custom_field_ids.include?(id)
|
||||
'default'
|
||||
else
|
||||
'hidden'
|
||||
end
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
def non_milestone_date_field_visibility(type)
|
||||
values = [type.attribute_visibility['start_date'],
|
||||
type.attribute_visibility['due_date']]
|
||||
|
||||
BaseTypeService::Functions.max_visibility values
|
||||
type.update! attribute_visibility: type.type_attribute_visibility
|
||||
end
|
||||
end
|
||||
|
||||
@@ -628,7 +628,7 @@ class PermittedParams
|
||||
:is_default,
|
||||
:color_id,
|
||||
Proc.new do
|
||||
{ attribute_visibility: ::TypesHelper.work_package_form_attributes.keys }
|
||||
{ attribute_visibility: ::Type.all_work_package_form_attributes.keys }
|
||||
end,
|
||||
project_ids: [],
|
||||
custom_field_ids: []
|
||||
|
||||
+6
-24
@@ -30,6 +30,12 @@
|
||||
class ::Type < ActiveRecord::Base
|
||||
extend Pagination::Model
|
||||
|
||||
# Work Package attributes for this type
|
||||
# and constraints to specifc attributes (by plugins).
|
||||
include ::Type::Attributes
|
||||
include ::Type::AttributeGroups
|
||||
include ::Type::AttributeVisibility
|
||||
|
||||
before_destroy :check_integrity
|
||||
|
||||
has_many :work_packages
|
||||
@@ -49,15 +55,6 @@ class ::Type < ActiveRecord::Base
|
||||
belongs_to :color, class_name: 'PlanningElementTypeColor',
|
||||
foreign_key: 'color_id'
|
||||
|
||||
serialize :attribute_visibility, Hash
|
||||
validates_each :attribute_visibility do |record, _attr, visibility|
|
||||
visibility.each do |attr_name, value|
|
||||
unless attribute_visibilities.include? value.to_s
|
||||
record.errors.add(:attribute_visibility, "for '#{attr_name}' cannot be '#{value}'")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
acts_as_list
|
||||
|
||||
validates_presence_of :name
|
||||
@@ -101,21 +98,6 @@ class ::Type < ActiveRecord::Base
|
||||
::Type.includes(:projects).where(projects: { id: project })
|
||||
end
|
||||
|
||||
##
|
||||
# The possible visibility values for a work package attribute
|
||||
# as defined by a type are:
|
||||
#
|
||||
# - default The attribute is displayed in forms if it has a value.
|
||||
# - visible The attribute is displayed in forms even if empty.
|
||||
# - hidden The attribute is hidden in forms even if it has a value.
|
||||
def self.attribute_visibilities
|
||||
['visible', 'hidden', 'default']
|
||||
end
|
||||
|
||||
def self.default_attribute_visibility
|
||||
'visible'
|
||||
end
|
||||
|
||||
def statuses
|
||||
return [] if new_record?
|
||||
@statuses ||= ::Type.statuses([id])
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
#-- 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 Type::AttributeGroups
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
validate :validate_attribute_group_names
|
||||
validate :validate_attribute_groups
|
||||
serialize :attribute_groups, Array
|
||||
|
||||
# Mapping from AR attribute name to a default group
|
||||
# May be extended by plugins
|
||||
mattr_accessor :default_group_map do
|
||||
{
|
||||
author: :people,
|
||||
assignee: :people,
|
||||
responsible: :people,
|
||||
estimated_time: :estimates_and_time,
|
||||
spent_time: :estimates_and_time
|
||||
}
|
||||
end
|
||||
|
||||
# All known default
|
||||
mattr_accessor :default_groups do
|
||||
{
|
||||
people: :label_people,
|
||||
estimates_and_time: :label_estimates_and_time,
|
||||
details: :label_details,
|
||||
other: :label_other,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
class_methods do
|
||||
##
|
||||
# Add a new default group name
|
||||
def add_default_group(name, label_key)
|
||||
default_groups[name.to_sym] = label_key
|
||||
end
|
||||
|
||||
##
|
||||
# Add a mapping from attribute key to an existing default group
|
||||
def add_default_mapping(group, *keys)
|
||||
unless default_groups.include? group
|
||||
raise ArgumentError, "Can't add mapping for '#{keys.inspect}'. Unknown default group '#{group}'."
|
||||
end
|
||||
|
||||
keys.each do |key|
|
||||
default_group_map[key.to_sym] = group
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Read the serialized attribute groups, if customized.
|
||||
# Otherwise, return +default_attribute_groups+
|
||||
def attribute_groups
|
||||
groups = read_attribute :attribute_groups
|
||||
# The attributes might not be present anymore, for instance when you remove
|
||||
# a plugin leaving an empty group behind. If we did not delete such a
|
||||
# group, the admin saving such a form configuration would encounter an
|
||||
# unexpected/unexplicable validation error.
|
||||
valid_keys = work_package_attributes.keys
|
||||
groups.each do |_, attributes|
|
||||
attributes.select! { |attribute| valid_keys.include? attribute }
|
||||
end
|
||||
groups.select! { |_,attributes| attributes.any? }
|
||||
|
||||
groups.presence || default_attribute_groups
|
||||
end
|
||||
|
||||
##
|
||||
# Returns the default +attribute_groups+ put together by
|
||||
# the default group map.
|
||||
def default_attribute_groups
|
||||
values = work_package_attributes
|
||||
.keys
|
||||
.reject { |key| custom_field?(key) && !has_custom_field?(key) }
|
||||
.group_by { |key| default_group_key(key.to_sym) }
|
||||
|
||||
ordered = []
|
||||
default_groups.map do |groupkey, label_key|
|
||||
members = values[groupkey]
|
||||
ordered << [I18n.t(label_key, locale: Setting.default_language), members.sort] if members.present?
|
||||
end
|
||||
|
||||
ordered
|
||||
end
|
||||
|
||||
##
|
||||
# Collect active and inactive form configuration groups for editing.
|
||||
def form_configuration_groups
|
||||
available = work_package_attributes
|
||||
# First we create a complete list of all attributes.
|
||||
# Later we will remove those that are members of an attribute group.
|
||||
# This way attributes that were created after the las group definitions
|
||||
# will fall back into the inactives group.
|
||||
inactive = available.clone
|
||||
|
||||
active_form = get_active_groups(available, inactive)
|
||||
inactive_form = inactive
|
||||
.map { |key, attribute| attr_form_map(key, attribute) }
|
||||
.sort_by { |_key, _attribute, translation| translation }
|
||||
|
||||
{
|
||||
actives: active_form,
|
||||
inactives: inactive_form
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_group_key(key)
|
||||
if custom_field?(key)
|
||||
:other
|
||||
else
|
||||
default_group_map.fetch(key.to_sym, :details)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Collect active attributes from the current form configuration.
|
||||
# Using the available attributes from +work_package_attributes+,
|
||||
# determines which attributes are not used
|
||||
def get_active_groups(available, inactive)
|
||||
attribute_groups.map do |group|
|
||||
extended_attributes =
|
||||
group.second
|
||||
.select { |key| inactive.delete(key) }
|
||||
.map! { |key| attr_form_map(key, available[key]) }
|
||||
|
||||
[group[0], extended_attributes]
|
||||
end
|
||||
end
|
||||
|
||||
def validate_attribute_group_names
|
||||
seen = Set.new
|
||||
attribute_groups.each do |group_key,_|
|
||||
errors.add(:attribute_groups, :group_without_name) unless group_key.present?
|
||||
errors.add(:attribute_groups, :duplicate_group, group: group_key) if seen.add?(group_key).nil?
|
||||
end
|
||||
end
|
||||
|
||||
def validate_attribute_groups
|
||||
valid_attributes = work_package_attributes.keys
|
||||
attribute_groups.each do |_, attributes|
|
||||
attributes.each do |key|
|
||||
if valid_attributes.exclude? key
|
||||
errors.add(:attribute_groups, :attribute_unknown)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,120 @@
|
||||
#-- 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 Type::AttributeVisibility
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
serialize :attribute_visibility, Hash
|
||||
validates_each :attribute_visibility do |record, _attr, visibility|
|
||||
visibility.each do |attr_name, value|
|
||||
unless attribute_visibilities.include? value.to_s
|
||||
record.errors.add(:attribute_visibility, "for '#{attr_name}' cannot be '#{value}'")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class_methods do
|
||||
##
|
||||
# The possible visibility values for a work package attribute
|
||||
# as defined by a type are:
|
||||
#
|
||||
# - default The attribute is displayed in forms if it has a value.
|
||||
# - visible The attribute is displayed in forms even if empty.
|
||||
# - hidden The attribute is hidden in forms even if it has a value.
|
||||
def attribute_visibilities
|
||||
['visible', 'hidden', 'default']
|
||||
end
|
||||
|
||||
def default_attribute_visibility
|
||||
'visible'
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Calculates the visibility for all attributes of the given type.
|
||||
#
|
||||
# @param type [Type] Type for which to get the attribute visibilities.
|
||||
# @return [Hash{String => String}] A map from each attribute name to the attribute's visibility.
|
||||
def type_attribute_visibility
|
||||
enabled_cfs = custom_field_ids.join("|")
|
||||
visibility = ::Type.all_work_package_form_attributes
|
||||
.keys
|
||||
.select { |name| name !~ /^custom_field/ || name =~ /^custom_field_(#{enabled_cfs})$/ }
|
||||
.map { |name| [name, attr_visibility(name) || "default"] }
|
||||
.to_h
|
||||
end
|
||||
|
||||
##
|
||||
# Checks visibility of a work package type's attribute.
|
||||
#
|
||||
# @param name [String] Name of the field of which to check the visibility.
|
||||
# @param type [Type] Work package type whose field visibility is checked.
|
||||
# @return [String] Either 'hidden', 'default' or 'visible'.
|
||||
def attr_visibility(name)
|
||||
if name =~ /^custom_field_/
|
||||
custom_field_visibility name
|
||||
elsif name == 'date' && !is_milestone
|
||||
non_milestone_date_field_visibility
|
||||
else
|
||||
attribute_visibility[name]
|
||||
end
|
||||
end
|
||||
|
||||
#
|
||||
# Bases visibility of custom fields on `type.custom_field_ids`
|
||||
# if no visibility is defined yet. After the first update
|
||||
# attribute_visibility and custom_field_ids will be kept in sync
|
||||
# by the type service.
|
||||
def custom_field_visibility(name)
|
||||
id = name.split('_').last.to_i
|
||||
value = attribute_visibility[name]
|
||||
|
||||
if value.nil? || value == 'hidden'
|
||||
if custom_field_ids.include?(id)
|
||||
'default'
|
||||
else
|
||||
'hidden'
|
||||
end
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
def non_milestone_date_field_visibility
|
||||
values = [
|
||||
attribute_visibility['start_date'],
|
||||
attribute_visibility['due_date']
|
||||
]
|
||||
|
||||
BaseTypeService::Functions.max_visibility values
|
||||
end
|
||||
|
||||
end
|
||||
@@ -0,0 +1,170 @@
|
||||
#-- 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 Type::Attributes
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
# Allow plugins to define constraints
|
||||
# that disable a given attribute for this type.
|
||||
mattr_accessor :attribute_constraints do
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
class_methods do
|
||||
##
|
||||
# Add a constraint for the given attribute
|
||||
def add_constraint(attribute, callable)
|
||||
unless callable.respond_to?(:call)
|
||||
raise ArgumentError, "Expecting callable object for constraint #{key}"
|
||||
end
|
||||
|
||||
attribute_constraints[attribute.to_sym] = callable
|
||||
end
|
||||
|
||||
##
|
||||
# Provides a map of all work package form attributes as seen when creating
|
||||
# or updating a work package. Through this map it can be checked whether or
|
||||
# not an attribute is required.
|
||||
#
|
||||
# E.g.
|
||||
#
|
||||
# ::Type.work_package_form_attributes['author'][:required] # => true
|
||||
#
|
||||
# @return [Hash{String => Hash}] Map from attribute names to options.
|
||||
def all_work_package_form_attributes(merge_date: false)
|
||||
rattrs = API::V3::WorkPackages::Schema::WorkPackageSchemaRepresenter.representable_attrs
|
||||
definitions = rattrs[:definitions]
|
||||
skip = ['_type', '_dependencies', 'attribute_groups', 'links', 'parent_id', 'parent', 'description']
|
||||
attributes = definitions.keys
|
||||
.reject { |key| skip.include?(key) || definitions[key][:required] }
|
||||
.map { |key| [key, definitions[key]] }.to_h
|
||||
|
||||
# within the form date is shown as a single entry including start and due
|
||||
if merge_date
|
||||
attributes['date'] = { required: false, has_default: false }
|
||||
attributes.delete 'due_date'
|
||||
attributes.delete 'start_date'
|
||||
end
|
||||
|
||||
WorkPackageCustomField.includes(:translations).all.each do |field|
|
||||
attributes["custom_field_#{field.id}"] = {
|
||||
required: field.is_required,
|
||||
has_default: field.default_value.present?,
|
||||
is_cf: true,
|
||||
display_name: field.name
|
||||
}
|
||||
end
|
||||
|
||||
attributes
|
||||
end
|
||||
end
|
||||
|
||||
def attr_form_map(key, represented)
|
||||
{
|
||||
key: key,
|
||||
is_cf: custom_field?(key),
|
||||
always_visible: attr_visibility(key) == 'visible',
|
||||
translation: translated_attribute_name(key, represented)
|
||||
}
|
||||
end
|
||||
|
||||
def attr_i18n_key(name)
|
||||
if name == 'percentage_done'
|
||||
'done_ratio'
|
||||
else
|
||||
name
|
||||
end
|
||||
end
|
||||
|
||||
def custom_field?(attribute_name)
|
||||
attribute_name.to_s.start_with? 'custom_field_'
|
||||
end
|
||||
|
||||
def attr_translate(name)
|
||||
if name == 'date'
|
||||
I18n.t('label_date')
|
||||
else
|
||||
key = attr_i18n_key(name)
|
||||
I18n.t("activerecord.attributes.work_package.#{key}", default: '')
|
||||
.presence || I18n.t("attributes.#{key}")
|
||||
end
|
||||
end
|
||||
|
||||
def translated_attribute_name(name, attr)
|
||||
if attr[:name_source]
|
||||
attr[:name_source].call
|
||||
else
|
||||
attr[:display_name] || attr_translate(name)
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
# Get all applicale work package attributes
|
||||
def work_package_attributes(merge_date: true)
|
||||
all_attributes = self.class.all_work_package_form_attributes(merge_date: merge_date)
|
||||
|
||||
# Reject those attributes that are not available for this type.
|
||||
all_attributes.select { |key, _| passes_attribute_constraint? key }
|
||||
end
|
||||
|
||||
##
|
||||
# Verify that the given attribute is applicable
|
||||
# in this type instance.
|
||||
# If a project context is given, that context is passed
|
||||
# to the constraint validator.
|
||||
def passes_attribute_constraint?(attribute, project: nil)
|
||||
|
||||
# Check custom field constraints
|
||||
if custom_field?(attribute) && !project.nil?
|
||||
return custom_field_in_project?(attribute, project)
|
||||
end
|
||||
|
||||
# Check other constraints (none in the core, but costs/backlogs adds constraints)
|
||||
constraint = attribute_constraints[attribute.to_sym]
|
||||
constraint.nil? || constraint.call(self, project: project)
|
||||
end
|
||||
|
||||
##
|
||||
# Returns whether this type has the custom field currently
|
||||
# (e.g. because it was checked in the removed CF view).
|
||||
def has_custom_field?(attribute)
|
||||
custom_field_ids.map { |id| "custom_field_#{id}" }.include? attribute
|
||||
end
|
||||
|
||||
##
|
||||
# Returns whether the custom field is active in the given project.
|
||||
def custom_field_in_project?(attribute, project)
|
||||
project
|
||||
.all_work_package_custom_fields.pluck(:id)
|
||||
.map { |id| "custom_field_#{id}" }
|
||||
.include? attribute
|
||||
end
|
||||
end
|
||||
@@ -30,15 +30,26 @@
|
||||
class BaseTypeService
|
||||
attr_accessor :type
|
||||
|
||||
def call(attributes: {})
|
||||
update(attributes)
|
||||
def call(permitted_params: {}, unsafe_params: {})
|
||||
update(permitted_params, unsafe_params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update(attributes)
|
||||
success = Type.transaction {
|
||||
type.attributes = attributes
|
||||
def update(permitted_params = {}, unsafe_params = {})
|
||||
success = Type.transaction do
|
||||
permitted = permitted_params
|
||||
permitted.delete(:attribute_groups)
|
||||
permitted.delete(:attribute_visibility)
|
||||
|
||||
type.attributes = permitted
|
||||
|
||||
if unsafe_params[:attribute_groups].present?
|
||||
type.attribute_groups = JSON.parse(unsafe_params[:attribute_groups])
|
||||
end
|
||||
if unsafe_params[:attribute_visibility].present?
|
||||
type.attribute_visibility = JSON.parse(unsafe_params[:attribute_visibility])
|
||||
end
|
||||
|
||||
set_date_attribute_visibility
|
||||
set_active_custom_fields
|
||||
@@ -48,7 +59,7 @@ class BaseTypeService
|
||||
else
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
}
|
||||
end
|
||||
|
||||
ServiceResult.new(success: success,
|
||||
errors: type.errors)
|
||||
|
||||
@@ -104,28 +104,6 @@ See doc/COPYRIGHT.rdoc for more details.
|
||||
<section class="form--section">
|
||||
<% case @custom_field.class.name
|
||||
when "WorkPackageCustomField" %>
|
||||
<fieldset class="form--fieldset">
|
||||
<legend class="form--fieldset-legend"><%=l(:label_type_plural)%></legend>
|
||||
<div class="form--field">
|
||||
<div class="form--field-container -wrap-around">
|
||||
<% for type in @types %>
|
||||
<%= content_tag :label, '',
|
||||
class: "form--label-with-check-box",
|
||||
for: "custom_field_type_ids_#{type.id}" do %>
|
||||
<div class="form--check-box-container">
|
||||
<%= check_box_tag "custom_field[type_ids][]", type.id, (@custom_field.types.include? type),
|
||||
id: "custom_field_type_ids_#{type.id}",
|
||||
class: 'form--check-box' %>
|
||||
</div>
|
||||
<%= (type.is_standard) ? l(:label_custom_field_default_type) : h(type) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<%= hidden_field_tag "custom_field[type_ids][]", '' %>
|
||||
</fieldset>
|
||||
|
||||
|
||||
<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_filter %></div>
|
||||
|
||||
@@ -84,7 +84,7 @@ See doc/COPYRIGHT.rdoc for more details.
|
||||
label_options: { class: 'hidden-for-sighted' }
|
||||
}
|
||||
%>
|
||||
<tr>
|
||||
<tr class="custom-field-<%= custom_field.id %>">
|
||||
<td>
|
||||
<%= custom_field.name %>
|
||||
</td>
|
||||
|
||||
@@ -27,61 +27,118 @@ See doc/COPYRIGHT.rdoc for more details.
|
||||
|
||||
++#%>
|
||||
|
||||
<% form_attributes = @type.form_configuration_groups %>
|
||||
|
||||
<section class="form--section">
|
||||
<div class="grid-block wrap">
|
||||
<div class="grid-content small-12 large-6">
|
||||
<div>
|
||||
<p><%= I18n.t('text_form_configuration') %></p>
|
||||
<table class="attributes-table -two-options">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= I18n.t('label_attribute') %></th>
|
||||
<th class="attributes-table--option"><%= I18n.t('label_active') %></th>
|
||||
<th class="attributes-table--option"><%= I18n.t('label_always_visible') %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%
|
||||
attributes = ::TypesHelper
|
||||
.work_package_form_attributes(merge_date: true)
|
||||
.reject { |name, attr|
|
||||
# display all custom fields don't display required fields without a default
|
||||
not name =~ /custom_field_/ and (attr[:required] and not attr[:has_default])
|
||||
}
|
||||
keys = attributes.keys.sort_by do |name|
|
||||
translated_attribute_name(name, attributes[name])
|
||||
end
|
||||
%>
|
||||
<% keys.each do |name| %>
|
||||
<% attr = attributes[name] %>
|
||||
<tr>
|
||||
<td>
|
||||
<%= label_tag "type_attribute_visibility_#{name}",
|
||||
translated_attribute_name(name, attr),
|
||||
value: "type_attribute_visibility[#{name}]",
|
||||
class: 'ellipsis' %>
|
||||
</td>
|
||||
<td>
|
||||
<input name="<%= "type[attribute_visibility][#{name}]" %>" type="hidden" value="hidden" />
|
||||
<% active_checked = [nil, 'default', 'visible'].include?(attr_visibility(name, @type)) %>
|
||||
<%= check_box_tag "type[attribute_visibility][#{name}]",
|
||||
'default',
|
||||
active_checked,
|
||||
id: "type_attribute_visibility_default_#{name}",
|
||||
title: I18n.t('tooltip.attribute_visibility.default') %>
|
||||
</td>
|
||||
<td>
|
||||
<%= check_box_tag "type[attribute_visibility][#{name}]",
|
||||
'visible',
|
||||
attr_visibility(name, @type) == 'visible',
|
||||
id: "type_attribute_visibility_visible_#{name}",
|
||||
title: I18n.t('tooltip.attribute_visibility.visible'),
|
||||
disabled: !active_checked %>
|
||||
</td>
|
||||
</tr>
|
||||
<%= f.hidden_field :attribute_groups, value: @type.attribute_groups.to_json %>
|
||||
<%= f.hidden_field :attribute_visibility, value: @type.attribute_visibility.to_json %>
|
||||
<div id="types-form-configuration" op-drag-scroll ng-controller="TypesFormConfigurationCtrl">
|
||||
<div class="grid-block wrap">
|
||||
<div class="grid-content small-12 large-10">
|
||||
<p><%= t('text_form_configuration') %></p>
|
||||
|
||||
<%= toolbar title: '' do %>
|
||||
<li class="toolbar-item">
|
||||
<button type="button" class="form-configuration--reset button" ng-click="resetToDefault($event)">
|
||||
<i class="button--icon icon-undo"></i>
|
||||
<span class="button--text"><%= t('types.edit.reset') %></span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="toolbar-item">
|
||||
<button type="button" class="form-configuration--add-group button -alt-highlight" ng-click="addGroup($event)">
|
||||
<i class="button--icon icon-add"></i>
|
||||
<span class="button--text"><%= t('types.edit.add_group') %></span>
|
||||
</button>
|
||||
</li>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-block wrap">
|
||||
<div class="grid-content small-12 medium-6 large-5">
|
||||
<div id="type-form-conf-group-template" class="type-form-conf-group" data-original-key="" data-key="">
|
||||
<div class="group-head">
|
||||
<span class="group-handle icon-toggle"></span>
|
||||
<group-edit-in-place
|
||||
name=""
|
||||
key=""
|
||||
onvaluechange="groupNameChange"
|
||||
class="group-name">
|
||||
</group-edit-in-place>
|
||||
<span class="visibility-check"><%= t('label_always_visible') %></span>
|
||||
<span class="delete-group icon icon-close" ng-click="deleteGroup($event)"></span>
|
||||
</div>
|
||||
<div class="attributes" dragula='"attributes"'>
|
||||
</div>
|
||||
</div>
|
||||
<div id="draggable-groups" dragula='"groups"'>
|
||||
<% form_attributes[:actives].each do |group, attributes| %>
|
||||
<div class="type-form-conf-group" data-original-key="<%= group %>" data-key="<%= group %>">
|
||||
<div class="group-head">
|
||||
<span class="group-handle icon-toggle"></span>
|
||||
<group-edit-in-place
|
||||
name="<%= group %>"
|
||||
key="<%= group %>"
|
||||
onvaluechange="groupNameChange"
|
||||
class="group-name">
|
||||
<%= group %>
|
||||
</group-edit-in-place>
|
||||
<span class="visibility-check"><%= t('label_always_visible') %></span>
|
||||
<span class="delete-group icon icon-close" ng-click="deleteGroup($event)"></span>
|
||||
</div>
|
||||
<div class="attributes" dragula='"attributes"'>
|
||||
<% attributes.each do |attribute| %>
|
||||
<div class="type-form-conf-attribute" data-key="<%= attribute[:key] %>">
|
||||
<span class="attribute-handle icon-toggle"></span>
|
||||
<span class="attribute-name">
|
||||
<%= attribute[:translation] %>
|
||||
<% if attribute[:is_cf] %>
|
||||
<span class="attribute-cf-label"><%= CustomField.model_name.human %></span>
|
||||
<% end %>
|
||||
</span>
|
||||
<div class="visibility-check">
|
||||
<%= check_box_tag "",
|
||||
'visible',
|
||||
attribute[:always_visible],
|
||||
title: t('tooltip.attribute_visibility.visible'),
|
||||
'ng-click': "updateHiddenFields()" %>
|
||||
</div>
|
||||
<span class="delete-group icon icon-small icon-close" ng-click="deactivateAttribute($event)"></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div> <!-- END attribute group -->
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-content small-12 medium-6 large-5">
|
||||
<div id="type-form-conf-inactive-group">
|
||||
<div class="group-head">
|
||||
<span class="group-name"><%= t(:label_inactive) %></span>
|
||||
<span class="advice">(Drag attributes from here to activate them)</span>
|
||||
</div>
|
||||
<div class="attributes" dragula='"attributes"'>
|
||||
<% form_attributes[:inactives].each do |inactive_attribute| %>
|
||||
<div class="type-form-conf-attribute" data-key="<%= inactive_attribute[:key] %>">
|
||||
<span class="attribute-handle icon-toggle"></span>
|
||||
<span class="attribute-name">
|
||||
<%= inactive_attribute[:translation] %>
|
||||
<% if inactive_attribute[:is_cf] %>
|
||||
<span class="attribute-cf-label"><%= CustomField.model_name.human %></span>
|
||||
<% end %>
|
||||
</span>
|
||||
|
||||
<div class="visibility-check">
|
||||
<%= check_box_tag "",
|
||||
'hidden',
|
||||
false,
|
||||
title: t('tooltip.attribute_visibility.visible'),
|
||||
'ng-click': "updateHiddenFields()" %>
|
||||
</div>
|
||||
<span class="delete-group icon icon-close" ng-click="deactivateAttribute($event)"></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div><!-- END type form configurator -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,6 +147,6 @@ See doc/COPYRIGHT.rdoc for more details.
|
||||
<div class="grid-block">
|
||||
<div class="generic-table--action-buttons">
|
||||
<%= styled_button_tag t(@type.new_record? ? :button_create : :button_save),
|
||||
class: '-highlight -with-icon icon-checkmark' %>
|
||||
class: 'form-configuration--save -highlight -with-icon icon-checkmark' %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,7 @@ OpenProject::Application.configure do
|
||||
config.active_record.migration_error = :page_load
|
||||
|
||||
# Disable compression and asset digests, but disable debug
|
||||
config.assets.debug = true
|
||||
config.assets.debug = false
|
||||
config.assets.digest = false
|
||||
|
||||
# Suppress asset output
|
||||
|
||||
+20
-5
@@ -164,6 +164,8 @@ en:
|
||||
form_configuration: "Form configuration"
|
||||
projects: "Projects"
|
||||
enabled_projects: "Enabled projects"
|
||||
add_group: "Add group"
|
||||
reset: "Reset to defaults"
|
||||
|
||||
versions:
|
||||
overview:
|
||||
@@ -488,6 +490,12 @@ en:
|
||||
does_not_exist: "The specified category does not exist."
|
||||
estimated_hours:
|
||||
only_values_greater_or_equal_zeroes_allowed: "must be >= 0."
|
||||
type:
|
||||
attributes:
|
||||
attribute_groups:
|
||||
group_without_name: "Unnamed groups are not allowed."
|
||||
duplicate_group: "The group name %{group} is used more than once. Group names must be unique."
|
||||
attribute_unknown: "Invalid work package attribute used."
|
||||
user:
|
||||
attributes:
|
||||
password:
|
||||
@@ -1079,6 +1087,7 @@ en:
|
||||
label_enterprise: "Enterprise"
|
||||
label_enterprise_edition: "Enterprise Edition"
|
||||
label_environment: "Environment"
|
||||
label_estimates_and_time: "Estimates and time"
|
||||
label_equals: "is"
|
||||
label_example: "Example"
|
||||
label_export_to: "Also available in:"
|
||||
@@ -1112,6 +1121,7 @@ en:
|
||||
label_in: "in"
|
||||
label_in_less_than: "in less than"
|
||||
label_in_more_than: "in more than"
|
||||
label_inactive: "Inactive"
|
||||
label_incoming_emails: "Incoming emails"
|
||||
label_includes: 'includes'
|
||||
label_index_by_date: "Index by date"
|
||||
@@ -1204,6 +1214,7 @@ en:
|
||||
label_open_work_packages_plural: "open"
|
||||
label_optional_description: "Description"
|
||||
label_options: "Options"
|
||||
label_other: "Other"
|
||||
label_overall_activity: "Overall activity"
|
||||
label_overall_spent_time: "Overall spent time"
|
||||
label_overview: "Overview"
|
||||
@@ -1216,6 +1227,7 @@ en:
|
||||
label_path_encoding: "Path encoding"
|
||||
label_pdf_with_descriptions: "PDF with Descriptions"
|
||||
label_per_page: "Per page"
|
||||
label_people: "People"
|
||||
label_permissions: "Permissions"
|
||||
label_permissions_report: "Permissions report"
|
||||
label_personalize_page: "Personalize this page"
|
||||
@@ -1914,11 +1926,14 @@ en:
|
||||
text_assign_to_project: "Assign to the project"
|
||||
text_form_configuration: >
|
||||
You can customize which attributes will be displayed
|
||||
in forms for work packages of this type. 'Active' fields will be shown in
|
||||
the collapsed work package view if they have a value,
|
||||
fields that are 'Always displayed' even if they don't. Inactive fields
|
||||
will never be shown, even if they have a value.
|
||||
Only fields that are not required or have a default value can be customized.
|
||||
in forms for work packages of this type. You can freely group the
|
||||
attributes to reflect the needs for your domain.
|
||||
|
||||
By default fields will only be shown in
|
||||
the collapsed work package view if they have a value.
|
||||
Fields that are 'Always displayed' will be visibile even
|
||||
if they don't have a value.
|
||||
|
||||
text_caracters_maximum: "%{count} characters maximum."
|
||||
text_caracters_minimum: "Must be at least %{count} characters long."
|
||||
text_comma_separated: "Multiple values allowed (comma separated)."
|
||||
|
||||
@@ -164,6 +164,7 @@ en:
|
||||
label_per_page: "Per page:"
|
||||
label_please_wait: "Please wait"
|
||||
label_quote_comment: "Quote this comment"
|
||||
label_reset: "Reset"
|
||||
label_remove_columns: "Remove selected columns"
|
||||
label_save_as: "Save as"
|
||||
label_select_watcher: "Select a watcher..."
|
||||
@@ -229,6 +230,14 @@ en:
|
||||
|
||||
text_are_you_sure: "Are you sure?"
|
||||
|
||||
types:
|
||||
attribute_groups:
|
||||
error_duplicate_group_name: "The name %{group} is used more than once. Group names must be unique."
|
||||
reset_title: "Reset form configuration"
|
||||
confirm_reset: >
|
||||
Are you sure you want to reset the form configuration?
|
||||
This will remove all customizations you made in this tab and also disable ALL custom fields.
|
||||
|
||||
watchers:
|
||||
label_loading: loading watchers...
|
||||
label_error_loading: An error occurred while loading the watchers
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddAttributeGroupsToType < ActiveRecord::Migration[5.0]
|
||||
def change
|
||||
add_column :types, :attribute_groups, :text
|
||||
end
|
||||
end
|
||||
@@ -1,63 +0,0 @@
|
||||
#-- 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.
|
||||
#++
|
||||
|
||||
Feature: Viewing an issue
|
||||
Background:
|
||||
Given there is 1 project with the following:
|
||||
| identifier | omicronpersei8 |
|
||||
| name | omicronpersei8 |
|
||||
And I am working in project "omicronpersei8"
|
||||
And the project "omicronpersei8" has the following types:
|
||||
| name | position |
|
||||
| Bug | 1 |
|
||||
And there is a default issuepriority with:
|
||||
| name | Normal |
|
||||
And there is a issuepriority with:
|
||||
| name | High |
|
||||
And there is a issuepriority with:
|
||||
| name | Immediate |
|
||||
And there is a role "member"
|
||||
And the role "member" may have the following rights:
|
||||
| view_work_packages |
|
||||
| edit_work_packages |
|
||||
And there is 1 user with the following:
|
||||
| login | bob |
|
||||
And the user "bob" is a "member" in the project "omicronpersei8"
|
||||
And there are the following issue status:
|
||||
| name | is_closed | is_default |
|
||||
| New | false | true |
|
||||
And the user "bob" has 1 issue with the following:
|
||||
| Subject | issue1 |
|
||||
| type | Bug |
|
||||
And I am already logged in as "bob"
|
||||
|
||||
@javascript
|
||||
Scenario: Calling the issue page and view the issue
|
||||
When I go to the page of the issue "issue1"
|
||||
Then I should see "issue1" within ".wp-edit-field.subject"
|
||||
And I should see "Bug #1" within ".work-packages--left-panel"
|
||||
@@ -56,6 +56,7 @@ Feature: Type Administration
|
||||
And I fill in "New Phase" for "Name"
|
||||
And I press "Create"
|
||||
Then I should see a notice flash stating "Successful creation."
|
||||
When I go to the global index page of types
|
||||
And I should see that "New Phase" is not a milestone and shown in aggregation
|
||||
And "New Phase" should be the last element in the list
|
||||
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
#-- 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.
|
||||
#++
|
||||
|
||||
Feature: Viewing a work package
|
||||
Background:
|
||||
Given there is 1 project with the following:
|
||||
| identifier | omicronpersei8 |
|
||||
| name | omicronpersei8 |
|
||||
And I am working in project "omicronpersei8"
|
||||
And the project "omicronpersei8" has the following types:
|
||||
| name | position |
|
||||
| Bug | 1 |
|
||||
And there is a default issuepriority with:
|
||||
| name | Normal |
|
||||
And there is a issuepriority with:
|
||||
| name | High |
|
||||
And there is a issuepriority with:
|
||||
| name | Immediate |
|
||||
And there are the following types:
|
||||
| Name | Is Milestone | In aggregation | Is default |
|
||||
| Phase | false | true | true |
|
||||
And there is a role "member"
|
||||
And the role "member" may have the following rights:
|
||||
| manage_subtasks |
|
||||
| manage_work_package_relations |
|
||||
| view_work_packages |
|
||||
| edit_work_packages |
|
||||
| move_work_packages |
|
||||
| add_work_packages |
|
||||
| edit_work_packages |
|
||||
| log_time |
|
||||
| delete_work_packages |
|
||||
And there is 1 user with the following:
|
||||
| login | bob |
|
||||
And the user "bob" is a "member" in the project "omicronpersei8"
|
||||
And there are the following issue status:
|
||||
| name | is_closed | is_default |
|
||||
| New | false | true |
|
||||
And there are the following issues in project "omicronpersei8":
|
||||
| subject | type | description | author |
|
||||
| issue1 | Bug | "1" | bob |
|
||||
| issue2 | Bug | "2" | bob |
|
||||
| issue3 | Bug | "3" | bob |
|
||||
And there are the following work packages in project "omicronpersei8":
|
||||
| subject | start_date | due_date |
|
||||
| pe1 | 2013-01-01 | 2013-12-31 |
|
||||
| pe2 | 2013-01-01 | 2013-12-31 |
|
||||
And the work package "issue1" has the following children:
|
||||
| issue2 |
|
||||
And the work package "pe1" has the following children:
|
||||
| pe2 |
|
||||
And I am already logged in as "bob"
|
||||
|
||||
@javascript
|
||||
Scenario: View child work package of type issue
|
||||
When I go to the page of the work package "issue1"
|
||||
And I open the work package tab "Relations"
|
||||
And I click on "issue2" within ".work-packages--right-panel"
|
||||
Then I should see "issue2" within ".wp-edit-field.subject"
|
||||
And I should see "Bug #2" within ".work-packages--left-panel"
|
||||
When I open the work package tab "Relations"
|
||||
Then I should see "issue1" within ".work-packages--right-panel"
|
||||
|
||||
@javascript
|
||||
Scenario: Log time leads to time entry creation page for issues
|
||||
When I go to the page of the work package "issue1"
|
||||
When I select "Log time" from the action menu
|
||||
Then I should see "Spent time"
|
||||
|
||||
@javascript
|
||||
Scenario: For an issue move leads to work package copy page
|
||||
When I go to the page of the work package "issue1"
|
||||
# saveguard to ensure that the page is loaded
|
||||
Then I should see "Anonymous"
|
||||
When I select "Move" from the action menu
|
||||
Then I should see "Move"
|
||||
|
||||
@javascript @selenium
|
||||
Scenario: For an issue deletion leads to the work package list
|
||||
When I go to the page of the work package "issue1"
|
||||
When I select "Delete" from the action menu
|
||||
And I confirm popups
|
||||
Then I should see "Work packages"
|
||||
@@ -119,11 +119,15 @@ function NotificationsService($rootScope:ng.IRootScopeService, $timeout:ng.ITime
|
||||
},
|
||||
remove = function (notification:any) {
|
||||
broadcast('notification.remove', notification);
|
||||
},
|
||||
clear = function () {
|
||||
broadcast('notification.clearAll', null);
|
||||
};
|
||||
|
||||
return {
|
||||
add: add,
|
||||
remove: remove,
|
||||
clear: clear,
|
||||
addError: addError,
|
||||
addWarning: addWarning,
|
||||
addSuccess: addSuccess,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// -- 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 IAugmentedJQuery = angular.IAugmentedJQuery;
|
||||
import { IDialogOpenResult, IDialogService } from 'ng-dialog';
|
||||
import {IDialogScope} from 'ng-dialog';
|
||||
|
||||
export interface ConfirmDialogOptions {
|
||||
text:{
|
||||
title:string;
|
||||
text:string;
|
||||
button_continue?:string;
|
||||
button_cancel?:string;
|
||||
};
|
||||
closeByEscape?:boolean;
|
||||
showClose?:boolean;
|
||||
closeByDocument?:boolean;
|
||||
}
|
||||
|
||||
export class ConfirmDialogService {
|
||||
|
||||
private defaultTexts:any;
|
||||
|
||||
constructor(protected $rootScope:ng.IRootScopeService,
|
||||
protected $q:angular.IQService,
|
||||
protected ngDialog:IDialogService,
|
||||
protected I18n:op.I18n) {
|
||||
|
||||
this.defaultTexts = {
|
||||
title: I18n.t('js.modals.form_submit.title'),
|
||||
text: I18n.t('js.modals.form_submit.text'),
|
||||
button_continue: I18n.t('js.button_continue'),
|
||||
button_cancel: I18n.t('js.button_cancel')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm an action with an ng dialog with the given options
|
||||
*/
|
||||
public confirm(options:ConfirmDialogOptions):ng.IPromise<void> {
|
||||
const deferred = this.$q.defer<void>();
|
||||
const scope = this.$rootScope.$new();
|
||||
let dialog:IDialogOpenResult;
|
||||
|
||||
scope.text = options.text;
|
||||
_.defaults(scope.text, this.defaultTexts);
|
||||
scope.confirmAndClose = () => {
|
||||
scope.confirmed = true;
|
||||
dialog.close();
|
||||
};
|
||||
|
||||
|
||||
dialog = this.ngDialog.open({
|
||||
closeByEscape: _.defaultTo(options.closeByDocument, true),
|
||||
showClose: _.defaultTo(options.closeByDocument, true),
|
||||
scope: <IDialogScope> scope,
|
||||
template: '/components/modals/confirm-dialog/confirm-dialog.modal.html',
|
||||
className: 'ngdialog-theme-openproject',
|
||||
preCloseCallback: () => {
|
||||
if (scope.confirmed) {
|
||||
deferred.resolve();
|
||||
} else {
|
||||
deferred.reject();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
}
|
||||
|
||||
angular
|
||||
.module('openproject.uiComponents')
|
||||
.service('confirmDialog', ConfirmDialogService);
|
||||
+12
-25
@@ -28,31 +28,22 @@
|
||||
|
||||
import IAugmentedJQuery = angular.IAugmentedJQuery;
|
||||
import { IDialogOpenResult, IDialogService } from 'ng-dialog';
|
||||
import { ConfirmDialogService } from './../confirm-dialog/confirm-dialog.service';
|
||||
import {IDialogScope} from 'ng-dialog';
|
||||
|
||||
export class ConfirmFormSubmitController {
|
||||
|
||||
// Allow original form submission after dialog was closed
|
||||
private confirmed = false;
|
||||
private dialog: IDialogOpenResult;
|
||||
private text:any;
|
||||
|
||||
constructor(protected $element:IAugmentedJQuery,
|
||||
protected $scope:angular.IScope,
|
||||
protected $http:angular.IHttpService,
|
||||
protected $q:angular.IQService,
|
||||
protected ngDialog:IDialogService,
|
||||
protected confirmDialog:ConfirmDialogService,
|
||||
protected I18n:op.I18n) {
|
||||
|
||||
this.$scope['text'] = {
|
||||
this.text = {
|
||||
title: I18n.t('js.modals.form_submit.title'),
|
||||
text: I18n.t('js.modals.form_submit.text'),
|
||||
button_continue: I18n.t('js.button_continue'),
|
||||
button_cancel: I18n.t('js.button_cancel')
|
||||
};
|
||||
|
||||
this.$scope['confirmAndClose'] = () => {
|
||||
this.confirmed = true;
|
||||
this.dialog.close();
|
||||
text: I18n.t('js.modals.form_submit.text')
|
||||
};
|
||||
|
||||
$element.on('submit', (evt) => {
|
||||
@@ -67,20 +58,16 @@ export class ConfirmFormSubmitController {
|
||||
}
|
||||
|
||||
public openConfirmationDialog() {
|
||||
this.dialog = this.ngDialog.open({
|
||||
this.confirmDialog.confirm({
|
||||
text: this.text,
|
||||
closeByEscape: true,
|
||||
showClose: true,
|
||||
closeByDocument: true,
|
||||
scope: <IDialogScope> this.$scope,
|
||||
template: '/components/modals/confirm-form-submit/confirm-form-submit.modal.html',
|
||||
className: 'ngdialog-theme-openproject',
|
||||
preCloseCallback: () => {
|
||||
if (this.confirmed) {
|
||||
this.$element.submit();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}).then(() => {
|
||||
this.confirmed = true;
|
||||
this.$element.trigger('submit');
|
||||
})
|
||||
.catch(() => this.confirmed = false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div
|
||||
class="work-package--new-state"
|
||||
class="work-package--new-state work-packages--show-view"
|
||||
ng-if="$ctrl.newWorkPackage"
|
||||
has-edit-mode="true"
|
||||
wp-edit-form="$ctrl.newWorkPackage"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<span ng-if="!editing" ng-click="enterEditingMode()" class="group-edit-handler">
|
||||
{{ name }}
|
||||
</span>
|
||||
<input
|
||||
ng-if="editing"
|
||||
class="group-edit-in-place--input"
|
||||
type="text"
|
||||
ng-model="name"
|
||||
ng-blur="saveEdition()"
|
||||
ng-keydown="keyDown($event)">
|
||||
@@ -0,0 +1,116 @@
|
||||
//-- 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 {openprojectModule} from '../../../angular-modules';
|
||||
|
||||
interface GroupEditInPlaceScope {
|
||||
cancelEdition:Function,
|
||||
editing:boolean,
|
||||
enterEditingMode:Function,
|
||||
keyDown:Function,
|
||||
leaveEditingMode:Function,
|
||||
// The current name:
|
||||
name:string|null,
|
||||
// The name before this edition. Important in case user changes several times
|
||||
// before submitting the form:
|
||||
nameBefore:string|null,
|
||||
// The orginal value in case user cancels edition.
|
||||
nameOriginal:string|null,
|
||||
onvaluechange:Function,
|
||||
saveEdition:Function
|
||||
}
|
||||
|
||||
function groupEditInPlace($timeout:any, $parse:any) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: '/components/types/form-configuration/group-edit-in-place.directive.html',
|
||||
scope: {
|
||||
onvaluechange: '='
|
||||
},
|
||||
link: function(scope:GroupEditInPlaceScope, element:any, attributes:any) {
|
||||
scope.editing = false;
|
||||
scope.name = attributes.name || '';
|
||||
// The name before last change;
|
||||
scope.nameOriginal = attributes.name || '';
|
||||
|
||||
scope.enterEditingMode = function() {
|
||||
scope.editing = true;
|
||||
scope.nameBefore = scope.name;
|
||||
$timeout(function(){
|
||||
angular.element('input', element).trigger('focus');
|
||||
}, 100);
|
||||
};
|
||||
|
||||
scope.leaveEditingMode = function() {
|
||||
// Only leave Editing mode if name not empty.
|
||||
if (scope.name != null && scope.name.trim().length > 0) {
|
||||
scope.editing = false;
|
||||
}
|
||||
};
|
||||
|
||||
scope.cancelEdition = function() {
|
||||
scope.name = scope.nameBefore;
|
||||
scope.leaveEditingMode();
|
||||
};
|
||||
|
||||
scope.saveEdition = function() {
|
||||
let newValue: string = angular.element("input", element[0]).first().val();
|
||||
scope.nameOriginal = scope.name;
|
||||
scope.name = newValue.trim();
|
||||
scope.leaveEditingMode();
|
||||
if (attributes.onvaluechange) {
|
||||
scope.onvaluechange(attributes.key, newValue);
|
||||
}
|
||||
};
|
||||
|
||||
scope.keyDown = function($event:KeyboardEvent) {
|
||||
if ($event.keyCode == 27) {
|
||||
// ESC
|
||||
scope.cancelEdition();
|
||||
}
|
||||
if ($event.keyCode == 13) {
|
||||
// ENTER
|
||||
// a blur event will trigger `saveEdition`
|
||||
angular.element('input', element[0]).blur();
|
||||
// Do not submit the whole form:
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
}
|
||||
// Prevent submitting the form
|
||||
return false;
|
||||
};
|
||||
|
||||
if (attributes.name == null || attributes.name.length === 0) {
|
||||
// Group name is empty so open in editing mode straight away.
|
||||
scope.enterEditingMode();
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
openprojectModule.directive('groupEditInPlace', groupEditInPlace);
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
import { ConfirmDialogService } from './../../modals/confirm-dialog/confirm-dialog.service';
|
||||
//-- 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 {openprojectModule} from '../../../angular-modules';
|
||||
const autoScroll:any = require('dom-autoscroller');
|
||||
|
||||
function typesFormConfigurationCtrl(
|
||||
dragulaService:any,
|
||||
NotificationsService:any,
|
||||
I18n:op.I18n,
|
||||
$scope:any,
|
||||
$element:any,
|
||||
confirmDialog:ConfirmDialogService,
|
||||
$window:ng.IWindowService,
|
||||
$compile:any) {
|
||||
|
||||
// Setup autoscroll
|
||||
var scroll = autoScroll(window, {
|
||||
margin: 20,
|
||||
maxSpeed: 5,
|
||||
scrollWhenOutside: true,
|
||||
autoScroll: function(this:any) {
|
||||
const groups = dragulaService.find($scope, 'groups').drake;
|
||||
const attributes = dragulaService.find($scope, 'attributes').drake;
|
||||
return this.down && (groups.dragging || attributes.dragging);
|
||||
}
|
||||
});
|
||||
|
||||
dragulaService.options($scope, 'groups', {
|
||||
moves: function (el:any, container:any, handle:any) {
|
||||
return handle.classList.contains('group-handle');
|
||||
}
|
||||
});
|
||||
|
||||
dragulaService.options($scope, 'attributes', {
|
||||
moves: function (el:any, container:any, handle:any) {
|
||||
return handle.classList.contains('attribute-handle');
|
||||
}
|
||||
});
|
||||
|
||||
$scope.resetToDefault = ($event:any):void => {
|
||||
confirmDialog.confirm({
|
||||
text: {
|
||||
title: I18n.t('js.types.attribute_groups.reset_title'),
|
||||
text: I18n.t('js.types.attribute_groups.confirm_reset'),
|
||||
button_continue: I18n.t('js.label_reset')
|
||||
}
|
||||
}).then(() => {
|
||||
let form:JQuery = angular.element($event.target).parents('form');
|
||||
angular.element('input#type_attribute_groups').first().val(JSON.stringify([]));
|
||||
angular.element('input#type_attribute_visibility').first().val(JSON.stringify({}));
|
||||
form.submit();
|
||||
});
|
||||
};
|
||||
|
||||
$scope.deactivateAttribute = ($event:any) => {
|
||||
angular.element($event.target)
|
||||
.parents('.type-form-conf-attribute')
|
||||
.appendTo('#type-form-conf-inactive-group .attributes');
|
||||
};
|
||||
|
||||
$scope.deleteGroup = ($event:any):void => {
|
||||
let group:JQuery = angular.element($event.target).parents('.type-form-conf-group');
|
||||
let attributes:JQuery = angular.element('.attributes', group).children();
|
||||
let inactiveAttributes:JQuery = angular.element('#type-form-conf-inactive-group .attributes');
|
||||
|
||||
if (attributes.length > 0) {
|
||||
angular.forEach(attributes, function(attribute:HTMLElement) {
|
||||
// reset visibility
|
||||
let checkbox:HTMLInputElement = angular.element('input[type=checkbox]', attribute)[0] as HTMLInputElement;
|
||||
checkbox.checked = false;
|
||||
});
|
||||
}
|
||||
|
||||
inactiveAttributes.prepend(attributes);
|
||||
|
||||
group.remove();
|
||||
$scope.updateHiddenFields();
|
||||
};
|
||||
|
||||
$scope.addGroup = (event:any) => {
|
||||
let newGroup:JQuery = angular.element('#type-form-conf-group-template').clone();
|
||||
let draggableGroups:JQuery = angular.element('#draggable-groups');
|
||||
let randomId:string = Math.ceil(Math.random() * 10000000).toString();
|
||||
|
||||
// Remove the id of the template:
|
||||
newGroup.attr('id', null);
|
||||
// Every group needs a key and an original-key:
|
||||
newGroup.attr('data-key', randomId);
|
||||
newGroup.attr('data-original-key', randomId);
|
||||
angular.element('group-edit-in-place', newGroup).attr('key', randomId);
|
||||
|
||||
draggableGroups.prepend(newGroup);
|
||||
$compile(newGroup)($scope);
|
||||
};
|
||||
|
||||
$scope.updateHiddenFields = ():void => {
|
||||
let groups:HTMLElement[] = angular.element('.type-form-conf-group').not('#type-form-conf-group-template').toArray();
|
||||
let seenGroupNames:{[name:string]:boolean} = {};
|
||||
let newAttrGroups:Array<Array<(string | Array<string>)>> = [];
|
||||
let newAttrVisibility:any = {};
|
||||
let inputAttributeGroups:JQuery;
|
||||
let inputAttributeVisibility:JQuery;
|
||||
|
||||
// Clean up previous error states
|
||||
NotificationsService.clear();
|
||||
|
||||
// Extract new grouping and visibility setup from DOM structure, starting
|
||||
// with the active groups.
|
||||
groups.forEach((group:HTMLElement) => {
|
||||
let groupKey:string = angular.element(group).attr('data-key');
|
||||
let attributes:HTMLElement[] = angular.element('.type-form-conf-attribute', group).toArray();
|
||||
let attrKeys:string[] = [];
|
||||
|
||||
angular.element(group).removeClass('-error');
|
||||
if (groupKey == null || groupKey.length === 0) {
|
||||
// Do not save groups without a name.
|
||||
return;
|
||||
}
|
||||
|
||||
if (seenGroupNames[groupKey]) {
|
||||
NotificationsService.addError(
|
||||
I18n.t('js.types.attribute_groups.error_duplicate_group_name', { group: groupKey })
|
||||
);
|
||||
angular.element(group).addClass('-error');
|
||||
return;
|
||||
}
|
||||
|
||||
seenGroupNames[groupKey] = true;
|
||||
attributes.forEach((attribute:HTMLElement) => {
|
||||
let attr:JQuery = angular.element(attribute);
|
||||
let key:string = attr.attr('data-key');
|
||||
attrKeys.push(key);
|
||||
newAttrVisibility[key] = 'default';
|
||||
if (angular.element('input[type=checkbox]', attr)) {
|
||||
let checkbox:HTMLInputElement = angular.element('input[type=checkbox]', attr)[0] as HTMLInputElement;
|
||||
if (checkbox.checked) {
|
||||
newAttrVisibility[key] = 'visible';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (attrKeys.length > 0) {
|
||||
newAttrGroups.push([groupKey, attrKeys]);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Then get visibility states for inactive attributes.
|
||||
let inactiveAttributes:HTMLElement[] = angular.element('#type-form-conf-inactive-group .type-form-conf-attribute').toArray();
|
||||
inactiveAttributes.forEach((attr:HTMLElement) => {
|
||||
let key:string = angular.element(attr).attr('data-key');
|
||||
newAttrVisibility[key] = 'hidden';
|
||||
});
|
||||
|
||||
// Finally update hidden input fields
|
||||
inputAttributeGroups = angular.element('input#type_attribute_groups').first();
|
||||
inputAttributeVisibility = angular.element('input#type_attribute_visibility').first();
|
||||
|
||||
inputAttributeGroups.val(JSON.stringify(newAttrGroups));
|
||||
inputAttributeVisibility.val(JSON.stringify(newAttrVisibility));
|
||||
};
|
||||
|
||||
$scope.groupNameChange = function(key:string, newValue:string):void {
|
||||
angular.element(`.type-form-conf-group[data-original-key="${key}"]`).attr('data-key', newValue);
|
||||
$scope.updateHiddenFields();
|
||||
};
|
||||
|
||||
$scope.$on('groups.drop', function (e:any, el:any) {
|
||||
$scope.updateHiddenFields();
|
||||
});
|
||||
$scope.$on('attributes.drop', function (e:any, el:any) {
|
||||
$scope.updateHiddenFields();
|
||||
});
|
||||
};
|
||||
|
||||
openprojectModule.controller('TypesFormConfigurationCtrl', typesFormConfigurationCtrl);
|
||||
@@ -1,180 +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 {opServicesModule} from '../../../angular-modules';
|
||||
import {WorkPackageDisplayFieldService} from '../../wp-display/wp-display-field/wp-display-field.service';
|
||||
import {Field} from '../../wp-field/wp-field.module';
|
||||
|
||||
var $filter:ng.IFilterService;
|
||||
var I18n:op.I18n;
|
||||
var wpDisplayField:WorkPackageDisplayFieldService;
|
||||
|
||||
export class SingleViewWorkPackage {
|
||||
|
||||
private fields:{[attr:string]: Field} = {};
|
||||
|
||||
constructor(protected workPackage:any) {
|
||||
}
|
||||
|
||||
public isSingleField(field:string) {
|
||||
return angular.isString(field);
|
||||
};
|
||||
|
||||
public canHideField(field:string) {
|
||||
var attrVisibility = this.getVisibility(field);
|
||||
var notRequired = !this.isRequired(field) || this.hasDefault(field);
|
||||
var empty = this.isEmpty(field);
|
||||
var visible = attrVisibility === 'visible';
|
||||
var hidden = attrVisibility === 'hidden';
|
||||
|
||||
if (this.workPackage.isNew) {
|
||||
return !visible && (field === 'author' || notRequired || hidden);
|
||||
}
|
||||
|
||||
return notRequired && !visible && (empty || hidden);
|
||||
}
|
||||
|
||||
public getVisibility(field:string) {
|
||||
var schema = this.workPackage.schema;
|
||||
var prop = schema && schema[field];
|
||||
|
||||
return prop && prop.visibility;
|
||||
}
|
||||
|
||||
public isRequired(field:string) {
|
||||
var schema = this.workPackage.schema;
|
||||
|
||||
if (_.isUndefined(schema[field])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return schema[field].required;
|
||||
}
|
||||
|
||||
public hasDefault(field:string) {
|
||||
var schema = this.workPackage.schema;
|
||||
|
||||
if (_.isUndefined(schema[field])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return schema[field].hasDefault;
|
||||
}
|
||||
|
||||
public isEmpty(fieldName:string) {
|
||||
if (this.workPackage.schema[fieldName]) {
|
||||
this.fields[fieldName] = this.fields[fieldName] ||
|
||||
wpDisplayField.getField(this.workPackage,
|
||||
fieldName,
|
||||
this.workPackage.schema[fieldName]);
|
||||
|
||||
return this.fields[fieldName].isEmpty();
|
||||
}
|
||||
else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public isEditable(field:string) {
|
||||
// no form - no _editing
|
||||
if (!this.workPackage.form) {
|
||||
return false;
|
||||
}
|
||||
var schema = this.workPackage.schema;
|
||||
var isWritable = schema[field].writable;
|
||||
|
||||
if (isWritable && schema[field].$links && this.getLinkedAllowedValues(field)) {
|
||||
isWritable = this.getEmbeddedAllowedValues(field).length > 0;
|
||||
}
|
||||
|
||||
return isWritable;
|
||||
}
|
||||
|
||||
public getLinkedAllowedValues(field:string) {
|
||||
return _.isArray(this.workPackage.schema[field].$links.allowedValues);
|
||||
}
|
||||
|
||||
public getEmbeddedAllowedValues(field:string) {
|
||||
return this.workPackage.schema[field].$embedded.allowedValues;
|
||||
}
|
||||
|
||||
public getLabel(field:string) {
|
||||
if (field === 'date') {
|
||||
return I18n.t('js.work_packages.properties.date');
|
||||
}
|
||||
|
||||
return this.workPackage.schema[field].name;
|
||||
}
|
||||
|
||||
public isSpecified(field:string) {
|
||||
return !_.isUndefined(this.workPackage.schema[field]);
|
||||
}
|
||||
|
||||
public hasNiceStar(field:string) {
|
||||
return this.isRequired(field) && this.workPackage.schema[field].writable;
|
||||
}
|
||||
|
||||
public isGroupHideable(groupedFields:any, groupName:string) {
|
||||
var group:any = _.find(groupedFields, {groupName: groupName});
|
||||
|
||||
return group.attributes.length === 0 || _.every(group.attributes, (field:string) => {
|
||||
return this.canHideField(field);
|
||||
});
|
||||
}
|
||||
|
||||
public isGroupEmpty(groupedFields:any, groupName:string) {
|
||||
var group:any = _.find(groupedFields, {groupName: groupName});
|
||||
|
||||
return group.attributes.length === 0;
|
||||
}
|
||||
|
||||
public shouldHideGroup(hideEmptyActive:boolean, groupedFields:any, groupName:string) {
|
||||
return hideEmptyActive && this.isGroupHideable(groupedFields, groupName) ||
|
||||
!hideEmptyActive && this.isGroupEmpty(groupedFields, groupName);
|
||||
}
|
||||
|
||||
public shouldHideField(field:string, hideEmptyFields:boolean) {
|
||||
var hidden = this.getVisibility(field) === 'hidden';
|
||||
|
||||
return this.canHideField(field) && (hideEmptyFields || hidden);
|
||||
}
|
||||
}
|
||||
|
||||
function singleViewWpService(...args:any[]) {
|
||||
[$filter, I18n, wpDisplayField] = args;
|
||||
return SingleViewWorkPackage;
|
||||
}
|
||||
|
||||
singleViewWpService.$inject = [
|
||||
'$filter',
|
||||
'I18n',
|
||||
'wpDisplayField'
|
||||
];
|
||||
|
||||
opServicesModule.factory('SingleViewWorkPackage', singleViewWpService);
|
||||
+54
-42
@@ -7,6 +7,35 @@
|
||||
<op-date-time date-time-value="$ctrl.workPackage.updatedAt"></op-date-time>.
|
||||
</div>
|
||||
|
||||
<div class="attributes-group">
|
||||
<div class="attributes-group--header">
|
||||
<div class="attributes-group--header-container">
|
||||
</div>
|
||||
<panel-expander tabindex="-1"
|
||||
collapsed="$ctrl.hideEmptyFields"
|
||||
expand-text="{{ $ctrl.I18n.t('js.label_show_attributes') }}"
|
||||
collapse-text="{{ $ctrl.I18n.t('js.label_hide_attributes') }}">
|
||||
</panel-expander>
|
||||
</div>
|
||||
<div class="-columns-2">
|
||||
<div class="attributes-key-value" ng-repeat="descriptor in $ctrl.specialFields track by descriptor.name">
|
||||
<div class="attributes-key-value--key"
|
||||
ng-hide="$ctrl.shouldHideField(descriptor.field)"
|
||||
wp-replacement-label="descriptor.name">
|
||||
{{ descriptor.label }}
|
||||
<span class="required" ng-if="descriptor.field.required && descriptor.field.writable"> *</span>
|
||||
</div>
|
||||
<div
|
||||
ng-hide="$ctrl.shouldHideField(descriptor.field)"
|
||||
wp-edit-field="descriptor.name"
|
||||
wp-edit-field-label="descriptor.label"
|
||||
wp-edit-field-wrapper-classes="'-small'"
|
||||
class="attributes-key-value--value-container">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="attributes-group">
|
||||
<div class="attributes-group--header">
|
||||
<div class="attributes-group--header-container">
|
||||
@@ -23,81 +52,64 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-repeat="group in $ctrl.groupedFields" ng-hide="$ctrl.shouldHideGroup(group.groupName)"
|
||||
<div ng-repeat="group in $ctrl.groupedFields track by group.name" ng-hide="$ctrl.shouldHideGroup(group)"
|
||||
data-group-name="{{ group.name }}"
|
||||
class="attributes-group">
|
||||
|
||||
<div class="attributes-group--header">
|
||||
<div class="attributes-group--header-container">
|
||||
<h3 class="attributes-group--header-text"
|
||||
ng-bind="$ctrl.I18n.t('js.work_packages.property_groups.' + group.groupName)"></h3>
|
||||
</div>
|
||||
<div class="attributes-group--header-toggle">
|
||||
<panel-expander tabindex="-1" ng-if="$first"
|
||||
collapsed="$ctrl.hideEmptyFields"
|
||||
expand-text="{{ $ctrl.I18n.t('js.label_show_attributes') }}"
|
||||
collapse-text="{{ $ctrl.I18n.t('js.label_hide_attributes') }}">
|
||||
</panel-expander>
|
||||
ng-bind="group.name"></h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="-columns-2">
|
||||
<div
|
||||
class="attributes-key-value"
|
||||
ng-repeat="field in group.attributes">
|
||||
ng-repeat="descriptor in group.members track by descriptor.name">
|
||||
<div
|
||||
class="attributes-key-value--key"
|
||||
ng-hide="$ctrl.shouldHideField(field)"
|
||||
ng-if="$ctrl.singleViewWp.isSingleField(field) && $ctrl.singleViewWp.isSpecified(field)"
|
||||
wp-replacement-label="field">
|
||||
ng-hide="$ctrl.shouldHideField(descriptor.field)"
|
||||
ng-if="!descriptor.multiple"
|
||||
wp-replacement-label="descriptor.name">
|
||||
|
||||
{{$ctrl.singleViewWp.getLabel(field)}}
|
||||
|
||||
<span class="required" ng-if="$ctrl.singleViewWp.hasNiceStar(field)"> *</span>
|
||||
{{ descriptor.label }}
|
||||
<span class="required" ng-if="descriptor.field.required && descriptor.field.writable"> *</span>
|
||||
</div>
|
||||
<div
|
||||
ng-hide="$ctrl.shouldHideField(field)"
|
||||
ng-if="$ctrl.singleViewWp.isSingleField(field) && $ctrl.singleViewWp.isSpecified(field)"
|
||||
wp-edit-field="field"
|
||||
wp-edit-field-label="$ctrl.singleViewWp.getLabel(field)"
|
||||
ng-hide="$ctrl.shouldHideField(descriptor.field)"
|
||||
ng-if="!descriptor.multiple"
|
||||
wp-edit-field="descriptor.name"
|
||||
wp-edit-field-label="descriptor.label"
|
||||
wp-edit-field-wrapper-classes="'-small'"
|
||||
class="attributes-key-value--value-container">
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="attributes-key-value--key"
|
||||
ng-hide="$ctrl.shouldHideField(field.fields[0]) &&
|
||||
$ctrl.shouldHideField(field.fields[1])"
|
||||
ng-if="!$ctrl.singleViewWp.isSingleField(field) &&
|
||||
$ctrl.singleViewWp.isSpecified(field.fields[0]) &&
|
||||
$ctrl.singleViewWp.isSpecified(field.fields[1]) "
|
||||
wp-replacement-label="field.label">
|
||||
|
||||
{{$ctrl.singleViewWp.getLabel(field.label)}}
|
||||
|
||||
<span class="required" ng-if="$ctrl.singleViewWp.hasNiceStar(field.label)"> *</span>
|
||||
ng-if="descriptor.multiple"
|
||||
ng-hide="$ctrl.shouldHideField(descriptor.fields[0]) && $ctrl.shouldHideField(descriptor.fields[1])"
|
||||
ng-bind="descriptor.label"
|
||||
wp-replacement-label="descriptor.label">
|
||||
</div>
|
||||
<div
|
||||
ng-hide="$ctrl.shouldHideField(field.fields[0]) &&
|
||||
$ctrl.shouldHideField(field.fields[1])"
|
||||
ng-if="!$ctrl.singleViewWp.isSingleField(field) &&
|
||||
$ctrl.singleViewWp.isSpecified(field.fields[0]) &&
|
||||
$ctrl.singleViewWp.isSpecified(field.fields[1]) "
|
||||
ng-if="descriptor.multiple"
|
||||
ng-hide="$ctrl.shouldHideField(descriptor.fields[0]) && $ctrl.shouldHideField(descriptor.fields[1])"
|
||||
class="attributes-key-value--value-container -minimal">
|
||||
|
||||
<div
|
||||
wp-edit-field="field.fields[0]"
|
||||
wp-edit-field-label="$ctrl.singleViewWp.getLabel(field.fields[0])"
|
||||
wp-edit-field="descriptor.fields[0].name"
|
||||
wp-edit-field-label="descriptor.fields[0].label"
|
||||
wp-edit-field-wrapper-classes="'-small -shrink'"
|
||||
display-placeholder="::$ctrl.text.fields[field.label][field.fields[0]]">
|
||||
display-placeholder="::$ctrl.text[descriptor.name][descriptor.fields[0].name]">
|
||||
</div>
|
||||
|
||||
<span class="attributes-key-value--value-separator"></span>
|
||||
|
||||
<div
|
||||
wp-edit-field="field.fields[1]"
|
||||
wp-edit-field-label="$ctrl.singleViewWp.getLabel(field.fields[1])"
|
||||
wp-edit-field="descriptor.fields[1].name"
|
||||
wp-edit-field-label="descriptor.fields[1].label"
|
||||
wp-edit-field-wrapper-classes="'-small -shrink'"
|
||||
display-placeholder="::$ctrl.text.fields[field.label][field.fields[1]]">
|
||||
display-placeholder="::$ctrl.text[descriptor.name][descriptor.fields[1].name]">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,12 +35,31 @@ import {
|
||||
import {WorkPackageEditFormController} from '../../wp-edit/wp-edit-form.directive';
|
||||
import {WorkPackageNotificationService} from '../../wp-edit/wp-notification.service';
|
||||
import {WorkPackageCacheService} from '../work-package-cache.service';
|
||||
import { WorkPackageDisplayFieldService } from "../../wp-display/wp-display-field/wp-display-field.service";
|
||||
import { DisplayField } from "../../wp-display/wp-display-field/wp-display-field.module";
|
||||
import { debugLog } from "../../../helpers/debug_output";
|
||||
|
||||
interface FieldDescriptor {
|
||||
name:string;
|
||||
label:string;
|
||||
field?:DisplayField;
|
||||
fields?:DisplayField[];
|
||||
multiple:boolean;
|
||||
}
|
||||
|
||||
interface GroupDescriptor {
|
||||
name:string;
|
||||
members:FieldDescriptor[];
|
||||
}
|
||||
|
||||
export class WorkPackageSingleViewController {
|
||||
public formCtrl: WorkPackageEditFormController;
|
||||
public workPackage: WorkPackageResourceInterface;
|
||||
public singleViewWp:any;
|
||||
public groupedFields: any[] = [];
|
||||
|
||||
// Grouped fields returned from API
|
||||
public groupedFields:GroupDescriptor[] = [];
|
||||
// Special fields (project, type)
|
||||
public specialFields:FieldDescriptor[];
|
||||
public hideEmptyFields: boolean = true;
|
||||
public text: any;
|
||||
public scope: any;
|
||||
@@ -48,56 +67,72 @@ export class WorkPackageSingleViewController {
|
||||
protected firstTimeFocused: boolean = false;
|
||||
|
||||
constructor(protected $scope:ng.IScope,
|
||||
protected $rootScope:ng.IRootScopeService,
|
||||
protected $stateParams:ng.ui.IStateParamsService,
|
||||
protected I18n:op.I18n,
|
||||
protected wpCacheService:WorkPackageCacheService,
|
||||
protected wpNotificationsService: WorkPackageNotificationService,
|
||||
protected TimezoneService:any,
|
||||
protected WorkPackagesOverviewService:any,
|
||||
protected SingleViewWorkPackage:any) {
|
||||
protected wpDisplayField:WorkPackageDisplayFieldService,
|
||||
protected wpCacheService:WorkPackageCacheService) {
|
||||
|
||||
var wpId = this.workPackage ? this.workPackage.id : $stateParams['workPackageId'];
|
||||
// Create I18n texts
|
||||
this.setupI18nTexts();
|
||||
|
||||
this.groupedFields = WorkPackagesOverviewService.getGroupedWorkPackageOverviewAttributes();
|
||||
this.text = {
|
||||
dropFiles: I18n.t('js.label_drop_files'),
|
||||
dropFilesHint: I18n.t('js.label_drop_files_hint'),
|
||||
fields: {
|
||||
description: I18n.t('js.work_packages.properties.description'),
|
||||
date: {
|
||||
startDate: I18n.t('js.label_no_start_date'),
|
||||
dueDate: I18n.t('js.label_no_due_date')
|
||||
}
|
||||
},
|
||||
infoRow: {
|
||||
createdBy: I18n.t('js.label_created_by'),
|
||||
lastUpdatedOn: I18n.t('js.label_last_updated_on')
|
||||
},
|
||||
};
|
||||
|
||||
if (this.workPackage) {
|
||||
this.init(this.workPackage);
|
||||
}
|
||||
|
||||
wpCacheService.loadWorkPackage(wpId).observeOnScope($scope)
|
||||
.subscribe((wp:WorkPackageResourceInterface) => this.init(wp));
|
||||
$scope.$on('workPackageUpdatedInEditor', () => {
|
||||
this.wpNotificationsService.showSave(this.workPackage);
|
||||
// Subscribe to work package
|
||||
const workPackageId = this.workPackage ? this.workPackage.id : $stateParams['workPackageId'];
|
||||
wpCacheService.loadWorkPackage(workPackageId)
|
||||
.observeOnScope($scope)
|
||||
.subscribe((wp:WorkPackageResourceInterface) => {
|
||||
this.init(wp);
|
||||
});
|
||||
}
|
||||
|
||||
public shouldHideGroup(group:any) {
|
||||
return this.singleViewWp.shouldHideGroup(this.hideEmptyFields, this.groupedFields, group);
|
||||
}
|
||||
/**
|
||||
* Determines whether the given field can be hidden
|
||||
* according to its type configuration and current work package.
|
||||
*/
|
||||
public canHideField(field:DisplayField) {
|
||||
var attrVisibility = field.visibility;
|
||||
var notRequired = !field.required || field.hasDefault;
|
||||
var empty = field.isEmpty();
|
||||
var visible = attrVisibility === 'visible';
|
||||
var hidden = attrVisibility === 'hidden';
|
||||
|
||||
public shouldHideField(field:any) {
|
||||
let hideEmpty = this.hideEmptyFields;
|
||||
|
||||
if (this.formCtrl.fields[field]) {
|
||||
hideEmpty = !this.formCtrl.fields[field].hasFocus() && this.hideEmptyFields;
|
||||
if (this.workPackage.isNew) {
|
||||
return !visible && (field.name === 'author' || notRequired || hidden);
|
||||
}
|
||||
|
||||
return this.singleViewWp.shouldHideField(field, hideEmpty);
|
||||
return notRequired && !visible && (empty || hidden);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether we should hide the given group
|
||||
*/
|
||||
public shouldHideGroup(group:GroupDescriptor) {
|
||||
// Hide if the group is empty
|
||||
if (group.members.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Hide group if all fields are hidden
|
||||
if (this.hideEmptyFields) {
|
||||
return _.every(group.members, (d:FieldDescriptor) => this.shouldHideField(d.field || d.fields![0]));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether we should hide the given field.
|
||||
*/
|
||||
public shouldHideField(field:DisplayField) {
|
||||
let hideEmpty = this.hideEmptyFields;
|
||||
const editField = this.formCtrl.fields[field.name];
|
||||
|
||||
if (editField) {
|
||||
hideEmpty = !editField.hasFocus() && this.hideEmptyFields;
|
||||
}
|
||||
|
||||
const hidden = field.visibility === 'hidden';
|
||||
return this.canHideField(field) && (hideEmpty || hidden);
|
||||
};
|
||||
|
||||
public setFocus() {
|
||||
@@ -107,24 +142,16 @@ export class WorkPackageSingleViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns the work package label
|
||||
*/
|
||||
public get idLabel() {
|
||||
var text;
|
||||
|
||||
if (!(this.workPackage && this.workPackage.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
text = this.workPackage.type.name;
|
||||
if (!this.workPackage.isNew) {
|
||||
text += ' #' + this.workPackage.id;
|
||||
}
|
||||
|
||||
return text;
|
||||
const label = this.I18n.t('js.label_work_package');
|
||||
return `${label} #${this.workPackage.id}`;
|
||||
}
|
||||
|
||||
private init(wp:WorkPackageResourceInterface) {
|
||||
this.workPackage = wp;
|
||||
this.singleViewWp = new this.SingleViewWorkPackage(wp);
|
||||
|
||||
if (this.workPackage.attachments) {
|
||||
this.workPackage.attachments.updateElements();
|
||||
@@ -132,23 +159,105 @@ export class WorkPackageSingleViewController {
|
||||
|
||||
this.setFocus();
|
||||
|
||||
var otherGroup: any = _.find(this.groupedFields, {groupName: 'other'});
|
||||
otherGroup.attributes = [];
|
||||
// Accept the fields you always need to show.
|
||||
this.specialFields = this.getFields(['project', 'status', 'priority']);
|
||||
|
||||
angular.forEach(this.workPackage.schema, (prop, propName) => {
|
||||
if (propName.match(/^customField/)) {
|
||||
otherGroup.attributes.push(propName);
|
||||
}
|
||||
// Get attribute groups if they are available (in project context)
|
||||
const attributeGroups = this.workPackage.schema._attributeGroups;
|
||||
|
||||
if (!attributeGroups) {
|
||||
this.groupedFields = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.groupedFields = attributeGroups.map((groups:any[]) => {
|
||||
return {
|
||||
name: groups[0],
|
||||
members: this.getFields(groups[1])
|
||||
};
|
||||
});
|
||||
|
||||
otherGroup.attributes.sort((leftField:any, rightField:any) => {
|
||||
var getLabel = (field:any) => this.singleViewWp.getLabel(field);
|
||||
var left = getLabel(leftField).toLowerCase();
|
||||
var right = getLabel(rightField).toLowerCase();
|
||||
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
}
|
||||
|
||||
private setupI18nTexts() {
|
||||
this.text = {
|
||||
dropFiles: I18n.t('js.label_drop_files'),
|
||||
dropFilesHint: I18n.t('js.label_drop_files_hint'),
|
||||
fields: {
|
||||
description: I18n.t('js.work_packages.properties.description'),
|
||||
},
|
||||
date: {
|
||||
startDate: I18n.t('js.label_no_start_date'),
|
||||
dueDate: I18n.t('js.label_no_due_date')
|
||||
},
|
||||
infoRow: {
|
||||
createdBy: I18n.t('js.label_created_by'),
|
||||
lastUpdatedOn: I18n.t('js.label_last_updated_on')
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the grouped fields into their display fields.
|
||||
* May return multiple fields (for the date virtual field).
|
||||
*/
|
||||
private getFields(fieldNames:string[]):FieldDescriptor[] {
|
||||
const descriptors:FieldDescriptor[] = [];
|
||||
|
||||
fieldNames.forEach((fieldName:string) => {
|
||||
if (fieldName === 'date') {
|
||||
descriptors.push(this.getDateField());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.workPackage.schema[fieldName]) {
|
||||
debugLog('Unknown field for current schema', fieldName);
|
||||
return;
|
||||
}
|
||||
|
||||
const field:DisplayField = this.displayField(fieldName);
|
||||
descriptors.push({
|
||||
name: fieldName,
|
||||
label: field.label,
|
||||
multiple: false,
|
||||
field: field
|
||||
});
|
||||
});
|
||||
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
/**
|
||||
* We need to discern between milestones, which have a single
|
||||
* 'date' field vs. all other types which should display a
|
||||
* combined 'start' and 'due' date field.
|
||||
*/
|
||||
private getDateField():FieldDescriptor {
|
||||
let object:any = {
|
||||
name: 'date',
|
||||
label: this.I18n.t('js.work_packages.properties.date'),
|
||||
multiple: false
|
||||
};
|
||||
|
||||
if (this.workPackage.isMilestone) {
|
||||
object.field = this.displayField('date');
|
||||
} else {
|
||||
object.fields = [this.displayField('startDate'), this.displayField('dueDate')];
|
||||
object.multiple = true;
|
||||
}
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
|
||||
private displayField(name:string):DisplayField {
|
||||
return this.wpDisplayField.getField(
|
||||
this.workPackage,
|
||||
name,
|
||||
this.workPackage.schema[name]
|
||||
) as DisplayField;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function wpSingleViewDirective() {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<div ng-if="$ctrl.workPackage">
|
||||
|
||||
<div ng-if="$ctrl.workPackage" class="work-packages--subject-type-row">
|
||||
<div class="work-packages--type-selector work-packages--subject-element"
|
||||
wp-edit-field="'type'"
|
||||
wp-edit-field-wrapper-classes="'-no-label'">
|
||||
</div>
|
||||
<div
|
||||
wp-edit-field="'subject'"
|
||||
wp-edit-field-wrapper-classes="'-no-label'"
|
||||
class="work-packages--details--subject">
|
||||
class="work-packages--details--subject work-packages--subject-element">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
ng-change="vm.handleUserSubmit()"
|
||||
ng-required="vm.field.required"
|
||||
ng-focus="vm.handleUserFocus()"
|
||||
ng-blur="vm.handleUserBlur()"
|
||||
ng-disabled="vm.workPackage.inFlight"
|
||||
focus="vm.shouldFocus()"
|
||||
focus-priority="vm.shouldFocus()"
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
ng-model="vm.workPackage[vm.fieldName]"
|
||||
ng-required="vm.field.required"
|
||||
ng-focus="vm.handleUserFocus()"
|
||||
ng-blur="vm.handleUserBlur()"
|
||||
ng-disabled="vm.workPackage.inFlight"
|
||||
ng-keydown="vm.handleUserSubmitOnEnter($event)"
|
||||
focus="vm.shouldFocus()"
|
||||
|
||||
@@ -48,10 +48,18 @@ export class Field {
|
||||
return !!this.schema.required;
|
||||
}
|
||||
|
||||
public get writable():boolean {
|
||||
return !!this.schema.writable;
|
||||
}
|
||||
|
||||
public get visibility():string {
|
||||
return this.schema.visibility as string;
|
||||
}
|
||||
|
||||
public get hasDefault():boolean {
|
||||
return this.schema.hasDefault;
|
||||
}
|
||||
|
||||
public get hidden():boolean {
|
||||
return this.visibility === 'hidden';
|
||||
}
|
||||
|
||||
@@ -185,6 +185,7 @@ declare namespace op {
|
||||
allowedValues:any;
|
||||
required?:boolean;
|
||||
visibility?:string;
|
||||
hasDefault:boolean;
|
||||
name?:string;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,11 +49,4 @@ angular.module('openproject.workPackages.helpers')
|
||||
])
|
||||
.factory('WorkPackagesHelper', ['TimezoneService', 'currencyFilter',
|
||||
'CustomFieldHelper', require('./work-packages-helper')
|
||||
])
|
||||
.factory('WorkPackagesDisplayHelper', [
|
||||
'WorkPackageFieldService',
|
||||
'$window',
|
||||
'$timeout',
|
||||
require(
|
||||
'./work-package-display-helper')
|
||||
]);
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
//-- 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.exports = function(WorkPackageFieldService, $window, $timeout) {
|
||||
|
||||
// specifies unhideable (during creation)
|
||||
var unhideableFields = [
|
||||
'subject',
|
||||
'description'
|
||||
];
|
||||
var firstTimeFocused = false;
|
||||
var isGroupHideable = function (groupedFields, groupName, workPackage, cb) {
|
||||
if (!workPackage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (groupName === 'details') {
|
||||
return false; // never hide details to keep show all button arround
|
||||
}
|
||||
|
||||
var group = _.find(groupedFields, {groupName: groupName});
|
||||
var isHideable = typeof cb === 'undefined' ? isFieldHideable : cb;
|
||||
return group.attributes.length === 0 || _.every(group.attributes, function(field) {
|
||||
return isHideable(workPackage, field);
|
||||
});
|
||||
},
|
||||
isGroupEmpty = function (groupedFields, groupName) {
|
||||
var group = _.find(groupedFields, {groupName: groupName});
|
||||
|
||||
return group.attributes.length === 0;
|
||||
},
|
||||
shouldHideGroup = function(hideEmptyActive, groupedFields, groupName, workPackage, cb) {
|
||||
return hideEmptyActive && isGroupHideable(groupedFields, groupName, workPackage, cb) ||
|
||||
!hideEmptyActive && isGroupEmpty(groupedFields, groupName);
|
||||
},
|
||||
isFieldHideable = function (workPackage, field) {
|
||||
if (!workPackage) {
|
||||
return true;
|
||||
}
|
||||
return WorkPackageFieldService.isHideable(workPackage, field);
|
||||
},
|
||||
shouldHideField = function(workPackage, field, hideEmptyFields) {
|
||||
var hidden = WorkPackageFieldService.getVisibility(workPackage, field) === 'hidden';
|
||||
|
||||
return isFieldHideable(workPackage, field) && (hideEmptyFields || hidden);
|
||||
},
|
||||
isSpecified = function (workPackage, field) {
|
||||
if (!workPackage) {
|
||||
return false;
|
||||
}
|
||||
return WorkPackageFieldService.isSpecified(workPackage, field);
|
||||
},
|
||||
isEditable = function(workPackage, field) {
|
||||
return WorkPackageFieldService.isEditable(workPackage, field);
|
||||
},
|
||||
hasNiceStar = function (workPackage, field) {
|
||||
if (!workPackage) {
|
||||
return false;
|
||||
}
|
||||
return WorkPackageFieldService.isRequired(workPackage, field) &&
|
||||
WorkPackageFieldService.isEditable(workPackage, field);
|
||||
},
|
||||
getLabel = function (workPackage, field) {
|
||||
if (!(workPackage && typeof field === 'string')) {
|
||||
return '';
|
||||
}
|
||||
return WorkPackageFieldService.getLabel(workPackage, field);
|
||||
},
|
||||
setFocus = function() {
|
||||
if (!firstTimeFocused) {
|
||||
firstTimeFocused = true;
|
||||
$timeout(function() {
|
||||
// TODO: figure out a better way to fix the wp table columns bug
|
||||
// where arrows are misplaced when not resizing the window
|
||||
angular.element($window).trigger('resize');
|
||||
angular.element('.work-packages--details--subject .focus-input').focus();
|
||||
});
|
||||
}
|
||||
},
|
||||
showToggleButton = function () {
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
isGroupHideable: isGroupHideable,
|
||||
isGroupEmpty: isGroupEmpty,
|
||||
shouldHideGroup: shouldHideGroup,
|
||||
isFieldHideable: isFieldHideable,
|
||||
shouldHideField: shouldHideField,
|
||||
isSpecified: isSpecified,
|
||||
isEditable: isEditable,
|
||||
hasNiceStar: hasNiceStar,
|
||||
getLabel: getLabel,
|
||||
setFocus: setFocus,
|
||||
showToggleButton: showToggleButton
|
||||
};
|
||||
};
|
||||
@@ -31,5 +31,4 @@ require('./controllers');
|
||||
require('./directives');
|
||||
require('./helpers');
|
||||
require('./models');
|
||||
require('./services');
|
||||
require('./tabs');
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
//-- 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.
|
||||
//++
|
||||
|
||||
angular.module('openproject.workPackages.services')
|
||||
.constant('WORK_PACKAGE_ATTRIBUTES', [
|
||||
{
|
||||
groupName: 'details',
|
||||
attributes: [
|
||||
'project',
|
||||
'type',
|
||||
'status',
|
||||
'percentageDone',
|
||||
{ label: 'date',
|
||||
fields: ['startDate', 'dueDate'] },
|
||||
'date',
|
||||
'priority',
|
||||
'version',
|
||||
'category']
|
||||
},
|
||||
{
|
||||
groupName: 'people',
|
||||
attributes: ['author', 'assignee', 'responsible']
|
||||
},
|
||||
{
|
||||
groupName: 'estimatesAndTime',
|
||||
attributes: ['estimatedTime', 'spentTime']
|
||||
},
|
||||
{
|
||||
groupName: 'other',
|
||||
attributes: []
|
||||
}
|
||||
])
|
||||
.factory('WorkPackageFieldConfigurationService', [
|
||||
'VersionService',
|
||||
require('./work-package-field-configuration-service')
|
||||
])
|
||||
.service('WorkPackagesOverviewService', [
|
||||
'WORK_PACKAGE_ATTRIBUTES',
|
||||
require('./work-packages-overview-service')
|
||||
]);
|
||||
@@ -1,62 +0,0 @@
|
||||
//-- 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.exports = function(VersionService) {
|
||||
function getDropdownSortingStrategy(field) {
|
||||
var sorting;
|
||||
|
||||
switch(field) {
|
||||
case 'version':
|
||||
sorting = function(option) {
|
||||
var definingProject = VersionService.getDefininingProject(option) || '';
|
||||
|
||||
// This is a hack to work around limited lodash multi-attribute
|
||||
// sorting and works fine for string-based sorting in our case.
|
||||
// TODO Possibly refactor when v3 hits
|
||||
return definingProject + '_' + option.name.toLowerCase();
|
||||
};
|
||||
break;
|
||||
default:
|
||||
sorting = null;
|
||||
}
|
||||
return sorting;
|
||||
}
|
||||
|
||||
function getDropDownOptionGroup(field, option) {
|
||||
switch(field) {
|
||||
case 'version':
|
||||
return VersionService.getDefininingProject(option);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getDropdownSortingStrategy: getDropdownSortingStrategy,
|
||||
getDropDownOptionGroup: getDropDownOptionGroup
|
||||
};
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
//-- 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.exports = function(WORK_PACKAGE_ATTRIBUTES) {
|
||||
|
||||
var workPackageDetailsAttributes = angular.copy(WORK_PACKAGE_ATTRIBUTES);
|
||||
|
||||
var WorkPackagesOverviewService = {
|
||||
getGroupedWorkPackageOverviewAttributes: function() {
|
||||
return angular.copy(workPackageDetailsAttributes);
|
||||
},
|
||||
getGroup: function(groupName, groupedAttributes) {
|
||||
for (var x=0; x < groupedAttributes.length; x++) {
|
||||
if (groupedAttributes[x].groupName === groupName) {
|
||||
return groupedAttributes[x];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
getGroupAttributesForGroupedAttributes: function(groupName, groupedAttributes) {
|
||||
var group = this.getGroup(groupName, groupedAttributes);
|
||||
|
||||
return (group ? group.attributes : null);
|
||||
},
|
||||
getGroupAttributes: function(groupName) {
|
||||
return this.getGroupAttributesForGroupedAttributes(groupName, workPackageDetailsAttributes);
|
||||
},
|
||||
addAttributesToGroup: function(groupName, attributes) {
|
||||
var groupAttributes = this.getGroupAttributes(groupName);
|
||||
|
||||
if (groupAttributes) {
|
||||
angular.forEach(attributes, function(attribute) {
|
||||
this.push(attribute);
|
||||
}, groupAttributes);
|
||||
}
|
||||
},
|
||||
addAttributeToGroup: function(groupName, attribute, position) {
|
||||
var attributes = this.getGroupAttributes(groupName);
|
||||
|
||||
if (attributes) {
|
||||
if (position) {
|
||||
attributes.splice(position, 0, attribute);
|
||||
} else {
|
||||
attributes.push(attribute);
|
||||
}
|
||||
}
|
||||
},
|
||||
addGroup: function(groupName, position) {
|
||||
var group = this.getGroup(groupName, workPackageDetailsAttributes);
|
||||
|
||||
if (!group) {
|
||||
group = { groupName: groupName, attributes: [] };
|
||||
|
||||
if (position) {
|
||||
workPackageDetailsAttributes.splice(position, 0, group);
|
||||
} else {
|
||||
workPackageDetailsAttributes.push(group);
|
||||
}
|
||||
}
|
||||
},
|
||||
removeAttribute: function(attribute) {
|
||||
for (var x=0; x < workPackageDetailsAttributes.length; x++) {
|
||||
var group = workPackageDetailsAttributes[x];
|
||||
var index = group.attributes.indexOf(attribute);
|
||||
|
||||
if (index >= 0) {
|
||||
group.attributes.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return WorkPackagesOverviewService;
|
||||
};
|
||||
Generated
+50
@@ -219,6 +219,11 @@
|
||||
"from": "angular-ui-router@>=0.3.1 <0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/angular-ui-router/-/angular-ui-router-0.3.2.tgz"
|
||||
},
|
||||
"animation-frame-polyfill": {
|
||||
"version": "1.0.1",
|
||||
"from": "animation-frame-polyfill@>=1.0.0 <2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/animation-frame-polyfill/-/animation-frame-polyfill-1.0.1.tgz"
|
||||
},
|
||||
"ansi-regex": {
|
||||
"version": "2.1.1",
|
||||
"from": "ansi-regex@>=2.0.0 <3.0.0",
|
||||
@@ -249,6 +254,11 @@
|
||||
"from": "arr-flatten@>=1.0.1 <2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.0.1.tgz"
|
||||
},
|
||||
"array-from": {
|
||||
"version": "2.1.1",
|
||||
"from": "array-from@>=2.1.1 <3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/array-from/-/array-from-2.1.1.tgz"
|
||||
},
|
||||
"array-slice": {
|
||||
"version": "0.2.3",
|
||||
"from": "array-slice@>=0.2.3 <0.3.0",
|
||||
@@ -651,6 +661,11 @@
|
||||
"from": "create-hmac@>=1.1.0 <2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.4.tgz"
|
||||
},
|
||||
"create-point-cb": {
|
||||
"version": "1.2.0",
|
||||
"from": "create-point-cb@>=1.0.0 <2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/create-point-cb/-/create-point-cb-1.2.0.tgz"
|
||||
},
|
||||
"crossvent": {
|
||||
"version": "1.5.5",
|
||||
"from": "crossvent@>=1.5.4 <2.0.0",
|
||||
@@ -723,11 +738,31 @@
|
||||
"from": "diffie-hellman@>=5.0.0 <6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.2.tgz"
|
||||
},
|
||||
"dom-autoscroller": {
|
||||
"version": "2.2.8",
|
||||
"from": "dom-autoscroller@latest",
|
||||
"resolved": "https://registry.npmjs.org/dom-autoscroller/-/dom-autoscroller-2.2.8.tgz"
|
||||
},
|
||||
"dom-mousemove-dispatcher": {
|
||||
"version": "1.0.1",
|
||||
"from": "dom-mousemove-dispatcher@>=1.0.1 <2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-mousemove-dispatcher/-/dom-mousemove-dispatcher-1.0.1.tgz"
|
||||
},
|
||||
"dom-plane": {
|
||||
"version": "1.0.2",
|
||||
"from": "dom-plane@>=1.0.1 <2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-plane/-/dom-plane-1.0.2.tgz"
|
||||
},
|
||||
"dom-serialize": {
|
||||
"version": "2.2.1",
|
||||
"from": "dom-serialize@>=2.2.0 <3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz"
|
||||
},
|
||||
"dom-set": {
|
||||
"version": "1.1.0",
|
||||
"from": "dom-set@>=1.0.1 <2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-set/-/dom-set-1.1.0.tgz"
|
||||
},
|
||||
"domain-browser": {
|
||||
"version": "1.1.7",
|
||||
"from": "domain-browser@>=1.1.1 <2.0.0",
|
||||
@@ -1875,6 +1910,11 @@
|
||||
"from": "invert-kv@>=1.0.0 <2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz"
|
||||
},
|
||||
"is-array": {
|
||||
"version": "1.0.1",
|
||||
"from": "is-array@>=1.0.1 <2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-array/-/is-array-1.0.1.tgz"
|
||||
},
|
||||
"is-arrayish": {
|
||||
"version": "0.2.1",
|
||||
"from": "is-arrayish@>=0.2.1 <0.3.0",
|
||||
@@ -1955,6 +1995,11 @@
|
||||
"from": "isbinaryfile@>=3.0.0 <4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-3.0.2.tgz"
|
||||
},
|
||||
"iselement": {
|
||||
"version": "1.1.4",
|
||||
"from": "iselement@>=1.1.4 <2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/iselement/-/iselement-1.1.4.tgz"
|
||||
},
|
||||
"isobject": {
|
||||
"version": "2.1.0",
|
||||
"from": "isobject@>=2.0.0 <3.0.0",
|
||||
@@ -2911,6 +2956,11 @@
|
||||
"from": "tty-browserify@0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz"
|
||||
},
|
||||
"type-func": {
|
||||
"version": "1.0.3",
|
||||
"from": "type-func@>=1.0.1 <2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/type-func/-/type-func-1.0.3.tgz"
|
||||
},
|
||||
"type-is": {
|
||||
"version": "1.6.14",
|
||||
"from": "type-is@>=1.6.14 <1.7.0",
|
||||
|
||||
@@ -66,15 +66,16 @@
|
||||
"awesome-typescript-loader": "^2.2.4",
|
||||
"bourbon": "~4.2.1",
|
||||
"bundle-loader": "^0.5.4",
|
||||
"clean-webpack-plugin": "^0.1.15",
|
||||
"contra": "^1.9.4",
|
||||
"crossvent": "^1.5.4",
|
||||
"css-loader": "^0.9.0",
|
||||
"custom-event": "^1.0.0",
|
||||
"dom-autoscroller": "^2.2.8",
|
||||
"dragula": "^3.5.2",
|
||||
"exports-loader": "^0.6.2",
|
||||
"expose-loader": "^0.6.0",
|
||||
"extract-text-webpack-plugin": "^2.0.0-rc.2",
|
||||
"clean-webpack-plugin": "^0.1.15",
|
||||
"file-loader": "^0.8.1",
|
||||
"foundation-apps": "1.1.0",
|
||||
"glob": "^4.5.3",
|
||||
|
||||
-130
@@ -1,130 +0,0 @@
|
||||
//-- 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.
|
||||
//++
|
||||
|
||||
/*jshint expr: true*/
|
||||
|
||||
describe('WorkPackagesOverviewService', function() {
|
||||
|
||||
var Service;
|
||||
|
||||
beforeEach(angular.mock.module('openproject.services'));
|
||||
|
||||
beforeEach(inject(function(_WorkPackagesOverviewService_){
|
||||
Service = _WorkPackagesOverviewService_;
|
||||
}));
|
||||
|
||||
describe('getGroupAttributes', function() {
|
||||
var groupName = 'details';
|
||||
var attributes = ['status', 'percentageDone', 'date', 'priority', 'versionName', 'category'];
|
||||
|
||||
it('has details', function() {
|
||||
var attributes = Service.getGroupAttributes(groupName);
|
||||
|
||||
expect(attributes).not.to.be.null;
|
||||
expect(attributes).to.have.length(attributes.length);
|
||||
expect(attributes).to.include.members(attributes);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAttributesToGroup', function() {
|
||||
var groupName = 'other';
|
||||
var attributes = ['me', 'myself', 'I'];
|
||||
|
||||
it('adds attributes to group', function() {
|
||||
Service.addAttributesToGroup(groupName, attributes);
|
||||
var attributes = Service.getGroupAttributes(groupName);
|
||||
|
||||
expect(attributes).not.to.be.null;
|
||||
expect(attributes).to.include.members(attributes);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAttributeToGroup', function() {
|
||||
var groupName = 'people';
|
||||
var attribute = 'me';
|
||||
var position = 1;
|
||||
|
||||
it('adds attribute to specific position in group', function() {
|
||||
Service.addAttributeToGroup(groupName, attribute, position);
|
||||
var attributes = Service.getGroupAttributes(groupName);
|
||||
|
||||
expect(attributes).not.to.be.null;
|
||||
expect(attributes).to.have.length(4);
|
||||
expect(attributes).to.include.members(['author', 'assignee', 'responsible', attribute]);
|
||||
expect(attributes[1]).to.equal(attribute);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addGroup', function() {
|
||||
var groupName = 'myPluginGroup';
|
||||
|
||||
describe('w/o position', function() {
|
||||
it('adds group to the end', function() {
|
||||
Service.addGroup(groupName);
|
||||
var groupedAttributs = Service.getGroupedWorkPackageOverviewAttributes();
|
||||
var lastElementIndex = groupedAttributs.length - 1;
|
||||
|
||||
expect(groupedAttributs[lastElementIndex].groupName).to.equal(groupName);
|
||||
expect(groupedAttributs[lastElementIndex].attributes).to.be.empty;
|
||||
});
|
||||
});
|
||||
|
||||
describe('with position', function() {
|
||||
var position = 2;
|
||||
|
||||
it('adds group to the specified position', function() {
|
||||
Service.addGroup(groupName, position);
|
||||
var groupedAttributs = Service.getGroupedWorkPackageOverviewAttributes();
|
||||
|
||||
expect(groupedAttributs.length).to.equal(5);
|
||||
expect(groupedAttributs[position].groupName).to.equal(groupName);
|
||||
expect(groupedAttributs[position].attributes).to.be.empty;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAttribute', function() {
|
||||
var groupName = 'estimatesAndTime';
|
||||
var attribute = 'spentTime';
|
||||
|
||||
it('group contains attribute', function() {
|
||||
var attributes = Service.getGroupAttributes(groupName);
|
||||
|
||||
expect(attributes.indexOf(attribute)).to.be.above(-1);
|
||||
});
|
||||
|
||||
it('removes attribute from group', function() {
|
||||
Service.removeAttribute(attribute);
|
||||
var attributes = Service.getGroupAttributes(groupName);
|
||||
|
||||
expect(attributes).not.to.be.null;
|
||||
expect(attributes).to.have.length(1);
|
||||
expect(attributes).to.include.members(['estimatedTime']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ module API
|
||||
has_default: false,
|
||||
writable: true,
|
||||
visibility: nil,
|
||||
attribute_group: nil,
|
||||
current_user: nil)
|
||||
@value_representer = value_representer
|
||||
@link_factory = link_factory
|
||||
@@ -54,6 +55,7 @@ module API
|
||||
has_default: has_default,
|
||||
writable: writable,
|
||||
visibility: visibility,
|
||||
attribute_group: attribute_group,
|
||||
current_user: current_user)
|
||||
end
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ module API
|
||||
class PropertySchemaRepresenter < ::API::Decorators::Single
|
||||
def initialize(
|
||||
type:, name:, required: true, has_default: false, writable: true,
|
||||
visibility: nil, current_user: nil
|
||||
visibility: nil, attribute_group: nil, current_user: nil
|
||||
)
|
||||
@type = type
|
||||
@name = name
|
||||
@@ -47,6 +47,7 @@ module API
|
||||
else
|
||||
visibility || 'default'
|
||||
end
|
||||
@attribute_group = attribute_group
|
||||
|
||||
super(nil, current_user: current_user)
|
||||
end
|
||||
@@ -57,6 +58,7 @@ module API
|
||||
:has_default,
|
||||
:writable,
|
||||
:visibility,
|
||||
:attribute_group,
|
||||
:min_length,
|
||||
:max_length,
|
||||
:regular_expression
|
||||
@@ -67,6 +69,7 @@ module API
|
||||
property :has_default, exec_context: :decorator
|
||||
property :writable, exec_context: :decorator
|
||||
property :visibility, exec_context: :decorator
|
||||
property :attribute_group, exec_context: :decorator
|
||||
property :min_length, exec_context: :decorator
|
||||
property :max_length, exec_context: :decorator
|
||||
property :regular_expression, exec_context: :decorator
|
||||
|
||||
@@ -58,6 +58,7 @@ module API
|
||||
has_default: false,
|
||||
writable: default_writable_property(property),
|
||||
visibility: nil,
|
||||
attribute_group: nil,
|
||||
min_length: nil,
|
||||
max_length: nil,
|
||||
regular_expression: nil,
|
||||
@@ -69,6 +70,7 @@ module API
|
||||
has_default,
|
||||
writable,
|
||||
visibility,
|
||||
attribute_group,
|
||||
min_length,
|
||||
max_length,
|
||||
regular_expression)
|
||||
@@ -90,6 +92,7 @@ module API
|
||||
has_default: false,
|
||||
writable: default_writable_property(property),
|
||||
visibility: nil,
|
||||
attribute_group: nil,
|
||||
show_if: true)
|
||||
getter = ->(*) do
|
||||
schema_with_allowed_link_property_getter(type,
|
||||
@@ -98,6 +101,7 @@ module API
|
||||
has_default,
|
||||
writable,
|
||||
visibility,
|
||||
attribute_group,
|
||||
href_callback)
|
||||
end
|
||||
|
||||
@@ -121,6 +125,7 @@ module API
|
||||
has_default: false,
|
||||
writable: default_writable_property(property),
|
||||
visibility: nil,
|
||||
attribute_group: nil,
|
||||
show_if: true)
|
||||
|
||||
getter = ->(*) do
|
||||
@@ -133,6 +138,7 @@ module API
|
||||
has_default,
|
||||
writable,
|
||||
visibility,
|
||||
attribute_group,
|
||||
values_callback)
|
||||
end
|
||||
|
||||
@@ -229,6 +235,7 @@ module API
|
||||
has_default,
|
||||
writable,
|
||||
visibility,
|
||||
attribute_group,
|
||||
min_length,
|
||||
max_length,
|
||||
regular_expression)
|
||||
@@ -239,7 +246,8 @@ module API
|
||||
required: call_or_use(required),
|
||||
has_default: call_or_use(has_default),
|
||||
writable: call_or_use(writable),
|
||||
visibility: call_or_use(visibility))
|
||||
visibility: call_or_use(visibility),
|
||||
attribute_group: call_or_use(attribute_group))
|
||||
schema.min_length = min_length
|
||||
schema.max_length = max_length
|
||||
schema.regular_expression = regular_expression
|
||||
@@ -253,6 +261,7 @@ module API
|
||||
has_default,
|
||||
writable,
|
||||
visibility,
|
||||
attribute_group,
|
||||
href_callback)
|
||||
representer = ::API::Decorators::AllowedValuesByLinkRepresenter
|
||||
.new(type: call_or_use(type),
|
||||
@@ -260,7 +269,8 @@ module API
|
||||
required: call_or_use(required),
|
||||
has_default: call_or_use(has_default),
|
||||
writable: call_or_use(writable),
|
||||
visibility: call_or_use(visibility))
|
||||
visibility: call_or_use(visibility),
|
||||
attribute_group: call_or_use(attribute_group))
|
||||
|
||||
if form_embedded
|
||||
representer.allowed_values_href = instance_eval(&href_callback)
|
||||
@@ -278,6 +288,7 @@ module API
|
||||
has_default,
|
||||
writable,
|
||||
visibility,
|
||||
attribute_group,
|
||||
values_callback)
|
||||
representer = ::API::Decorators::AllowedValuesByCollectionRepresenter
|
||||
.new(type: call_or_use(type),
|
||||
@@ -288,7 +299,8 @@ module API
|
||||
required: call_or_use(required),
|
||||
has_default: call_or_use(has_default),
|
||||
writable: call_or_use(writable),
|
||||
visibility: call_or_use(visibility))
|
||||
visibility: call_or_use(visibility),
|
||||
attribute_group: call_or_use(attribute_group))
|
||||
|
||||
if form_embedded
|
||||
representer.allowed_values = instance_exec(&values_callback)
|
||||
|
||||
@@ -94,8 +94,48 @@ module API
|
||||
false
|
||||
end
|
||||
|
||||
##
|
||||
# Return of a map of attribute => group name
|
||||
def attribute_group_map(key)
|
||||
return nil if type.nil?
|
||||
@attribute_group_map ||= begin
|
||||
mapping = {}
|
||||
attribute_groups.each do |group, attributes|
|
||||
attributes.each { |prop| mapping[prop] = group }
|
||||
end
|
||||
|
||||
mapping
|
||||
end
|
||||
|
||||
@attribute_group_map[key]
|
||||
end
|
||||
|
||||
def attribute_groups
|
||||
return nil if type.nil?
|
||||
|
||||
@attribute_groups ||= begin
|
||||
type.attribute_groups.map do |group|
|
||||
group[1].map! do |prop|
|
||||
if type.passes_attribute_constraint?(prop, project: project)
|
||||
convert_property(prop)
|
||||
end
|
||||
end
|
||||
|
||||
group[1].compact!
|
||||
group
|
||||
end
|
||||
end
|
||||
|
||||
@attribute_groups
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def convert_property(prop)
|
||||
::API::Utilities::PropertyNameConverter.from_ar_name(prop)
|
||||
end
|
||||
|
||||
|
||||
def percentage_done_writable?
|
||||
Setting.work_package_done_ratio == 'field'
|
||||
end
|
||||
|
||||
@@ -60,12 +60,20 @@ module API
|
||||
end
|
||||
end
|
||||
|
||||
def attribute_group(property)
|
||||
lambda do
|
||||
key = property.to_s.gsub /^customField/, "custom_field_"
|
||||
represented.attribute_group_map key
|
||||
end
|
||||
end
|
||||
|
||||
# override the various schema methods to include
|
||||
# the same visibility lambda for all properties by default
|
||||
|
||||
def schema(property, *args)
|
||||
opts, _ = args
|
||||
opts[:visibility] = visibility property
|
||||
opts[:attribute_group] = attribute_group property
|
||||
|
||||
super property, **opts
|
||||
end
|
||||
@@ -73,6 +81,7 @@ module API
|
||||
def schema_with_allowed_link(property, *args)
|
||||
opts, _ = args
|
||||
opts[:visibility] = visibility property
|
||||
opts[:attribute_group] = attribute_group property
|
||||
|
||||
super property, **opts
|
||||
end
|
||||
@@ -80,6 +89,7 @@ module API
|
||||
def schema_with_allowed_collection(property, *args)
|
||||
opts, _ = args
|
||||
opts[:visibility] = visibility property
|
||||
opts[:attribute_group] = attribute_group property
|
||||
|
||||
super property, **opts
|
||||
end
|
||||
@@ -107,6 +117,10 @@ module API
|
||||
{ href: @base_schema_link } if @base_schema_link
|
||||
end
|
||||
|
||||
property :attribute_groups,
|
||||
type: "[]String",
|
||||
as: "_attributeGroups"
|
||||
|
||||
schema :lock_version,
|
||||
type: 'Integer',
|
||||
name_source: -> (*) { I18n.t('api_v3.attributes.lock_version') },
|
||||
|
||||
@@ -61,31 +61,6 @@ describe CustomFieldsController, type: :controller do
|
||||
it { expect(custom_field.name(:en)).to eq(en_name) }
|
||||
end
|
||||
|
||||
describe "activating it in a type" do
|
||||
let(:project) { FactoryGirl.create :project }
|
||||
let(:type) { FactoryGirl.create :type }
|
||||
let(:custom_field) { FactoryGirl.create :wp_custom_field }
|
||||
|
||||
let(:params) do
|
||||
{
|
||||
"custom_field" => {
|
||||
"type_ids" => [type.id]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
expect(type.attribute_visibility.keys).not_to include "custom_field_#{custom_field.id}"
|
||||
|
||||
put :update, params: params
|
||||
end
|
||||
|
||||
it "should update the type's attribute visibility map" do
|
||||
expect(type.reload.attribute_visibility["custom_field_#{custom_field.id}"])
|
||||
.to eq "default"
|
||||
end
|
||||
end
|
||||
|
||||
describe 'WITH one empty name params' do
|
||||
let(:en_name) { 'Issue Field' }
|
||||
let(:de_name) { '' }
|
||||
|
||||
@@ -139,7 +139,10 @@ describe TypesController, type: :controller do
|
||||
end
|
||||
|
||||
it { expect(response).to be_redirect }
|
||||
it { expect(response).to redirect_to(types_path) }
|
||||
it do
|
||||
type = ::Type.find_by(name: 'New type')
|
||||
expect(response).to redirect_to(action: 'edit', tab: 'settings', id: type.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'WITH an empty name' do
|
||||
@@ -182,7 +185,10 @@ describe TypesController, type: :controller do
|
||||
end
|
||||
|
||||
it { expect(response).to be_redirect }
|
||||
it { expect(response).to redirect_to(types_path) }
|
||||
it do
|
||||
type = ::Type.find_by(name: 'New type')
|
||||
expect(response).to redirect_to(action: 'edit', tab: 'settings', id: type.id)
|
||||
end
|
||||
it 'should have the copied workflows' do
|
||||
expect(::Type.find_by(name: 'New type')
|
||||
.workflows.count).to eq(existing_type.workflows.count)
|
||||
|
||||
@@ -10,22 +10,12 @@ describe 'custom fields', js: true do
|
||||
end
|
||||
|
||||
describe "available fields" do
|
||||
let!(:types) do
|
||||
["Bug", "Feature", "Support"].each_with_index.map do |name, i|
|
||||
FactoryGirl.create :type, name: name, position: i + 1
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
cf_page.visit!
|
||||
click_on "Create a new custom field"
|
||||
end
|
||||
|
||||
it "shows all form elements" do
|
||||
expect(cf_page).to have_type("Bug")
|
||||
expect(cf_page).to have_type("Feature")
|
||||
expect(cf_page).to have_type("Support")
|
||||
|
||||
expect(cf_page).to have_form_element("Name")
|
||||
expect(cf_page).to have_form_element("Required")
|
||||
expect(cf_page).to have_form_element("For all projects")
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#-- 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.
|
||||
#++
|
||||
|
||||
class NgConfirmationDialog
|
||||
include Capybara::DSL
|
||||
include RSpec::Matchers
|
||||
|
||||
def container
|
||||
'.ngdialog-content'
|
||||
end
|
||||
|
||||
def expect_open
|
||||
expect(page).to have_selector(container)
|
||||
end
|
||||
|
||||
def confirm
|
||||
page.find('.confirm-form-submit--continue').click
|
||||
end
|
||||
|
||||
def cancel
|
||||
page.find('.ngdialog-close').click
|
||||
end
|
||||
end
|
||||
@@ -27,108 +27,349 @@
|
||||
#++
|
||||
|
||||
require 'spec_helper'
|
||||
require 'features/projects/project_settings_page'
|
||||
|
||||
describe 'form configuration', type: :feature, js: true do
|
||||
let(:current_user) { FactoryGirl.create :admin }
|
||||
let(:type) { FactoryGirl.create :type, attribute_visibility: attribute_visibility }
|
||||
let(:admin) { FactoryGirl.create :admin }
|
||||
let(:type) { FactoryGirl.create :type }
|
||||
|
||||
let(:attribute_visibility) do
|
||||
{
|
||||
'version' => 'hidden',
|
||||
'status' => 'default',
|
||||
'priority' => 'visible'
|
||||
# assignee => 'default'
|
||||
# not defined attributes shall get default visibility
|
||||
}
|
||||
let(:project) { FactoryGirl.create :project, types: [type] }
|
||||
let(:category) { FactoryGirl.create :category, project: project }
|
||||
let(:work_package) {
|
||||
FactoryGirl.create :work_package,
|
||||
project: project,
|
||||
type: type,
|
||||
done_ratio: 10,
|
||||
category: category
|
||||
}
|
||||
|
||||
let(:wp_page) { Pages::FullWorkPackage.new(work_package) }
|
||||
|
||||
let(:add_button) { page.find '.form-configuration--add-group' }
|
||||
let(:reset_button) { page.find '.form-configuration--reset' }
|
||||
let(:inactive_drop) { page.find '#type-form-conf-inactive-group .attributes' }
|
||||
|
||||
def group_selector(name)
|
||||
".type-form-conf-group[data-key='#{name}']"
|
||||
end
|
||||
|
||||
def selector(attribute, visibility)
|
||||
"input#type_attribute_visibility_#{visibility}_#{attribute}"
|
||||
def checkbox_selector(attribute)
|
||||
".type-form-conf-attribute[data-key='#{attribute}'] .attribute-visibility input"
|
||||
end
|
||||
|
||||
before do
|
||||
allow(User).to receive(:current).and_return current_user
|
||||
|
||||
visit edit_type_tab_path(id: type.id, tab: "form_configuration")
|
||||
def attribute_selector(attribute)
|
||||
".type-form-conf-attribute[data-key='#{attribute}']"
|
||||
end
|
||||
|
||||
shared_examples 'attribute visibility' do
|
||||
let(:attribute) { 'status' }
|
||||
let(:visibility) { 'default' }
|
||||
def find_group_handle(label)
|
||||
page.find("#{group_selector(label)} .group-handle")
|
||||
end
|
||||
|
||||
it 'is displayed correctly' do
|
||||
if visibility == 'hidden'
|
||||
all(selector(attribute, 'default')).each { |cb| expect(cb).not_to be_checked }
|
||||
all(selector(attribute, 'visible')).each { |cb| expect(cb).not_to be_checked }
|
||||
elsif visibility == 'default'
|
||||
all(selector(attribute, 'default')).each { |cb| expect(cb).to be_checked }
|
||||
all(selector(attribute, 'visible')).each { |cb| expect(cb).not_to be_checked }
|
||||
elsif visibility == 'visible'
|
||||
all(selector(attribute, 'default')).each { |cb| expect(cb).to be_checked }
|
||||
all(selector(attribute, 'visible')).each { |cb| expect(cb).to be_checked }
|
||||
def find_attribute_handle(attribute)
|
||||
page.find("#{attribute_selector(attribute)} .attribute-handle")
|
||||
end
|
||||
|
||||
def set_visibility(attribute, checked:)
|
||||
attribute = page.find(attribute_selector(attribute))
|
||||
checkbox = attribute.find('input[type=checkbox]')
|
||||
checkbox.set checked
|
||||
end
|
||||
|
||||
def expect_attribute(key:, checked: nil, translation: nil)
|
||||
attribute = page.find(attribute_selector(key))
|
||||
|
||||
unless translation.nil?
|
||||
expect(attribute).to have_selector('.attribute-name', text: translation)
|
||||
end
|
||||
|
||||
unless checked.nil?
|
||||
checkbox = attribute.find('input[type=checkbox]')
|
||||
expect(checkbox.checked?).to eq(checked)
|
||||
end
|
||||
end
|
||||
|
||||
def move_to(attribute, group_label)
|
||||
handle = find_attribute_handle(attribute)
|
||||
group = find("#{group_selector(group_label)} .attributes")
|
||||
|
||||
handle.drag_to group
|
||||
expect_group(group_label, key: attribute)
|
||||
end
|
||||
|
||||
def add_group(name, expect: true)
|
||||
add_button.click
|
||||
input = find('.group-edit-in-place--input')
|
||||
input.set(name)
|
||||
input.send_keys(:return)
|
||||
|
||||
expect_group(name) if expect
|
||||
end
|
||||
|
||||
def rename_group(from, to)
|
||||
find('.group-edit-handler', text: from.upcase).click
|
||||
|
||||
input = find('.group-edit-in-place--input')
|
||||
input.click
|
||||
input.set(to)
|
||||
input.send_keys(:return)
|
||||
|
||||
expect(page).to have_selector('.group-edit-handler', text: to.upcase)
|
||||
end
|
||||
|
||||
def expect_group(label, *attributes)
|
||||
expect(page).to have_selector("#{group_selector(label)} .group-edit-handler", text: label.upcase)
|
||||
|
||||
within group_selector(label) do
|
||||
attributes.each do |attribute|
|
||||
expect_attribute(attribute)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'before update' do
|
||||
it_behaves_like 'attribute visibility' do
|
||||
let(:attribute) { 'version' }
|
||||
let(:visibility) { 'hidden' }
|
||||
def expect_inactive(attribute)
|
||||
expect(inactive_drop).to have_selector(".type-form-conf-attribute[data-key='#{attribute}']")
|
||||
end
|
||||
|
||||
describe 'default configuration' do
|
||||
let(:dialog) { ::NgConfirmationDialog.new }
|
||||
before do
|
||||
login_as(admin)
|
||||
visit edit_type_tab_path(id: type.id, tab: "form_configuration")
|
||||
end
|
||||
|
||||
it_behaves_like 'attribute visibility' do
|
||||
let(:attribute) { 'status' }
|
||||
let(:visibility) { 'default' }
|
||||
it 'resets the form properly after changes' do
|
||||
rename_group('Details', 'Whatever')
|
||||
set_visibility(:assignee, checked: true)
|
||||
expect_attribute(key: :assignee, checked: true)
|
||||
|
||||
# Reset and cancel
|
||||
reset_button.click
|
||||
dialog.expect_open
|
||||
dialog.cancel
|
||||
expect(page).to have_selector(group_selector('Whatever'))
|
||||
|
||||
# Reset and confirm
|
||||
reset_button.click
|
||||
dialog.expect_open
|
||||
dialog.confirm
|
||||
|
||||
expect(page).to have_no_selector(group_selector('Whatever'))
|
||||
expect_group('Details')
|
||||
expect_attribute(key: :assignee, checked: false)
|
||||
end
|
||||
|
||||
it_behaves_like 'attribute visibility' do
|
||||
let(:attribute) { 'priority' }
|
||||
let(:visibility) { 'visible' }
|
||||
it 'detects errors for duplicate group names' do
|
||||
add_group('New Group')
|
||||
add_group('New Group', expect: false) # would fail since two selectors exist now
|
||||
|
||||
expect(page).to have_selector("#{group_selector('New Group')}.-error", count: 1)
|
||||
end
|
||||
|
||||
it 'allows modification of the form configuration' do
|
||||
#
|
||||
# Test default set of groups
|
||||
#
|
||||
expect_group 'People',
|
||||
{ key: :assignee, checked: false, translation: 'Assignee' },
|
||||
{ key: :responsible, checked: false, translation: 'Responsible' }
|
||||
|
||||
expect_group 'Estimates and time',
|
||||
{ key: :estimated_time, checked: false, translation: 'Estimated time' },
|
||||
{ key: :spent_time, checked: false, translation: 'Spent time' }
|
||||
|
||||
expect_group 'Details',
|
||||
{ key: :category, checked: false, translation: 'Category' },
|
||||
{ key: :date, checked: false, translation: 'Date' },
|
||||
{ key: :percentage_done, checked: false, translation: 'Progress (%)' },
|
||||
{ key: :version, checked: false, translation: 'Version' }
|
||||
|
||||
|
||||
#
|
||||
# Modify configuration
|
||||
#
|
||||
|
||||
# Disable version
|
||||
find_attribute_handle(:version).drag_to inactive_drop
|
||||
expect_inactive(:version)
|
||||
|
||||
# Toggle assignee to be always visible
|
||||
set_visibility(:assignee, checked: true)
|
||||
expect_attribute(key: :assignee, checked: true)
|
||||
|
||||
# Rename group
|
||||
rename_group('Details', 'Whatever')
|
||||
rename_group('People', 'Cool Stuff')
|
||||
|
||||
# Start renaming, but cancel
|
||||
find('.group-edit-handler', text: 'COOL STUFF').click
|
||||
input = find('.group-edit-in-place--input')
|
||||
input.set('FOOBAR')
|
||||
input.send_keys(:escape)
|
||||
expect(page).to have_selector('.group-edit-handler', text: 'COOL STUFF')
|
||||
expect(page).to have_no_selector('.group-edit-handler', text: 'FOOBAR')
|
||||
|
||||
# Create new group
|
||||
add_group('New Group')
|
||||
move_to(:category, 'New Group')
|
||||
|
||||
# Save configuration
|
||||
# click_button doesn't seem to work when the button is out of view!?
|
||||
page.execute_script('jQuery(".form-configuration--save").click()')
|
||||
expect(page).to have_selector('.flash.notice', text: 'Successful update.', wait: 10)
|
||||
|
||||
# Expect configuration to be correct now
|
||||
expect_group 'Cool Stuff',
|
||||
{ key: :assignee, checked: true, translation: 'Assignee' },
|
||||
{ key: :responsible, checked: false, translation: 'Responsible' }
|
||||
|
||||
expect_group 'Estimates and time',
|
||||
{ key: :estimated_time, checked: false, translation: 'Estimated time' },
|
||||
{ key: :spent_time, checked: false, translation: 'Spent time' }
|
||||
|
||||
expect_group 'Whatever',
|
||||
{ key: :date, checked: false, translation: 'Date' },
|
||||
{ key: :percentage_done, checked: false, translation: 'Progress (%)' }
|
||||
|
||||
expect_group 'New Group',
|
||||
{ key: :category, checked: false, translation: 'Category' }
|
||||
|
||||
expect_inactive(:version)
|
||||
|
||||
# Visit work package with that type
|
||||
wp_page.visit!
|
||||
wp_page.ensure_page_loaded
|
||||
|
||||
# Category should be hidden
|
||||
wp_page.expect_hidden_field(:category)
|
||||
|
||||
wp_page.expect_group('New Group') do
|
||||
wp_page.expect_attributes category: category.name
|
||||
end
|
||||
|
||||
wp_page.expect_group('Whatever') do
|
||||
wp_page.expect_attributes percentageDone: '30'
|
||||
end
|
||||
|
||||
wp_page.expect_group('Cool Stuff') do
|
||||
wp_page.expect_attributes assignee: '-'
|
||||
end
|
||||
|
||||
# Empty attributes should be shown on toggle
|
||||
expected_attributes = ->() do
|
||||
wp_page.expect_hidden_field(:responsible)
|
||||
wp_page.expect_hidden_field(:estimated_time)
|
||||
wp_page.expect_hidden_field(:spent_time)
|
||||
wp_page.view_all_attributes
|
||||
|
||||
wp_page.expect_group('Cool Stuff') do
|
||||
wp_page.expect_attributes responsible: '-'
|
||||
end
|
||||
|
||||
wp_page.expect_group('Estimates and time') do
|
||||
wp_page.expect_attributes estimated_time: '-'
|
||||
wp_page.expect_attributes spent_time: '-'
|
||||
end
|
||||
end
|
||||
|
||||
# Should match on edit view
|
||||
expected_attributes.call
|
||||
|
||||
# New work package has the same configuration
|
||||
wp_page.click_create_wp_button(type)
|
||||
expected_attributes.call
|
||||
|
||||
find('#work-packages--edit-actions-cancel').click
|
||||
expect(wp_page).not_to have_alert_dialog
|
||||
loading_indicator_saveguard
|
||||
end
|
||||
end
|
||||
|
||||
describe 'after update' do
|
||||
describe 'custom fields' do
|
||||
let(:project_settings_page) { ProjectSettingsPage.new(project) }
|
||||
|
||||
let(:custom_fields) { [custom_field] }
|
||||
let(:custom_field) { FactoryGirl.create(:integer_issue_custom_field, name: 'MyNumber') }
|
||||
let(:cf_identifier) { "custom_field_#{custom_field.id}" }
|
||||
let(:cf_identifier_api) { "customField#{custom_field.id}" }
|
||||
|
||||
before do
|
||||
# change to visible by checking both checkboxes
|
||||
find(:css, selector('version', 'default')).set(true)
|
||||
find(:css, selector('version', 'visible')).set(true)
|
||||
project
|
||||
custom_field
|
||||
|
||||
# change to hidden by unchecking both checkboxes
|
||||
find(:css, selector('status', 'default')).set(false)
|
||||
find(:css, selector('status', 'visible')).set(false)
|
||||
login_as(admin)
|
||||
visit edit_type_tab_path(id: type.id, tab: "form_configuration")
|
||||
|
||||
# change to default by unchecking last checkbox
|
||||
find(:css, selector('priority', 'visible')).set(false)
|
||||
# Should be initially disabled
|
||||
expect_inactive(cf_identifier)
|
||||
|
||||
click_on 'Save'
|
||||
# Add into new group
|
||||
add_group('New Group')
|
||||
move_to(cf_identifier, 'New Group')
|
||||
|
||||
# Make visible
|
||||
set_visibility(cf_identifier, checked: true)
|
||||
expect_attribute(key: cf_identifier, checked: true)
|
||||
|
||||
page.execute_script('jQuery(".form-configuration--save").click()')
|
||||
expect(page).to have_selector('.flash.notice', text: 'Successful update.', wait: 10)
|
||||
end
|
||||
|
||||
it 'the type visibilities are set correctly' do
|
||||
type.reload
|
||||
context 'inactive in project' do
|
||||
it 'can be added to the type, but is not shown' do
|
||||
# Visit work package with that type
|
||||
wp_page.visit!
|
||||
wp_page.ensure_page_loaded
|
||||
|
||||
expect(type.attribute_visibility['version']).to eq 'visible'
|
||||
expect(type.attribute_visibility['status']).to eq 'hidden'
|
||||
expect(type.attribute_visibility['priority']).to eq 'default'
|
||||
# CF should be hidden
|
||||
wp_page.view_all_attributes
|
||||
wp_page.expect_no_group('New Group')
|
||||
wp_page.expect_attribute_hidden(cf_identifier_api)
|
||||
|
||||
# Enable in project, should then be visible
|
||||
project_settings_page.visit_settings_tab('custom_fields')
|
||||
expect(page).to have_selector(".custom-field-#{custom_field.id} td", text: 'MyNumber')
|
||||
expect(page).to have_selector(".custom-field-#{custom_field.id} td", text: type.name)
|
||||
|
||||
id_checkbox = find("#project_work_package_custom_field_ids_#{custom_field.id}")
|
||||
expect(id_checkbox).to_not be_checked
|
||||
id_checkbox.set(true)
|
||||
|
||||
click_button 'Save'
|
||||
|
||||
# Visit work package with that type
|
||||
wp_page.visit!
|
||||
wp_page.ensure_page_loaded
|
||||
|
||||
# Category should be hidden
|
||||
wp_page.expect_group('New Group') do
|
||||
wp_page.expect_attribute(cf_identifier_api)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'attribute visibility' do
|
||||
let(:attribute) { 'version' }
|
||||
let(:visibility) { 'visible' }
|
||||
end
|
||||
context 'active in project' do
|
||||
let(:project) {
|
||||
FactoryGirl.create :project,
|
||||
types: [type],
|
||||
work_package_custom_fields: custom_fields
|
||||
}
|
||||
|
||||
it_behaves_like 'attribute visibility' do
|
||||
let(:attribute) { 'status' }
|
||||
let(:visibility) { 'hidden' }
|
||||
end
|
||||
it 'can be added to type and is visible' do
|
||||
# Visit work package with that type
|
||||
wp_page.visit!
|
||||
wp_page.ensure_page_loaded
|
||||
|
||||
it_behaves_like 'attribute visibility' do
|
||||
let(:attribute) { 'priority' }
|
||||
let(:visibility) { 'default' }
|
||||
end
|
||||
# Category should be hidden
|
||||
wp_page.expect_group('New Group') do
|
||||
wp_page.expect_attribute(cf_identifier_api)
|
||||
end
|
||||
|
||||
it_behaves_like 'attribute visibility' do
|
||||
let(:attribute) { 'assignee' }
|
||||
let(:visibility) { 'default' }
|
||||
# Ensure CF is checked
|
||||
project_settings_page.visit_settings_tab('custom_fields')
|
||||
expect(page).to have_selector(".custom-field-#{custom_field.id} td", text: 'MyNumber')
|
||||
expect(page).to have_selector(".custom-field-#{custom_field.id} td", text: type.name)
|
||||
expect(page).to have_selector("#project_work_package_custom_field_ids_#{custom_field.id}[checked]")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -48,8 +48,7 @@ RSpec.feature 'Work package show page', selenium: true do
|
||||
|
||||
wp_page.visit!
|
||||
|
||||
wp_page.expect_attributes Author: work_package.author.name,
|
||||
Type: work_package.type.name,
|
||||
wp_page.expect_attributes Type: work_package.type.name,
|
||||
Status: work_package.status.name,
|
||||
Priority: work_package.priority.name,
|
||||
Assignee: work_package.assigned_to.name,
|
||||
|
||||
@@ -117,8 +117,7 @@ RSpec.feature 'Work package copy', js: true, selenium: true do
|
||||
Version: original_work_package.fixed_version,
|
||||
Priority: original_work_package.priority,
|
||||
Assignee: original_work_package.assigned_to,
|
||||
Responsible: original_work_package.responsible,
|
||||
Author: user
|
||||
Responsible: original_work_package.responsible
|
||||
|
||||
work_package_page.expect_activity user, number: 1
|
||||
work_package_page.expect_current_path
|
||||
@@ -149,8 +148,7 @@ RSpec.feature 'Work package copy', js: true, selenium: true do
|
||||
Version: original_work_package.fixed_version,
|
||||
Priority: original_work_package.priority,
|
||||
Assignee: original_work_package.assigned_to,
|
||||
Responsible: original_work_package.responsible,
|
||||
Author: user
|
||||
Responsible: original_work_package.responsible
|
||||
|
||||
work_package_page.expect_activity user, number: 1
|
||||
work_package_page.expect_current_path
|
||||
|
||||
@@ -21,9 +21,8 @@ describe 'new work package', js: true do
|
||||
|
||||
let(:subject_field) { wp_page.edit_field :subject }
|
||||
let(:description_field) { wp_page.edit_field :description }
|
||||
let(:project_field) { WorkPackageField.new page.find('wp-single-view'), :project }
|
||||
let(:type_field) { WorkPackageField.new page.find('wp-single-view'), :type }
|
||||
|
||||
let(:project_field) { wp_page.edit_field :project }
|
||||
let(:type_field) { wp_page.edit_field :type }
|
||||
let(:notification) { PageObjects::Notifications.new(page) }
|
||||
|
||||
def disable_leaving_unsaved_warning
|
||||
@@ -61,8 +60,8 @@ describe 'new work package', js: true do
|
||||
wp_page.subject_field.set(subject)
|
||||
|
||||
project_field.set_value project
|
||||
sleep 1
|
||||
|
||||
expect(page).to have_selector("#wp-new-inline-edit--field-type option[label=#{type}", wait: 10)
|
||||
type_field.set_value type
|
||||
sleep 1
|
||||
end
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
#-- 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.
|
||||
#++
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
describe ::Type, type: :model do
|
||||
let(:type) { FactoryGirl.build(:type) }
|
||||
|
||||
describe "#attribute_groups" do
|
||||
it 'returns #default_attribute_groups if not yet set' do
|
||||
expect(type.read_attribute(:attribute_groups)).to be_empty
|
||||
expect(type.attribute_groups).to_not be_empty
|
||||
expect(type.attribute_groups).to eq type.default_attribute_groups
|
||||
end
|
||||
|
||||
it 'removes unknown attributes from a group' do
|
||||
type.attribute_groups = [['foo', ['bar', 'date']]]
|
||||
expect(type.attribute_groups).to eq [['foo', ['date']]]
|
||||
end
|
||||
|
||||
it 'removes groups without attributes' do
|
||||
type.attribute_groups = [['foo', []], ['bar', ['date']]]
|
||||
expect(type.attribute_groups).to eq [['bar', ['date']]]
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
describe '#default_attribute_groups' do
|
||||
subject { type.default_attribute_groups }
|
||||
|
||||
it 'returns an array' do
|
||||
expect(subject.any?).to be_truthy
|
||||
end
|
||||
|
||||
it 'each attribute group is an array' do
|
||||
expect(subject.detect { |g| g.class != Array }).to be_falsey
|
||||
end
|
||||
|
||||
it "each attribute group's 1st element is a String (the group name)" do
|
||||
expect(subject.detect { |g| g.first.class != String }).to be_falsey
|
||||
end
|
||||
|
||||
it "each attribute group's 2nd element is a String (the group members)" do
|
||||
expect(subject.detect { |g| g.second.class != Array }).to be_falsey
|
||||
end
|
||||
|
||||
it 'does not return empty groups' do
|
||||
# For instance, the `type` factory instance does not have custom fields.
|
||||
# Thus the `other` group shall not be returned.
|
||||
expect(subject.detect do |attribute_group|
|
||||
group_members = attribute_group[1]
|
||||
group_members.nil? || group_members.size.zero?
|
||||
end).to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
describe "#validate_attribute_groups" do
|
||||
it 'raises an exception for invalid structure' do
|
||||
# Exampel for invalid structure:
|
||||
type.attribute_groups = ['foo']
|
||||
expect { type.save }.to raise_exception
|
||||
# Exampel for invalid structure:
|
||||
type.attribute_groups = [[]]
|
||||
expect { type.save }.to raise_exception
|
||||
# Exampel for invalid group name:
|
||||
type.attribute_groups = [['', ['date']]]
|
||||
expect(type).not_to be_valid
|
||||
end
|
||||
|
||||
it 'fails for duplicate group names' do
|
||||
type.attribute_groups = [['foo', ['date']], ['foo', ['date']]]
|
||||
expect(type).not_to be_valid
|
||||
end
|
||||
|
||||
it 'passes validations for known attributes' do
|
||||
type.attribute_groups = [['foo', ['date']]]
|
||||
expect(type.save).to be_truthy
|
||||
end
|
||||
|
||||
it 'passes validation for defaults' do
|
||||
expect(type.save).to be_truthy
|
||||
end
|
||||
|
||||
it 'passes validation for reset' do
|
||||
# A reset is to save an empty Array
|
||||
type.attribute_groups = []
|
||||
expect(type.save).to be_truthy
|
||||
expect(type.attribute_groups).to eq type.default_attribute_groups
|
||||
end
|
||||
end
|
||||
|
||||
describe "#form_configuration_groups" do
|
||||
it "returns a Hash with the keys :actives and :inactives Arrays" do
|
||||
expect(type.form_configuration_groups[:actives]).to be_an Array
|
||||
expect(type.form_configuration_groups[:inactives]).to be_an Array
|
||||
end
|
||||
|
||||
describe ":inactives" do
|
||||
subject { type.form_configuration_groups[:inactives] }
|
||||
|
||||
before do
|
||||
type.attribute_groups = [["group one", ["assignee"]]]
|
||||
end
|
||||
|
||||
it 'contains Hashes ordered by key :translation' do
|
||||
# The first left over attribute should currently be "date"
|
||||
expect(subject.first[:key]).to eq "date"
|
||||
expect(subject.first[:translation]).to be_present
|
||||
expect(subject.first[:translation] <= subject.second[:translation]).to be_truthy
|
||||
end
|
||||
|
||||
# The "assignee" is in "group one". It should not appear in :inactives.
|
||||
it 'does not contain attributes that do not exist anymore' do
|
||||
expect(subject.map { |inactive| inactive[:key] }).to_not include "assignee"
|
||||
end
|
||||
end
|
||||
|
||||
describe ":actives" do
|
||||
subject { type.form_configuration_groups[:actives] }
|
||||
|
||||
before do
|
||||
allow(type).to receive(:attribute_groups).and_return [["group one", ["date"]]]
|
||||
end
|
||||
|
||||
it 'has a proper structure' do
|
||||
# The group's name/key
|
||||
expect(subject.first.first).to eq "group one"
|
||||
|
||||
# The groups attributes
|
||||
expect(subject.first.second).to be_an Array
|
||||
expect(subject.first.second.first[:key]).to eq "date"
|
||||
expect(subject.first.second.first[:translation]).to eq "Date"
|
||||
expect(subject.first.second.first[:always_visible]).to be_falsey
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'custom fields' do
|
||||
let!(:custom_field) {
|
||||
FactoryGirl.create(
|
||||
:work_package_custom_field,
|
||||
field_format: 'string',
|
||||
)
|
||||
}
|
||||
let(:cf_identifier) {
|
||||
:"custom_field_#{custom_field.id}"
|
||||
}
|
||||
|
||||
it 'can be put into attribute groups' do
|
||||
# Is in inactive group
|
||||
form = type.form_configuration_groups
|
||||
expect(form[:inactives][0][:key]).to eq(cf_identifier.to_s)
|
||||
|
||||
# Can be enabled
|
||||
type.attribute_groups = [['foo', [cf_identifier.to_s]]]
|
||||
expect(type.save).to be_truthy
|
||||
expect(type.read_attribute(:attribute_groups)).not_to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -42,15 +42,15 @@ shared_examples_for 'type service' do
|
||||
end
|
||||
|
||||
it 'set the values provided on the call' do
|
||||
attributes = { name: 'blubs blubs' }
|
||||
permitted_params = { name: 'blubs blubs' }
|
||||
|
||||
instance.call(attributes: attributes)
|
||||
instance.call(permitted_params: permitted_params)
|
||||
|
||||
expect(type.name).to eql attributes[:name]
|
||||
expect(type.name).to eql permitted_params[:name]
|
||||
end
|
||||
|
||||
it 'enables the custom fields that are passed via attribute_visibility' do
|
||||
attributes = { 'attribute_visibility' => { 'custom_field_3' => 'visible',
|
||||
permitted_params = { 'attribute_visibility' => { 'custom_field_3' => 'visible',
|
||||
'custom_field_54' => 'default',
|
||||
'custom_field_86' => 'hidden' } }
|
||||
|
||||
@@ -58,7 +58,7 @@ shared_examples_for 'type service' do
|
||||
.to receive(:custom_field_ids=)
|
||||
.with([3, 54])
|
||||
|
||||
instance.call(attributes: attributes)
|
||||
instance.call(permitted_params: permitted_params)
|
||||
end
|
||||
|
||||
context 'for a milestone' do
|
||||
@@ -67,10 +67,10 @@ shared_examples_for 'type service' do
|
||||
end
|
||||
|
||||
it 'takes the values from the start and due_date if no value is set for date' do
|
||||
attributes = { 'attribute_visibility' => { 'start_date' => 'visible',
|
||||
permitted_params = { 'attribute_visibility' => { 'start_date' => 'visible',
|
||||
'due_date' => 'visible' } }
|
||||
|
||||
instance.call(attributes: attributes)
|
||||
instance.call(permitted_params: permitted_params)
|
||||
|
||||
expect(type.attribute_visibility).not_to include('start_date')
|
||||
expect(type.attribute_visibility).not_to include('due_date')
|
||||
@@ -85,9 +85,9 @@ shared_examples_for 'type service' do
|
||||
end
|
||||
|
||||
it 'takes the values from the date for start and due_date if no value is set' do
|
||||
attributes = { 'attribute_visibility' => { 'date' => 'visible' } }
|
||||
permitted_params = { 'attribute_visibility' => { 'date' => 'visible' } }
|
||||
|
||||
instance.call(attributes: attributes)
|
||||
instance.call(permitted_params: permitted_params)
|
||||
|
||||
expect(type.attribute_visibility).not_to include('date')
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ module Pages
|
||||
|
||||
def expect_hidden_field(attribute)
|
||||
within(container) do
|
||||
expect(page).to have_no_selectro(".inplace-edit.#{attribute}")
|
||||
expect(page).to have_no_selector(".inplace-edit.#{attribute}")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -83,6 +83,17 @@ module Pages
|
||||
wait: 10)
|
||||
end
|
||||
|
||||
def expect_group(name, &block)
|
||||
expect(page).to have_selector('.attributes-group--header-text', text: name.upcase)
|
||||
if block_given?
|
||||
within(".attributes-group[data-group-name='#{name}']", &block)
|
||||
end
|
||||
end
|
||||
|
||||
def expect_no_group(name)
|
||||
expect(page).to have_no_selector('.attributes-group--header-text', text: name.upcase)
|
||||
end
|
||||
|
||||
def expect_attributes(attribute_expectations)
|
||||
attribute_expectations.each do |label_name, value|
|
||||
label = label_name.to_s
|
||||
|
||||
@@ -46,10 +46,6 @@ module Pages
|
||||
find("#custom_field_default_value").set value
|
||||
end
|
||||
|
||||
def has_type?(name)
|
||||
expect(page).to have_css("label.form--label-with-check-box", text: name)
|
||||
end
|
||||
|
||||
def has_form_element?(name)
|
||||
page.has_css? "label.form--label", text: name
|
||||
end
|
||||
|
||||
@@ -52,25 +52,18 @@ describe TypesController, type: :controller do
|
||||
end
|
||||
|
||||
it 'should post create' do
|
||||
post :create, type: {
|
||||
post :create, tab: "settings", type: {
|
||||
name: 'New type',
|
||||
project_ids: ['1', '', ''],
|
||||
attribute_visibility: {
|
||||
custom_field_1: 'default',
|
||||
custom_field_6: 'visible'
|
||||
}
|
||||
}
|
||||
assert_redirected_to action: 'index'
|
||||
type = ::Type.find_by(name: 'New type')
|
||||
assert_equal [1], type.project_ids.sort
|
||||
assert_equal [1, 6], type.custom_field_ids
|
||||
assert_redirected_to action: 'edit', tab: 'settings', id: type.id
|
||||
assert_equal 0, type.workflows.count
|
||||
end
|
||||
|
||||
it 'should post create with workflow copy' do
|
||||
post :create, type: { name: 'New type' }, copy_workflow_from: 1
|
||||
assert_redirected_to action: 'index'
|
||||
type = ::Type.find_by(name: 'New type')
|
||||
assert_redirected_to action: 'edit', tab: 'settings', id: type.id
|
||||
assert_equal 0, type.projects.count
|
||||
assert_equal ::Type.find(1).workflows.count, type.workflows.count
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user