Merge branch 'dev' into feature/foundation-apps-framework

Signed-off-by: Alex Coles <alex@alexbcoles.com>

Conflicts:
	frontend/app/ui_components/index.js
	frontend/app/ui_components/with-dropdown-directive.js
	frontend/public/templates/work_packages.list.html
pull/2294/head
Alex Coles 10 years ago
commit d8b155377a
  1. 1
      app/assets/stylesheets/layout/_drop_down.sass
  2. 2
      app/models/attachment.rb
  3. 2
      app/models/project.rb
  4. 2
      app/models/version.rb
  5. 157
      app/models/version/project_sharing.rb
  6. 56
      app/policies/version_policy.rb
  7. 6
      db/migrate/20141215104802_migrate_attachments_to_carrier_wave.rb
  8. 49
      db/migrate/20150116095004_patch_corrupt_attachments.rb
  9. 2
      doc/README.md
  10. 310
      doc/apiv3-documentation.apib
  11. 2
      features/step_definitions/web_steps.rb
  12. 175
      frontend/app/ui_components/has-dropdown-menu-directive.js
  13. 2
      frontend/app/ui_components/inaccessible-by-tab-directive.js
  14. 12
      frontend/app/ui_components/index.js
  15. 200
      frontend/app/ui_components/selectable-title-directive.js
  16. 129
      frontend/app/ui_components/with-dropdown-directive.js
  17. 1
      frontend/app/work_packages/controllers/index.js
  18. 0
      frontend/app/work_packages/controllers/menus/column-context-menu-controller.js
  19. 140
      frontend/app/work_packages/controllers/menus/index.js
  20. 204
      frontend/app/work_packages/controllers/menus/query-select-dropdown-menu-controller.js
  21. 163
      frontend/app/work_packages/controllers/menus/settings-dropdown-menu-controller.js
  22. 18
      frontend/app/work_packages/controllers/menus/tasks-dropdown-menu-controller.js
  23. 6
      frontend/app/work_packages/controllers/menus/work-package-context-menu-controller.js
  24. 4
      frontend/app/work_packages/controllers/work-packages-list-controller.js
  25. 14
      frontend/app/work_packages/directives/index.js
  26. 169
      frontend/app/work_packages/directives/options-dropdown-directive.js
  27. 9
      frontend/app/work_packages/directives/sort-header-directive.js
  28. 49
      frontend/app/work_packages/index.js
  29. 2
      frontend/bower.json
  30. 28
      frontend/public/templates/components/selectable_title.html
  31. 68
      frontend/public/templates/work_packages.list.html
  32. 33
      frontend/public/templates/work_packages/menus/column_context_menu.html
  33. 14
      frontend/public/templates/work_packages/menus/details_more_dropdown_menu.html
  34. 22
      frontend/public/templates/work_packages/menus/query_select_dropdown_menu.html
  35. 50
      frontend/public/templates/work_packages/menus/settings_dropdown_menu.html
  36. 9
      frontend/public/templates/work_packages/menus/tasks_dropdown_menu.html
  37. 18
      frontend/public/templates/work_packages/menus/work_package_context_menu.html
  38. 34
      frontend/public/templates/work_packages/work_package_context_menu.html
  39. 20
      frontend/public/templates/work_packages/work_package_details_toolbar.html
  40. 11
      frontend/public/templates/work_packages/work_packages_table.html
  41. 66
      frontend/tests/unit/tests/ui_components/dropdown-directive-test.js
  42. 30
      frontend/tests/unit/tests/ui_components/selectable-title-directive-test.js
  43. 65
      frontend/tests/unit/tests/ui_components/with-dropdown-directive-test.js
  44. 2
      frontend/tests/unit/tests/work_packages/column-context-menu-test.js
  45. 20
      frontend/tests/unit/tests/work_packages/controllers/menus/options-dropdown-menu-controller-test.js
  46. 1
      frontend/tests/unit/tests/work_packages/directives/inplace-editor-directive-test.js
  47. 42
      frontend/tests/unit/tests/work_packages/directives/work-package-details-toolbar-test.js
  48. 9
      frontend/tests/unit/tests/work_packages/work-package-context-menu-test.js
  49. 24
      lib/api/decorators/collection.rb
  50. 53
      lib/api/decorators/single.rb
  51. 29
      lib/api/root.rb
  52. 8
      lib/api/v3/categories/category_collection_representer.rb
  53. 10
      lib/api/v3/categories/category_representer.rb
  54. 8
      lib/api/v3/priorities/priority_collection_representer.rb
  55. 10
      lib/api/v3/priorities/priority_representer.rb
  56. 43
      lib/api/v3/projects/project_collection_representer.rb
  57. 10
      lib/api/v3/projects/project_representer.rb
  58. 2
      lib/api/v3/projects/projects_api.rb
  59. 10
      lib/api/v3/queries/query_representer.rb
  60. 1
      lib/api/v3/root.rb
  61. 4
      lib/api/v3/statuses/status_collection_representer.rb
  62. 13
      lib/api/v3/statuses/status_representer.rb
  63. 4
      lib/api/v3/users/user_collection_representer.rb
  64. 47
      lib/api/v3/users/user_representer.rb
  65. 4
      lib/api/v3/users/users_api.rb
  66. 8
      lib/api/v3/utilities/path_helper.rb
  67. 51
      lib/api/v3/versions/projects_versions_api.rb
  68. 5
      lib/api/v3/versions/version_collection_representer.rb
  69. 48
      lib/api/v3/versions/version_representer.rb
  70. 34
      lib/api/v3/versions/versions_api.rb
  71. 51
      lib/api/v3/versions/versions_projects_api.rb
  72. 30
      lib/api/v3/work_packages/relation_representer.rb
  73. 59
      lib/api/v3/work_packages/work_package_representer.rb
  74. 7
      lib/api/v3/work_packages/work_packages_api.rb
  75. 5
      spec/controllers/attachments_controller_spec.rb
  76. 2
      spec/features/accessibility/work_packages/work_package_query_spec.rb
  77. 81
      spec/features/api/authentication_spec.rb
  78. 41
      spec/lib/api/v3/projects/project_collection_representer_spec.rb
  79. 6
      spec/lib/api/v3/support/api_v3_formattable.rb
  80. 26
      spec/lib/api/v3/users/user_representer_spec.rb
  81. 16
      spec/lib/api/v3/utilities/path_helper_spec.rb
  82. 4
      spec/lib/api/v3/versions/version_collection_representer_spec.rb
  83. 61
      spec/lib/api/v3/versions/version_representer_spec.rb
  84. 40
      spec/lib/api/v3/work_packages/work_package_representer_spec.rb
  85. 111
      spec/models/version_spec.rb
  86. 80
      spec/requests/api/v3/projects/version_resource_spec.rb
  87. 79
      spec/requests/api/v3/version_resource_spec.rb
  88. 97
      spec/requests/api/v3/versions/project_resource_spec.rb

@ -40,7 +40,6 @@
.dropdown
position: absolute
z-index: 9999999
display: none
.dropdown .dropdown-menu,
.dropdown .dropdown-panel

@ -139,7 +139,7 @@ class Attachment < ActiveRecord::Base
end
def filename
file.file.filename if file.file
attributes['file']
end
def file=(file)

@ -117,7 +117,7 @@ class Project < ActiveRecord::Base
scope :has_module, lambda { |mod| { conditions: ["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s] } }
scope :active, lambda { |*_args| where(status: STATUS_ACTIVE) }
scope :public, lambda { |*_args| where(is_public: true) }
scope :visible, lambda { { conditions: Project.visible_by(User.current) } }
scope :visible, ->(user = User.current) { { conditions: Project.visible_by(user) } }
# timelines stuff

@ -31,6 +31,8 @@ class Version < ActiveRecord::Base
include Redmine::SafeAttributes
extend DeprecatedAlias
include Version::ProjectSharing
after_update :update_issues_from_sharing_change
belongs_to :project
has_many :fixed_issues, class_name: 'WorkPackage', foreign_key: 'fixed_version_id', dependent: :nullify

@ -0,0 +1,157 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2014 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
module Version::ProjectSharing
# Returns all projects the version is available in
def projects
Project.scoped.joins(project_sharing_join)
end
private
def project_sharing_join
projects = Project.scoped
projects_table = projects.arel_table
versions_table = Version.scoped.arel_table
sharing_inner_select = project_sharing_select(versions_table)
join_condition = project_sharing_join_condition(sharing_inner_select, projects_table)
join_on = projects_table.create_on(join_condition)
projects_table.create_join(sharing_inner_select, join_on)
end
def project_sharing_select(versions_table)
sharing_select = if sharing == 'tree'
project_sharing_tree_select(versions_table)
else
project_sharing_default_select(versions_table)
end
sharing_id_condition = versions_table[:id].eq(id)
sharing_select
.where(sharing_id_condition)
.as('sharing')
end
def project_sharing_tree_select(versions_table)
hierarchy_table = Project.scoped.arel_table
roots_table = Project.arel_table.alias('roots')
roots_join_condition = project_sharing_tree_root_join_condition(roots_table, hierarchy_table)
sharing_select = join_project_and_version(hierarchy_table, versions_table)
sharing_select
.join(roots_table)
.on(roots_join_condition)
necessary_sharing_fields(sharing_select,
roots_table,
versions_table)
end
def project_sharing_default_select(versions_table)
hierarchy_table = Project.scoped.arel_table
sharing_select = join_project_and_version(hierarchy_table, versions_table)
necessary_sharing_fields(sharing_select,
hierarchy_table,
versions_table)
end
def necessary_sharing_fields(sharing_select, projects_table, versions_table)
sharing_select
.project(projects_table[:id],
versions_table[:id].as('version_id'),
projects_table[:lft],
projects_table[:rgt],
versions_table[:sharing])
end
def join_project_and_version(projects_table, versions_table)
join_condition = projects_table[:id].eq(versions_table[:project_id])
projects_table
.join(versions_table)
.on(join_condition)
end
def project_sharing_tree_root_join_condition(roots_table, hierarchy_table)
roots_table[:lft].lteq(hierarchy_table[:lft])
.and(roots_table[:rgt].gteq(hierarchy_table[:rgt]))
.and(roots_table[:parent_id].eq(nil))
end
def project_sharing_join_condition(sharing_table, projects_table)
case self[:sharing]
when 'tree'
project_sharing_tree_join_condition(sharing_table, projects_table)
when 'descendants'
project_sharing_descendants_join_condition(sharing_table, projects_table)
when 'hierarchy'
project_sharing_hierarchy_join_condition(sharing_table, projects_table)
when 'system'
Arel::Nodes::True.new
else
sharing_table[:id].eq(projects_table[:id])
end
end
def project_sharing_tree_join_condition(sharing_table, projects_table)
projects_table[:lft].gteq(sharing_table[:lft])
.and(projects_table[:rgt].lteq(sharing_table[:rgt]))
end
def project_sharing_descendants_join_condition(sharing_table, projects_table)
project_sharing_equal_condition(sharing_table, projects_table)
.or(project_sharing_below_condition(sharing_table, projects_table))
end
def project_sharing_hierarchy_join_condition(sharing_table, projects_table)
project_sharing_descendants_join_condition(sharing_table, projects_table)
.or(project_sharing_above_condition(sharing_table, projects_table))
end
def project_sharing_equal_condition(sharing_table, projects_table)
sharing_table[:id].eq(projects_table[:id])
end
def project_sharing_above_condition(sharing_table, projects_table)
projects_table[:lft].lt(sharing_table[:lft])
.and(projects_table[:rgt].gt(sharing_table[:rgt]))
end
def project_sharing_below_condition(sharing_table, projects_table)
projects_table[:lft].gt(sharing_table[:lft])
.and(projects_table[:rgt].lt(sharing_table[:rgt]))
end
end

@ -0,0 +1,56 @@
#-- 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.
#++
class VersionPolicy < BasePolicy
private
def cache
@cache ||= Hash.new do |hash, version|
# copy checks for the move_work_packages permission. This makes
# sense only because the work_packages/moves controller handles
# copying multiple work packages.
hash[version] = {
show: show_allowed?(version)
}
end
end
def show_allowed?(version)
@show_cache ||= Hash.new do |hash, queried_version|
permissions = [:view_work_packages, :manage_versions]
hash[queried_version] = permissions.any? do |permission|
allowed_condition = Project.allowed_to_condition(user, permission)
queried_version.projects.where(allowed_condition).exists?
end
end
@show_cache[version]
end
end

@ -100,7 +100,11 @@ class MigrateAttachmentsToCarrierWave < ActiveRecord::Migration
FileUtils.move file, old_file
attachment.update_column :file, nil
attachment.update_column :filename, Pathname(file).basename.to_s
attachment.update_column :disk_filename, Pathname(old_file).basename.to_s
# keep original disk filename if it was preserved
if attachment.disk_filename.blank?
attachment.update_column :disk_filename, Pathname(old_file).basename.to_s
end
FileUtils.rmdir Pathname(file).dirname
end

@ -0,0 +1,49 @@
##
# Goes through all attachments looking for ones whose 'file' column, which is the new column
# used by the carrierwave-based attachments, is not set.
#
# For every one of those attachments the migration then sets the 'file' column to
# whatever the value of the legacy column 'filename' is. If that one is empty too
# it falls back to the 'disk_filename' column. This one was not meant to be displayed
# to users but it's better than nothing, especially when trying to identify corrupt attachments.
#
# If *that* column is empty too, the attachment is broken beyond repair and will be dropped.
#
# Note: Just because the 'file' column is restored doesn't mean the actual file exists.
# Rather the 'file' column being empty means precisely that the file is missing.
# By still writing the filename into the file column the attachment can at least
# be displayed, if not downloaded.
#
# Important: The migration is irreversible.
class PatchCorruptAttachments < ActiveRecord::Migration
def up
Attachment.all.each do |attachment|
patch_attachment attachment
end
end
def down
puts "Won't revert this migration as it would mean breaking valid attachments. \
We could break the attachments with missing files again by deleting their
file column to restore the state before the migration. But that doesn't help.".squish
end
def patch_attachment(attachment)
attributes = attachment.attributes
if attributes['file'].blank?
# fall back to disk filename if necessary
file = attributes['filename'].presence || attributes['disk_filename'].presence
if file
attachment.update_column :file, file
puts "updated attachment #{attachment.id}'s file column: #{file}"
else
# this really shouldn't happen - but just in case it does, it is more sensible
# to just delete the attachment because it will just break things
puts "could not patch #{attachment.inspect} - missing file name information - \
it's hopeless ... deleting it".squish
attachment.destroy
end
end
end
end

@ -9,5 +9,5 @@ The documentation for APIv3 is written in the [API Blueprint Format](http://apib
You can use [aglio](https://github.com/danielgtaylor/aglio) to generate HTML documentation, e.g. using the following command:
```bash
aglio -i apiary.apib -o api.html
aglio -i apiv3-documentation.apib -o api.html
```

@ -63,7 +63,7 @@ Links to other resources in the API are represented uniformly by so called link
| Property | Description | Type | Required | Nullable | Default |
|:---------:| ------------------------------------------------------------------------ | ------- |:--------:|:--------:| ------- |
| href | URL to the referenced resource (might be relative) | String | ✓ | ✓ | |
| href | URL to the referenced resource (might be relative) | String | ✓ | ✓ | |
| title | Representative label for the resource | String | | | |
| templated | If true the `href` contains parts that need to be replaced by the client | Boolean | | | false |
| method | The HTTP verb to use when requesting the resource | String | | | GET |
@ -1943,10 +1943,16 @@ This endpoint lists the types that are *available* in a given project.
# Group Users
## Actions:
| Link | Description | Condition |
|:-------------------:| -------------------------------------------------------------------- | ----------------------------------------- |
| lock | Restrict the user from logging in and performing any actions | not locked; **Permission**: Administrator |
| unlock | Allow a locked user to login and act again | locked; **Permission**: Administrator |
| Link | Description | Condition |
|:-------------------:| -------------------------------------------------------------------- | ------------------------------------------ |
| lock | Restrict the user from logging in and performing any actions | not locked; **Permission**: Administrator |
| unlock | Allow a locked user to login and act again | locked; **Permission**: Administrator |
| delete | Permanently remove a user from the instance | **Permission**: Administrator, self-delete |
## Linked Properties:
| Link | Description | Type | Constraints | Supported operations |
|:---------:|-------------------------------------------- | ------------- | --------------------- | -------------------- |
| self | This user | User | not null | READ |
## Properties:
| Property | Description | Type | Constraints | Supported operations |
@ -1958,9 +1964,15 @@ This endpoint lists the types that are *available* in a given project.
| name | User's full name, formatting depends on instance settings | String | | READ |
| mail | User's email | String | | READ |
| avatar | URL to user's avatar | String | | READ |
| status | The current activation status of the user (see below) | String | | READ |
| createdAt | Time of creation | DateTime | | READ |
| updatedAt | Time of the most recent change to the user | DateTime | | READ |
The `status` of a user can be one of:
* `active` - the user can log in with the account
* `registered` - the user just registered to the instance, he can't log in yet, but will be able to, once the registration is completed
* `locked` - the user is locked and can't log in
## User [/api/v3/users/{id}]
@ -1991,9 +2003,9 @@ This endpoint lists the types that are *available* in a given project.
"lastName": "Sheppard",
"mail": "shep@mail.com",
"avatar": "https://gravatar/avatar",
"status": "active",
"createdAt": "2014-05-21T08:51:20Z",
"updatedAt": "2014-05-21T08:51:20Z",
"status": "active"
"updatedAt": "2014-05-21T08:51:20Z"
}
## View user [GET]
@ -2450,7 +2462,8 @@ Note that due to sharing this might be more than the versions *defined* by that
"title": "New"
},
"version": {
"href": "/api/v3/versions/1"
"href": "/api/v3/versions/1",
"title": "Version 1"
},
"availableWatchers": {
"href": "/api/v3/work_packages/1528/available_watchers",
@ -2649,27 +2662,6 @@ For more details and all possible responses see the general specification of [Fo
## Add Watcher [/api/v3/work_packages/{work_package_id}/watchers]
+ Model
+ Body
{
"_type": "User",
"_links": {
"self": {
"href": "/api/v3/users/1",
"title": "John Sheppard - j.sheppard"
}
},
"id": 1,
"login": "j.sheppard",
"firstName": "John",
"lastName": "Sheppard",
"mail": "shep@mail.com",
"avatar": "https://gravatar/avatar",
"createdAt": "2014-05-21T08:51:20Z",
"updatedAt": "2014-05-21T08:51:20Z"
}
## Add watcher [POST]
+ Parameters
@ -2683,7 +2675,7 @@ For more details and all possible responses see the general specification of [Fo
+ Response 200 (application/hal+json)
[Add Watcher][]
[User][]
## Remove Watcher [/api/v3/work_packages/{work_package_id}/watchers/{id}]
@ -2704,8 +2696,8 @@ For more details and all possible responses see the general specification of [Fo
"_links": {
"self": { "href": "/api/v3/work_packages/14/available_watchers" }
},
"total": 3,
"count": 3,
"total": 2,
"count": 2,
"_type": "Collection",
"_embedded": {
"elements": [
@ -2713,55 +2705,57 @@ For more details and all possible responses see the general specification of [Fo
"_type": "User",
"_links": {
"self": {
"href": "/api/v3/users/4581",
"title": "Mister X"
"href": "/api/v3/users/1",
"title": "John Sheppard - j.sheppard"
},
"lock": {
"href": "/api/v3/users/1/lock",
"title": "Set lock on j.sheppard"
"method": "POST"
},
"delete": {
"href": "/api/v3/users/1",
"title": "Delete j.sheppard"
"method": "DELETE"
}
},
"id": 4581,
"login": "m@x.org",
"firstName": "Mister",
"lastName": "X",
"name": "Mister X",
"mail": "m@x.org",
"avatar": "http://gravatar.com/avatar/12345678901234567890123456789012",
"createdAt": "2013-10-01T16:25:17Z",
"updatedAt": "2013-10-02T09:33:42Z"
"id": 1,
"login": "j.sheppard",
"firstName": "John",
"lastName": "Sheppard",
"mail": "shep@mail.com",
"avatar": "https://gravatar/avatar",
"status": "active",
"createdAt": "2014-05-21T08:51:20Z",
"updatedAt": "2014-05-21T08:51:20Z"
},
{
"_type": "User",
"_links": {
"self": {
"href": "/api/v3/users/972",
"title": "Mister Y"
"href": "/api/v3/users/2",
"title": "Jim Sheppard - j.sheppard2"
},
"lock": {
"href": "/api/v3/users/2/lock",
"title": "Set lock on j.sheppard2"
"method": "POST"
},
"delete": {
"href": "/api/v3/users/2",
"title": "Delete j.sheppard2"
"method": "DELETE"
}
},
"id": 972,
"login": "m@y.org",
"firstName": "Mister",
"lastName": "Y",
"name": "Mister Y",
"mail": "m@y.org",
"avatar": "http://gravatar.com/avatar/12345678901234567890123456789012",
"createdAt": "2013-01-11T11:47:06Z",
"updatedAt": "2013-11-18T07:13:56Z"
},
{
"_type": "User",
"_links": {
"self": {
"href": "/api/v3/users/4724",
"title": "Mister Z"
}
},
"id": 4724,
"login": "m@z.org",
"firstName": "Mister",
"lastName": "Z",
"name": "Mister Z",
"mail": "m@z.org",
"avatar": "http://gravatar.com/avatar/12345678901234567890123456789012",
"createdAt": "2013-10-08T09:28:46Z",
"updatedAt": "2013-10-08T09:28:52Z"
"id": 2,
"login": "j.sheppard2",
"firstName": "Jim",
"lastName": "Sheppard",
"mail": "shep@mail.net",
"avatar": "https://gravatar/avatar",
"status": "active",
"createdAt": "2014-05-21T08:51:20Z",
"updatedAt": "2014-05-21T08:51:20Z"
}]
}
}
@ -2851,8 +2845,8 @@ updated activity.
"templated": true
}
},
"total": 3,
"count": 3,
"total": 2,
"count": 2,
"_type": "Collection",
"_embedded": {
"elements": [
@ -2860,55 +2854,57 @@ updated activity.
"_type": "User",
"_links": {
"self": {
"href": "/api/v3/users/4581",
"title": "Mister X"
"href": "/api/v3/users/1",
"title": "John Sheppard - j.sheppard"
},
"lock": {
"href": "/api/v3/users/1/lock",
"title": "Set lock on j.sheppard"
"method": "POST"
},
"delete": {
"href": "/api/v3/users/1",
"title": "Delete j.sheppard"
"method": "DELETE"
}
},
"id": 4581,
"login": "m@x.org",
"firstName": "Mister",
"lastName": "X",
"name": "Mister X",
"mail": "m@x.org",
"avatar": "http://gravatar.com/avatar/12345678901234567890123456789012",
"createdAt": "2013-10-01T16:25:17Z",
"updatedAt": "2013-10-02T09:33:42Z"
"id": 1,
"login": "j.sheppard",
"firstName": "John",
"lastName": "Sheppard",
"mail": "shep@mail.com",
"avatar": "https://gravatar/avatar",
"status": "active",
"createdAt": "2014-05-21T08:51:20Z",
"updatedAt": "2014-05-21T08:51:20Z"
},
{
"_type": "User",
"_links": {
"self": {
"href": "/api/v3/users/972",
"title": "Mister Y"
"href": "/api/v3/users/2",
"title": "Jim Sheppard - j.sheppard2"
},
"lock": {
"href": "/api/v3/users/2/lock",
"title": "Set lock on j.sheppard2"
"method": "POST"
},
"delete": {
"href": "/api/v3/users/2",
"title": "Delete j.sheppard2"
"method": "DELETE"
}
},
"id": 972,
"login": "m@y.org",
"firstName": "Mister",
"lastName": "Y",
"name": "Mister Y",
"mail": "m@y.org",
"avatar": "http://gravatar.com/avatar/12345678901234567890123456789012",
"createdAt": "2013-01-11T11:47:06Z",
"updatedAt": "2013-11-18T07:13:56Z"
},
{
"_type": "User",
"_links": {
"self": {
"href": "/api/v3/users/4724",
"title": "Mister Z"
}
},
"id": 4724,
"login": "m@z.org",
"firstName": "Mister",
"lastName": "Z",
"name": "Mister Z",
"mail": "m@z.org",
"avatar": "http://gravatar.com/avatar/12345678901234567890123456789012",
"createdAt": "2013-10-08T09:28:46Z",
"updatedAt": "2013-10-08T09:28:52Z"
"id": 2,
"login": "j.sheppard2",
"firstName": "Jim",
"lastName": "Sheppard",
"mail": "shep@mail.net",
"avatar": "https://gravatar/avatar",
"status": "active",
"createdAt": "2014-05-21T08:51:20Z",
"updatedAt": "2014-05-21T08:51:20Z"
}]
}
}
@ -2957,8 +2953,8 @@ Gets a list of users that can be assigned to work packages in the given project.
"templated": true
}
},
"total": 3,
"count": 3,
"total": 2,
"count": 2,
"_type": "Collection",
"_embedded": {
"elements": [
@ -2966,55 +2962,57 @@ Gets a list of users that can be assigned to work packages in the given project.
"_type": "User",
"_links": {
"self": {
"href": "/api/v3/users/4581",
"title": "Mister X"
"href": "/api/v3/users/1",
"title": "John Sheppard - j.sheppard"
},
"lock": {
"href": "/api/v3/users/1/lock",
"title": "Set lock on j.sheppard"
"method": "POST"
},
"delete": {
"href": "/api/v3/users/1",
"title": "Delete j.sheppard"
"method": "DELETE"
}
},
"id": 4581,
"login": "m@x.org",
"firstName": "Mister",
"lastName": "X",
"name": "Mister X",
"mail": "m@x.org",
"avatar": "http://gravatar.com/avatar/12345678901234567890123456789012",
"createdAt": "2013-10-01T16:25:17Z",
"updatedAt": "2013-10-02T09:33:42Z"
"id": 1,
"login": "j.sheppard",
"firstName": "John",
"lastName": "Sheppard",
"mail": "shep@mail.com",
"avatar": "https://gravatar/avatar",
"status": "active",
"createdAt": "2014-05-21T08:51:20Z",
"updatedAt": "2014-05-21T08:51:20Z"
},
{
"_type": "User",
"_links": {
"self": {
"href": "/api/v3/users/972",
"title": "Mister Y"
"href": "/api/v3/users/2",
"title": "Jim Sheppard - j.sheppard2"
},
"lock": {
"href": "/api/v3/users/2/lock",
"title": "Set lock on j.sheppard2"
"method": "POST"
},
"delete": {
"href": "/api/v3/users/2",
"title": "Delete j.sheppard2"
"method": "DELETE"
}
},
"id": 972,
"login": "m@y.org",
"firstName": "Mister",
"lastName": "Y",
"name": "Mister Y",
"mail": "m@y.org",
"avatar": "http://gravatar.com/avatar/12345678901234567890123456789012",
"createdAt": "2013-01-11T11:47:06Z",
"updatedAt": "2013-11-18T07:13:56Z"
},
{
"_type": "User",
"_links": {
"self": {
"href": "/api/v3/users/4724",
"title": "Mister Z"
}
},
"id": 4724,
"login": "m@z.org",
"firstName": "Mister",
"lastName": "Z",
"name": "Mister Z",
"mail": "m@z.org",
"avatar": "http://gravatar.com/avatar/12345678901234567890123456789012",
"createdAt": "2013-10-08T09:28:46Z",
"updatedAt": "2013-10-08T09:28:52Z"
"id": 2,
"login": "j.sheppard2",
"firstName": "Jim",
"lastName": "Sheppard",
"mail": "shep@mail.net",
"avatar": "https://gravatar/avatar",
"status": "active",
"createdAt": "2014-05-21T08:51:20Z",
"updatedAt": "2014-05-21T08:51:20Z"
}]
}
}

@ -363,7 +363,7 @@ When /^(?:|I )click the toolbar button named "(.*?)"$/ do |action_name|
end
When /^(?:|I )choose "(.*?)" from the toolbar "(.*?)" dropdown$/ do |action_name, dropdown_id|
find("button[dropdown-id=#{dropdown_id}Dropdown]").click
find("button[has-dropdown-menu][target=#{dropdown_id}DropdownMenu]").click
find("##{dropdown_id}Dropdown").click_link action_name
end

@ -26,11 +26,14 @@
// See doc/COPYRIGHT.rdoc for more details.
//++
module.exports = function($injector, $window, $parse) {
module.exports = function($rootScope, $injector, $window, $parse, FocusHelper) {
function getCssPositionProperties(dropdown, trigger) {
var hOffset = 0,
vOffset = 0;
if (dropdown.hasClass('dropdown-anchor-top')) {
vOffset = - dropdown.outerHeight() - trigger.outerHeight() + parseInt(trigger.css('margin-top'), 10);
}
// Styling logic taken from jQuery-dropdown plugin: https://github.com/plapier/jquery-dropdown
// (dual MIT/GPL-Licensed)
@ -39,9 +42,13 @@ module.exports = function($injector, $window, $parse) {
if (dropdown.hasClass('dropdown-relative')) {
return {
left: dropdown.hasClass('dropdown-anchor-right') ?
trigger.position().left - (dropdown.outerWidth(true) - trigger.outerWidth(true)) - parseInt(trigger.css('margin-right')) + hOffset :
trigger.position().left + parseInt(trigger.css('margin-left')) + hOffset,
top: trigger.position().top + trigger.outerHeight(true) - parseInt(trigger.css('margin-top')) + vOffset
trigger.position().left -
(dropdown.outerWidth(true) - trigger.outerWidth(true)) -
parseInt(trigger.css('margin-right'), 10) + hOffset :
trigger.position().left + parseInt(trigger.css('margin-left'), 10) + hOffset,
top: trigger.position().top +
trigger.outerHeight(true) -
parseInt(trigger.css('margin-top'), 10) + vOffset
};
} else {
return {
@ -52,6 +59,31 @@ module.exports = function($injector, $window, $parse) {
}
}
function getPositionPropertiesOfEvent(event) {
var position = { };
if (event.pageX && event.pageY) {
position.top = Math.max(event.pageY, 0);
position.left = Math.max(event.pageX, 0);
} else {
var bounding = angular.element(event.target)[0].getBoundingClientRect();
position.top = Math.max(bounding.bottom, 0);
position.left = Math.max(bounding.left, 0);
}
return position;
}
function getCssPositionPropertiesOfEvent(event) {
var position = getPositionPropertiesOfEvent(event);
position.top += 'px';
position.left += 'px';
return position;
}
return {
restrict: 'A',
controller: [function() {
@ -70,22 +102,18 @@ module.exports = function($injector, $window, $parse) {
link: function(scope, element, attrs, ctrl) {
var contextMenu = $injector.get(attrs.target),
locals = {},
pointerPosition,
pointerCssPosition,
win = angular.element($window),
menuElement,
afterFocusOn = attrs.afterFocusOn,
positionRelativeTo = attrs.positionRelativeTo,
triggerOnEvent = attrs.triggerOnEvent || 'click';
triggerOnEvent = (attrs.triggerOnEvent || 'click') + '.dropdown.openproject';
/* contextMenu is a mandatory attribute and used to bind a specific context
menu to the trigger event
triggerOnEvent allows for binding the event for opening the menu to "click" */
// prepare locals, these define properties to be passed on to the context menu scope
var localKeys = attrs.locals.split(',').map(function(local) {
return local.trim();
});
angular.forEach(localKeys, function(key) {
locals[key] = scope[key];
});
function toggle(event) {
active() ? close() : open(event);
@ -96,75 +124,148 @@ module.exports = function($injector, $window, $parse) {
}
function open(event) {
var ignoreFocusOpener = true;
pointerPosition = getPositionPropertiesOfEvent(event);
pointerCssPosition = getCssPositionPropertiesOfEvent(event);
$rootScope.$broadcast('openproject.dropdown.closeDropdowns', ignoreFocusOpener);
// prepare locals, these define properties to be passed on to the context menu scope
var localKeys = (attrs.locals || '').split(',').map(function(local) {
return local.trim();
});
angular.forEach(localKeys, function(key) {
locals[key] = scope[key];
});
ctrl.open();
contextMenu.open(event.target, locals)
contextMenu.open(element, locals)
.then(function(element) {
menuElement = element;
angular.element(element).trap();
menuElement.trap();
positionDropdown();
menuElement.on('click', function(e) {
// allow inputs to be clickable
// without closing the dropdown
if (angular.element(e.target).is(':input')) {
e.stopPropagation();
}
});
});
}
function close() {
function close(ignoreFocusOpener) {
ctrl.close();
contextMenu.close();
var disableFocus = false;
contextMenu.close(disableFocus).then(function() {
if (!ignoreFocusOpener) {
FocusHelper.focusElement(afterFocusOn ? element.find(afterFocusOn) : element);
}
});
}
function positionDropdown() {
var positionRelativeToElement = positionRelativeTo ?
element.find(positionRelativeTo) : element;
if (attrs.triggerOnEvent == 'contextmenu') {
menuElement.css(pointerCssPosition);
adjustPosition(menuElement, pointerPosition);
} else {
menuElement.css(getCssPositionProperties(menuElement, positionRelativeToElement));
}
}
menuElement.css(getCssPositionProperties(menuElement, positionRelativeToElement));
function adjustPosition($element, pointerPosition) {
var viewport = {
top : win.scrollTop(),
left : win.scrollLeft()
};
viewport.right = viewport.left + win.width();
viewport.bottom = viewport.top + win.height();
var bounds = $element.offset();
bounds.right = bounds.left + $element.outerWidth();
bounds.bottom = bounds.top + $element.outerHeight();
if (viewport.right < bounds.right) {
$element.css('left', pointerPosition.left - $element.outerWidth());
}
if (viewport.bottom < bounds.bottom) {
$element.css('top', pointerPosition.top - $element.outerHeight());
}
}
element.bind(triggerOnEvent, function(event) {
event.preventDefault();
event.stopPropagation();
scope.$apply(function() {
toggle(event);
});
// set css position parameters after the digest has been completed
if (contextMenu.active()) positionDropdown();
scope.$root.$broadcast('openproject.markDropdownsAsClosed', element);
});
scope.$on('openproject.markDropdownsAsClosed', function(event, target) {
if (element !== target && ctrl.opened()) {
scope.$apply(ctrl.close);
scope.$on('openproject.dropdown.closeDropdowns', function(event, ignoreFocusOpener) {
if (!ctrl.opened()) {
return;
}
close(ignoreFocusOpener);
});
win.on('resize', function(event) {
scope.$on('openproject.dropdown.reposition', function() {
if (contextMenu.active() && menuElement && ctrl.opened()) {
positionDropdown();
}
});
win.bind('keyup', function(event) {
if (contextMenu.active() && event.keyCode === 27) {
scope.$apply(function() {
close();
});
var elementKeyUpString = 'keyup.contextmenu.dropdown.openproject';
element
.off(elementKeyUpString)
.on(elementKeyUpString, function(event) {
// Alt + Shift + F10
if (event.keyCode === 121 && event.shiftKey && event.altKey) {
if (!contextMenu.active()) {
open(event);
}
}
});
function handleWindowClickEvent(event) {
if (contextMenu.active() && event.button !== 2) {
scope.$apply(function() {
close();
});
// We need the off/on stuff in order to not have a new listener
// for every linking. It's not only not efficient, if causes bugs
// because of closures
// we also add an event namespace to avoid off'ing unrelated listeners
// We can leave it like this
// or move to the compile function of the directive
// or move to a service and make sure it's called only once
var repositioningEventString = 'resize.dropdown.openproject, mousewheel.dropdown.openproject';
win
.off(repositioningEventString)
.on(repositioningEventString, function() {
$rootScope.$broadcast('openproject.dropdown.reposition');
});
var keyUpEventString = 'keyup.dropdown.openproject';
win
.off(keyUpEventString).on(keyUpEventString, function(event) {
if (event.keyCode === 27) {
$rootScope.$broadcast('openproject.dropdown.closeDropdowns');
}
});
function handleWindowClickEvent() {
$rootScope.$broadcast('openproject.dropdown.closeDropdowns');
}
// Firefox treats a right-click as a click and a contextmenu event while other browsers
// just treat it as a contextmenu event
win.bind('click', handleWindowClickEvent);
win.bind(triggerOnEvent, handleWindowClickEvent);
var clickEventString = 'click.dropdown.openproject';
win
.off(clickEventString)
.on(clickEventString, handleWindowClickEvent);
win
.off(triggerOnEvent)
.on(triggerOnEvent, handleWindowClickEvent);
}
};
};

@ -42,7 +42,9 @@ module.exports = function() {
scope.oldTabIndex = currentTabIndex;
}
element.attr("tabindex", -1);
element.attr('aria-disabled', true);
} else {
element.attr('aria-disabled', false);
if (scope.oldTabIndex) {
element.attr("tabindex", scope.oldTabIndex);
} else {

@ -45,7 +45,6 @@ angular.module('openproject.uiComponents')
.directive('opDate', ['TimezoneService', require('./date/date-directive')])
.directive('opTime', ['TimezoneService', require('./date/time-directive')])
.directive('opDateTime', ['$compile', 'TimezoneService', require('./date/date-time-directive')])
.directive('dropdown', require('./dropdown-directive'))
.directive('emptyElement', [require('./empty-element-directive')])
.constant('ENTER_KEY', 13)
.directive('executeOnEnter', ['ENTER_KEY', require(
@ -61,9 +60,11 @@ angular.module('openproject.uiComponents')
.service('FocusHelper', ['$timeout', 'FOCUSABLE_SELECTOR', require(
'./focus-helper')])
.directive('hasDropdownMenu', [
'$rootScope',
'$injector',
'$window',
'$parse',
'FocusHelper',
require('./has-dropdown-menu-directive')
])
.service('I18n', [require('./i18n')])
@ -85,9 +86,7 @@ angular.module('openproject.uiComponents')
up: 38,
down: 40
})
.directive('selectableTitle', ['$sce', 'LABEL_MAX_CHARS', 'KEY_CODES',
require('./selectable-title-directive')
])
.directive('selectableTitle', [require('./selectable-title-directive')])
.constant('DOUBLE_CLICK_DELAY', 300)
// Thanks to http://stackoverflow.com/a/20445344
.directive('singleClick', [
@ -106,11 +105,6 @@ angular.module('openproject.uiComponents')
.directive('toolbar', require('./toolbar-directive'))
.constant('ESC_KEY', 27)
.directive('wikiToolbar', [require('./wiki-toolbar-directive')])
.directive('withDropdown', ['$rootScope',
'$window',
'ESC_KEY',
'FocusHelper', require('./with-dropdown-directive')
])
.directive('zoomSlider', ['I18n', require('./zoom-slider-directive')])
.filter('ancestorsExpanded', require('./filters/ancestors-expanded-filter'))
.filter('latestItems', require('./filters/latest-items-filter'));

@ -27,7 +27,7 @@
//++
// TODO move to UI components
module.exports = function($sce, LABEL_MAX_CHARS, KEY_CODES) {
module.exports = function() {
return {
restrict: 'E',
replace: true,
@ -36,202 +36,6 @@ module.exports = function($sce, LABEL_MAX_CHARS, KEY_CODES) {
groups: '=',
transitionMethod: '='
},
templateUrl: '/templates/components/selectable_title.html',
link: function(scope) {
scope.$watch('groups', refreshFilteredGroups);
scope.$watch('selectedId', selectTitle);
function refreshFilteredGroups() {
if(scope.groups){
initFilteredModels();
}
}
function selectTitle() {
angular.forEach(scope.filteredGroups, function(group) {
if(group.models.length) {
angular.forEach(group.models, function(model){
model.highlighted = model.id == scope.selectedId;
});
}
});
}
function initFilteredModels() {
scope.filteredGroups = angular.copy(scope.groups);
angular.forEach(scope.filteredGroups, function(group) {
group.models = group.models.map(function(model){
return {
label: model[0],
labelHtml: $sce.trustAsHtml(truncate(model[0], LABEL_MAX_CHARS)),
id: model[1],
highlighted: false
};
});
});
}
function labelHtml(label, filterBy) {
filterBy = filterBy.toLowerCase();
label = truncate(label, LABEL_MAX_CHARS);
if(label.toLowerCase().indexOf(filterBy) >= 0) {
var labelHtml = label.substr(0, label.toLowerCase().indexOf(filterBy))
+ "<span class='filter-selection'>" + label.substr(label.toLowerCase().indexOf(filterBy), filterBy.length) + "</span>"
+ label.substr(label.toLowerCase().indexOf(filterBy) + filterBy.length);
} else {
var labelHtml = label;
}
return $sce.trustAsHtml(labelHtml);
}
function truncate(text, chars) {
if (text.length > chars) {
return text.substr(0, chars) + "...";
}
return text;
}
function modelIndex(models) {
return models.map(function(model){
return model.id;
}).indexOf(scope.selectedId);
}
function performSelect() {
scope.transitionMethod(scope.selectedId);
}
function nextNonEmptyGroup(groups, currentGroupIndex) {
currentGroupIndex = (currentGroupIndex == undefined) ? -1 : currentGroupIndex;
while(currentGroupIndex < groups.length - 1) {
if(groups[currentGroupIndex + 1].models.length) {
return groups[currentGroupIndex + 1];
}
currentGroupIndex = currentGroupIndex + 1;
}
return null;
}
function previousNonEmptyGroup(groups, currentGroupIndex) {
while(currentGroupIndex > 0) {
if(groups[currentGroupIndex - 1].models.length) {
return groups[currentGroupIndex - 1];
}
currentGroupIndex = currentGroupIndex - 1;
}
return null;
}
function getModelPosition(groups, selectedId) {
for(var group_index = 0; group_index < groups.length; group_index++) {
var models = groups[group_index].models;
var model_index = modelIndex(models);
if(model_index >= 0) {
return {
group: group_index,
model: model_index
};
}
}
return false;
}
function selectNext() {
var groups = scope.filteredGroups;
if(!scope.selectedId) {
var nextGroup = nextNonEmptyGroup(groups);
scope.selectedId = nextGroup ? nextGroup.models[0].id : 0;
} else {
var position = getModelPosition(groups, scope.selectedId);
if (!position) return;
var models = groups[position.group].models;
if(position.model == models.length - 1){ // It is the last in the group
var nextGroup = nextNonEmptyGroup(groups, position.group);
if(nextGroup) {
scope.selectedId = nextGroup.models[0].id;
}
} else {
scope.selectedId = models[position.model + 1].id;
}
}
}
function selectPrevious() {
var groups = scope.filteredGroups;
if(scope.selectedId) {
var position = getModelPosition(groups, scope.selectedId);
if (!position) return;
var models = groups[position.group].models;
if(position.model == 0){ // It is the last in the group
var previousGroup = previousNonEmptyGroup(groups, position.group);
if(previousGroup) {
scope.selectedId = previousGroup.models[previousGroup.models.length - 1].id;
}
} else {
scope.selectedId = models[position.model - 1].id;
}
}
}
function preventDefault(event) {
event.preventDefault();
event.stopPropagation();
}
angular.element('#title-filter').bind('click', function(event) {
preventDefault(event);
});
scope.handleSelection = function(event) {
switch(event.which) {
case KEY_CODES.enter:
performSelect();
preventDefault(event);
break;
case KEY_CODES.down:
selectNext();
preventDefault(event);
break;
case KEY_CODES.up:
selectPrevious();
preventDefault(event);
break;
default:
break;
}
};
scope.reload = function(modelId, newTitle) {
scope.selectedTitle = newTitle;
scope.reloadMethod(modelId);
scope.$emit('hideAllDropdowns');
};
scope.filterModels = function(filterBy) {
initFilteredModels();
scope.selectedId = 0;
angular.forEach(scope.filteredGroups, function(group) {
if(filterBy.length) {
group.filterBy = filterBy;
group.models = group.models.filter(function(model){
return model.label.toLowerCase().indexOf(filterBy.toLowerCase()) >= 0;
});
if(group.models.length) {
angular.forEach(group.models, function(model){
model['labelHtml'] = labelHtml(model.label, filterBy);
});
if(!scope.selectedId) {
group.models[0].highlighted = true;
scope.selectedId = group.models[0].id;
}
}
}
});
};
}
templateUrl: '/templates/components/selectable_title.html'
};
};

@ -1,129 +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.
//++
// TODO move to UI components
module.exports = function ($rootScope, $window, ESC_KEY, FocusHelper) {
function position(dropdown, trigger) {
var hOffset = 0,
vOffset = 0;
if( dropdown.length === 0 || !trigger ) return;
// Styling logic taken from jQuery-dropdown plugin: https://github.com/plapier/jquery-dropdown
// (dual MIT/GPL-Licensed)
// Position the dropdown relative-to-parent or relative-to-document
if (dropdown.hasClass('dropdown-relative')) {
var leftPosition = dropdown.hasClass('dropdown-anchor-right') ?
trigger.position().left - (dropdown.outerWidth(true) - trigger.outerWidth(true)) - parseInt(trigger.css('margin-right')) + hOffset :
trigger.position().left + parseInt(trigger.css('margin-left')) + hOffset;
if (dropdown.hasClass('dropdown-up')) {
var dropdownHeight = dropdown.outerHeight(true);
dropdown.css({
left: leftPosition,
top: trigger.position().top - dropdownHeight + parseInt(trigger.css('margin-top')) - vOffset
});
} else {
var topBottomMargins = parseInt(trigger.css('margin-top')) +
parseInt(trigger.css('margin-bottom'));
dropdown.css({
left: leftPosition,
top: trigger.position().top + trigger.outerHeight(true) - topBottomMargins + vOffset
});
}
} else {
dropdown.css({
left: dropdown.hasClass('dropdown-anchor-right') ?
trigger.offset().left - (dropdown.outerWidth() - trigger.outerWidth()) + hOffset : trigger.offset().left + hOffset,
top: trigger.offset().top + trigger.outerHeight() + vOffset
});
}
}
function accessDropdown(dropdown) {
var links = dropdown.find('a');
if (links.length > 0) {
angular.element(links[0]).focus();
}
angular.element(dropdown).trap();
}
return {
restrict: 'EA',
scope: {
dropdownId: '@',
focusElementId: '@'
},
link: function (scope, element, attributes) {
var dropdown = jQuery("#" + attributes.dropdownId),
trigger;
$rootScope.$on('hideAllDropdowns', function(event){
jQuery('.dropdown').hide();
});
angular.element($window).on('resize', function(event) {
if(dropdown.is(':visible')) {
position(dropdown, trigger);
}
});
element.on('click', function (event) {
var showDropdown = dropdown.is(':hidden');
trigger = jQuery(this);
event.preventDefault();
event.stopPropagation();
scope.$emit('hideAllDropdowns');
if (showDropdown) dropdown.show();
position(dropdown, trigger);
accessDropdown(dropdown);
if(attributes.focusElementId) {
angular.element('#' + attributes.focusElementId).focus();
}
});
angular.element(dropdown).on('keyup', function(event) {
if (event.keyCode === ESC_KEY) {
scope.$emit('hideAllDropdowns');
FocusHelper.focusElement(element);
}
});
}
};
};

@ -238,3 +238,4 @@ angular.module('openproject.workPackages.controllers')
'I18n',
require('./dialogs/sorting')
]);
require('./menus');

@ -0,0 +1,140 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2014 the OpenProject Foundation (OPF)
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See doc/COPYRIGHT.rdoc for more details.
//++
angular.module('openproject.workPackages')
.factory('ColumnContextMenu', [
'ngContextMenu',
function(ngContextMenu) {
return ngContextMenu({
controller: 'ColumnContextMenuController',
controllerAs: 'contextMenu',
templateUrl: '/templates/work_packages/menus/column_context_menu.html',
container: '.work-packages--list-table-area'
});
}
])
.controller('ColumnContextMenuController', [
'$scope',
'ColumnContextMenu',
'I18n',
'QueryService',
'WorkPackagesTableHelper',
'WorkPackagesTableService',
'columnsModal',
require('./column-context-menu-controller')
])
.factory('SettingsDropdownMenu', [
'ngContextMenu',
function(ngContextMenu) {
return ngContextMenu({
controller: 'SettingsDropdownMenuController',
templateUrl: '/templates/work_packages/menus/settings_dropdown_menu.html',
container: '#toolbar'
});
}
])
.controller('SettingsDropdownMenuController', [
'$scope',
'I18n',
'columnsModal',
'exportModal',
'saveModal',
'settingsModal',
'shareModal',
'sortingModal',
'groupingModal',
'QueryService',
'AuthorisationService',
'$window',
'$state',
'$timeout', require('./settings-dropdown-menu-controller')
])
.factory('TasksDropdownMenu', [
'ngContextMenu',
function(ngContextMenu) {
return ngContextMenu({
controller: 'TasksDropdownMenuController',
templateUrl: '/templates/work_packages/menus/tasks_dropdown_menu.html',
container: '#toolbar'
});
}
])
.controller('TasksDropdownMenuController', [
'$scope',
'PathHelper', require('./tasks-dropdown-menu-controller')
])
.constant('PERMITTED_CONTEXT_MENU_ACTIONS', [
'edit', 'watch', 'log_time',
'duplicate', 'move', 'copy', 'delete'
])
.factory('WorkPackageContextMenu', [
'ngContextMenu',
function(ngContextMenu) {
return ngContextMenu({
controller: 'WorkPackageContextMenuController',
controllerAs: 'contextMenu',
templateUrl: '/templates/work_packages/menus/work_package_context_menu.html'
});
}
])
.controller('WorkPackageContextMenuController', [
'$scope',
'WorkPackagesTableHelper',
'WorkPackageContextMenuHelper',
'WorkPackageService',
'WorkPackagesTableService',
'I18n',
'$window',
'PERMITTED_CONTEXT_MENU_ACTIONS',
require('./work-package-context-menu-controller')
])
.factory('DetailsMoreDropdownMenu', [
'ngContextMenu',
function(ngContextMenu) {
return ngContextMenu({
templateUrl: '/templates/work_packages/menus/details_more_dropdown_menu.html',
container: '.work-packages--details-toolbar'
});
}
])
.factory('QuerySelectDropdownMenu', [
'ngContextMenu',
function(ngContextMenu) {
return ngContextMenu({
templateUrl: '/templates/work_packages/menus/query_select_dropdown_menu.html',
container: '.title-container',
controller: 'QuerySelectDropdownMenuController'
});
}
])
.controller('QuerySelectDropdownMenuController', [
'$scope',
'$sce', 'LABEL_MAX_CHARS', 'KEY_CODES',
require('./query-select-dropdown-menu-controller')
]);

@ -0,0 +1,204 @@
module.exports = function($scope, $sce, LABEL_MAX_CHARS, KEY_CODES) {
var scope = $scope;
scope.$watch('groups', refreshFilteredGroups);
scope.$watch('selectedId', selectTitle);
function refreshFilteredGroups() {
if (scope.groups) {
initFilteredModels();
}
}
function selectTitle() {
angular.forEach(scope.filteredGroups, function(group) {
if (group.models.length) {
angular.forEach(group.models, function(model){
model.highlighted = model.id == scope.selectedId;
});
}
});
}
function initFilteredModels() {
scope.filteredGroups = angular.copy(scope.groups);
angular.forEach(scope.filteredGroups, function(group) {
group.models = group.models.map(function(model){
return {
label: model[0],
labelHtml: $sce.trustAsHtml(truncate(model[0], LABEL_MAX_CHARS)),
id: model[1],
highlighted: false
};
});
});
}
function labelHtml(label, filterBy) {
var html;
filterBy = filterBy.toLowerCase();
label = truncate(label, LABEL_MAX_CHARS);
if (label.toLowerCase().indexOf(filterBy) >= 0) {
html = label.substr(0, label.toLowerCase().indexOf(filterBy)) +
'<span class=\'filter-selection\'>' +
label.substr(label.toLowerCase().indexOf(filterBy), filterBy.length) +
'</span>' + label.substr(label.toLowerCase().indexOf(filterBy) + filterBy.length);
} else {
html = label;
}
return $sce.trustAsHtml(html);
}
function truncate(text, chars) {
if (text.length > chars) {
return text.substr(0, chars) + '...';
}
return text;
}
function modelIndex(models) {
return models.map(function(model) {
return model.id;
}).indexOf(scope.selectedId);
}
function performSelect() {
scope.transitionMethod(scope.selectedId);
}
function nextNonEmptyGroup(groups, currentGroupIndex) {
currentGroupIndex = (currentGroupIndex === undefined) ? -1 : currentGroupIndex;
while (currentGroupIndex < groups.length - 1) {
if (groups[currentGroupIndex + 1].models.length) {
return groups[currentGroupIndex + 1];
}
currentGroupIndex = currentGroupIndex + 1;
}
return null;
}
function previousNonEmptyGroup(groups, currentGroupIndex) {
while (currentGroupIndex > 0) {
if(groups[currentGroupIndex - 1].models.length) {
return groups[currentGroupIndex - 1];
}
currentGroupIndex = currentGroupIndex - 1;
}
return null;
}
function getModelPosition(groups) {
for (var groupIdx = 0; groupIdx < groups.length; groupIdx++) {
var models = groups[groupIdx].models;
var modelIdx = modelIndex(models);
if(modelIdx >= 0) {
return {
group: groupIdx,
model: modelIdx
};
}
}
return false;
}
function selectNext() {
var groups = scope.filteredGroups,
nextGroup;
if(!scope.selectedId) {
nextGroup = nextNonEmptyGroup(groups);
scope.selectedId = nextGroup ? nextGroup.models[0].id : 0;
} else {
var position = getModelPosition(groups, scope.selectedId);
if (!position) {
return;
}
var models = groups[position.group].models;
if(position.model == models.length - 1){ // It is the last in the group
nextGroup = nextNonEmptyGroup(groups, position.group);
if(nextGroup) {
scope.selectedId = nextGroup.models[0].id;
}
} else {
scope.selectedId = models[position.model + 1].id;
}
}
}
function selectPrevious() {
var groups = scope.filteredGroups;
if (scope.selectedId) {
var position = getModelPosition(groups, scope.selectedId);
if (!position) {
return;
}
var models = groups[position.group].models;
if (position.model === 0) { // It is the last in the group
var previousGroup = previousNonEmptyGroup(groups, position.group);
if(previousGroup) {
scope.selectedId = previousGroup.models[previousGroup.models.length - 1].id;
}
} else {
scope.selectedId = models[position.model - 1].id;
}
}
}
function preventDefault(event) {
event.preventDefault();
event.stopPropagation();
}
angular.element('#title-filter').bind('click', function(event) {
preventDefault(event);
});
scope.handleSelection = function(event) {
switch(event.which) {
case KEY_CODES.enter:
performSelect();
preventDefault(event);
break;
case KEY_CODES.down:
selectNext();
preventDefault(event);
break;
case KEY_CODES.up:
selectPrevious();
preventDefault(event);
break;
default:
break;
}
};
scope.reload = function(modelId, newTitle) {
scope.selectedTitle = newTitle;
scope.reloadMethod(modelId);
scope.$emit('hideAllDropdowns');
};
scope.filterModels = function(filterBy) {
initFilteredModels();
scope.selectedId = 0;
angular.forEach(scope.filteredGroups, function(group) {
if (filterBy.length) {
group.filterBy = filterBy;
group.models = group.models.filter(function(model){
return model.label.toLowerCase().indexOf(filterBy.toLowerCase()) >= 0;
});
if (group.models.length) {
angular.forEach(group.models, function(model){
model['labelHtml'] = labelHtml(model.label, filterBy);
});
if (!scope.selectedId) {
group.models[0].highlighted = true;
scope.selectedId = group.models[0].id;
}
}
}
});
};
};

@ -0,0 +1,163 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2014 the OpenProject Foundation (OPF)
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See doc/COPYRIGHT.rdoc for more details.
//++
module.exports = function(
$scope, I18n, columnsModal,
exportModal, saveModal, settingsModal,
shareModal, sortingModal, groupingModal,
QueryService, AuthorisationService,
$window, $state, $timeout) {
$scope.$watch('query.displaySums', function(newValue) {
$timeout(function() {
$scope.displaySumsLabel = (newValue) ? I18n.t('js.toolbar.settings.hide_sums')
: I18n.t('js.toolbar.settings.display_sums');
});
});
$scope.saveQuery = function(event){
if($scope.query.isNew()){
if( allowQueryAction(event, 'create') ){
$scope.$emit('hideAllDropdowns');
saveModal.activate();
}
} else {
if( allowQueryAction(event, 'update') ) {
QueryService.saveQuery()
.then(function(data){
$scope.$emit('flashMessage', data.status);
$state.go('work-packages.list',
{ 'query_id': $scope.query.id, 'query_props': null },
{ notify: false });
});
}
}
};
$scope.deleteQuery = function(event){
if( allowQueryAction(event, 'delete') && preventNewQueryAction(event) && deleteConfirmed() ){
QueryService.deleteQuery()
.then(function(data){
settingsModal.deactivate();
$scope.$emit('flashMessage', data.status);
$state.go('work-packages.list',
{ 'query_id': null, 'query_props': null },
{ reload: true });
});
}
};
// Modals
$scope.showSaveAsModal = function(event){
if( allowQueryAction(event, 'create') ) {
showExistingQueryModal.call(saveModal, event);
}
};
$scope.showShareModal = function(event){
if (allowQueryAction(event, 'publicize') || allowQueryAction(event, 'star')) {
showExistingQueryModal.call(shareModal, event);
}
};
$scope.showSettingsModal = function(event){
if( allowQueryAction(event, 'update') ) {
showExistingQueryModal.call(settingsModal, event);
}
};
$scope.showExportModal = function(event){
if( allowWorkPackageAction(event, 'export') ) {
showModal.call(exportModal);
}
};
$scope.showColumnsModal = function(){
showModal.call(columnsModal);
};
$scope.showGroupingModal = function(){
showModal.call(groupingModal);
};
$scope.showSortingModal = function(){
showModal.call(sortingModal);
};
$scope.toggleDisplaySums = function(){
$scope.$emit('hideAllDropdowns');
$scope.query.displaySums = !$scope.query.displaySums;
// This eventually calls the resize event handler defined in the
// WorkPackagesTable directive and ensures that the sum row at the
// table footer is properly displayed.
angular.element($window).trigger('resize');
};
function preventNewQueryAction(event){
if (event && $scope.query.isNew()) {
event.preventDefault();
event.stopPropagation();
return false;
}
return true;
}
function showModal() {
$scope.$emit('hideAllDropdowns');
this.activate();
}
function showExistingQueryModal(event) {
if( preventNewQueryAction(event) ){
$scope.$emit('hideAllDropdowns');
this.activate();
}
}
function allowQueryAction(event, action) {
return allowAction(event, 'query', action);
}
function allowWorkPackageAction(event, action) {
return allowAction(event, 'work_package', action);
}
function allowAction(event, modelName, action) {
if(AuthorisationService.can(modelName, action)){
return true;
} else {
event.preventDefault();
event.stopPropagation();
return false;
}
}
function deleteConfirmed() {
return $window.confirm(I18n.t('js.text_query_destroy_confirmation'));
}
};

@ -1,6 +1,6 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
// Copyright (C) 2012-2014 the OpenProject Foundation (OPF)
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
@ -26,13 +26,11 @@
// See doc/COPYRIGHT.rdoc for more details.
//++
// TODO move to UI components
module.exports = function() {
return {
restrict: 'EA',
scope: {},
link: function(scope, element, attributes) {
// TODO: implement me
}
};
module.exports = function($scope, PathHelper) {
$scope.workPackageNewPath = function(typeId) {
return PathHelper.staticWorkPackageNewWithParametersPath(
$scope.projectIdentifier,
{ 'type_id': typeId }
);
};
};

@ -30,8 +30,6 @@ module.exports = function($scope, WorkPackagesTableHelper, WorkPackageContextMen
$scope.I18n = I18n;
$scope.hideResourceActions = true;
$scope.$watch('row', function() {
if (!$scope.row.checked) {
WorkPackagesTableService.setCheckedStateForAllRows($scope.rows, false);
@ -41,8 +39,8 @@ module.exports = function($scope, WorkPackagesTableHelper, WorkPackageContextMen
$scope.permittedActions = WorkPackageContextMenuHelper.getPermittedActions(getSelectedWorkPackages(), PERMITTED_CONTEXT_MENU_ACTIONS);
});
$scope.isDetailsViewLinkVisible = function() {
return angular.element('#work-package-context-menu li.open').is(':visible');
$scope.isDetailsViewLinkPresent = function() {
return !!angular.element('#work-package-context-menu li.open').length;
};
$scope.triggerContextMenuAction = function(action, link) {

@ -274,8 +274,4 @@ module.exports = function($scope, $rootScope, $state, $location, latestTab,
$state.go(latestTab.getStateName(), { workPackageId: id, query_props: $location.search().query_props });
}
};
$scope.workPackageNewPath = function(typeId) {
return PathHelper.staticWorkPackageNewWithParametersPath($scope.projectIdentifier, { type_id: typeId });
};
};

@ -28,20 +28,6 @@
angular.module('openproject.workPackages.directives')
.directive('langAttribute', require('./lang-attribute-directive'))
.directive('optionsDropdown', ['I18n',
'columnsModal',
'exportModal',
'saveModal',
'settingsModal',
'shareModal',
'sortingModal',
'groupingModal',
'QueryService',
'AuthorisationService',
'$window',
'$state',
'$timeout', require('./options-dropdown-directive')
])
.directive('queryColumns', [
'WorkPackagesTableHelper',
'WorkPackagesTableService',

@ -1,169 +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.
//++
module.exports = function(I18n, columnsModal, exportModal, saveModal, settingsModal, shareModal, sortingModal, groupingModal, QueryService, AuthorisationService, $window, $state, $timeout){
return {
restrict: 'AE',
scope: true,
link: function(scope, element, attributes) {
angular.element($window).bind('click', function() {
scope.$emit('hideAllDropdowns');
});
scope.$watch('query.displaySums', function(newValue, oldValue) {
$timeout(function() {
scope.displaySumsLabel = (newValue) ? I18n.t('js.toolbar.settings.hide_sums')
: I18n.t('js.toolbar.settings.display_sums');
});
});
scope.saveQuery = function(event){
if(scope.query.isNew()){
if( allowQueryAction(event, 'create') ){
scope.$emit('hideAllDropdowns');
saveModal.activate();
}
} else {
if( allowQueryAction(event, 'update') ) {
QueryService.saveQuery()
.then(function(data){
scope.$emit('flashMessage', data.status);
$state.go('work-packages.list',
{ query_id: scope.query.id, query_props: null },
{ notify: false });
});
}
}
};
scope.deleteQuery = function(event){
if( allowQueryAction(event, 'delete') && preventNewQueryAction(event) && deleteConfirmed() ){
QueryService.deleteQuery()
.then(function(data){
settingsModal.deactivate();
scope.$emit('flashMessage', data.status);
$state.go('work-packages.list',
{ query_id: null, query_props: null },
{ reload: true });
});
}
};
// Modals
scope.showSaveAsModal = function(event){
if( allowQueryAction(event, 'create') ) {
showExistingQueryModal.call(saveModal, event);
}
};
scope.showShareModal = function(event){
if (allowQueryAction(event, 'publicize') || allowQueryAction(event, 'star')) {
showExistingQueryModal.call(shareModal, event);
}
};
scope.showSettingsModal = function(event){
if( allowQueryAction(event, 'update') ) {
showExistingQueryModal.call(settingsModal, event);
}
};
scope.showExportModal = function(event){
if( allowWorkPackageAction(event, 'export') ) {
showModal.call(exportModal);
}
};
scope.showColumnsModal = function(){
showModal.call(columnsModal);
};
scope.showGroupingModal = function(){
showModal.call(groupingModal);
};
scope.showSortingModal = function(){
showModal.call(sortingModal);
};
scope.toggleDisplaySums = function(){
scope.$emit('hideAllDropdowns');
scope.query.displaySums = !scope.query.displaySums;
// This eventually calls the resize event handler defined in the
// WorkPackagesTable directive and ensures that the sum row at the
// table footer is properly displayed.
angular.element($window).trigger('resize');
};
function preventNewQueryAction(event){
if (event && scope.query.isNew()) {
event.preventDefault();
event.stopPropagation();
return false;
}
return true;
}
function showModal() {
scope.$emit('hideAllDropdowns');
this.activate();
}
function showExistingQueryModal(event) {
if( preventNewQueryAction(event) ){
scope.$emit('hideAllDropdowns');
this.activate();
}
}
function allowQueryAction(event, action) {
return allowAction(event, 'query', action);
}
function allowWorkPackageAction(event, action) {
return allowAction(event, 'work_package', action);
}
function allowAction(event, modelName, action) {
if(AuthorisationService.can(modelName, action)){
return true;
} else {
event.preventDefault();
event.stopPropagation();
return false;
}
}
function deleteConfirmed() {
return $window.confirm(I18n.t('js.text_query_destroy_confirmation'));
}
}
};
};

@ -38,8 +38,7 @@ module.exports = function(I18n){
sortable: '=',
locale: '='
},
require: 'hasDropdownMenu',
link: function(scope, element, attributes, dropdownMenuCtrl) {
link: function(scope, element) {
scope.$watch('query.sortation.sortElements', function(sortElements){
var latestSortElement = sortElements[0];
@ -66,12 +65,8 @@ module.exports = function(I18n){
// active-column class setting
function setActiveColumnClass() {
element.toggleClass('active-column', !!scope.currentSortDirection || scope.dropDownMenuOpened);
element.toggleClass('active-column', !!scope.currentSortDirection);
}
scope.$watch(dropdownMenuCtrl.opened, function(opened) {
scope.dropDownMenuOpened = opened;
setActiveColumnClass();
});
scope.$watch('currentSortDirection', setActiveColumnClass);
}

@ -35,52 +35,3 @@ require('./models');
require('./services');
require('./tabs');
require('./view_models');
angular.module('openproject.workPackages')
.factory('ColumnContextMenu', [
'ngContextMenu',
function(ngContextMenu) {
return ngContextMenu({
controller: 'ColumnContextMenuController',
controllerAs: 'contextMenu',
templateUrl: '/templates/work_packages/column_context_menu.html',
container: '.work-packages--list-table-area'
});
}
])
.controller('ColumnContextMenuController', [
'$scope',
'ColumnContextMenu',
'I18n',
'QueryService',
'WorkPackagesTableHelper',
'WorkPackagesTableService',
'columnsModal',
require('./column-context-menu')
])
.constant('PERMITTED_CONTEXT_MENU_ACTIONS', ['edit', 'watch', 'log_time',
'duplicate', 'move', 'copy', 'delete'
])
.factory('WorkPackageContextMenu', [
'ngContextMenu',
function(ngContextMenu) {
return ngContextMenu({
controller: 'WorkPackageContextMenuController',
controllerAs: 'contextMenu',
templateUrl: '/templates/work_packages/work_package_context_menu.html'
});
}
])
.controller('WorkPackageContextMenuController', [
'$scope',
'WorkPackagesTableHelper',
'WorkPackageContextMenuHelper',
'WorkPackageService',
'WorkPackagesTableService',
'I18n',
'$window',
'PERMITTED_CONTEXT_MENU_ACTIONS',
require('./work-package-context-menu')
]);

@ -22,7 +22,7 @@
"jquery-migrate": "~1.2.1",
"momentjs": "~2.7.0",
"moment-timezone": "~0.2.0",
"angular-context-menu": "finnlabs/angular-context-menu#v0.2.0",
"angular-context-menu": "0xF013/angular-context-menu#220a74c6c05eb084b1630420c2d8b5167e4fc024",
"angular-busy": "~4.1.1",
"hyperagent": "manwithtwowatches/hyperagent#v0.4.2",
"lodash": "~2.4.1",

@ -1,36 +1,12 @@
<div class="title-container">
<div class="text">
<h2 title="{{ selectedTitle }}">
<span with-dropdown dropdown-id="querySelectDropdown" focus-element-id="title-filter">
<span has-dropdown-menu target="QuerySelectDropdownMenu"
locals="selectedTitle,groups,transitionMethod">
<accessible-by-keyboard>
{{ selectedTitle | characters:50 }}<i class="icon-pulldown-arrow1 icon-button"></i>
</accessible-by-keyboard>
</span>
</h2>
</div>
<div class="dropdown dropdown-relative" id="querySelectDropdown">
<div class="search-query-wrapper">
<input type="search"
ng-model="filterBy"
ng-change="filterModels(filterBy)"
ng-keydown="handleSelection($event)"
id="title-filter"><i id="magnifier" class="icon-search"></i>
</input>
</div>
<div class="dropdown-scrollable">
<div class="query-menu-container" ng-if="group.models" ng-repeat="group in filteredGroups">
<ul class="query-menu">
<div class="title-group-header">{{ group.name }}</div>
<li ng-repeat="model in group.models"
ng-class="{'selected': model.highlighted }"
ui-sref="work-packages.list({ query_id: model.id, query_props: undefined })">
<a href title="{{ model.label }}" ng-bind-html="model.labelHtml"></a>
</li>
</ul>
</div>
</div>
</div>
</div>

@ -9,8 +9,9 @@
<ul id="toolbar-items">
<li class="toolbar-item">
<button class="button -highlight"
with-dropdown
dropdown-id="tasksDropdown"
has-dropdown-menu
target="TasksDropdownMenu"
locals="availableTypes,projectIdentifier"
ng-disabled="cannot('work_package', 'create')">
<i class="icon-add icon4"></i>
{{ I18n.t('js.toolbar.unselected_title') }}
@ -64,7 +65,9 @@
<button id="work-packages-settings-button"
title="{{ I18n.t('js.button_settings') }}"
class="button last work-packages-settings-button"
with-dropdown dropdown-id="settingsDropdown">
has-dropdown-menu
target="SettingsDropdownMenu"
locals="query">
<i class="icon-settings"></i>
<i class="icon-pulldown-arrow1 icon-dropdown"></i>
</button>
@ -73,65 +76,6 @@
</div>
</div>
<div class="dropdown dropdown-relative dropdown-anchor-right" id="tasksDropdown">
<ul class="dropdown-menu">
<li ng-repeat="type in availableTypes">
<a ng-href="{{ workPackageNewPath(type.id) }}">
{{type.name}}
</a>
</li>
</ul>
</div>
<div options-dropdown class="dropdown dropdown-relative dropdown-anchor-right" id="settingsDropdown">
<!-- The hrefs with empty URLs are necessary for IE10 to focus these links
properly. Thus, don't remove the hrefs or the empty URLs! -->
<ul class="dropdown-menu">
<li><a href="" ng-click="showColumnsModal()"><i class="icon-action-menu icon-columns"></i>{{ I18n.t('js.toolbar.settings.columns') }}</a></li>
<li><a href="" ng-click="showSortingModal()"><i class="icon-action-menu icon-sort-by2"></i>{{ I18n.t('js.toolbar.settings.sort_by') }}</a></li>
<li><a href="" ng-click="showGroupingModal()"><i class="icon-action-menu icon-group-by2"></i>{{ I18n.t('js.toolbar.settings.group_by') }}</a></li>
<li>
<a href="" ng-click="toggleDisplaySums()">
<i ng-if="query.displaySums" class="icon-action-menu icon-yes"></i><i ng-if="!query.displaySums" class="icon-action-menu no-icon"></i>
<accessible-element visible-text="I18n.t('js.toolbar.settings.display_sums')"
readable-text="displaySumsLabel">
</accessible-element>
</a>
</li>
<li class="dropdown-divider"></li>
<li><a href="" ng-click="saveQuery($event)"
inaccessible-by-tab="(!query.isDirty() && cannot('query', 'update')) || (query.isNew() && cannot('query', 'create'))"
ng-class="{'inactive': (!query.isDirty() && cannot('query', 'update')) || (query.isNew() && cannot('query', 'create'))}">
<i class="icon-action-menu icon-save1"></i>{{ I18n.t('js.toolbar.settings.save') }}</a>
</li>
<li><a href="" ng-click="showSaveAsModal($event)"
inaccessible-by-tab="query.isNew() || cannot('query', 'create')"
ng-class="{'inactive': query.isNew() || cannot('query', 'create')}">
<i class="icon-action-menu icon-save1"></i>{{ I18n.t('js.toolbar.settings.save_as') }}</a>
</li>
<li><a href="" ng-click="deleteQuery($event)"
inaccessible-by-tab="cannot('query', 'delete')"
ng-class="{'inactive': cannot('query', 'delete')}">
<i class="icon-action-menu icon-delete"></i>{{ I18n.t('js.toolbar.settings.delete') }}</a>
</li>
<li><a href="" ng-click="showExportModal($event)"
inaccessible-by-tab="cannot('work_package', 'export')"
ng-class="{'inactive': cannot('work_package', 'export')}">
<i class="icon-action-menu icon-export"></i>{{ I18n.t('js.toolbar.settings.export') }}</a>
</li>
<li><a href="" ng-click="showShareModal($event)"
inaccessible-by-tab="cannot('query', 'publicize') && cannot('query', 'star')"
ng-class="{'inactive': (cannot('query', 'publicize') && cannot('query', 'star'))}">
<i class="icon-action-menu icon-publish"></i>{{ I18n.t('js.toolbar.settings.share') }}</a>
</li>
<li><a href="" ng-click="showSettingsModal($event)"
inaccessible-by-tab="cannot('query', 'update')"
ng-class="{'inactive': cannot('query', 'update')}">
<i class="icon-action-menu icon-settings"></i>{{ I18n.t('js.toolbar.settings.page_settings') }}</a>
</li>
</ul>
</div>
<div class="work-packages--filters-optional-container" ng-show="showFiltersOptions">
<div query-form id="query_form_content" class="hide-when-print">
<query-filters></query-filters>

@ -1,51 +1,52 @@
<div id="column-context-menu"
class="action-menu dropdown-relative"
role="menu"
class="dropdown-relative dropdown action-menu"
ng-class="{'dropdown-anchor-right': column && column.name !== 'id'}">
<ul class="menu">
<li ng-if="canSort()" ng-click="sortAscending(column.name)">
<a focus href="">
<ul class="dropdown-menu">
<li ng-if="canSort()" role="menuitem">
<a role="menuitem" focus href="" ng-click="sortAscending(column.name)">
<i class="icon-action-menu icon-sort-ascending"></i>
<span ng-bind="I18n.t('js.work_packages.query.sort_ascending')"/>
</a>
</li>
<li ng-if="canSort()" ng-click="sortDescending(column.name)">
<a href="">
<li ng-if="canSort()">
<a role="menuitem" href="" ng-click="sortDescending(column.name)">
<i class="icon-action-menu icon-sort-descending"></i>
<span ng-bind="I18n.t('js.work_packages.query.sort_descending')"/>
</a>
</li>
<li ng-if="isGroupable" ng-click="groupBy(column.name)">
<a focus="focusFeature('group')" href="">
<li ng-if="isGroupable">
<a role="menuitem" focus="focusFeature('group')" href="" ng-click="groupBy(column.name)">
<i class="icon-action-menu icon-group-by2"></i>
<span ng-bind="I18n.t('js.work_packages.query.group')"/>
</a>
</li>
<li ng-if="canMoveLeft()" ng-click="moveLeft(column.name)">
<a focus="focusFeature('moveLeft')" href="">
<li ng-if="canMoveLeft()">
<a role="menuitem" focus="focusFeature('moveLeft')" href="" ng-click="moveLeft(column.name)">
<i class="icon-action-menu icon-column-left"></i>
<span ng-bind="I18n.t('js.work_packages.query.move_column_left')"/>
</a>
</li>
<li ng-if="canMoveRight()" ng-click="moveRight(column.name)">
<a focus="focusFeature('moveRight')" href="">
<li ng-if="canMoveRight()">
<a role="menuitem" focus="focusFeature('moveRight')" href="" ng-click="moveRight(column.name)">
<i class="icon-action-menu icon-column-right"></i>
<span ng-bind="I18n.t('js.work_packages.query.move_column_right')"/>
</a>
</li>
<li ng-if="canBeHidden()" ng-click="hideColumn(column.name)">
<a focus="focusFeature('hide')" href="">
<li ng-if="canBeHidden()">
<a role="menuitem" focus="focusFeature('hide')" href="" ng-click="hideColumn(column.name)">
<i class="icon-action-menu icon-delete2"></i>
<span ng-bind="I18n.t('js.work_packages.query.hide_column')"/>
</a>
</li>
<li ng-click="insertColumns()">
<a focus="focusFeature('insert')" href="">
<li>
<a role="menuitem" focus="focusFeature('insert')" href="" ng-click="insertColumns()">
<i class="icon-action-menu icon-columns"></i>
<span ng-bind="I18n.t('js.work_packages.query.insert_columns')"/>
</a>

@ -0,0 +1,14 @@
<div class="dropdown dropdown-relative dropdown-anchor-right dropdown-anchor-top" id="moreDropdown" role="menu">
<ul class="dropdown-menu" ng-if="actionsAvailable">
<li ng-repeat="(action, properties) in permittedActions"
class="{{action}}">
<!-- The hrefs with empty URLs are necessary for IE10 to focus these links
properly. Thus, don't remove the hrefs or the empty URLs! -->
<a role="menuitem" href="" focus="{{ !$index }}"
ng-click="triggerMoreMenuAction(action, properties.link)"
ng-class="['icon-context'].concat(properties.css)"
ng-bind="I18n.t('js.button_' + action)">
</a>
</li>
</ul>
</div>

@ -0,0 +1,22 @@
<div class="dropdown dropdown-relative" id="querySelectDropdown">
<div class="search-query-wrapper">
<input type="search" focus=""
ng-model="filterBy"
ng-change="filterModels(filterBy)"
ng-keydown="handleSelection($event)"
id="title-filter"><i id="magnifier" class="icon-search"></i>
</input>
</div>
<div class="dropdown-scrollable">
<div class="query-menu-container" ng-if="group.models" ng-repeat="group in filteredGroups">
<ul class="query-menu">
<div class="title-group-header">{{ group.name }}</div>
<li ng-repeat="model in group.models"
ng-class="{'selected': model.highlighted }">
<a href="" ui-sref="work-packages.list({ query_id: model.id, query_props: undefined })" title="{{ model.label }}" ng-bind-html="model.labelHtml"></a>
</li>
</ul>
</div>
</div>
</div>

@ -0,0 +1,50 @@
<div class="dropdown dropdown-relative dropdown-anchor-right" id="settingsDropdown" role="menu">
<!-- The hrefs with empty URLs are necessary for IE10 to focus these links
properly. Thus, don't remove the hrefs or the empty URLs! -->
<ul class="dropdown-menu">
<li>
<a role="menuitem" focus="" href="" ng-click="showColumnsModal()"><i class="icon-action-menu icon-columns"></i>{{ I18n.t('js.toolbar.settings.columns') }}</a>
</li>
<li><a href="" ng-click="showSortingModal()"><i class="icon-action-menu icon-sort-by2"></i>{{ I18n.t('js.toolbar.settings.sort_by') }}</a></li>
<li><a href="" ng-click="showGroupingModal()"><i class="icon-action-menu icon-group-by2"></i>{{ I18n.t('js.toolbar.settings.group_by') }}</a></li>
<li>
<a role="menuitem" role="menuitem" href="" ng-click="toggleDisplaySums()">
<i ng-if="query.displaySums" class="icon-action-menu icon-yes"></i><i ng-if="!query.displaySums" class="icon-action-menu no-icon"></i>
<accessible-element visible-text="I18n.t('js.toolbar.settings.display_sums')"
readable-text="displaySumsLabel">
</accessible-element>
</a>
</li>
<li class="dropdown-divider"></li>
<li><a role="menuitem" href="" ng-click="saveQuery($event)"
inaccessible-by-tab="(!query.isDirty() && cannot('query', 'update')) || (query.isNew() && cannot('query', 'create'))"
ng-class="{'inactive': (!query.isDirty() && cannot('query', 'update')) || (query.isNew() && cannot('query', 'create'))}">
<i class="icon-action-menu icon-save1"></i>{{ I18n.t('js.toolbar.settings.save') }}</a>
</li>
<li><a role="menuitem" href="" ng-click="showSaveAsModal($event)"
inaccessible-by-tab="query.isNew() || cannot('query', 'create')"
ng-class="{'inactive': query.isNew() || cannot('query', 'create')}">
<i class="icon-action-menu icon-save1"></i>{{ I18n.t('js.toolbar.settings.save_as') }}</a>
</li>
<li><a role="menuitem" href="" ng-click="deleteQuery($event)"
inaccessible-by-tab="cannot('query', 'delete')"
ng-class="{'inactive': cannot('query', 'delete')}">
<i class="icon-action-menu icon-delete"></i>{{ I18n.t('js.toolbar.settings.delete') }}</a>
</li>
<li><a role="menuitem" href="" ng-click="showExportModal($event)"
inaccessible-by-tab="cannot('work_package', 'export')"
ng-class="{'inactive': cannot('work_package', 'export')}">
<i class="icon-action-menu icon-export"></i>{{ I18n.t('js.toolbar.settings.export') }}</a>
</li>
<li><a role="menuitem" href="" ng-click="showShareModal($event)"
inaccessible-by-tab="cannot('query', 'publicize') && cannot('query', 'star')"
ng-class="{'inactive': (cannot('query', 'publicize') && cannot('query', 'star'))}">
<i class="icon-action-menu icon-publish"></i>{{ I18n.t('js.toolbar.settings.share') }}</a>
</li>
<li><a role="menuitem" href="" ng-click="showSettingsModal($event)"
inaccessible-by-tab="cannot('query', 'update')"
ng-class="{'inactive': cannot('query', 'update')}">
<i class="icon-action-menu icon-settings"></i>{{ I18n.t('js.toolbar.settings.page_settings') }}</a>
</li>
</ul>
</div>

@ -0,0 +1,9 @@
<div class="dropdown action-menu dropdown-relative dropdown-anchor-right" id="tasksDropdown" role="menu">
<ul class="dropdown-menu">
<li ng-repeat="type in availableTypes">
<a role="menuitem" focus="{{ !$index }}" ng-href="{{ workPackageNewPath(type.id) }}">
{{type.name}}
</a>
</li>
</ul>
</div>

@ -0,0 +1,18 @@
<div id="work-package-context-menu" class="action-menu dropdown" role="menu">
<ul class="dropdown-menu">
<li class="open"
feature-flag="detailsView">
<a role="menuitem" focus="isDetailsViewLinkPresent()" ui-sref="work-packages.list.details.overview({workPackageId: row.object.id})">
<i ng-class="['icon-action-menu', 'icon-table-detail-view']"></i>
<span ng-bind="I18n.t('js.button_open_details')"/>
</a>
</li>
<li ng-repeat="(action, link) in permittedActions"
class="{{action}}">
<a role="menuitem" focus="$index == 0 && !isDetailsViewLinkPresent()" href="" ng-click="triggerContextMenuAction(action, link)">
<i ng-class="['icon-action-menu', 'icon-' + action]"></i>
<span ng-bind="I18n.t('js.button_' + action)"/>
</a>
</li>
</ul>
</div>

@ -1,34 +0,0 @@
<div id="work-package-context-menu" class="action-menu">
<ul class="menu">
<li class="open"
feature-flag="detailsView"
ui-sref="work-packages.list.details.overview({workPackageId: row.object.id})">
<a focus="isDetailsViewLinkVisible()" href="">
<i ng-class="['icon-action-menu', 'icon-table-detail-view']"></i>
<span ng-bind="I18n.t('js.button_open_details')"/>
</a>
</li>
<li ng-repeat="(action, link) in permittedActions"
ng-click="triggerContextMenuAction(action, link)"
class="{{action}}">
<a focus="$index == 0 && !isDetailsViewLinkVisible()" href="" ng-click="deleteWorkPackages()">
<i ng-class="['icon-action-menu', 'icon-' + action]"></i>
<span ng-bind="I18n.t('js.button_' + action)"/>
</a>
</li>
<li class="folder priority" ng-hide="hideResourceActions">
<a href="" class="context_item">TODO Priority</a>
<i class="icon-pulldown-arrow4 icon-submenu"></i>
<ul class="sub-menu">
<li><a href="" class=" disabled">Immediate</a></li>
<li><a href="" class=" disabled">Urgent</a></li>
<li><a href="" class=" disabled">High</a></li>
<li><a href="" class=" disabled">Normal</a></li>
<li><a href="" class=" disabled">Low</a></li>
<li><a href="" class=" disabled">Pointless</a></li>
</ul>
<div class="submenu"></div>
</li>
</ul>
</div>

@ -2,24 +2,10 @@
<button class="button" ng-click="editWorkPackage()"><i class="icon-left icon-edit"></i>{{ I18n.t('js.button_edit') }}</button>
<button class="button dropdown-relative"
ng-disabled="!(actionsAvailable || pluginActionsAvailable)"
with-dropdown
dropdown-id="moreDropdown">
has-dropdown-menu
target="DetailsMoreDropdownMenu"
locals="permittedActions,actionsAvailable,triggerMoreMenuAction">
{{ I18n.t('js.button_more') }}
<i class="icon-pulldown-arrow1 icon-edit"></i>
</button>
<div>
<div class="dropdown dropdown-relative dropdown-anchor-right dropdown-up" id="moreDropdown">
<ul class="dropdown-menu" ng-if="actionsAvailable">
<li ng-repeat="(action, properties) in permittedActions"
ng-click="triggerMoreMenuAction(action, properties.link)"
class="{{action}}">
<!-- The hrefs with empty URLs are necessary for IE10 to focus these links
properly. Thus, don't remove the hrefs or the empty URLs! -->
<a href=""
ng-class="['icon-context'].concat(properties.css)"
ng-bind="I18n.t('js.button_' + action)">
</a>
</li>
</ul>
</div>

@ -27,15 +27,14 @@
header-title="'#'"
has-dropdown-menu
target="ColumnContextMenu"
position-relative-to=".sort-header-outer"
locals="columns, column"
sortable="true"
query="query"/>
query="query">
</th>
<th sort-header ng-repeat="column in columns"
has-dropdown-menu
target="ColumnContextMenu"
position-relative-to=".sort-header-outer"
locals="columns, column"
locale="column.custom_field && columns.custom_field.name_locale || I18n.locale"
header-name="column.name"
@ -104,9 +103,11 @@
<tr work-package-row
id="work-package-{{ row.object.id }}"
has-context-menu
has-dropdown-menu
trigger-on-event="contextmenu"
target="WorkPackageContextMenu"
locals="rows, row"
locals="rows,row"
after-focus-on=".id a"
single-click="selectWorkPackage(row, $event)"
ng-dblclick="showWorkPackageDetails(row)"
ng-class="[

@ -1,66 +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.
//++
/*jshint expr: true*/
describe('dropdown Directive', function() {
var compile, element, rootScope, scope;
beforeEach(angular.mock.module('openproject.uiComponents'));
beforeEach(module('openproject.templates'));
beforeEach(inject(function($rootScope, $compile) {
var html;
html = '<div dropdown></div>';
element = angular.element(html);
rootScope = $rootScope;
scope = $rootScope.$new();
scope.doNotShow = true;
compile = function() {
$compile(element)(scope);
scope.$digest();
};
}));
describe('element', function() {
beforeEach(function() {
compile();
});
it('should preserve its div', function() {
expect(element.prop('tagName')).to.equal('DIV');
});
it('should be in a collapsed state', function() {
expect(element.is(":visible")).to.be.false;
});
});
});

@ -30,13 +30,16 @@
describe('selectableTitle Directive', function() {
var MODEL_SELECTOR = 'div.dropdown-scrollable a';
var compile, element, rootScope, scope;
beforeEach(angular.mock.module('openproject.uiComponents'));
beforeEach(module('openproject.templates', 'truncate'));
beforeEach(inject(function($rootScope, $compile) {
var compile, element, rootScope, scope, $timeout;
beforeEach(module(
'openproject.workPackages',
'openproject.workPackages.controllers',
'openproject.templates',
'truncate'));
beforeEach(inject(function($rootScope, $compile, _$timeout_) {
var html;
$timeout = _$timeout_;
html = '<selectable-title selected-title="selectedTitle" reload-method="reloadMethod" groups="groups"></selectable-title>';
element = angular.element(html);
@ -45,15 +48,21 @@ describe('selectableTitle Directive', function() {
scope.doNotShow = true;
compile = function() {
angular.element(document).find('body').append(element);
$compile(element)(scope);
scope.$digest();
};
}));
afterEach(function() {
element.remove();
});
describe('element', function() {
beforeEach(function() {
scope.selectedTitle = 'Title1';
scope.reloadMethod = function(){ return false; };
scope.transitionMethod = function(){ return false; };
scope.groups = [{
name: 'pinkies',
models: [
@ -71,6 +80,9 @@ describe('selectableTitle Directive', function() {
}];
compile();
element.find('span:first').click();
scope.$digest();
});
it('should compile to a div', function() {
@ -135,15 +147,13 @@ describe('selectableTitle Directive', function() {
var title = element.find('span').first();
expect(title.text().replace(/(\n|\s)/gm,"")).to.equal('Title1');
element.find('h2 span').first().click();
var listElements = element.find('li');
expect(jQuery(listElements[0]).hasClass('selected')).to.be.false;
var e = jQuery.Event('keydown');
e.which = 40;
element.find('#title-filter').first().trigger(e);
element.find('input').first().trigger(e);
expect(jQuery(listElements[0]).hasClass('selected')).to.be.true;
});
@ -151,7 +161,6 @@ describe('selectableTitle Directive', function() {
var title = element.find('span').first();
expect(title.text().replace(/(\n|\s)/gm,"")).to.equal('Title1');
element.find('h2 span').first().click();
var listElements = element.find('li');
expect(jQuery(listElements[1]).hasClass('selected')).to.be.false;
@ -172,7 +181,6 @@ describe('selectableTitle Directive', function() {
var title = element.find('span').first();
expect(title.text()).to.equal('Title1');
element.find('h2 span').first().click();
var listElements = element.find('li');
var e = jQuery.Event('keydown');
e.which = 40;

@ -1,65 +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.
//++
/*jshint expr: true*/
describe('withDropdown Directive', function() {
var compile, element, rootScope, scope;
beforeEach(angular.mock.module('openproject.uiComponents'));
beforeEach(module('openproject.templates'));
beforeEach(inject(function($rootScope, $compile) {
var html;
html = '<div with-dropdown dropdown-id="2"></div>';
element = angular.element(html);
rootScope = $rootScope;
scope = $rootScope.$new();
scope.doNotShow = true;
compile = function() {
$compile(element)(scope);
scope.$digest();
};
}));
describe('element', function() {
beforeEach(function() {
compile();
});
it('should preserve its div', function() {
expect(element.prop('tagName')).to.equal('DIV');
});
it('should be in a collapsed state', function() {
expect(element.is(":visible")).to.be.false;
});
});
});

@ -59,7 +59,7 @@ describe('columnContextMenu', function() {
$rootScope = _$rootScope_;
ngContextMenu = _ngContextMenu_;
var template = $templateCache.get('/templates/work_packages/column_context_menu.html');
var template = $templateCache.get('/templates/work_packages/menus/column_context_menu.html');
$templateCache.put('column_context_menu.html', [200, template, {}]);
contextMenu = ngContextMenu({

@ -31,9 +31,8 @@
describe('optionsDropdown Directive', function() {
var compile, element, rootScope, scope, Query, I18n, AuthorisationService, stateParams = {};
beforeEach(angular.mock.module('openproject.workPackages.directives'));
beforeEach(module('openproject.models',
'openproject.workPackages.controllers',
'openproject.workPackages',
'openproject.api',
'openproject.layout',
'openproject.services'));
@ -53,18 +52,20 @@ describe('optionsDropdown Directive', function() {
beforeEach(inject(function($rootScope, $compile) {
var optionsDropdownHtml;
optionsDropdownHtml = '<div options-dropdown><a href ng-click="showSaveAsModal($event)" ng-class="{\'inactive\': query.isNew()}"></a></div>';
optionsDropdownHtml = '<div id="toolbar"><button has-dropdown-menu="" target="SettingsDropdownMenu" locals="query"></button></div>';
element = angular.element(optionsDropdownHtml);
rootScope = $rootScope;
scope = $rootScope.$new();
compile = function() {
angular.element(document).find('body').append(element);
$compile(element)(scope);
element.find('button').click();
scope.$digest();
};
}));
beforeEach(inject(function(_AuthorisationService_, _Query_, _I18n_){
AuthorisationService = _AuthorisationService_;
Query = _Query_;
@ -78,6 +79,7 @@ describe('optionsDropdown Directive', function() {
afterEach(function() {
I18n.t.restore();
element.remove();
});
describe('element', function() {
@ -96,13 +98,12 @@ describe('optionsDropdown Directive', function() {
});
it('should have an inactive save as option', function() {
var saveAsLink = element.find('a').first();
var saveAsLink = element.find('a[ng-click="showSaveAsModal($event)"]').first();
expect(saveAsLink.hasClass('inactive')).to.be.ok;
});
context('share option', function() {
beforeEach(function() {
var optionsDropdownHtml = '<div options-dropdown><a class="publicize-or-star-link" href ng-click="showShareModal($event)" ng-class="{\'inactive\': (cannot(\'query\', \'publicize\') && cannot(\'query\', \'star\'))}"></a></div>';
var query = new Query({
id: 1
});
@ -110,19 +111,18 @@ describe('optionsDropdown Directive', function() {
AuthorisationService.initModelAuth('query', {
create: '/queries'
});
element = angular.element(optionsDropdownHtml);
compile();
});
it('should check with AuthorisationService when called', function() {
var shareLink = element.find('.publicize-or-star-link').first();
var shareLink = element.find('a[ng-click="showShareModal($event)"]').first();
sinon.spy(AuthorisationService, "can");
shareLink.click();
expect(AuthorisationService.can).to.have.been.called;
});
});
it('should not open save as modal', function() {
var saveAsLink = element.find('a').first();
var saveAsLink = element.find('a[ng-click="showSaveAsModal($event)"]').first();
saveAsLink.click();
expect(jQuery('.ng-modal-window').length).to.equal(0);
@ -149,7 +149,7 @@ describe('optionsDropdown Directive', function() {
});
it('should open save as modal', function() {
var saveAsLink = element.find('a').first();
var saveAsLink = element.find('a[ng-click="showSaveAsModal($event)"]').first();
saveAsLink.click();
expect(jQuery('.ng-modal-window').length).to.equal(1);

@ -391,7 +391,6 @@ describe('inplaceEditor Directive', function() {
});
it('should trigger edit mode on click', function() {
element.find('.ined-read-value').click();
scope.$digest();
expect(elementScope.isEditing).to.eq(true);
});

@ -33,15 +33,18 @@ describe('workPackageDetailsToolbar', function() {
var html = "<work-package-details-toolbar work-package='workPackage'></work-package-details-toolbar>";
stateParams = {};
beforeEach(module('ui.router',
'openproject.workPackages.controllers',
'openproject.uiComponents',
'openproject.workPackages',
'openproject.api',
'openproject.models',
'openproject.layout',
'openproject.services',
'openproject.uiComponents',
'openproject.workPackages.directives',
'openproject.workPackages.models',
'openproject.workPackages.services'));
'openproject.templates'
));
beforeEach(module('openproject.templates', function($provide) {
var configurationService = {};
@ -53,27 +56,31 @@ describe('workPackageDetailsToolbar', function() {
beforeEach(inject(function($rootScope, $compile, _I18n_, _HookService_) {
I18n = _I18n_;
HookService = _HookService_;
var stub = sinon.stub(I18n, 't');
stub.withArgs('js.button_log_time').returns('Log time');
stub.withArgs('js.button_duplicate').returns('Duplicate');
stub.withArgs('js.button_move').returns('Move');
stub.withArgs('js.button_delete').returns('Delete');
stub.withArgs('js.button_plugin_action_1').returns('plugin_action_1');
stub.withArgs('js.button_plugin_action_2').returns('plugin_action_2');
scope = $rootScope.$new();
compile = function() {
element = $compile(html)(scope);
angular.element(document).find('body').html('');
angular.element(document).find('body').append(element);
element = $compile(element)(scope);
scope.$digest();
element.find('button:eq(1)').click();
};
var stub = sinon.stub(I18n, 't');
stub.withArgs('js.button_log_time').returns('trans_log_time');
stub.withArgs('js.button_duplicate').returns('trans_duplicate');
stub.withArgs('js.button_move').returns('trans_move');
stub.withArgs('js.button_delete').returns('trans_delete');
stub.withArgs('js.button_plugin_action_1').returns('trans_plugin_action_1');
stub.withArgs('js.button_plugin_action_2').returns('trans_plugin_action_2');
}));
afterEach(function() {
I18n.t.restore();
element.remove();
});
var pluginActions = {
@ -99,13 +106,13 @@ describe('workPackageDetailsToolbar', function() {
var actions = [pluginActions.plugin_action_1, pluginActions.plugin_action_2];
callStub.withArgs('workPackageDetailsMoreMenu').returns(actions);
element = angular.element(html);
compile();
});
var getLink = function(listRoot, action) {
return angular.element(listRoot.find('li a.' + action));
return listRoot.find('.' + action);
};
var shouldBehaveLikeListOfWorkPackageActionLinks = function(listRootSelector, actions) {
@ -116,6 +123,7 @@ describe('workPackageDetailsToolbar', function() {
});
describe('links', function() {
it('contains links for all core actions', function() {
angular.forEach(actions, function(css, action) {
var link = getLink(listRoot, css);
@ -128,7 +136,7 @@ describe('workPackageDetailsToolbar', function() {
angular.forEach(actions, function(css, action) {
var link = getLink(listRoot, css);
expect(link.text()).to.eq(I18n.t('js.button_' + action));
expect(link.text()).to.match(new RegExp(I18n.t('js.button_' + action)));
});
});
});
@ -150,7 +158,7 @@ describe('workPackageDetailsToolbar', function() {
angular.forEach(pluginCss, function(value) {
expect(link.hasClass(value)).to.be.true;
});
expect(link.text()).to.eq(I18n.t('js.button_' + action));
expect(link.text()).to.match(new RegExp(I18n.t('js.button_' + action)));
});
});
});

@ -58,7 +58,8 @@ describe('workPackageContextMenu', function() {
$rootScope = _$rootScope_;
ngContextMenu = _ngContextMenu_;
var template = $templateCache.get('/templates/work_packages/work_package_context_menu.html');
var template = $templateCache
.get('/templates/work_packages/menus/work_package_context_menu.html');
$templateCache.put('work_package_context_menu.html', [200, template, {}]);
contextMenu = ngContextMenu({
@ -98,7 +99,7 @@ describe('workPackageContextMenu', function() {
$rootScope.$digest();
directListElements = container.find('.menu > li:not(.folder)');
directListElements = container.find('.dropdown-menu > li:not(.folder)');
});
it('lists link tags for any permitted action', function(){
@ -137,10 +138,10 @@ describe('workPackageContextMenu', function() {
$rootScope.row = {object: workPackage};
$rootScope.$digest();
directListElements = container.find('.menu > li:not(.folder)');
directListElements = container.find('.dropdown-menu > li:not(.folder)');
});
it('displays a link triggering deleteWorkPackages within the scope', function() {
xit('displays a link triggering deleteWorkPackages within the scope', function() {
expect(directListElements.find('a:has(i.icon-delete)').attr('ng-click')).to.equal('deleteWorkPackages()');
});
});

@ -37,14 +37,24 @@ module API
include Roar::Hypermedia
include API::Utilities::UrlHelper
def initialize(models, total, self_link, decorator)
def initialize(models, total, self_link, context: {})
@total = total
@self_link = self_link
@decorator = decorator
@context = context
super(models)
end
class_attribute :element_decorator_class
def self.element_decorator(klass)
self.element_decorator_class = klass
end
def element_decorator
self.class.element_decorator_class
end
as_strategy = API::Utilities::CamelCasingStrategy.new
link :self do
@ -56,9 +66,17 @@ module API
property :count, getter: -> (*) { empty? ? 0 : count }
collection :elements,
getter: -> (*) { represented.map { |model| @decorator.new(model) } },
getter: -> (*) {
represented.map { |model|
element_decorator.new(model, context)
}
},
exec_context: :decorator,
embedded: true
private
attr_reader :context
end
end
end

@ -0,0 +1,53 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'roar/decorator'
require 'roar/json/hal'
module API
module Decorators
class Single < Roar::Decorator
include Roar::JSON::HAL
include Roar::Hypermedia
include API::V3::Utilities::PathHelper
attr_reader :context
class_attribute :as_strategy
self.as_strategy = API::Utilities::CamelCasingStrategy.new
def initialize(model, context = {})
@context = context
super(model)
end
property :_type, exec_context: :decorator
end
end
end

@ -61,7 +61,7 @@ module API
helpers do
def current_user
return User.current if Rails.env.test?
return User.current if running_in_test_env?
user_id = env['rack.session']['user_id']
User.current = user_id ? User.find(user_id) : User.anonymous
end
@ -75,6 +75,27 @@ module API
raise API::Errors::Unauthorized unless is_authorized && allow
is_authorized
end
def running_in_test_env?
Rails.env.test? && ENV['CAPYBARA_DISABLE_TEST_AUTH_PROTECTION'] != 'true'
end
# checks whether the user has
# any of the provided permission in any of the provided
# projects
def authorize_any(permissions, projects, user: current_user)
projects = Array(projects)
authorized = permissions.any? do |permission|
allowed_condition = Project.allowed_to_condition(user, permission)
allowed_projects = Project.where(allowed_condition)
!(allowed_projects & projects).empty?
end
raise API::Errors::Unauthorized unless authorized
authorized
end
end
rescue_from ActiveRecord::RecordNotFound do |e|
@ -96,6 +117,12 @@ module API
# run authentication before each request
before do
# Call current_user as it sets User.current.
# Not doing this might cause devs to use User.current without that value
# being set to the actually current user. That might result in standard
# users becoming admins and otherwise based on who called the ruby
# process last.
current_user
authenticate
end

@ -27,17 +27,11 @@
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'roar/decorator'
require 'roar/json/collection'
require 'roar/json/hal'
module API
module V3
module Categories
class CategoryCollectionRepresenter < ::API::Decorators::Collection
def initialize(models, total, self_link)
super(models, total, self_link, ::API::V3::Categories::CategoryRepresenter)
end
element_decorator ::API::V3::Categories::CategoryRepresenter
end
end
end

@ -33,15 +33,7 @@ require 'roar/json/hal'
module API
module V3
module Categories
class CategoryRepresenter < Roar::Decorator
include Roar::JSON::HAL
include Roar::Hypermedia
include OpenProject::StaticRouting::UrlHelpers
self.as_strategy = API::Utilities::CamelCasingStrategy.new
property :_type, exec_context: :decorator
class CategoryRepresenter < ::API::Decorators::Single
property :id, render_nil: true
property :name, render_nil: true

@ -27,17 +27,11 @@
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'roar/decorator'
require 'roar/json/collection'
require 'roar/json/hal'
module API
module V3
module Priorities
class PriorityCollectionRepresenter < ::API::Decorators::Collection
def initialize(models, total, self_link)
super(models, total, self_link, ::API::V3::Priorities::PriorityRepresenter)
end
element_decorator ::API::V3::Priorities::PriorityRepresenter
end
end
end

@ -33,15 +33,7 @@ require 'roar/json/hal'
module API
module V3
module Priorities
class PriorityRepresenter < Roar::Decorator
include Roar::JSON::HAL
include Roar::Hypermedia
include OpenProject::StaticRouting::UrlHelpers
self.as_strategy = API::Utilities::CamelCasingStrategy.new
property :_type, exec_context: :decorator
class PriorityRepresenter < ::API::Decorators::Single
property :id, render_nil: true
property :name

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

@ -33,15 +33,7 @@ require 'roar/json/hal'
module API
module V3
module Projects
class ProjectRepresenter < Roar::Decorator
include Roar::JSON::HAL
include Roar::Hypermedia
include API::V3::Utilities::PathHelper
self.as_strategy = API::Utilities::CamelCasingStrategy.new
property :_type, exec_context: :decorator
class ProjectRepresenter < ::API::Decorators::Single
link :self do
{
href: api_v3_paths.project(represented.id),

@ -48,7 +48,7 @@ module API
mount API::V3::Projects::AvailableAssigneesAPI
mount API::V3::Projects::AvailableResponsiblesAPI
mount API::V3::Categories::CategoriesAPI
mount API::V3::Versions::VersionsAPI
mount API::V3::Versions::ProjectsVersionsAPI
end
end
end

@ -33,15 +33,7 @@ require 'roar/json/hal'
module API
module V3
module Queries
class QueryRepresenter < Roar::Decorator
include Roar::JSON::HAL
include Roar::Hypermedia
include API::V3::Utilities::PathHelper
self.as_strategy = API::Utilities::CamelCasingStrategy.new
property :_type, exec_context: :decorator
class QueryRepresenter < ::API::Decorators::Single
link :self do
{
href: api_v3_paths.query(represented.id),

@ -44,6 +44,7 @@ module API
mount ::API::V3::Render::RenderAPI
mount ::API::V3::Statuses::StatusesAPI
mount ::API::V3::Users::UsersAPI
mount ::API::V3::Versions::VersionsAPI
mount ::API::V3::WorkPackages::WorkPackagesAPI
get '/' do

@ -35,9 +35,7 @@ module API
module V3
module Statuses
class StatusCollectionRepresenter < ::API::Decorators::Collection
def initialize(models, total, self_link)
super(models, total, self_link, ::API::V3::Statuses::StatusRepresenter)
end
element_decorator ::API::V3::Statuses::StatusRepresenter
end
end
end

@ -27,21 +27,10 @@
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'roar/decorator'
require 'roar/json/hal'
module API
module V3
module Statuses
class StatusRepresenter < Roar::Decorator
include Roar::JSON::HAL
include Roar::Hypermedia
include API::V3::Utilities::PathHelper
self.as_strategy = API::Utilities::CamelCasingStrategy.new
property :_type, exec_context: :decorator
class StatusRepresenter < ::API::Decorators::Single
link :self do
{
href: api_v3_paths.status(represented.id),

@ -31,9 +31,7 @@ module API
module V3
module Users
class UserCollectionRepresenter < ::API::Decorators::Collection
def initialize(models, total, self_link)
super(models, total, self_link, ::API::V3::Users::UserRepresenter)
end
element_decorator ::API::V3::Users::UserRepresenter
end
end
end

@ -33,24 +33,9 @@ require 'roar/json/hal'
module API
module V3
module Users
class UserRepresenter < Roar::Decorator
include Roar::JSON::HAL
include Roar::Hypermedia
include API::V3::Utilities::PathHelper
class UserRepresenter < ::API::Decorators::Single
include AvatarHelper
self.as_strategy = API::Utilities::CamelCasingStrategy.new
def initialize(model, options = {}, *expand)
@current_user = options[:current_user]
@work_package = options[:work_package]
@expand = expand
super(model)
end
property :_type, exec_context: :decorator
link :self do
{
href: api_v3_paths.user(represented.id),
@ -74,12 +59,20 @@ module API
} if current_user_is_admin && represented.activatable?
end
link :delete do
{
href: api_v3_paths.user(represented.id),
title: "Delete #{represented.login}",
method: :delete
} if current_user_can_delete_represented?
end
link :removeWatcher do
{
href: api_v3_paths.watcher(represented.id, @work_package.id),
href: api_v3_paths.watcher(represented.id, work_package.id),
method: :delete,
title: 'Remove watcher'
} if @work_package && current_user_allowed_to(:delete_work_package_watchers, @work_package)
} if work_package && current_user_allowed_to(:delete_work_package_watchers, work_package)
end
property :id, render_nil: true
@ -101,11 +94,25 @@ module API
end
def current_user_is_admin
@current_user && @current_user.admin?
current_user && current_user.admin?
end
def current_user_allowed_to(permission, work_package)
@current_user && @current_user.allowed_to?(permission, work_package.project)
current_user && current_user.allowed_to?(permission, work_package.project)
end
private
def current_user
context[:current_user]
end
def work_package
context[:work_package]
end
def current_user_can_delete_represented?
@current_user && DeleteUserService.deletion_allowed?(represented, @current_user)
end
end
end

@ -60,7 +60,7 @@ module API
end
delete do
if DeleteUserService.new(@user, User.current).call
if DeleteUserService.new(@user, current_user).call
status 202
else
fail ::API::Errors::Unauthorized
@ -71,7 +71,7 @@ module API
# Authenticate lock transitions
before do
if !User.current.admin?
unless current_user.admin?
fail ::API::Errors::Unauthorized
end
end

@ -106,10 +106,18 @@ module API
"#{user(id)}/lock"
end
def self.version(version_id)
"#{root}/versions/#{version_id}"
end
def self.versions(project_id)
"#{project(project_id)}/versions"
end
def self.versions_projects(version_id)
"#{version(version_id)}/projects"
end
def self.watcher(id, work_package_id)
"#{work_package(work_package_id)}/watchers/#{id}"
end

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

@ -28,6 +28,7 @@
#++
require 'roar/decorator'
require 'roar/json'
require 'roar/json/collection'
require 'roar/json/hal'
@ -35,9 +36,7 @@ module API
module V3
module Versions
class VersionCollectionRepresenter < ::API::Decorators::Collection
def initialize(models, total, self_link)
super(models, total, self_link, ::API::V3::Versions::VersionRepresenter)
end
element_decorator ::API::V3::Versions::VersionRepresenter
end
end
end

@ -33,21 +33,55 @@ require 'roar/json/hal'
module API
module V3
module Versions
class VersionRepresenter < Roar::Decorator
include Roar::JSON::HAL
include Roar::Hypermedia
include OpenProject::StaticRouting::UrlHelpers
class VersionRepresenter < ::API::Decorators::Single
link :self do
{
href: api_v3_paths.version(represented.id),
title: "#{represented.name}"
}
end
self.as_strategy = API::Utilities::CamelCasingStrategy.new
link :definingProject do
{
href: api_v3_paths.project(represented.project.id),
title: represented.project.name
} if represented.project.visible?(current_user)
end
property :_type, exec_context: :decorator
link :availableInProjects do
{
href: api_v3_paths.versions_projects(represented.id)
}
end
property :id, render_nil: true
property :name
property :name, render_nil: true
property :description,
exec_context: :decorator,
getter: -> (*) {
{
format: 'plain',
raw: represented.description,
}
},
render_nil: true
property :start_date, render_nil: true
property :due_date, as: 'endDate', render_nil: true
property :status, render_nil: true
property :created_on, as: 'createdAt', render_nil: true
property :updated_on, as: 'updatedAt', render_nil: true
def _type
'Version'
end
private
def current_user
context[:current_user]
end
end
end
end

@ -32,14 +32,34 @@ module API
module Versions
class VersionsAPI < Grape::API
resources :versions do
before do
@versions = @project.shared_versions.all
end
get do
VersionCollectionRepresenter.new(@versions,
@versions.count,
api_v3_paths.versions(@project.identifier))
namespace ':id' do
before do
@version = Version.find(params[:id])
authorized_for_version?(@version)
end
helpers do
def authorized_for_version?(version)
projects = version.projects
permissions = [:view_work_packages, :manage_versions]
authorize_any(permissions, projects, user: current_user)
end
def context
{ current_user: current_user }
end
end
get do
VersionRepresenter.new(@version, context)
end
mount API::V3::Versions::VersionsProjectsAPI
end
end
end

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

@ -33,23 +33,7 @@ require 'roar/json/hal'
module API
module V3
module WorkPackages
class RelationRepresenter < Roar::Decorator
include Roar::JSON::HAL
include Roar::Hypermedia
include API::V3::Utilities::PathHelper
self.as_strategy = API::Utilities::CamelCasingStrategy.new
def initialize(model, options = {}, *expand)
@current_user = options[:current_user]
@work_package = options[:work_package]
@expand = expand
super(model)
end
property :_type, exec_context: :decorator
class RelationRepresenter < ::API::Decorators::Single
link :self do
{ href: api_v3_paths.relation(represented.id) }
end
@ -79,11 +63,19 @@ module API
private
def current_user_allowed_to(permission)
@current_user && @current_user.allowed_to?(permission, represented.from.project)
current_user && current_user.allowed_to?(permission, represented.from.project)
end
def relation_type
represented.relation_type_for(@work_package).camelize
represented.relation_type_for(work_package).camelize
end
def work_package
context[:work_package]
end
def current_user
context[:current_user]
end
end
end

@ -33,21 +33,8 @@ require 'roar/json/hal'
module API
module V3
module WorkPackages
class WorkPackageRepresenter < Roar::Decorator
include Roar::JSON::HAL
include Roar::Hypermedia
include API::V3::Utilities::PathHelper
self.as_strategy = ::API::Utilities::CamelCasingStrategy.new
def initialize(model, options = {}, *expand)
@current_user = options[:current_user]
@expand = expand
super(model)
end
property :_type, exec_context: :decorator, writeable: false
class WorkPackageRepresenter < ::API::Decorators::Single
include OpenProject::TextFormatting
link :self do
{
@ -143,20 +130,20 @@ module API
{
href: api_v3_paths.work_package_watchers(represented.id),
method: :post,
data: { user_id: @current_user.id },
data: { user_id: current_user.id },
title: 'Watch work package'
} if !@current_user.anonymous? &&
} if !current_user.anonymous? &&
current_user_allowed_to(:view_work_packages) &&
!represented.watcher_users.include?(@current_user)
!represented.watcher_users.include?(current_user)
end
link :unwatchChanges do
{
href: "#{api_v3_paths.work_package_watchers(represented.id)}/#{@current_user.id}",
href: "#{api_v3_paths.work_package_watchers(represented.id)}/#{current_user.id}",
method: :delete,
title: 'Unwatch work package'
} if current_user_allowed_to(:view_work_packages) &&
represented.watcher_users.include?(@current_user)
represented.watcher_users.include?(current_user)
end
link :addWatcher do
@ -217,10 +204,10 @@ module API
link :version do
{
href: version_path(represented.fixed_version),
type: 'text/html',
href: api_v3_paths.version(represented.fixed_version.id),
title: "#{represented.fixed_version.to_s_for_project(represented.project)}"
} if represented.fixed_version && @current_user.allowed_to?({ controller: 'versions', action: 'show' }, represented.fixed_version.project, global: false)
} if represented.fixed_version &&
version_policy.allowed?(represented.fixed_version, :show)
end
links :children do
@ -289,6 +276,12 @@ module API
property :category, embedded: true, class: ::Category, decorator: ::API::V3::Categories::CategoryRepresenter, if: -> (*) { !category.nil? }
property :activities, embedded: true, exec_context: :decorator
property :version,
embedded: true,
exec_context: :decorator,
if: ->(*) { represented.fixed_version.present? }
property :watchers, embedded: true, exec_context: :decorator, if: -> (*) { current_user_allowed_to(:view_work_package_watchers) }
collection :attachments, embedded: true, class: ::Attachment, decorator: ::API::V3::Attachments::AttachmentRepresenter
property :relations, embedded: true, exec_context: :decorator
@ -298,18 +291,22 @@ module API
end
def activities
represented.journals.map { |activity| ::API::V3::Activities::ActivityRepresenter.new(activity, current_user: @current_user) }
represented.journals.map { |activity| ::API::V3::Activities::ActivityRepresenter.new(activity, current_user: current_user) }
end
def watchers
watchers = represented.watcher_users.order(User::USER_FORMATS_STRUCTURE[Setting.user_format])
watchers.map { |watcher| ::API::V3::Users::UserRepresenter.new(watcher, work_package: represented, current_user: @current_user) }
watchers.map { |watcher| ::API::V3::Users::UserRepresenter.new(watcher, work_package: represented, current_user: current_user) }
end
def relations
relations = represented.relations
visible_relations = relations.select { |relation| relation.other_work_package(represented).visible? }
visible_relations.map { |relation| RelationRepresenter.new(relation, work_package: represented, current_user: @current_user) }
visible_relations.map { |relation| RelationRepresenter.new(relation, work_package: represented, current_user: current_user) }
end
def version
Versions::VersionRepresenter.new(represented.fixed_version, current_user: current_user)
end
def custom_properties
@ -320,7 +317,7 @@ module API
end
def current_user_allowed_to(permission)
@current_user && @current_user.allowed_to?(permission, represented.project)
current_user && current_user.allowed_to?(permission, represented.project)
end
def visible_children
@ -343,6 +340,14 @@ module API
{ hours: hours.to_i, minutes: minutes }
end
def current_user
context[:current_user]
end
def version_policy
@version_policy ||= ::VersionPolicy.new(current_user)
end
end
end
end

@ -76,12 +76,13 @@ module API
before do
@work_package = WorkPackage.find(params[:id])
@representer = ::API::V3::WorkPackages::WorkPackageRepresenter
.new(work_package, { current_user: current_user }, :activities, :users)
@representer = WorkPackageRepresenter.new(work_package,
current_user: current_user)
end
get do
authorize({ controller: :work_packages_api, action: :get }, context: @work_package.project)
authorize({ controller: :work_packages_api, action: :get },
context: @work_package.project)
@representer
end

@ -105,6 +105,10 @@ describe AttachmentsController, type: :controller do
let(:work_package) { FactoryGirl.create :work_package, project: project }
let(:uploader) { nil }
##
# Stubs an attachment instance of the respective uploader.
# It's an anonymous subclass of Attachment and can therefore
# not be saved.
let(:attachment) do
clazz = Class.new Attachment
clazz.mount_uploader :file, uploader
@ -118,6 +122,7 @@ describe AttachmentsController, type: :controller do
att = clazz.new container: work_package, author: user, file: file
att.id = 42
att.file.store!
att.send :write_attribute, :file, file.original_filename
att
end

@ -290,7 +290,7 @@ describe 'Work package index accessibility', type: :feature do
describe 'column header drop down menu', js: true do
it_behaves_like 'context menu' do
let(:source_link) { 'table.workpackages-table th:nth-of-type(2) a' }
let(:target_link) { '#column-context-menu .menu li:first-of-type a' }
let(:target_link) { '#column-context-menu .dropdown-menu li:first-of-type a' }
let(:keys) { :enter }
end
end

@ -0,0 +1,81 @@
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe 'Login', type: :feature do
after do
User.current = nil
enable_test_auth_protection
end
let(:user_password) { 'bob1!' * 4 }
let(:user) do
FactoryGirl.create(:user,
force_password_change: false,
first_login: false,
login: 'bob',
mail: 'bob@example.com',
firstname: 'Bo',
lastname: 'B',
password: user_password,
password_confirmation: user_password,
)
end
let(:other_user) { FactoryGirl.create(:user) }
it 'enforces the current user to be set correctly on each api request' do
# login to set the session
visit signin_path
within('#login-form') do
fill_in('username', with: user.login)
fill_in('password', with: user_password)
click_link_or_button I18n.t(:button_login)
end
# simulate another user having used the process
# which would cause User.current to be set
User.current = other_user
# disable a hack in the API's authenticate method
# which would cause authentication to not work
disable_test_auth_protection
# taking /api/v3 as it does not run any authorization
visit '/api/v3'
expect(User.current).to eql(user)
end
def disable_test_auth_protection
ENV['CAPYBARA_DISABLE_TEST_AUTH_PROTECTION'] = 'true'
end
def enable_test_auth_protection
ENV.delete 'CAPYBARA_DISABLE_TEST_AUTH_PROTECTION'
end
end

@ -0,0 +1,41 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe ::API::V3::Projects::ProjectCollectionRepresenter do
let(:self_link) { '/api/v3/versions/1/projects' }
let(:projects) { FactoryGirl.build_list(:project, 3) }
let(:representer) { described_class.new(projects, 42, self_link) }
context 'generation' do
subject(:collection) { representer.to_json }
it_behaves_like 'API V3 collection decorated', 42, 3, 'versions/1/projects', 'Project'
end
end

@ -35,5 +35,9 @@ shared_examples_for 'API V3 formattable' do |property|
it { is_expected.to be_json_eql(raw.to_json).at_path(property + '/raw') }
it { is_expected.to be_json_eql(html.to_json).at_path(property + '/html') }
it do
if defined?(html)
is_expected.to be_json_eql(html.to_json).at_path(property + '/html')
end
end
end

@ -76,7 +76,7 @@ describe ::API::V3::Users::UserRepresenter do
end
context 'when current_user is admin' do
let(:current_user) { FactoryGirl.create(:admin) }
let(:current_user) { FactoryGirl.build_stubbed(:admin) }
it 'should link to lock' do
expect(subject).to have_json_path('_links/lock/href')
@ -89,6 +89,30 @@ describe ::API::V3::Users::UserRepresenter do
end
end
end
context 'when deletion is allowed' do
before do
allow(DeleteUserService).to receive(:deletion_allowed?)
.with(user, current_user)
.and_return(true)
end
it 'should link to delete' do
expect(subject).to have_json_path('_links/delete/href')
end
end
context 'when deletion is not allowed' do
before do
allow(DeleteUserService).to receive(:deletion_allowed?)
.with(user, current_user)
.and_return(false)
end
it 'should not link to delete' do
expect(subject).to_not have_json_path('_links/delete/href')
end
end
end
describe 'avatar' do

@ -177,6 +177,14 @@ describe ::API::V3::Utilities::PathHelper do
it { is_expected.to match(/^\/api\/v3\/users\/1/) }
end
describe '#version' do
subject { helper.version 42 }
it_behaves_like 'api v3 path'
it { is_expected.to match(/^\/api\/v3\/versions\/42/) }
end
describe '#versions' do
subject { helper.versions 42 }
@ -185,6 +193,14 @@ describe ::API::V3::Utilities::PathHelper do
it { is_expected.to match(/^\/api\/v3\/projects\/42\/versions/) }
end
describe '#versions_projects' do
subject { helper.versions_projects 42 }
it_behaves_like 'api v3 path'
it { is_expected.to match(/^\/api\/v3\/versions\/42\/projects/) }
end
describe 'work packages paths' do
shared_examples_for 'api v3 work packages path' do
it { is_expected.to match(/^\/api\/v3\/work_packages/) }

@ -31,7 +31,9 @@ require 'spec_helper'
describe ::API::V3::Versions::VersionCollectionRepresenter do
let(:self_link) { '/api/v3/projects/1/versions' }
let(:versions) { FactoryGirl.build_list(:version, 3) }
let(:representer) { described_class.new(versions, 42, self_link) }
let(:user) { FactoryGirl.build_stubbed(:user) }
let(:context) { { current_user: user } }
let(:representer) { described_class.new(versions, 42, self_link, context: context) }
context 'generation' do
subject(:collection) { representer.to_json }

@ -29,22 +29,69 @@
require 'spec_helper'
describe ::API::V3::Versions::VersionRepresenter do
let(:version) { FactoryGirl.build(:version) }
let(:representer) { described_class.new(version) }
let(:version) { FactoryGirl.build_stubbed(:version) }
let(:user) { FactoryGirl.build_stubbed(:user) }
let(:context) { { current_user: user } }
let(:representer) { described_class.new(version, context) }
include API::V3::Utilities::PathHelper
context 'generation' do
subject(:generated) { representer.to_json }
it { should include_json('Version'.to_json).at_path('_type') }
xit { should have_json_type(Object).at_path('_links') }
xit 'should link to self' do
expect(subject).to have_json_path('_links/self/href')
context 'links' do
it { should have_json_type(Object).at_path('_links') }
it 'to self' do
path = api_v3_paths.version(version.id)
expect(subject).to be_json_eql(path.to_json).at_path('_links/self/href')
end
context 'to the defining project' do
let(:path) { api_v3_paths.project(version.project.id) }
it 'exists if the user has the permission to see the project' do
allow(version.project).to receive(:visible?).with(user).and_return(true)
subject = representer.to_json
expect(subject).to be_json_eql(path.to_json).at_path('_links/definingProject/href')
end
it 'does not exist if the user lacks the permission to see the project' do
allow(version.project).to receive(:visible?).with(user).and_return(false)
subject = representer.to_json
expect(subject).to_not have_json_path('_links/definingProject/href')
end
end
it 'to available projects' do
path = api_v3_paths.versions_projects(version.id)
expect(subject).to be_json_eql(path.to_json).at_path('_links/availableInProjects/href')
end
end
describe 'version' do
it { should have_json_path('id') }
it { should have_json_path('name') }
it { is_expected.to be_json_eql(version.id.to_json).at_path('id') }
it { is_expected.to be_json_eql(version.name.to_json).at_path('name') }
it_behaves_like 'API V3 formattable', 'description' do
let(:format) { 'plain' }
let(:raw) { version.description }
end
it { is_expected.to be_json_eql(version.start_date.to_json).at_path('startDate') }
it { is_expected.to be_json_eql(version.due_date.to_json).at_path('endDate') }
it { is_expected.to be_json_eql(version.status.to_json).at_path('status') }
it { is_expected.to be_json_eql(version.created_on.to_json).at_path('createdAt') }
it { is_expected.to be_json_eql(version.updated_on.to_json).at_path('updatedAt') }
end
end
end

@ -29,6 +29,8 @@
require 'spec_helper'
describe ::API::V3::WorkPackages::WorkPackageRepresenter do
include ::API::V3::Utilities::PathHelper
let(:member) { FactoryGirl.create(:user, member_in_project: project, member_through_role: role) }
let(:current_user) { member }
@ -236,31 +238,51 @@ describe ::API::V3::WorkPackages::WorkPackageRepresenter do
end
describe 'version' do
let(:embedded_path) { '_embedded/version' }
let(:href_path) { '_links/version/href' }
context 'no version set' do
it { is_expected.to_not have_json_path('versionViewable') }
it 'has no version linked' do
is_expected.to_not have_json_path(href_path)
end
it 'has no version embedded' do
is_expected.to_not have_json_path(embedded_path)
end
end
context 'version set' do
let!(:version) { FactoryGirl.create :version, project: project }
let(:expected_url) { "/versions/#{version.id}".to_json }
let(:expected_url) { api_v3_paths.version(version.id).to_json }
before do
work_package.fixed_version = version
end
it {
is_expected.to be_json_eql(expected_url).at_path('_links/version/href')
}
it 'has a link to the version' do
is_expected.to be_json_eql(expected_url).at_path(href_path)
end
it { is_expected.to be_json_eql('text/html'.to_json).at_path('_links/version/type') }
it 'has the version embedded' do
is_expected.to be_json_eql('Version'.to_json).at_path("#{embedded_path}/_type")
is_expected.to be_json_eql(version.name.to_json).at_path("#{embedded_path}/name")
end
context ' but is not accessible due to permissions' do
before do
current_user.stub(:allowed_to?).and_call_original
current_user.stub(:allowed_to?).with({ controller: 'versions', action: 'show' }, project, global: false).and_return(false)
policy = double('VersionPolicy')
allow(policy).to receive(:allowed?).with(version, :show).and_return(false)
representer.instance_variable_set(:@version_policy, policy)
end
it { is_expected.to_not have_json_path('_links/version/href') }
it 'has no version linked' do
is_expected.to_not have_json_path(href_path)
end
it 'has the version embedded as the user has the view work package permission' do
is_expected.to be_json_eql('Version'.to_json).at_path("#{embedded_path}/_type")
is_expected.to be_json_eql(version.name.to_json).at_path("#{embedded_path}/name")
end
end
end
end

@ -81,4 +81,115 @@ describe Version, type: :model do
expect(Version.systemwide.all).to be_empty
end
end
context '#projects' do
let(:grand_parent_project) do
FactoryGirl.build(:project, name: 'grand_parent_project')
end
let(:parent_project) do
FactoryGirl.build(:project, parent: grand_parent_project, name: 'parent_project')
end
let(:sibling_parent_project) do
FactoryGirl.build(:project, parent: grand_parent_project, name: 'sibling_parent_project')
end
let(:child_project) do
FactoryGirl.build(:project, parent: parent_project, name: 'child_project')
end
let(:sibling_project) do
FactoryGirl.build(:project, parent: parent_project, name: 'sibling_project')
end
let(:unrelated_project) do
FactoryGirl.build(:project, name: 'unrelated_project')
end
let(:unshared_version) do
FactoryGirl.build(:version, project: parent_project, sharing: 'none')
end
let(:hierarchy_shared_version) do
FactoryGirl.build(:version, project: parent_project, sharing: 'hierarchy')
end
let(:descendants_shared_version) do
FactoryGirl.build(:version, project: parent_project, sharing: 'descendants')
end
let(:system_shared_version) do
FactoryGirl.build(:version, project: parent_project, sharing: 'system')
end
let(:tree_shared_version) do
FactoryGirl.build(:version, project: parent_project, sharing: 'tree')
end
def save_all_projects
grand_parent_project.save!
parent_project.save!
sibling_parent_project.save!
child_project.save!
sibling_project.save!
unrelated_project.save!
end
before do
save_all_projects
end
it 'returns a scope' do
unshared_version.save
expect(unshared_version.projects).to be_a(ActiveRecord::Relation)
end
it 'is empty for a new version' do
expect(Version.new.projects).to be_empty
end
it 'returns project the version is defined in for unshared' do
unshared_version.save
expect(unshared_version.projects).to match_array([parent_project])
end
it 'returns all projects the version is shared with (hierarchy)' do
hierarchy_shared_version.save!
expect(hierarchy_shared_version.projects).to match_array([grand_parent_project,
parent_project,
child_project,
sibling_project])
end
it 'returns all projects the version is shared with (descendants)' do
descendants_shared_version.save!
expect(descendants_shared_version.projects).to match_array([parent_project,
child_project,
sibling_project])
end
it 'returns all projects the version is shared with (tree)' do
tree_shared_version.save!
expect(tree_shared_version.projects).to match_array([grand_parent_project,
parent_project,
sibling_parent_project,
child_project,
sibling_project])
end
it 'returns all projects the version is shared with (system)' do
system_shared_version.save!
expect(system_shared_version.projects).to match_array([grand_parent_project,
parent_project,
sibling_parent_project,
child_project,
sibling_project,
unrelated_project])
end
it 'returns only the projects for the version although there is a system shared version' do
unshared_version.save
system_shared_version.save!
expect(unshared_version.projects).to match_array([parent_project])
end
end
end

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

@ -32,30 +32,79 @@ require 'rack/test'
describe 'API v3 Version resource' do
include Rack::Test::Methods
let(:current_user) { FactoryGirl.create(:user) }
let(:role) { FactoryGirl.create(:role, permissions: []) }
let(:current_user) do
user = FactoryGirl.create(:user,
member_in_project: project,
member_through_role: role)
allow(User).to receive(:current).and_return user
user
end
let(:role) { FactoryGirl.create(:role, permissions: [:view_work_packages]) }
let(:project) { FactoryGirl.create(:project, is_public: false) }
let(:versions) { FactoryGirl.create_list(:version, 4, project: project) }
let(:other_versions) { FactoryGirl.create_list(:version, 2) }
let(:other_project) { FactoryGirl.create(:project, is_public: false) }
let(:version_in_project) { FactoryGirl.build(:version, project: project) }
let(:version_in_other_project) do
FactoryGirl.build(:version, project: other_project,
sharing: 'system')
end
subject(:response) { last_response }
describe '#get (:id)' do
let(:get_path) { "/api/v3/versions/#{version_in_project.id}" }
shared_examples_for 'successful response' do
it 'responds with 200' do
expect(last_response.status).to eq(200)
end
it 'returns the version' do
expect(last_response.body).to be_json_eql('Version'.to_json).at_path('_type')
expect(last_response.body).to be_json_eql(expected_version.id.to_json).at_path('id')
end
end
context 'logged in user with permissions' do
before do
version_in_project.save!
current_user
describe '#get' do
subject(:response) { last_response }
get get_path
end
it_should_behave_like 'successful response' do
let(:expected_version) { version_in_project }
end
end
context 'logged in user with permission on project a version is shared with' do
let(:get_path) { "/api/v3/versions/#{version_in_other_project.id}" }
context 'logged in user' do
let(:get_path) { "/api/v3/projects/#{project.id}/versions" }
before do
allow(User).to receive(:current).and_return current_user
member = FactoryGirl.build(:member, user: current_user, project: project)
member.role_ids = [role.id]
member.save!
version_in_other_project.save!
current_user
get get_path
end
it_should_behave_like 'successful response' do
let(:expected_version) { version_in_other_project }
end
end
context 'logged in user without permission' do
let(:role) { FactoryGirl.create(:role, permissions: []) }
versions
other_versions
before(:each) do
version_in_project.save!
current_user
get get_path
end
it_behaves_like 'API V3 collection response', 4, 4, 'Version'
it_behaves_like 'unauthorized access'
end
end
end

@ -0,0 +1,97 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
require 'rack/test'
describe "API v3 version's projects resource" do
include Rack::Test::Methods
let(:current_user) do
user = FactoryGirl.create(:user,
member_in_project: project,
member_through_role: role)
allow(User).to receive(:current).and_return user
user
end
let(:role) { FactoryGirl.create(:role, permissions: [:view_work_packages]) }
let(:role_without_permissions) { FactoryGirl.create(:role, permissions: []) }
let(:project) { FactoryGirl.create(:project, is_public: false) }
let(:project2) { FactoryGirl.create(:project, is_public: false) }
let(:project3) { FactoryGirl.create(:project, is_public: false) }
let(:project4) { FactoryGirl.create(:project, is_public: false) }
let(:version) { FactoryGirl.create(:version, project: project, sharing: 'system') }
subject(:response) { last_response }
describe '#get (index)' do
let(:get_path) { "/api/v3/versions/#{version.id}/projects" }
context 'logged in user with permissions' do
before do
current_user
# this is to be included
FactoryGirl.create(:member, user: current_user,
project: project2,
roles: [role])
# this is to be included as the user is a member of the project, the
# lack of permissions is irrelevant.
FactoryGirl.create(:member, user: current_user,
project: project3,
roles: [role_without_permissions])
# project4 should NOT be included
project4
get get_path
end
it_behaves_like 'API V3 collection response', 3, 3, 'Project'
it 'includes only the projects which the user can see' do
id_in_response = JSON.parse(response.body)['_embedded']['elements'].map { |p| p['id'] }
expect(id_in_response).to match_array [project.id, project2.id, project3.id]
end
end
context 'logged in user without permissions' do
let(:role) { role_without_permissions }
before do
current_user
get get_path
end
it_behaves_like 'unauthorized access'
end
end
end
Loading…
Cancel
Save