Merge pull request #5212 from opf/feature/attribute_groups

[24123] Work package group form configuration
This commit is contained in:
Oliver Günther
2017-03-29 21:49:46 +02:00
committed by GitHub
66 changed files with 2175 additions and 1378 deletions
@@ -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
@@ -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
+4 -4
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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])
+181
View File
@@ -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
+120
View File
@@ -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
+170
View File
@@ -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
+17 -6
View File
@@ -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)
-22
View File
@@ -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>
&nbsp;
<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>
+111 -54
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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)."
+9
View File
@@ -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
-63
View File
@@ -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);
@@ -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);
@@ -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);
@@ -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';
}
+1
View File
@@ -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
};
};
-1
View File
@@ -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;
};
+50
View File
@@ -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",
+2 -1
View File
@@ -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",
@@ -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
+15 -3
View File
@@ -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) { '' }
+8 -2
View File
@@ -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
+310 -69
View File
@@ -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
+1 -2
View File
@@ -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,
+2 -4
View File
@@ -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
+186
View File
@@ -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
+9 -9
View File
@@ -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')
+12 -1
View File
@@ -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
-4
View File
@@ -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