Merge branch 'release/11.3' into dev

pull/9341/head
ulferts 4 years ago
commit f7cc6096ce
No known key found for this signature in database
GPG Key ID: A205708DE1284017
  1. 11
      app/cells/placeholder_users/row_cell.rb
  2. 9
      app/controllers/placeholder_users_controller.rb
  3. 39
      app/helpers/placeholder_users_helper.rb
  4. 48
      app/helpers/tooltip_helper.rb
  5. 8
      app/views/placeholder_users/_toolbar.html.erb
  6. 48
      app/views/placeholder_users/_toolbar_delete.html.erb
  7. 8
      app/views/placeholder_users/show.html.erb
  8. 3
      config/locales/en.yml
  9. 9
      docs/api/apiv3/basic-objects.apib
  10. 4
      docs/api/apiv3/endpoints/projects.apib
  11. 4
      docs/getting-started/projects/README.md
  12. BIN
      docs/getting-started/projects/create-project-header.png
  13. 10
      docs/getting-started/work-packages-introduction/README.md
  14. BIN
      docs/getting-started/work-packages-introduction/create-work-package-define-project.png
  15. BIN
      docs/getting-started/work-packages-introduction/create-work-package-header.png
  16. BIN
      docs/system-admin-guide/github-integration/Github integration PR overview.png
  17. BIN
      docs/system-admin-guide/github-integration/Github integration actions.png
  18. BIN
      docs/system-admin-guide/github-integration/Github integration create branch.png
  19. BIN
      docs/system-admin-guide/github-integration/Github integration tab.png
  20. BIN
      docs/system-admin-guide/github-integration/Github-module.png
  21. 28
      docs/system-admin-guide/github-integration/README.md
  22. 4
      frontend/karma.conf.js
  23. 4
      frontend/src/app/components/modals/wp-destroy-modal/wp-destroy.modal.html
  24. 4
      frontend/src/app/core/services/forms/forms.service.ts
  25. 6
      frontend/src/app/core/services/forms/typings.d.ts
  26. 42
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-field-group-wrapper/dynamic-field-group-wrapper.component.html
  27. 12
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-inputs/boolean-input/boolean-input.component.html
  28. 11
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-inputs/date-input/date-input.component.html
  29. 3
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-inputs/date-input/date-input.component.ts
  30. 9
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-inputs/formattable-textarea-input/formattable-textarea-input.component.html
  31. 15
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-inputs/integer-input/integer-input.component.html
  32. 30
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-inputs/select-input/select-input.component.html
  33. 22
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-inputs/select-project-status-input/select-project-status-input.component.html
  34. 14
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-inputs/text-input/text-input.component.html
  35. 46
      frontend/src/app/modules/common/dynamic-forms/services/dynamic-fields/dynamic-fields.service.ts
  36. 58
      frontend/src/app/modules/common/forms/fieldset.sass
  37. 21
      frontend/src/app/modules/common/forms/form-field/form-field.component.html
  38. 24
      frontend/src/app/modules/common/forms/form-field/form-field.component.ts
  39. 1
      frontend/src/app/modules/common/forms/index.sass
  40. 28
      frontend/src/app/modules/common/op-date-picker/op-date-picker.component.html
  41. 2
      frontend/src/app/modules/common/option-list/option-list.component.html
  42. 10
      lib/api/decorators/link_object.rb
  43. 34
      lib/api/decorators/linked_resource.rb
  44. 46
      lib/api/v3.rb
  45. 4
      lib/api/v3/errors/error_representer.rb
  46. 15
      lib/api/v3/projects/project_representer.rb
  47. 6
      lib/api/v3/projects/schemas/project_schema_representer.rb
  48. 2
      lib/api/v3/queries/sort_bys/sort_by_decorator.rb
  49. 2
      lib/api/v3/utilities/custom_field_injector.rb
  50. 18
      modules/github_integration/config/locales/js-en.yml
  51. 15
      modules/github_integration/frontend/module/git-actions-menu/git-actions-menu.component.spec.ts
  52. 26
      modules/github_integration/frontend/module/git-actions-menu/git-actions-menu.component.ts
  53. 39
      modules/github_integration/frontend/module/git-actions-menu/git-actions-menu.template.html
  54. 13
      modules/github_integration/frontend/module/git-actions-menu/styles/git-actions-menu.sass
  55. 12
      modules/github_integration/frontend/module/git-actions/git-actions.service.spec.ts
  56. 4
      modules/github_integration/frontend/module/git-actions/git-actions.service.ts
  57. 62
      modules/github_integration/frontend/module/pull-request/pr-check.component.sass
  58. 61
      modules/github_integration/frontend/module/pull-request/pull-request.component.html
  59. 124
      modules/github_integration/frontend/module/pull-request/pull-request.component.sass
  60. 6
      modules/github_integration/frontend/module/pull-request/pull-request.component.spec.ts
  61. 56
      modules/github_integration/frontend/module/pull-request/pull-request.component.ts
  62. 42
      modules/github_integration/frontend/module/pull-request/pull-request.template.html
  63. 5
      modules/github_integration/frontend/module/tab-header/styles/tab-header.sass
  64. 3
      modules/github_integration/frontend/module/tab-prs/tab-prs.component.ts
  65. 4
      modules/github_integration/frontend/module/tab-prs/tab-prs.template.html
  66. 6
      modules/github_integration/frontend/module/typings.d.ts
  67. 2
      modules/github_integration/spec/features/work_package_github_tab_spec.rb
  68. 4
      modules/github_integration/spec/support/pages/work_package_github_tab.rb
  69. 31
      spec/controllers/placeholder_users_controller_spec.rb
  70. 25
      spec/features/placeholder_users/delete_spec.rb
  71. 2
      spec/features/projects/attribute_help_texts_spec.rb
  72. 127
      spec/features/projects/create_spec.rb
  73. 25
      spec/features/projects/destroy_spec.rb
  74. 210
      spec/features/projects/edit_settings_spec.rb
  75. 2
      spec/features/projects/project_status_administration_spec.rb
  76. 10
      spec/features/projects/projects_custom_fields_spec.rb
  77. 339
      spec/features/projects/projects_spec.rb
  78. 2
      spec/features/projects/template_spec.rb
  79. 49
      spec/features/projects/work_package_type_mgmt_spec.rb
  80. 66
      spec/lib/api/v3/projects/project_payload_representer_parsing_spec.rb
  81. 519
      spec/lib/api/v3/projects/project_representer_rendering_spec.rb
  82. 474
      spec/lib/api/v3/projects/project_representer_spec.rb
  83. 4
      spec/lib/api/v3/projects/schemas/project_schema_representer_spec.rb
  84. 4
      spec/requests/api/v3/project_resource_spec.rb
  85. 23
      spec/requests/api/v3/work_packages/form/work_package_form_resource_spec.rb

@ -32,6 +32,8 @@ module PlaceholderUsers
class RowCell < ::RowCell
include AvatarHelper
include UsersHelper
include PlaceholderUsersHelper
include TooltipHelper
def placeholder_user
model
@ -46,15 +48,12 @@ module PlaceholderUsers
end
def delete_link
if PlaceholderUsers::DeleteContract.deletion_allowed?(placeholder_user,
User.current,
table.user_allowed_service)
if can_delete_placeholder_user?(placeholder_user, User.current)
link_to deletion_info_placeholder_user_path(placeholder_user) do
"<span class=\"tooltip--left\" data-tooltip=\"#{I18n.t('placeholder_users.delete_tooltip')}\"><i class=\"icon icon-delete\"></i></span>".html_safe
tooltip_tag I18n.t('placeholder_users.delete_tooltip'), icon: 'icon-delete'
end
else
"<span class=\"tooltip--left\" data-tooltip=\"#{I18n.t('placeholder_users.right_to_manage_members_missing')}\"><i class=\"icon icon-help2\"></i></span>".html_safe
tooltip_tag I18n.t('placeholder_users.right_to_manage_members_missing'), icon: 'icon-help2'
end
end

@ -41,6 +41,9 @@ class PlaceholderUsersController < ApplicationController
deletion_info
destroy]
before_action :authorize_deletion, only: %i[deletion_info destroy]
def index
@placeholder_users = PlaceholderUsers::PlaceholderUserFilterCell.query params
@ -149,6 +152,12 @@ class PlaceholderUsersController < ApplicationController
protected
def authorize_deletion
unless helpers.can_delete_placeholder_user?(@placeholder_user, current_user)
render_403 message: I18n.t('placeholder_users.right_to_manage_members_missing')
end
end
def default_breadcrumb
if action_name == 'index'
t('label_placeholder_user_plural')

@ -0,0 +1,39 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# 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 docs/COPYRIGHT.rdoc for more details.
#++
module PlaceholderUsersHelper
##
# Determine whether the given actor can delete the placeholder user
def can_delete_placeholder_user?(placeholder, actor = User.current)
PlaceholderUsers::DeleteContract.deletion_allowed? placeholder,
actor,
Authorization::UserAllowedService.new(actor)
end
end

@ -0,0 +1,48 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# 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 docs/COPYRIGHT.rdoc for more details.
#++
module TooltipHelper
include OpenProject::FormTagHelper
##
# Render a tooltip span
#
# @param text [string] Content of the tooltip
# @param placement [string] placement (top, left, right, bottom)
# @param span_classes [string] Additional classes on the span
# @param icon [string] icon class
def tooltip_tag(text, placement: 'left', icon: 'icon-help', span_classes: nil)
content_tag :span,
class: "tooltip--#{placement} #{span_classes}",
data: { tooltip: text } do
op_icon "icon #{icon}"
end
end
end

@ -34,12 +34,6 @@ See docs/COPYRIGHT.rdoc for more details.
<span class="button--text"><%= t(:label_profile) %></span>
<% end %>
</li>
<li class="toolbar-item">
<%= link_to deletion_info_placeholder_user_path(@placeholder_user),
class: 'button' do %>
<%= op_icon('button--icon icon-delete') %>
<span class="button--text"><%= t(:button_delete) %></span>
<% end %>
</li>
<%= render partial: 'placeholder_users/toolbar_delete' %>
<% end %>
<% end %>

@ -0,0 +1,48 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2021 the OpenProject GmbH
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 docs/COPYRIGHT.rdoc for more details.
++#%>
<% if can_delete_placeholder_user?(@placeholder_user) %>
<li class="toolbar-item">
<%= link_to deletion_info_placeholder_user_path(@placeholder_user),
class: 'button' do %>
<%= op_icon('button--icon icon-delete') %>
<span class="button--text"><%= t(:button_delete) %></span>
<% end %>
</li>
<% else %>
<li class="toolbar-item">
<%= content_tag :span, title: I18n.t('placeholder_users.right_to_manage_members_missing') do %>
<%= link_to '#',
class: 'button -disabled' do %>
<%= op_icon('button--icon icon-delete') %>
<span class="button--text"><%= t(:button_delete) %></span>
<% end %>
<% end %>
</li>
<% end %>

@ -39,13 +39,7 @@ See docs/COPYRIGHT.rdoc for more details.
<span class="button--text"><%= t(:button_edit) %></span>
<% end %>
</li>
<li class="toolbar-item">
<%= link_to deletion_info_placeholder_user_path(@placeholder_user),
class: 'button' do %>
<%= op_icon('button--icon icon-delete') %>
<span class="button--text"><%= t(:button_delete) %></span>
<% end %>
</li>
<%= render partial: 'placeholder_users/toolbar_delete' %>
<% end %>
<% end %>

@ -2931,6 +2931,9 @@ en:
resources:
schema: 'Schema'
undisclosed:
parent: Undisclosed - The selected parent is invisible because of lacking permissions.
doorkeeper:
pre_authorization:
status: 'Pre-authorization'

@ -42,6 +42,15 @@ in its strings (e.g. `{ "href": "/api/v3/examples/{example_id}" }`).
Note: When writing links (e.g. during a `PATCH` operation) only changes to `href` are accepted.
Changes to all other properties will be **silently ignored**.
For resources invisible to the client (e.g. because of missing permissions), a link will contain
the uri `urn:openproject-org:api:v3:undisclosed` instead of a url. This indicates the existence of a value
without revealing the actual value. An example for this is the parent project. A project resource which itself might be
visible to the client can have a reference to a parent project invisible to the same client. Revealing the existence of
a parent over hiding has the benefit of allowing the client to make an informed decision of whether to keep the existing reference
or updating it. Sending ‘{ "href": "urn:openproject-org:api:v3:undisclosed" }` for a resource will have the effect of keeping the
original value. This is especially beneficial if the client creates and updates resources based on the payload object provided
as part of a form as is recommended when interacting with the API.
# Errors
In case of an error, the API will respond with an appropriate HTTP status code.

@ -29,6 +29,10 @@ As containers, they also control behaviour of the elements within them. One of t
Depending on custom fields defined for projects, additional links might exist.
Note, that the parent link may contain the "undisclosed uri" `urn:openproject-org:api:v3:undisclosed` in case a
parent project is defined but the client lacks permission to see it. See the
[general introduction into links' properties](/api/basic-objects/#header-local-properties-1) for more information.
## Local Properties
| Property | Description | Type | Constraints | Supported operations |

@ -61,6 +61,10 @@ Also, you can click the green button **+ Project** directly on the system's home
![Create-project-home-screen](Create-project-home-screen.png)
Alternatively, you can use the green **+ button** in the header menu to create a new project.
![create-project-header](create-project-header.png)
- You can either create a completely new project, create a subproject of an existing project or create a (sub)project from a template. For the latter option, choose a [template](../../user-guide/projects/#create-a-project-template) using the drop-down menu.
- Enter a **name** for your project and click the blue **Create** button.
- The **Advanced Settings** allow for further configuration, e.g. description, URL, etc.

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

@ -48,6 +48,16 @@ The work package will the be displayed in the list view:
![list-view-work-package](1569611758166.png)
Another option to create a work package is to do it from the header menu. The [work package types](../../user-guide/projects/proejct-settings/work-package-types/#work-pacakge-types) that are activated, will be shown and you can select the relevant work package type to be created.
![create-work-package-header](create-work-package-header.png)
Once you click on the work package type that you want to create, the work package detail view opens and you have to **select the project** that you want to create the work package for.
![create-work-package-define-project](create-work-package-define-project.png)
Then you follow the same steps as mentioned above to fill in the your work package attributes and save it.
## Open and edit a work package
To open and edit an existing work package from the list, select the work package in the list which you want to edit and click on the **open details view** icon in the work package list or on top of the list to open the split screen view. Other ways to open it would be to double-click on the work package or to click on the work package ID.

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

@ -9,14 +9,13 @@ keywords: github integration
# GitHub integration
OpenProject offers an integration for GitHub pull requests.
You create a pull request in GitHub and link to an OpenProject work package.
You create a pull request in GitHub and link it to an OpenProject work package.
![New pull request linking to an OpenProject work package](github-pr-workpackage-reference.png)
Rather than inserting a link to the work package you can also reference it just by adding "OP#87" to the pull request's description where 87 is the ID of the work package.
OpenProject will add comments to work package about the pull request when
the pull request is
OpenProject will add comments to work packages about the pull request when the pull request is
* first referenced (usually when opened)
* merged
@ -32,6 +31,29 @@ You will have to configure both OpenProject and GitHub for the integration to wo
### OpenProject
In *Project settings* and *Modules* you will need to activate the GitHub module.
![Github-module](Github-module.png)
Then you will have a GitHub tab appearing in your work package view where you will see all information pulling through from GitHub.
![Github integration tab](Github integration tab.png)
In your OpenProject work package, the new GitHub integration supports you to create a branch straight from the work package and consequently the matching pull request.
![Github integration create branch](Github integration create branch.png)
If you already have an existing pull request in GitHub, you can link it using the code OP#5999 (5999 being the ID of the work package) in the GitHub pull request description.
![Github integration PR overview](Github integration PR overview.png)
![Github integration actions](Github integration actions.png)
First you will need to create a user in OpenProject that will make the comments.
The user will have to be added to each project with a role that allows them
to comment on work packages.

@ -49,6 +49,10 @@ module.exports = function (config) {
ChromeHeadlessNoSandbox: {
base: 'ChromeHeadless',
flags: ['--no-sandbox']
},
ChromeWithDebug: {
base: 'Chrome',
flags: ['--no-sandbox', '--debug', '--auto-open-devtools-for-tabs']
}
},
singleRun: false

@ -1,5 +1,5 @@
<div
class="op-modal wp-table--configuration-modal -danger-zone loading-indicator--location"
class="op-modal op-modal_autoheight wp-table--configuration-modal -danger-zone loading-indicator--location"
data-indicator-name="modal"
id="wp_destroy_modal"
>
@ -50,7 +50,7 @@
</ul>
</ng-container>
<div *ngIf="mustConfirmChildren">
<label class="form--label-with-check-box">
<label class="form--label-with-check-box -no-ellipsis">
<div class="form--check-box-container">
<input type="checkbox"
name="confirm-children-deletion"

@ -86,8 +86,8 @@ export class FormsService {
// Form.payload resources have a HalLinkSource interface while
// API resource options have a IAllowedValue interface
const resourceValue = Array.isArray(resource) ?
resource.map(resourceElement => ({ href: resourceElement?.href || resourceElement?._links?.self?.href || null })) :
{ href: resource?.href || resource?._links?.self?.href || null };
resource.map(resourceElement => ({ href: resourceElement?.href || resourceElement?._links?.self?.href })) :
{ href: resource?.href || resource?._links?.self?.href };
return {
...result,

@ -80,7 +80,7 @@ interface IOPApiCall {
}
interface IOPApiOption {
href:string;
href:string|null;
title?:string;
}
@ -99,8 +99,8 @@ interface IOPAllowedValue {
name:string;
[key:string]:unknown;
_links?:{
self:HalSource | IOPApiOption;
[key:string]:HalSource;
self:HalSourceLink | IOPApiOption;
[key:string]:HalSourceLink;
};
}

@ -1,31 +1,31 @@
<fieldset class="form--fieldset -collapsible"
[ngClass]="{'collapsed': to.collapsibleFieldGroupsCollapsed}"
*ngIf="to?.collapsibleFieldGroups">
<legend class="form--fieldset-legend"
title="Show/hide"
(click)="to.collapsibleFieldGroupsCollapsed = !to.collapsibleFieldGroupsCollapsed">
<a href="#">
<fieldset
class="op-fieldset op-fieldset_collapsible"
[ngClass]="{'op-fieldset_collapsed': to.collapsibleFieldGroupsCollapsed}"
*ngIf="to?.collapsibleFieldGroups"
>
<legend class="op-fieldset--legend">
<button
title="Show/hide"
type="button"
class="op-fieldset--toggle"
(click)="to.collapsibleFieldGroupsCollapsed = !to.collapsibleFieldGroupsCollapsed"
>
{{ to.label }}
</a>
</button>
</legend>
<div
[ngStyle]="{
'height': to.collapsibleFieldGroupsCollapsed ? 0 : 'auto',
'visibility': to.collapsibleFieldGroupsCollapsed ? 'hidden' : 'visible',
'overflow': to.collapsibleFieldGroupsCollapsed ? 'hidden' : 'visible'
}"
>
<div class="op-fieldset--fields">
<ng-container #fieldComponent></ng-container>
</div>
</fieldset>
<ng-container *ngIf="!to?.collapsibleFieldGroups">
<div>
{{ to.label }}
</div>
<fieldset
class="op-fieldset"
*ngIf="!to?.collapsibleFieldGroups"
>
<legend class="op-fieldset--legend">{{ to.label }}</legend>
<div>
<div class="op-fieldset--fields">
<ng-container #fieldComponent></ng-container>
</div>
</ng-container>
</fieldset>

@ -1,5 +1,7 @@
<input type="checkbox"
[attr.aria-required]="to.required"
[attr.required]="to.required"
[formControl]="formControl"
[formlyAttributes]="field">
<input
type="checkbox"
[attr.aria-required]="to.required"
[attr.required]="to.required"
[formControl]="formControl"
[formlyAttributes]="field"
>

@ -1,5 +1,6 @@
<op-date-picker-adapter [required]="to.required"
[disable]="to.disabled"
[formControl]="formControl"
[formlyAttributes]="field">
</op-date-picker-adapter>
<op-date-picker-adapter
[required]="to.required"
[disabled]="to.disabled"
[formControl]="formControl"
[formlyAttributes]="field"
></op-date-picker-adapter>

@ -6,5 +6,4 @@ import { FieldType } from "@ngx-formly/core";
templateUrl: './date-input.component.html',
styleUrls: ['./date-input.component.scss'],
})
export class DateInputComponent extends FieldType {
}
export class DateInputComponent extends FieldType {}

@ -1,4 +1,5 @@
<op-formattable-control [templateOptions]="to"
[formControl]="formControl"
[formlyAttributes]="field">
</op-formattable-control>
<op-formattable-control
[templateOptions]="to"
[formControl]="formControl"
[formlyAttributes]="field"
></op-formattable-control>

@ -1,7 +1,8 @@
<input [type]="to.type"
[attr.aria-required]="to.required"
[attr.required]="to.required"
[attr.lang]="to.locale"
[formControl]="formControl"
[formlyAttributes]="field"
class="op-input">
<input
[type]="to.type"
[attr.required]="to.required"
[attr.lang]="to.locale"
[formControl]="formControl"
[formlyAttributes]="field"
class="op-input"
>

@ -1,17 +1,19 @@
<ng-select [items]="to?.options | async"
[formControl]="formControl"
[formlyAttributes]="field"
[attr.aria-required]="to.required"
[attr.required]="to.required"
[multiple]="to.multiple"
[bindLabel]="to.bindLabel"
[searchable]="to.searchable"
[virtualScroll]="to.virtualScroll"
[clearable]="to.clearable"
[typeahead]="to.typeahead"
[clearOnBackspace]="to.clearOnBackspace"
[clearSearchOnAdd]="to.clearSearchOnAdd"
[hideSelected]="to.hideSelected">
<ng-select
[items]="to?.options | async"
[formControl]="formControl"
[formlyAttributes]="field"
[attr.aria-required]="to.required"
[attr.required]="to.required"
[multiple]="to.multiple"
[bindLabel]="to.bindLabel"
[searchable]="to.searchable"
[virtualScroll]="to.virtualScroll"
[clearable]="to.clearable"
[typeahead]="to.typeahead"
[clearOnBackspace]="to.clearOnBackspace"
[clearSearchOnAdd]="to.clearSearchOnAdd"
[hideSelected]="to.hideSelected"
>
<ng-template ng-tag-tmp let-search="searchTerm">
<b [textContent]="to.text.add_new_action"></b>: {{search}}
</ng-template>

@ -1,13 +1,15 @@
<ng-select [items]="to?.options | async"
[formControl]="formControl"
[formlyAttributes]="field"
bindLabel="name"
[searchable]="true"
[virtualScroll]="true"
[clearable]="false"
[clearOnBackspace]="false"
[clearSearchOnAdd]="false"
[hideSelected]="true">
<ng-select
[items]="to?.options | async"
[formControl]="formControl"
[formlyAttributes]="field"
bindLabel="name"
[searchable]="true"
[virtualScroll]="true"
[clearable]="false"
[clearOnBackspace]="false"
[clearSearchOnAdd]="false"
[hideSelected]="true"
>
<ng-template ng-label-tmp let-item="item">
<span class="project-status--bulb -inline" [ngClass]="cssClass(item)"></span>
<span class="project-status--name" [ngClass]="cssClass(item)">{{item.name}}</span>

@ -1,6 +1,8 @@
<input [type]="to.type"
[attr.aria-required]="to.required"
[attr.required]="to.required"
[formControl]="formControl"
[formlyAttributes]="field"
class="op-input">
<input
[type]="to.type"
[attr.aria-required]="to.required"
[attr.required]="to.required"
[formControl]="formControl"
[formlyAttributes]="field"
class="op-input"
>

@ -9,11 +9,12 @@ import { Observable, of } from "rxjs";
import { map } from "rxjs/operators";
import { HttpClient } from "@angular/common/http";
import { I18nService } from "core-app/modules/common/i18n/i18n.service";
import { HalLink } from "core-app/modules/hal/hal-link/hal-link";
@Injectable()
export class DynamicFieldsService {
readonly selectDefaultValue = {name:'-'};
readonly selectDefaultValue = { name: '-', _links: { self: { href: null } } };
readonly inputsCatalogue:IOPDynamicInputTypeSettings[] = [
{
config: {
@ -145,7 +146,7 @@ export class DynamicFieldsService {
const elementValue = otherElements[key];
if (this.isValue(elementValue)) {
model = {...model, [key]:elementValue}
model = { ...model, [key]: elementValue }
}
return model;
@ -212,7 +213,7 @@ export class DynamicFieldsService {
result = {
...result,
...this.isValue(resourceModel) && {[resourceKey]: resourceModel},
...this.isValue(resourceModel) && { [resourceKey]: resourceModel },
};
return result;
@ -226,9 +227,9 @@ export class DynamicFieldsService {
return null;
}
const { templateOptions, ...fieldTypeConfig } = fieldTypeConfigSearch;
const fieldOptions = this.getFieldOptions(fieldSchema);
const property = this.getFieldProperty(key);
const payloadValue = property && formPayload[property];
const payloadValue = property && (formPayload[property] || formPayload['_links'] && formPayload['_links'][property]);
const fieldOptions = this.getFieldOptions(fieldSchema, payloadValue);
const formlyFieldConfig = {
...fieldTypeConfig,
key,
@ -269,8 +270,8 @@ export class DynamicFieldsService {
className: field.name,
templateOptions: {
...inputConfig.templateOptions,
...this.isMultiSelectField(field) && {multiple: true},
...fieldType === 'User' && {showAddNewUserButton: true},
...this.isMultiSelectField(field) && { multiple: true },
...fieldType === 'User' && { showAddNewUserButton: true },
},
};
} else if (inputConfig.type === 'formattableInput') {
@ -286,7 +287,7 @@ export class DynamicFieldsService {
return { ...inputConfig, ...configCustomizations };
}
private getFieldOptions(field:IOPFieldSchemaWithKey):Observable<IOPAllowedValue[]>|undefined {
private getFieldOptions(field:IOPFieldSchemaWithKey, currentValue:HalLink|null):Observable<IOPAllowedValue[]>|undefined {
const allowedValues = field._embedded?.allowedValues || field._links?.allowedValues;
let options;
@ -309,7 +310,10 @@ export class DynamicFieldsService {
);
}
return options?.pipe(map(options => !field.required && !this.isMultiSelectField(field) ? [{name: '-'}, ...options] : options));
return options?.pipe(
map(options => this.prependCurrentValue(options, currentValue)),
map(options => this.prependDefaultValue(options, field))
);
}
// ng-select needs a 'name' in order to show the label
@ -400,6 +404,30 @@ export class DynamicFieldsService {
}
}
// Invalid values, ones that are not in the list of allowedValues (Array or backend fetched) do occur, e.g.
// if constraints change or in case a value is undisclosed as for a project's parent.
private prependCurrentValue(options:IOPAllowedValue[], currentValue:HalLink|null):IOPAllowedValue[] {
if (!currentValue?.href || options.some(option => option?._links?.self?.href === currentValue.href)) {
return options;
} else {
return [
{ name: currentValue.title, _links: { self: currentValue } },
...options
];
}
}
// So select properties that are not required always get a default ('-'/'none') option.
// This way, the user can more easily deselect a value.
// Multi seleccts do not have the same behaviour since the x next to each option is quite clear.
private prependDefaultValue(options:IOPAllowedValue[], field:IOPFieldSchemaWithKey):IOPAllowedValue[] {
if (field.required || this.isMultiSelectField(field)) {
return options;
} else {
return [this.selectDefaultValue, ...options];
}
}
private isMultiSelectField(field:IOPFieldSchemaWithKey) {
return field?.type?.startsWith('[]');
}

@ -0,0 +1,58 @@
.op-fieldset
padding: 1rem 0 0
margin-bottom: 1rem
border: 0
min-width: 0
word-break: break-word
&_collapsible &
&--toggle::before
display: inline-block
content: ""
padding: .625rem .25rem 0
&_collapsed &
&--toggle::before
content: ""
&--fields
height: 0
visibility: hidden
overflow: hidden
&--legend
width: 100%
color: #4d4d4d
font-size: 1rem
font-weight: 700
line-height: 1.8
text-transform: uppercase
border-bottom: 1px solid #dfdfdf
&--toggle
text-align: left
width: 100%
border: 0
cursor: pointer
color: inherit
text-decoration: none
text-transform: inherit
background: transparent
&::before
font-family: openproject-icon-font
font-style: normal
font-weight: 400
font-variant: normal
text-transform: none
text-decoration: none
speak: none
line-height: 1
-webkit-font-smoothing: antialiased
-moz-osx-font-smoothing: grayscale
font-size: .75rem
&--fields
height: auto
visibility: visible
overflow: visible

@ -1,6 +1,10 @@
<ng-container *ngIf="!hidden">
<label class="op-form-field--label-wrap">
<div class="op-form-field--label">
<span
*ngIf="showErrorMessage"
class="Hidden for sighted"
>Invalid</span>
{{ label }}
<span *ngIf="required" class="op-form-field--label-indicator">*</span>
<attribute-help-text
@ -22,18 +26,27 @@
<ng-content select="[slot=help-text]"></ng-content>
</div>
<div class="op-form-field--errors"
*ngIf="showErrorMessage">
<div
class="op-form-field--errors"
*ngIf="showErrorMessage"
[id]="errorsID"
>
<ng-content select="[slot=errors]"></ng-content>
</div>
</ng-container>
<ng-template #inputTemplate>
<div class="op-form-field--description">
<div
class="op-form-field--description"
[id]="descriptionID"
>
<ng-content select="[slot=description]"></ng-content>
</div>
<div class="op-form-field--input">
<div
class="op-form-field--input"
[attr.aria-describedby]="describedByID"
>
<ng-content select="[slot=input]"></ng-content>
</div>
</ng-template>

@ -32,26 +32,38 @@ export class OpFormFieldComponent {
@ContentChild(NgControl) ngControl:NgControl;
internalID = `op-form-field-${+new Date()}`;
get errorsID() {
return `${this.internalID}-errors`;
}
get descriptionID() {
return `${this.internalID}-description`;
}
get describedByID() {
return this.showErrorMessage ? this.errorsID : this.descriptionID;
}
get formControl():AbstractControl|undefined|null {
return this.ngControl?.control || this.control;
}
get showErrorMessage():boolean {
let showErrorMessage = false;
if (!this.formControl) {
return false;
}
if (this.showValidationErrorOn === 'submit') {
showErrorMessage = this.formControl.invalid && this._formGroupDirective?.submitted;
return this.formControl.invalid && this._formGroupDirective?.submitted;
} else if (this.showValidationErrorOn === 'blur') {
showErrorMessage = this.formControl.invalid && this.formControl.touched;
return this.formControl.invalid && this.formControl.touched;
} else if (this.showValidationErrorOn === 'change') {
showErrorMessage = this.formControl.invalid && this.formControl.dirty;
return this.formControl.invalid && this.formControl.dirty;
}
return showErrorMessage;
return false;
}
constructor(

@ -1,3 +1,4 @@
@import './form-field/form-field'
@import './form'
@import './fieldset'
@import './highlighted-input'

@ -1,13 +1,15 @@
<input #dateInput
[id]="id"
[name]="name"
[value]="initialDate"
[ngClass]="classes + ' op-input'"
[size]="size"
[required]="required"
[disabled]="disabled"
(click)="openOnClick()"
(keydown.escape)="close()"
(blur)="closeOnOutsideClick($event)"
(input)="onInputChange($event)"
type="text">
<input
#dateInput
[id]="id"
[name]="name"
[value]="initialDate"
[ngClass]="classes + ' op-input'"
[size]="size"
[required]="required"
[disabled]="disabled"
(click)="openOnClick()"
(keydown.escape)="close()"
(blur)="closeOnOutsideClick($event)"
(input)="onInputChange($event)"
type="text"
>

@ -3,11 +3,11 @@
[class]="getClassListForItem(option)"
>
<input
class="op-option-list--input"
type="radio"
[attr.name]="name"
[value]="option.value"
[(ngModel)]="selected"
class="op-option-list--input"
[disabled]="option.disabled"
/>
<div>

@ -66,12 +66,16 @@ module API
end
def href=(value)
if value
id = ::API::Utilities::ResourceLinkParser.parse_id value,
# Ignore linked resources that are hidden to the client
# See lib/api/v3.rb for more details.
return if value == API::V3::URN_UNDISCLOSED
id = if value
::API::Utilities::ResourceLinkParser.parse_id value,
property: @property_name,
expected_version: '3',
expected_namespace: @namespace
end
end
represented.send(@setter, id)
end

@ -115,12 +115,19 @@ module API
embedded: false)
end
# Includes _link and _embedded elements into the HAL representer for
# resources that are connected to the current resource via a belongs_to association, e.g.
# WorkPackage -> belongs_to -> project.
#
# @param skip_render [optional, Proc] If the proc returns true, neither _link nor _embedded of the resource will be rendered.
# @param undisclosed [optional, true, false] If true, instead of not rendering the resource upon `skip_render`, an { "href": "urn:openproject-org:api:v3:undisclosed" } link will be rendered. This can be used e.g. when the parent of a project is invisible to the user and the existence, if not the actual parent, is to be communicated. The resource is still not embedded in this case.
def associated_resource(name,
as: nil,
representer: nil,
v3_path: name,
skip_render: ->(*) { false },
skip_link: skip_render,
undisclosed: false,
link_title_attribute: :name,
uncacheable_link: false,
getter: associated_resource_default_getter(name, representer),
@ -128,6 +135,7 @@ module API
link: associated_resource_default_link(name,
v3_path: v3_path,
skip_link: skip_link,
undisclosed: undisclosed,
title_attribute: link_title_attribute))
resource((as || name),
@ -172,17 +180,23 @@ module API
v3_path:,
skip_link:,
title_attribute:,
getter: :"#{name}_id")
getter: :"#{name}_id",
undisclosed: false)
->(*) do
next if instance_exec(&skip_link)
::API::Decorators::LinkObject
.new(represented,
path: v3_path,
property_name: name,
title_attribute: title_attribute,
getter: getter)
.to_hash
if undisclosed && instance_exec(&skip_link)
{
href: API::V3::URN_UNDISCLOSED,
title: I18n.t(:"api_v3.undisclosed.#{name}")
}
elsif !instance_exec(&skip_link)
::API::Decorators::LinkObject
.new(represented,
path: v3_path,
property_name: name,
title_attribute: title_attribute,
getter: getter)
.to_hash
end
end
end

@ -0,0 +1,46 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# 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 docs/COPYRIGHT.rdoc for more details.
#++
module API
module V3
URN_PREFIX = 'urn:openproject-org:api:v3:'.freeze
URN_ERROR_PREFIX = "#{URN_PREFIX}errors:".freeze
# For resources invisible to the user, a resource (including a payload) will contain
# an "undisclosed uri" instead of a url. This indicates the existence of a value
# without revealing anything. An example for this is the parent project which might be
# invisible to a user.
# In case a "undisclosed uri" is provided as a link, the current value is not
# to be altered and thus it is treated as if the value where never provided in
# the first place. This allows a schema/_embedded/payload -> client -> POST/PUT
# request/response round trip where the user knows of the existence of the value without revealing
# the contents. The payload remains valid in this case and the client can distinguish between
# keeping the value and unsetting the linked resource to null.
URN_UNDISCLOSED = "#{URN_PREFIX}undisclosed".freeze
end
end

@ -38,8 +38,6 @@ module API
include Roar::JSON::HAL
include Roar::Hypermedia
ERROR_PREFIX = 'urn:openproject-org:api:v3:errors:'.freeze
self.as_strategy = API::Utilities::CamelCasingStrategy.new
property :_type, exec_context: :decorator
@ -58,7 +56,7 @@ module API
end
def error_identifier
ERROR_PREFIX + represented.class.identifier
::API::V3::URN_ERROR_PREFIX + represented.class.identifier
end
end
end

@ -157,19 +157,8 @@ module API
v3_path: :project,
representer: ::API::V3::Projects::ProjectRepresenter,
uncacheable_link: true,
link: ->(*) {
if represented.parent&.visible?
{
href: api_v3_paths.project(represented.parent.id),
title: represented.parent.name
}
else
{
href: nil,
title: nil
}
end
}
undisclosed: true,
skip_render: ->(*) { represented.parent && !represented.parent.visible? }
property :id
property :identifier,

@ -56,10 +56,12 @@ module API
required: false
schema :public,
type: 'Boolean'
type: 'Boolean',
required: false
schema :active,
type: 'Boolean'
type: 'Boolean',
required: false
schema_with_allowed_collection :status,
type: 'ProjectStatus',

@ -65,7 +65,7 @@ module API
end
def direction_uri
"urn:openproject-org:api:v3:queries:directions:#{direction}"
"#{API::V3::URN_PREFIX}queries:directions:#{direction}"
end
def direction_l10n

@ -272,7 +272,7 @@ module API
def property_value_setter_for(custom_field)
->(fragment:, **) {
value = if custom_field.field_format == 'text'
value = if fragment && custom_field.field_format == 'text'
fragment['raw']
else
fragment

@ -32,19 +32,15 @@ en:
work_packages:
tab_name: "GitHub"
tab_header:
title: "Pull Requests"
title: "Pull requests"
copy_menu:
label: Git
description: Copy important git content to clipboard
label: Git snippets
description: Copy git snippets to clipboard
git_actions:
branch: Branch
branch_help: Copy the default branch name.
message: Message
message_help: Copy the default commit message.
cmd: Command
cmd_help: Copy a shell command which creates a new branch and an empty commit using the default branch name and commit message.
title: Copy to Clipboard
copy_button_help: Copy to clipboard
branch_name: Branch name
commit_message: Commit message
cmd: Create branch with empty commit
title: Quick snippets for Git
copy_success: ✅ Copied!
copy_error: ❌ Copy failed!
tab_prs:

@ -64,21 +64,10 @@ describe('GitActionsMenuComponent', () => {
expect(component).toBeTruthy();
});
it('should select tab', () => {
const tabToSelect = component.tabs[0];
component.selectedTab = tabToSelect;
fixture.detectChanges();
expect(component.selectedTab).toBe(tabToSelect);
});
it('should select tab', () => {
const tabToSelect = component.tabs[0];
const copyButton = fixture.debugElement.query(By.css('button')).nativeElement;
it('should generate the branch name on copy button click', () => {
const copyButton = fixture.debugElement.query(By.css('.copy-button')).nativeElement;
gitActionsService.branchName.and.returnValue('test branch');
component.selectedTab = tabToSelect;
copyButton.click();
fixture.detectChanges();

@ -36,7 +36,7 @@ import {
OpContextMenuLocalsMap,
OpContextMenuLocalsToken
} from 'core-app/components/op-context-menu/op-context-menu.types';
import { ITab } from "core-app/modules/plugins/linked/openproject-github_integration/typings";
import { ISnippet} from "core-app/modules/plugins/linked/openproject-github_integration/typings";
@Component({
@ -60,33 +60,26 @@ export class GitActionsMenuComponent extends OPContextMenuComponent {
public lastCopyResult:string = this.text.copyResult.success;
public showCopyResult:boolean = false;
public copiedSnippetId:string = '';
public tabs:ITab[] = [
public snippets:ISnippet[] = [
{
id: 'branch',
name: this.I18n.t('js.github_integration.tab_header.git_actions.branch'),
help: this.I18n.t('js.github_integration.tab_header.git_actions.branch_help'),
lines: 1,
name: this.I18n.t('js.github_integration.tab_header.git_actions.branch_name'),
textToCopy: () => this.gitActions.branchName(this.workPackage)
},
{
id: 'message',
name: this.I18n.t('js.github_integration.tab_header.git_actions.message'),
help: this.I18n.t('js.github_integration.tab_header.git_actions.message_help'),
lines: 6,
name: this.I18n.t('js.github_integration.tab_header.git_actions.commit_message'),
textToCopy: () => this.gitActions.commitMessage(this.workPackage)
},
{
id: 'command',
name: this.I18n.t('js.github_integration.tab_header.git_actions.cmd'),
help: this.I18n.t('js.github_integration.tab_header.git_actions.cmd_help'),
lines: 6,
textToCopy: () => this.gitActions.gitCommand(this.workPackage)
},
];
public selectedTab:ITab = this.tabs[0];
constructor(@Inject(OpContextMenuLocalsToken)
public locals:OpContextMenuLocalsMap,
readonly I18n:I18nService,
@ -95,21 +88,18 @@ export class GitActionsMenuComponent extends OPContextMenuComponent {
this.workPackage = this.locals.workPackage;
}
public onCopyButtonClick():void {
const success = this.copySelectedTabText();
public onCopyButtonClick(snippet:ISnippet):void {
const success = copy(snippet.textToCopy());
if (success) {
this.lastCopyResult = this.text.copyResult.success;
} else {
this.lastCopyResult = this.text.copyResult.error;
}
this.copiedSnippetId = snippet.id;
this.showCopyResult = true;
window.setTimeout(() => {
this.showCopyResult = false;
}, 2000);
}
public copySelectedTabText():boolean {
return copy(this.selectedTab.textToCopy());
}
}

@ -1,23 +1,22 @@
<div class="git-actions-menu dropdown-relative dropdown -overflow-in-view dropdown-anchor-right">
<h3 class="title">
<op-icon icon-classes="button--icon icon-console-light"></op-icon>
{{text.title}}
</h3>
<op-scrollable-tabs
[tabs]="tabs"
[currentTabId]="selectedTab.id"
(tabSelected)="selectedTab = $event"
>
</op-scrollable-tabs>
<div class="copy-wrapper">
<textarea class="copy-content" [textContent]="selectedTab.textToCopy()" [style.height.em]="selectedTab.lines" readonly="true"></textarea>
<button class="button copy-button"
type="button"
[attr.aria-label]="text.copyButtonHelpText"
(click)="onCopyButtonClick()">
<op-icon icon-classes="button--icon icon-copy"></op-icon>
</button>
<div class="copy-result-message" *ngIf="showCopyResult" [textContent]="lastCopyResult"></div>
<h3 class="title">{{text.title}}</h3>
<div class="copy-wrapper op-form-field" *ngFor="let snippet of snippets">
<label class="op-form-field--label-wrap">
<div class="op-form-field--label">{{ snippet.name }}</div>
<div class="op-form-field--input">
<input type="text" class="copy-content op-input" readonly="true" [value]="snippet.textToCopy()">
<button class="button copy-button"
type="button"
[attr.aria-label]="text.copyButtonHelpText"
(click)="onCopyButtonClick(snippet)">
<op-icon icon-classes="button--icon icon-copy"></op-icon>
</button>
<div class="copy-result-message" *ngIf="showCopyResult && snippet.id === copiedSnippetId" [textContent]="lastCopyResult"></div>
</div>
</label>
</div>
<div class="help-text" [textContent]="selectedTab.help"></div>
</div>

@ -1,7 +1,7 @@
.git-actions-menu
background-color: var(--body-background)
border: var(--content-default-border-width) solid var(--content-default-border-color)
padding: 1rem
padding: 1rem 1rem 2rem 1rem
min-width: 25rem
box-shadow: .1em .1em .4em rgba(0,0,0,0.1)
@ -11,7 +11,7 @@
margin-bottom: 1rem
.copy-content
width: calc(100% - 2.2em)
width: calc(100% - 3em)
// the min-height should be the size of the copy-icon, which is the sum of:
// 2 * button padding (0.65em)
// font-size of the icon (0.9em)
@ -22,7 +22,7 @@
color: var(--gray-dark)
white-space: pre
resize: none
font-size: 1rem
font-size: 0.9rem
display: inline-block
.copy-button
@ -32,6 +32,7 @@
vertical-align: top
left: -1px
position: relative
font-size: 0.9rem
&:hover
border-color: #999
@ -46,6 +47,7 @@
right: 0
top: calc(2 * 0.65em + 0.9em + 1px + 9px)
box-shadow: 1px 1px 4px var(--gray-dark)
z-index: 1
&:before
content: ""
@ -58,8 +60,3 @@
border-left: 0.3em solid transparent
border-right: 0.3em solid transparent
.help-text
color: var(--gray-dark)
margin-bottom: 1rem
display: inline-block
max-width: 20em

@ -71,25 +71,17 @@ describe('GitActionsService', function() {
expect(service.branchName(wp)).toEqual('user-story/42-find-the-question');
expect(service.commitMessage(wp)).toEqual(`[#42] Find the question
I recently found the answer is 42. We need to compute the correct
question.
http://localhost:9876/work_packages/42
`);
expect(service.gitCommand(wp)).toEqual(`git checkout -b 'user-story/42-find-the-question' && git commit --allow-empty -m '[#42] Find the question
I recently found the answer is 42. We need to compute the correct
question.
http://localhost:9876/work_packages/42
'`);
});
it('shell-escapes output for the git-command', () => {
const wp = createWorkPackage({description: { raw: "' && rm -rf / #"}});
expect(service.gitCommand(wp)).toEqual(`git checkout -b 'user-story/42-find-the-question' && git commit --allow-empty -m '[#42] Find the question
'\\'' && rm -rf / #
const wp = createWorkPackage({ subject: "' && rm -rf / #" });
expect(service.gitCommand(wp)).toEqual(`git checkout -b 'user-story/42-and-and-rm-rf' && git commit --allow-empty -m '[#42] '\\'' && Rm -rf / #
http://localhost:9876/work_packages/42
'`);

@ -54,10 +54,10 @@ export class GitActionsService {
const id = workPackage.id || '';
const title = workPackage.subject;
const url = window.location.origin + workPackage.pathHelper.workPackagePath(id);
const description = workPackage.description.raw || '';
const description = '';
return({
id, type, title, url, description
});
}
}
}

@ -0,0 +1,62 @@
.op-pr-check
display: grid
grid-row: span 4
grid-template-columns: 28px 33px 1fr auto
grid-template-areas: "check-state-icon check-avatar check-info check-details"
list-style-type: none
border: 1px solid #dddddd
padding: 0.3rem 1rem
background: rgba(0, 0, 0, 0.05)
font-size: 0.9rem
&:first-child
border-top-right-radius: 5px
border-top-left-radius: 5px
&:last-child
border-bottom-right-radius: 5px
border-bottom-left-radius: 5px
&--avatar img
grid-area: check-avatar
display: inline-block
width: 22px
height: 22px
margin-right: 5px
border-radius: var(--user-avatar-border-radius)
&--info
grid-area: check-info
&--state
color: var(--gray-dark)
font-style: italic
margin-left: 1em
&--state-icon
grid-area: check-state-icon
&_queued
color: cadetblue
&_in_progress
color: orange
&_success
color: green
&_failure,
&_timed_out,
&_action_required,
&_stale
color: red
&_skipped,
&_neutral,
&_cancelled
color: gray
color: gray
color: gray
&--details
grid-area: check-details

@ -0,0 +1,61 @@
<a
class='op-pull-request--link'
[href]="pullRequest.htmlUrl"
target="blank"
[textContent]="pullRequest.repository + '#' + pullRequest.number"
></a>
<div
class='op-pull-request--title'
[textContent]="pullRequest.title"
></div>
<div class="op-pull-request--info">
{{ text.label_created_by }}
<img
alt='PR author avatar'
class='op-pull-request--avatar op-avatar op-avatar_mini'
[src]="pullRequest.githubUser.avatarUrl"
*ngIf="pullRequest.githubUser"
/>
<span class='op-pull-request--user'>
<a
[href]="pullRequest.githubUser.htmlUrl"
[textContent]="pullRequest.githubUser.login"
*ngIf="pullRequest.githubUser"
></a>.
</span>
<span class='op-pull-request--date'>
{{ text.label_last_updated_on }}
<op-date-time [dateTimeValue]="pullRequest.githubUpdatedAt"></op-date-time>
</span>.
</div>
<span class='op-pull-request--state' [ngClass]="'op-pull-request--state_' + state">
<op-icon icon-classes="button--icon icon-merge-branch"></op-icon>
{{state}}
</span>
<span class="op-pull-request--checks-label" *ngIf="pullRequest.checkRuns?.length">{{ text.label_actions }}</span>
<ul [attr.aria-label]="text.label_actions" class='op-pull-request--checks' *ngIf="pullRequest.checkRuns?.length">
<li class='op-pr-check' *ngFor="let checkRun of pullRequest.checkRuns">
<span class='op-pr-check--state-icon' [ngClass]="'op-pr-check--state-icon_' + checkRunState(checkRun)">
<op-icon icon-classes="icon-{{ checkRunStateIcon(checkRun) }}"
[icon-title]="checkRunStateText(checkRun)"></op-icon>
</span>
<span class='op-pr-check--avatar'><img alt='app owner avatar' [src]="checkRun.appOwnerAvatarUrl" /></span>
<span class='op-pr-check--info'>
<span class='op-pr-check--name' [textContent]="checkRun.name"></span>
<span class='op-pr-check--state' [textContent]="checkRunStateText(checkRun)"></span>
</span>
<span class='op-pr-check--details'>
<a [href]="checkRun.detailsUrl">
{{ text.label_details }}
</a>
</span>
</li>
</ul>

@ -28,82 +28,76 @@
@import "helpers"
.op-pr-pull-request
:host,
.op-pull-request
display: grid
grid-template-columns: auto 1fr auto auto
grid-template-areas: "title title title state" "avatar user link link" "avatar date link link" "avatar checks checks checks"
margin-bottom: 24px
grid-template-areas: "link link link link" "title title title state" "info info info info" "checks-label checks-label checks-label checks-label" "checks checks checks checks"
margin-bottom: 11px
padding-bottom: 11px
border-bottom: 1px solid #dddddd
.op-pr-title
@include text-shortener
font-weight: bold
grid-area: title
// have the same line height as the status "button" next to it
line-height: 34px
margin-right: 20px
&:last-child
border-bottom: none
.op-avatar
grid-area: avatar
&--title
@include text-shortener
font-weight: bold
grid-area: title
// have the same line height as the status "button" next to it
line-height: 32px
margin-right: 20px
.op-pr-user
display: block
font-weight: bold
line-height: 16px
margin-bottom: 3px
grid-area: user
&--avatar
grid-area: info
.op-pr-date
display: block
font-size: 0.8rem
color: var(--gray-dark)
grid-area: date
&--info
display: block
grid-area: info
margin-bottom: 3px
font-size: 0.9rem
grid-area: info
color: var(--gray-dark)
// The mini avatar is much higher than the text line. Compensate it.
margin-top: -4px
.op-pr-link
grid-area: link
margin-top: 6px
&--date
grid-area: info
.op-pr-state
grid-area: state
display: inline-block
padding: 8px
border-radius: 6px
border: 1px solid #fff
color: #fff
.op-pr-state_draft
background-color: #6a737d
.op-pr-state_open
background-color: #28a745
.op-pr-state_merged
background-color: #6f42c1
.op-pr-state_closed
background-color: #d73a49
&--link
grid-area: link
margin-top: 6px
font-size: 0.9rem
.op-pr-checks
margin-top: 12px
grid-area: checks
margin-left: 0
&--state
grid-area: state
display: inline-block
padding: 8px 11px
border-radius: 16px
border: 1px solid #fff
color: #fff
font-size: 0.9rem
text-transform: capitalize
&:before
content: attr(aria-label)
&_draft
background-color: #6a737d
.op-pr-check
list-style-type: none
padding-top: 8px
margin-left: 5px
&_open
background-color: #28a745
.op-pr-check-avatar img
width: 1.4em
height: 1.4em
margin-right: 5px
border-radius: var(--user-avatar-border-radius)
&_merged
background-color: #6f42c1
.op-pr-check-state
margin-left: 1em
color: var(--gray-dark)
font-style: italic
&_closed
background-color: #d73a49
.op-pr-check-details
float: right
top: 10px
line-height: 16px
text-align: right
&--checks-label
grid-area: checks-label
margin-top: 12px
font-size: 0.9rem
font-weight: bold
&--checks
margin-top: 12px
margin-left: 0
grid-area: checks

@ -90,10 +90,10 @@ describe('PullRequestComponent', () => {
});
it('should render pull request data', () => {
const titleElement = fixture.debugElement.query(By.css('.op-pr-title')).nativeElement;
const titleElement = fixture.debugElement.query(By.css('.op-pull-request--title')).nativeElement;
const avatarElement = fixture.debugElement.query(By.css('.op-avatar')).nativeElement;
const userElement = fixture.debugElement.query(By.css('.op-pr-user')).nativeElement;
const detailsElement = fixture.debugElement.query(By.css('.op-pr-link')).nativeElement;
const userElement = fixture.debugElement.query(By.css('.op-pull-request--user')).nativeElement;
const detailsElement = fixture.debugElement.query(By.css('.op-pull-request--link')).nativeElement;
const checkRuns = fixture.debugElement.queryAll(By.css('.op-pr-check'));
const checkRunElement = checkRuns[0].nativeElement;
const checkRunLinkElement = checkRuns[0].query(By.css('a')).nativeElement;

@ -34,14 +34,20 @@ import { IGithubPullRequestResource } from "../../../../../../../../modules/gith
@Component({
selector: 'github-pull-request',
templateUrl: './pull-request.template.html',
styleUrls: ['./pull-request.component.sass']
templateUrl: './pull-request.component.html',
styleUrls: [
'./pull-request.component.sass',
'./pr-check.component.sass',
],
host: { class: 'op-pull-request' }
})
export class PullRequestComponent {
@Input() public pullRequest:IGithubPullRequestResource;
public text = {
label_updated_on: this.I18n.t('js.label_updated_on'),
label_created_by: this.I18n.t('js.label_created_by'),
label_last_updated_on: this.I18n.t('js.label_last_updated_on'),
label_details: this.I18n.t('js.label_details'),
label_actions: this.I18n.t('js.github_integration.github_actions'),
};
@ -58,10 +64,52 @@ export class PullRequestComponent {
}
}
public checkRunState(checkRun:GithubCheckRunResource) {
public checkRunStateText(checkRun:GithubCheckRunResource) {
/* Github apps can *optionally* add an output object (and a title) which is the most relevant information to display.
If that is not present, we can display the conclusion (which is present only on finished runs).
If that is not present, we can always fall back to the status. */
return(checkRun.outputTitle || checkRun.conclusion || checkRun.status);
}
public checkRunState(checkRun:GithubCheckRunResource) {
return(checkRun.conclusion || checkRun.status);
}
public checkRunStateIcon(checkRun:GithubCheckRunResource) {
switch (this.checkRunState(checkRun)) {
case 'success': {
return 'checkmark'
}
case 'queued': {
return 'getting-started'
}
case 'in_progress': {
return 'loading1'
}
case 'failure': {
return 'cancel'
}
case 'timed_out': {
return 'reminder'
}
case 'action_required': {
return 'warning'
}
case 'stale': {
return 'not-supported'
}
case 'skipped': {
return 'redo'
}
case 'neutral': {
return 'minus1'
}
case 'cancelled': {
return 'minus1'
}
default: {
return 'not-supported'
}
}
}
}

@ -1,42 +0,0 @@
<div class='op-pr-pull-request'>
<div class='op-pr-title'
[textContent]="pullRequest.title">
</div>
<img alt='PR author avatar'
class='op-avatar'
[src]="pullRequest.githubUser.avatarUrl"
*ngIf="pullRequest.githubUser"
/>
<span class='op-pr-user'>
<a [href]="pullRequest.githubUser.htmlUrl"
[textContent]="pullRequest.githubUser.login"
*ngIf="pullRequest.githubUser"
>
</a>
</span>
<span class='op-pr-date'>
{{ text.label_updated_on }}
<op-date-time [dateTimeValue]="pullRequest.githubUpdatedAt"></op-date-time>
</span>
<span class='op-pr-state' [ngClass]="'op-pr-state_' + state">{{state}}</span>
<a class='op-pr-link' [href]="pullRequest.htmlUrl" [textContent]="pullRequest.repository + '#' + pullRequest.number"></a>
<ul [attr.aria-label]="text.label_actions" class='op-pr-checks' *ngIf="pullRequest.checkRuns?.length">
<li class='op-pr-check' *ngFor="let checkRun of pullRequest.checkRuns">
<span class='op-pr-check-avatar'><img alt='app owner avatar' [src]="checkRun.appOwnerAvatarUrl" /></span>
<span class='op-pr-check-name' [textContent]="checkRun.name"></span>
<span class='op-pr-check-state' [textContent]="checkRunState(checkRun)"></span>
<div class='op-pr-check-details'>
<a [href]="checkRun.detailsUrl">
{{ text.label_details }}
</a>
</div>
</li>
</ul>
</div>

@ -34,10 +34,13 @@
border-bottom: 1px solid #ddd
margin: 1.5rem 0 0.8rem 0
padding: 0 0 0.5rem 0
.title
flex: 1 1 auto
border-bottom: 0
margin: 0
padding: 0
font-weight: bold
font-size: 1rem
line-height: 32px
text-transform: uppercase

@ -37,7 +37,8 @@ import { IGithubPullRequestResource } from "../../../../../../../../modules/gith
@Component({
selector: 'tab-prs',
templateUrl: './tab-prs.template.html'
templateUrl: './tab-prs.template.html',
host: { class: 'op-prs' }
})
export class TabPrsComponent implements OnInit {
@Input() public workPackage:WorkPackageResource;

@ -2,6 +2,4 @@
<p [innerHTML]="getEmptyText()"></p>
</ng-container>
<div *ngFor="let pullRequest of pullRequests">
<github-pull-request [pullRequest]="pullRequest"></github-pull-request>
</div>
<github-pull-request [pullRequest]="pullRequest" *ngFor="let pullRequest of pullRequests"></github-pull-request>

@ -1,9 +1,9 @@
import { TabDefinition } from "core-app/modules/common/tabs/tab.interface";
import { HalResourceClass } from "core-app/modules/hal/resources/hal-resource";
export interface ITab extends TabDefinition {
help:string,
lines:number,
export interface ISnippet {
id: string;
name: string;
textToCopy: ()=>string
}

@ -71,7 +71,7 @@ describe 'Open the GitHub tab', type: :feature, js: true do
work_package_page.switch_to_tab(tab: 'github')
github_tab.git_actions_menu_button.click
github_tab.git_actions_copy_button.click
github_tab.git_actions_copy_branch_name_button.click
expect(page).to have_text('Copied!')
expect_clipboard_content("#{work_package.type.name.downcase}/#{work_package.id}-a-test-work_package")

@ -46,8 +46,8 @@ module Pages
find('.github-git-copy:not([disabled])', text: 'Git')
end
def git_actions_copy_button
find('.git-actions-menu .copy-button:not([disabled])')
def git_actions_copy_branch_name_button
find('.git-actions-menu .copy-button:not([disabled])', match: :first)
end
def paste_clipboard_content

@ -332,5 +332,36 @@ describe PlaceholderUsersController, type: :controller do
it_behaves_like 'do not allow non-admins'
end
end
context 'as a user that may not delete the placeholder' do
current_user { FactoryBot.create :user }
before do
allow(PlaceholderUsers::DeleteContract)
.to receive(:deletion_allowed?).and_return false
end
describe 'GET deletion_info' do
before do
get :deletion_info, params: { id: placeholder_user.id }
end
it 'responds with unauthorized status' do
expect(response).to_not be_successful
expect(response.status).to eq 403
end
end
describe 'POST destroy' do
before do
delete :destroy, params: { id: placeholder_user.id }
end
it 'responds with unauthorized status' do
expect(response).to_not be_successful
expect(response.status).to eq 403
end
end
end
end

@ -72,6 +72,31 @@ describe 'delete placeholder user', type: :feature, js: true do
it_behaves_like 'placeholders delete flow'
end
context 'as user with global permission, but placeholder in an invisble project' do
current_user { FactoryBot.create :user, global_permission: %i[manage_placeholder_user] }
let!(:project) { FactoryBot.create :project }
let!(:member) do
FactoryBot.create :member,
principal: placeholder_user,
project: project,
roles: [FactoryBot.create(:role)]
end
it 'returns an error when trying to delete and disables the button' do
visit deletion_info_placeholder_user_path(placeholder_user)
expect(page).to have_content I18n.t('placeholder_users.right_to_manage_members_missing').strip
visit placeholder_user_path(placeholder_user)
expect(page).to have_selector '.button.-disabled', text: 'Delete'
visit edit_placeholder_user_path(placeholder_user)
expect(page).to have_selector '.button.-disabled', text: 'Delete'
end
end
context 'as user without global permission' do
current_user { FactoryBot.create :user }

@ -86,7 +86,7 @@ describe 'Project attribute help texts', type: :feature, js: true do
it 'shows the help text on the project create form' do
visit new_project_path
page.find('.form--fieldset-legend', text: 'ADVANCED SETTINGS').click
page.find('.op-fieldset--legend', text: 'ADVANCED SETTINGS').click
expect(page).to have_selector('.op-form-field--label attribute-help-text', wait: 10)

@ -0,0 +1,127 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe 'Projects', 'creation', type: :feature, js: true do
let(:name_field) { ::FormFields::InputFormField.new :name }
current_user { FactoryBot.create(:admin) }
shared_let(:project) { FactoryBot.create(:project, name: 'Foo project', identifier: 'foo-project') }
before do
visit projects_path
end
it 'can create a project' do
click_on 'New project'
name_field.set_value 'Foo bar'
click_button 'Save'
sleep 1
expect(page).to have_content 'Foo bar'
expect(page).to have_current_path /\/projects\/foo-bar\/?/
end
it 'does not create a project with an already existing identifier' do
click_on 'New project'
name_field.set_value 'Foo project'
click_on 'Save'
expect(page).to have_current_path /\/projects\/foo-project-1\/?/
project = Project.last
expect(project.identifier).to eq 'foo-project-1'
end
context 'with a multi-select custom field' do
let!(:list_custom_field) { FactoryBot.create(:list_project_custom_field, name: 'List CF', multi_value: true) }
let(:list_field) { ::FormFields::SelectFormField.new list_custom_field }
it 'can create a project' do
click_on 'New project'
name_field.set_value 'Foo bar'
find('.op-fieldset--toggle', text: 'ADVANCED SETTINGS').click
list_field.select_option 'A', 'B'
click_button 'Save'
expect(page).to have_current_path /\/projects\/foo-bar\/?/
expect(page).to have_content 'Foo bar'
project = Project.last
expect(project.name).to eq 'Foo bar'
cvs = project.custom_value_for(list_custom_field)
expect(cvs.count).to eq 2
expect(cvs.map(&:typed_value)).to contain_exactly 'A', 'B'
end
end
it 'hides the active field and the identifier' do
visit new_project_path
find('.op-fieldset--toggle', text: 'ADVANCED SETTINGS').click
expect(page).to have_no_content 'Active'
expect(page).to have_no_content 'Identifier'
end
context 'with optional and required custom fields' do
let!(:optional_custom_field) do
FactoryBot.create(:custom_field, name: 'Optional Foo',
type: ProjectCustomField,
is_for_all: true)
end
let!(:required_custom_field) do
FactoryBot.create(:custom_field, name: 'Required Foo',
type: ProjectCustomField,
is_for_all: true,
is_required: true)
end
it 'seperates optional and required custom fields for new' do
visit new_project_path
expect(page).to have_content 'Required Foo'
click_on 'Advanced settings'
within('.op-fieldset') do
expect(page).to have_text 'Optional Foo'
expect(page).to have_no_text 'Required Foo'
end
end
end
end

@ -32,25 +32,34 @@ describe 'Projects#destroy',
type: :feature,
js: true do
let!(:project) { FactoryBot.create(:project, name: 'foo', identifier: 'foo') }
let(:user) { FactoryBot.create(:admin) }
let(:projects_page) { Pages::Projects::Destroy.new(project) }
let(:project_page) { Pages::Projects::Destroy.new(project) }
let(:danger_zone) { DangerZone.new(page) }
before do
login_as user
current_user { FactoryBot.create(:admin) }
before do
# Disable background worker
allow(Delayed::Worker)
.to receive(:delay_jobs)
.and_return(false)
expect(project)
projects_page.visit!
project_page.visit!
end
it 'can destroy the project' do
it 'destroys the project' do
# Confirm the deletion
# Without confirmation, the button is disabled
expect(danger_zone)
.to be_disabled
# With wrong confirmation, the button is disabled
danger_zone.confirm_with("#{project.identifier}_wrong")
expect(danger_zone)
.to be_disabled
# With correct confirmation, the button is enabled
# and the project can be deleted
danger_zone.confirm_with(project.identifier)
expect(danger_zone).not_to be_disabled
danger_zone.danger_button.click

@ -0,0 +1,210 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe 'Projects', 'editing settings', type: :feature, js: true do
let(:name_field) { ::FormFields::InputFormField.new :name }
let(:parent_field) { ::FormFields::SelectFormField.new :parent }
let(:permissions) { %i(edit_project) }
current_user do
FactoryBot.create(:user,
member_in_project: project,
member_with_permissions: permissions)
end
shared_let(:project) do
FactoryBot.create(:project, name: 'Foo project', identifier: 'foo-project')
end
it 'hides the field whose functionality is presented otherwise' do
visit settings_generic_project_path(project.id)
expect(page).to have_no_text :all, 'Active'
expect(page).to have_no_text :all, 'Identifier'
end
describe 'identifier edit' do
it 'updates the project identifier' do
visit projects_path
click_on project.name
SeleniumHubWaiter.wait
click_on 'Project settings'
SeleniumHubWaiter.wait
click_on 'Change identifier'
expect(page).to have_content "CHANGE THE PROJECT'S IDENTIFIER"
expect(current_path).to eq '/projects/foo-project/identifier'
fill_in 'project[identifier]', with: 'foo-bar'
click_on 'Update'
expect(page).to have_content 'Successful update.'
expect(current_path).to match '/projects/foo-bar/settings/generic'
expect(Project.first.identifier).to eq 'foo-bar'
end
it 'displays error messages on invalid input' do
visit identifier_project_path(project)
fill_in 'project[identifier]', with: 'FOOO'
click_on 'Update'
expect(page).to have_content 'Identifier is invalid.'
expect(current_path).to eq '/projects/foo-project/identifier'
end
end
context 'with optional and required custom fields' do
let!(:optional_custom_field) do
FactoryBot.create(:custom_field, name: 'Optional Foo',
type: ProjectCustomField,
is_for_all: true)
end
let!(:required_custom_field) do
FactoryBot.create(:custom_field, name: 'Required Foo',
type: ProjectCustomField,
is_for_all: true,
is_required: true)
end
it 'shows optional and required custom fields for edit without a separation' do
project.custom_field_values.last.value = 'FOO'
project.save!
visit settings_generic_project_path(project.id)
expect(page).to have_text 'Optional Foo'
expect(page).to have_text 'Required Foo'
end
end
context 'with a length restricted custom field' do
let!(:required_custom_field) do
FactoryBot.create(:string_project_custom_field,
name: 'Foo',
type: ProjectCustomField,
min_length: 1,
max_length: 2,
is_for_all: true)
end
let(:foo_field) { ::FormFields::InputFormField.new required_custom_field }
it 'shows the errors of that field when saving (Regression #33766)' do
visit settings_generic_project_path(project.id)
expect(page).to have_content 'Foo'
# Enter something too long
foo_field.set_value '1234'
# It should cut of that remaining value
foo_field.expect_value '12'
click_button 'Save'
expect(page).to have_text 'Successful update.'
end
end
context 'with a multi-select custom field' do
include_context 'ng-select-autocomplete helpers'
let!(:list_custom_field) { FactoryBot.create(:list_project_custom_field, name: 'List CF', multi_value: true) }
let(:form_field) { ::FormFields::SelectFormField.new list_custom_field }
it 'can select multiple values' do
visit settings_generic_project_path(project.id)
form_field.select_option 'A', 'B'
click_on 'Save'
expect(page).to have_content 'Successful update.'
form_field.expect_selected 'A', 'B'
cvs = project.reload.custom_value_for(list_custom_field)
expect(cvs.count).to eq 2
expect(cvs.map(&:typed_value)).to contain_exactly 'A', 'B'
end
end
context 'with a date custom field' do
let!(:date_custom_field) { FactoryBot.create(:date_project_custom_field, name: 'Date') }
let(:form_field) { ::FormFields::InputFormField.new date_custom_field }
it 'can save and remove the date (Regression #37459)' do
visit settings_generic_project_path(project.id)
form_field.set_value '2021-05-26'
form_field.send_keys :escape
click_on 'Save'
expect(page).to have_content 'Successful update.'
form_field.expect_value '2021-05-26'
cv = project.reload.custom_value_for(date_custom_field)
expect(cv.typed_value).to eq '2021-05-26'.to_date
end
end
context 'with a user not allowed to see the parent project' do
include_context 'ng-select-autocomplete helpers'
let(:parent_project) { FactoryBot.create(:project) }
let(:parent_field) { ::FormFields::SelectFormField.new 'parent' }
before do
project.update_attribute(:parent, parent_project)
end
it 'can update the project without destroying the relation to the parent' do
visit settings_generic_project_path(project.id)
fill_in 'Name', with: 'New project name'
parent_field.expect_selected I18n.t(:'api_v3.undisclosed.parent')
click_on 'Save'
expect(page).to have_content 'Successful update.'
project.reload
expect(project.name)
.to eql 'New project name'
expect(project.parent)
.to eql parent_project
end
end
end

@ -60,7 +60,7 @@ describe 'Projects status administration', type: :feature, js: true do
visit new_project_path
# Create the project with status
click_link 'Advanced settings'
click_button 'Advanced settings'
name_field.set_value 'New project'
status_field.select_option 'On track'

@ -49,7 +49,7 @@ describe 'Projects custom fields', type: :feature, js: true do
name_field.set_value 'My project name'
find('.form--fieldset-legend a', text: 'ADVANCED SETTINGS').click
find('.op-fieldset--toggle', text: 'ADVANCED SETTINGS').click
cf_field.expect_visible
@ -79,7 +79,7 @@ describe 'Projects custom fields', type: :feature, js: true do
visit new_project_path
name_field.set_value 'My project name'
find('.form--fieldset-legend a', text: 'ADVANCED SETTINGS').click
find('.op-fieldset--toggle', text: 'ADVANCED SETTINGS').click
default_int_field.expect_value default_int_custom_field.default_value.to_s
default_string_field.expect_value default_string_custom_field.default_value.to_s
@ -144,7 +144,7 @@ describe 'Projects custom fields', type: :feature, js: true do
visit new_project_path
name_field.set_value 'My project name'
find('.form--fieldset-legend a', text: 'ADVANCED SETTINGS').click
find('.op-fieldset--toggle', text: 'ADVANCED SETTINGS').click
float_field.set_value '10000.55'
@ -169,7 +169,7 @@ describe 'Projects custom fields', type: :feature, js: true do
visit new_project_path
name_field.set_value 'My project name'
find('.form--fieldset-legend a', text: 'ERWEITERTE EINSTELLUNGEN').click
find('.op-fieldset--toggle', text: 'ERWEITERTE EINSTELLUNGEN').click
float_field.set_value '10000,55'
@ -235,7 +235,7 @@ describe 'Projects custom fields', type: :feature, js: true do
name_field.set_value 'My project name'
find('.form--fieldset-legend a', text: 'ADVANCED SETTINGS').click
find('.op-fieldset--toggle', text: 'ADVANCED SETTINGS').click
cf_field.expect_visible
cf_field.expect_no_option invisible_user

@ -1,339 +0,0 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe 'Projects', type: :feature, js: true do
let(:current_user) { FactoryBot.create(:admin) }
let(:name_field) { ::FormFields::InputFormField.new :name }
let(:parent_field) { ::FormFields::SelectFormField.new :parent }
before do
allow(User).to receive(:current).and_return current_user
end
describe 'creation' do
shared_let(:project) { FactoryBot.create(:project, name: 'Foo project', identifier: 'foo-project') }
before do
visit projects_path
end
it 'can create a project' do
click_on 'New project'
name_field.set_value 'Foo bar'
click_button 'Save'
sleep 1
expect(page).to have_content 'Foo bar'
expect(page).to have_current_path /\/projects\/foo-bar\/?/
end
it 'can create a subproject' do
click_on project.name
SeleniumHubWaiter.wait
click_on 'Project settings'
SeleniumHubWaiter.wait
click_on 'New subproject'
name_field.set_value 'Foo child'
sleep 1
parent_field.expect_selected project.name
click_button 'Save'
sleep 1
expect(page).to have_current_path /\/projects\/foo-child\/?/
child = Project.last
expect(child.identifier).to eq 'foo-child'
expect(child.parent).to eq project
end
it 'does not create a project with an already existing identifier' do
click_on 'New project'
name_field.set_value 'Foo project'
click_on 'Save'
expect(page).to have_current_path /\/projects\/foo-project-1\/?/
project = Project.last
expect(project.identifier).to eq 'foo-project-1'
end
context 'with a multi-select custom field' do
let!(:list_custom_field) { FactoryBot.create(:list_project_custom_field, name: 'List CF', multi_value: true) }
let(:list_field) { ::FormFields::SelectFormField.new list_custom_field }
it 'can create a project' do
click_on 'New project'
name_field.set_value 'Foo bar'
find('.form--fieldset-legend a', text: 'ADVANCED SETTINGS').click
list_field.select_option 'A', 'B'
click_button 'Save'
expect(page).to have_current_path /\/projects\/foo-bar\/?/
expect(page).to have_content 'Foo bar'
project = Project.last
expect(project.name).to eq 'Foo bar'
cvs = project.custom_value_for(list_custom_field)
expect(cvs.count).to eq 2
expect(cvs.map(&:typed_value)).to contain_exactly 'A', 'B'
end
end
end
describe 'project types' do
let(:phase_type) { FactoryBot.create(:type, name: 'Phase', is_default: true) }
let(:milestone_type) { FactoryBot.create(:type, name: 'Milestone', is_default: false) }
let!(:project) { FactoryBot.create(:project, name: 'Foo project', types: [phase_type, milestone_type]) }
it "have the correct types checked for the project's types" do
visit projects_path
click_on 'Foo project'
click_on 'Project settings'
click_on 'Work package types'
field_checked = find_field('Phase', visible: false)['checked']
expect(field_checked).to be_truthy
field_checked = find_field('Milestone', visible: false)['checked']
expect(field_checked).to be_truthy
end
end
describe 'deletion' do
let(:project) { FactoryBot.create(:project) }
let(:projects_page) { Pages::Projects::Destroy.new(project) }
before do
projects_page.visit!
end
describe 'disable delete w/o confirm' do
it { expect(page).to have_css('.danger-zone .button[disabled]') }
end
describe 'disable delete with wrong input' do
let(:input) { find('.danger-zone input') }
it do
input.set 'Not the project name'
expect(page).to have_css('.danger-zone .button[disabled]')
end
end
describe 'enable delete with correct input' do
let(:input) { find('.danger-zone input') }
it do
input.set project.name
expect(page).to have_css('.danger-zone .button:not([disabled])')
end
end
end
describe 'identifier edit' do
let!(:project) { FactoryBot.create(:project, identifier: 'foo') }
it 'updates the project identifier' do
visit projects_path
click_on project.name
SeleniumHubWaiter.wait
click_on 'Project settings'
SeleniumHubWaiter.wait
click_on 'Change identifier'
expect(page).to have_content "CHANGE THE PROJECT'S IDENTIFIER"
expect(current_path).to eq '/projects/foo/identifier'
fill_in 'project[identifier]', with: 'foo-bar'
click_on 'Update'
expect(page).to have_content 'Successful update.'
expect(current_path).to match '/projects/foo-bar/settings/generic'
expect(Project.first.identifier).to eq 'foo-bar'
end
it 'displays error messages on invalid input' do
visit identifier_project_path(project)
fill_in 'project[identifier]', with: 'FOOO'
click_on 'Update'
expect(page).to have_content 'Identifier is invalid.'
expect(current_path).to eq '/projects/foo/identifier'
end
end
describe 'form' do
let(:project) { FactoryBot.build(:project, name: 'Foo project', identifier: 'foo-project') }
context 'when creating' do
it 'hides the active field and the identifier' do
visit new_project_path
find('.form--fieldset-legend a', text: 'ADVANCED SETTINGS').click
expect(page).to have_no_content 'Active'
expect(page).to have_no_content 'Identifier'
end
end
context 'when editing' do
it 'hides the active field' do
project.save!
visit settings_generic_project_path(project.id)
expect(page).to have_no_text :all, 'Active'
expect(page).to have_no_text :all, 'Identifier'
end
end
context 'with optional and required custom fields' do
let!(:optional_custom_field) do
FactoryBot.create(:custom_field, name: 'Optional Foo',
type: ProjectCustomField,
is_for_all: true)
end
let!(:required_custom_field) do
FactoryBot.create(:custom_field, name: 'Required Foo',
type: ProjectCustomField,
is_for_all: true,
is_required: true)
end
it 'seperates optional and required custom fields for new' do
visit new_project_path
expect(page).to have_content 'Required Foo'
click_on 'Advanced settings'
within('.form--fieldset') do
expect(page).to have_text 'Optional Foo'
expect(page).to have_no_text 'Required Foo'
end
end
it 'shows optional and required custom fields for edit without a separation' do
project.custom_field_values.last.value = 'FOO'
project.save!
visit settings_generic_project_path(project.id)
expect(page).to have_text 'Optional Foo'
expect(page).to have_text 'Required Foo'
end
end
context 'with a length restricted custom field' do
let(:project) { FactoryBot.create(:project, name: 'Foo project', identifier: 'foo-project') }
let!(:required_custom_field) do
FactoryBot.create(:string_project_custom_field,
name: 'Foo',
type: ProjectCustomField,
min_length: 1,
max_length: 2,
is_for_all: true)
end
let(:foo_field) { ::FormFields::InputFormField.new required_custom_field }
it 'shows the errors of that field when saving (Regression #33766)' do
visit settings_generic_project_path(project.id)
expect(page).to have_content 'Foo'
# Enter something too long
foo_field.set_value '1234'
# It should cut of that remaining value
foo_field.expect_value '12'
click_button 'Save'
expect(page).to have_text 'Successful update.'
end
end
end
context 'with a multi-select custom field' do
include_context 'ng-select-autocomplete helpers'
let(:project) { FactoryBot.create(:project, name: 'Foo project', identifier: 'foo-project') }
let!(:list_custom_field) { FactoryBot.create(:list_project_custom_field, name: 'List CF', multi_value: true) }
let(:form_field) { ::FormFields::SelectFormField.new list_custom_field }
it 'can create a project' do
visit settings_generic_project_path(project.id)
form_field.select_option 'A', 'B'
click_on 'Save'
expect(page).to have_content 'Successful update.'
form_field.expect_selected 'A', 'B'
cvs = project.reload.custom_value_for(list_custom_field)
expect(cvs.count).to eq 2
expect(cvs.map(&:typed_value)).to contain_exactly 'A', 'B'
end
end
context 'with a date custom field' do
let(:project) { FactoryBot.create(:project, name: 'Foo project', identifier: 'foo-project') }
let!(:date_custom_field) { FactoryBot.create(:date_project_custom_field, name: 'Date') }
let(:form_field) { ::FormFields::InputFormField.new date_custom_field }
it 'can save and remove the date (Regression #37459)' do
visit settings_generic_project_path(project.id)
form_field.set_value '2021-05-26'
form_field.send_keys :escape
click_on 'Save'
expect(page).to have_content 'Successful update.'
form_field.expect_value '2021-05-26'
cv = project.reload.custom_value_for(date_custom_field)
expect(cv.typed_value).to eq '2021-05-26'.to_date
end
end
end

@ -95,7 +95,7 @@ describe 'Project templates', type: :feature, js: true do
template_field.expect_selected 'My template'
# Updates the identifier in advanced settings
page.find('.form--fieldset-legend', text: 'ADVANCED SETTINGS').click
page.find('.op-fieldset--toggle', text: 'ADVANCED SETTINGS').click
status_field.expect_selected 'ON TRACK'
# It does not show the copy meta flags

@ -0,0 +1,49 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe 'Projects', 'work package type mgmt', type: :feature, js: true do
current_user { FactoryBot.create(:admin) }
let(:phase_type) { FactoryBot.create(:type, name: 'Phase', is_default: true) }
let(:milestone_type) { FactoryBot.create(:type, name: 'Milestone', is_default: false) }
let!(:project) { FactoryBot.create(:project, name: 'Foo project', types: [phase_type, milestone_type]) }
it "have the correct types checked for the project's types" do
visit projects_path
click_on 'Foo project'
click_on 'Project settings'
click_on 'Work package types'
field_checked = find_field('Phase', visible: false)['checked']
expect(field_checked).to be_truthy
field_checked = find_field('Milestone', visible: false)['checked']
expect(field_checked).to be_truthy
end
end

@ -105,7 +105,7 @@ describe ::API::V3::Projects::ProjectPayloadRepresenter, 'parsing' do
end
end
context 'with null for a scope' do
context 'with null for a status' do
let(:hash) do
{
'_links' => {
@ -135,4 +135,68 @@ describe ::API::V3::Projects::ProjectPayloadRepresenter, 'parsing' do
end
end
end
describe '_links' do
context 'with a parent link' do
context 'with the href being an url' do
let(:hash) do
{
'_links' => {
'parent' => {
'href' => api_v3_paths.project(5)
}
}
}
end
it 'sets the parent_id to the value' do
project = representer.from_hash(hash).to_h
expect(project[:parent_id])
.to eq "5"
end
end
context 'with the href being nil' do
let(:hash) do
{
'_links' => {
'parent' => {
'href' => nil
}
}
}
end
it 'sets the parent_id to nil' do
project = representer.from_hash(hash).to_h
expect(project)
.to have_key(:parent_id)
expect(project[:parent_id])
.to eq nil
end
end
context 'with the href being the hidden uri' do
let(:hash) do
{
'_links' => {
'parent' => {
'href' => API::V3::URN_UNDISCLOSED
}
}
}
end
it 'omits the parent information' do
project = representer.from_hash(hash).to_h
expect(project)
.not_to have_key(:parent_id)
end
end
end
end
end

@ -0,0 +1,519 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe ::API::V3::Projects::ProjectRepresenter, 'rendering' do
include ::API::V3::Utilities::PathHelper
subject(:generated) { representer.to_json }
let(:project) do
FactoryBot.build_stubbed(:project,
parent: parent_project,
description: 'some description',
status: status).tap do |p|
allow(p)
.to receive(:available_custom_fields)
.and_return([int_custom_field, version_custom_field])
allow(p)
.to receive(:"custom_field_#{int_custom_field.id}")
.and_return(int_custom_value.value)
allow(p)
.to receive(:custom_value_for)
.with(version_custom_field)
.and_return(version_custom_value)
end
end
let(:status) do
FactoryBot.build_stubbed(:project_status)
end
let(:parent_project) { FactoryBot.build_stubbed(:project) }
let(:representer) { described_class.create(project, current_user: user, embed_links: true) }
let(:user) do
FactoryBot.build_stubbed(:user).tap do |u|
allow(u)
.to receive(:allowed_to?) do |permission, context|
permissions.include?(permission) && context == project
end
end
end
let(:int_custom_field) { FactoryBot.build_stubbed(:int_project_custom_field, visible: false) }
let(:version_custom_field) { FactoryBot.build_stubbed(:version_project_custom_field, visible: true) }
let(:int_custom_value) do
CustomValue.new(custom_field: int_custom_field,
value: '1234',
customized: nil)
end
let(:version) { FactoryBot.build_stubbed(:version) }
let(:version_custom_value) do
CustomValue.new(custom_field: version_custom_field,
value: version.id,
customized: nil).tap do |cv|
allow(cv)
.to receive(:typed_value)
.and_return(version)
end
end
let(:permissions) { %i[add_work_packages view_members] }
it { is_expected.to include_json('Project'.to_json).at_path('_type') }
describe 'properties' do
it_behaves_like 'property', :_type do
let(:value) { 'Project' }
end
it_behaves_like 'property', :id do
let(:value) { project.id }
end
it_behaves_like 'property', :identifier do
let(:value) { project.identifier }
end
it_behaves_like 'property', :name do
let(:value) { project.name }
end
it_behaves_like 'property', :active do
let(:value) { project.active }
end
it_behaves_like 'property', :public do
let(:value) { project.public }
end
it_behaves_like 'formattable property', :description do
let(:value) { project.description }
end
context 'statusExplanation' do
it_behaves_like 'formattable property', 'statusExplanation' do
let(:value) { status.explanation }
end
end
it_behaves_like 'has UTC ISO 8601 date and time' do
let(:date) { project.created_at }
let(:json_path) { 'createdAt' }
end
it_behaves_like 'has UTC ISO 8601 date and time' do
let(:date) { project.updated_at }
let(:json_path) { 'updatedAt' }
end
context 'int custom field' do
context 'if the user is admin' do
before do
allow(user)
.to receive(:admin?)
.and_return(true)
end
it "has a property for the int custom field" do
is_expected
.to be_json_eql(int_custom_value.value.to_json)
.at_path("customField#{int_custom_field.id}")
end
end
context 'if the user is no admin' do
it "has no property for the int custom field" do
is_expected
.not_to have_json_path("customField#{int_custom_field.id}")
end
end
end
end
describe '_links' do
it { is_expected.to have_json_type(Object).at_path('_links') }
it 'links to self' do
expect(subject).to have_json_path('_links/self/href')
end
it 'has a title for link to self' do
expect(subject).to have_json_path('_links/self/title')
end
describe 'create work packages' do
context 'user allowed to create work packages' do
it 'has the correct path for a create form' do
is_expected
.to be_json_eql(api_v3_paths.create_project_work_package_form(project.id).to_json)
.at_path('_links/createWorkPackage/href')
end
it 'has the correct path to create a work package' do
is_expected.to be_json_eql(api_v3_paths.work_packages_by_project(project.id).to_json)
.at_path('_links/createWorkPackageImmediately/href')
end
end
context 'user not allowed to create work packages' do
let(:permissions) { [] }
it_behaves_like 'has no link' do
let(:link) { 'createWorkPackage' }
end
it_behaves_like 'has no link' do
let(:link) { 'createWorkPackageImmediately' }
end
end
end
describe 'parent' do
before do
allow(parent_project)
.to receive(:visible?)
.and_return(visible)
end
let(:visible) { true }
it_behaves_like 'has a titled link' do
let(:link) { 'parent' }
let(:href) { api_v3_paths.project(parent_project.id) }
let(:title) { parent_project.name }
end
context 'if lacking the permissions to see the parent' do
let(:visible) { false }
it_behaves_like 'has a titled link' do
let(:link) { 'parent' }
let(:href) { API::V3::URN_UNDISCLOSED }
let(:title) { I18n.t(:'api_v3.undisclosed.parent') }
end
end
context 'without a parent' do
let(:parent_project) { nil }
it_behaves_like 'has an untitled link' do
let(:link) { 'parent' }
let(:href) { nil }
end
end
end
context 'status' do
it_behaves_like 'has a titled link' do
let(:link) { 'status' }
let(:href) { api_v3_paths.project_status(project.status.code) }
let(:title) { I18n.t(:"activerecord.attributes.projects/status.codes.#{project.status.code}") }
end
context 'if the status is nil' do
let(:status) { nil }
it_behaves_like 'has an untitled link' do
let(:link) { 'status' }
let(:href) { nil }
end
end
end
describe 'categories' do
it 'has the correct link to its categories' do
is_expected.to be_json_eql(api_v3_paths.categories_by_project(project.id).to_json)
.at_path('_links/categories/href')
end
end
describe 'versions' do
context 'with only manage_versions permission' do
let(:permissions) { [:manage_versions] }
it_behaves_like 'has an untitled link' do
let(:link) { 'versions' }
let(:href) { api_v3_paths.versions_by_project(project.id) }
end
end
context 'with only view_work_packages permission' do
let(:permissions) { [:view_work_packages] }
it_behaves_like 'has an untitled link' do
let(:link) { 'versions' }
let(:href) { api_v3_paths.versions_by_project(project.id) }
end
end
context 'without both permissions' do
let(:permissions) { [:add_work_packages] }
it_behaves_like 'has no link' do
let(:link) { 'versions' }
end
end
end
describe 'types' do
context 'for a user having the view_work_packages permission' do
let(:permissions) { [:view_work_packages] }
it 'links to the types active in the project' do
is_expected.to be_json_eql(api_v3_paths.types_by_project(project.id).to_json)
.at_path('_links/types/href')
end
it 'links to the work packages in the project' do
is_expected.to be_json_eql(api_v3_paths.work_packages_by_project(project.id).to_json)
.at_path('_links/workPackages/href')
end
end
context 'for a user having the manage_types permission' do
let(:permissions) { [:manage_types] }
it 'links to the types active in the project' do
is_expected.to be_json_eql(api_v3_paths.types_by_project(project.id).to_json)
.at_path('_links/types/href')
end
end
context 'for a user not having the necessary permissions' do
let(:permission) { [] }
it 'has no types link' do
is_expected.to_not have_json_path('_links/types/href')
end
it 'has no work packages link' do
is_expected.to_not have_json_path('_links/workPackages/href')
end
end
end
describe 'memberships' do
it_behaves_like 'has an untitled link' do
let(:link) { 'memberships' }
let(:href) { api_v3_paths.path_for(:memberships, filters: [{ project: { operator: "=", values: [project.id.to_s] } }]) }
end
context 'without the view_members permission' do
let(:permissions) { [] }
it_behaves_like 'has no link' do
let(:link) { 'memberships' }
end
end
end
context 'link custom field' do
context 'if the user is admin and the field is invisible' do
before do
allow(user)
.to receive(:admin?)
.and_return(true)
version_custom_field.visible = false
end
it 'links custom fields' do
is_expected
.to be_json_eql(api_v3_paths.version(version.id).to_json)
.at_path("_links/customField#{version_custom_field.id}/href")
end
end
context 'if the user is no admin and the field is invisible' do
before do
version_custom_field.visible = false
end
it "has no property for the int custom field" do
is_expected
.not_to have_json_path("links/customField#{version_custom_field.id}")
end
end
context 'if the user is no admin and the field is visible' do
it 'links custom fields' do
is_expected
.to be_json_eql(api_v3_paths.version(version.id).to_json)
.at_path("_links/customField#{version_custom_field.id}/href")
end
end
end
describe 'update' do
context 'for a user having the edit_project permission' do
let(:permissions) { [:edit_project] }
it_behaves_like 'has an untitled link' do
let(:link) { 'update' }
let(:href) { api_v3_paths.project_form project.id }
end
end
context 'for a user lacking the edit_project permission' do
let(:permissions) { [] }
it_behaves_like 'has no link' do
let(:link) { 'update' }
end
end
end
describe 'updateImmediately' do
context 'for a user having the edit_project permission' do
let(:permissions) { [:edit_project] }
it_behaves_like 'has an untitled link' do
let(:link) { 'updateImmediately' }
let(:href) { api_v3_paths.project project.id }
end
end
context 'for a user lacking the edit_project permission' do
let(:permissions) { [] }
it_behaves_like 'has no link' do
let(:link) { 'updateImmediately' }
end
end
end
describe 'delete' do
context 'for a user being admin' do
before do
allow(user)
.to receive(:admin?)
.and_return(true)
end
it_behaves_like 'has an untitled link' do
let(:link) { 'delete' }
let(:href) { api_v3_paths.project project.id }
end
end
context 'for a non admin user' do
let(:permissions) { [] }
it_behaves_like 'has no link' do
let(:link) { 'delete' }
end
end
end
end
describe '_embedded' do
describe 'parent' do
let(:embedded_path) { '_embedded/parent' }
before do
allow(parent_project)
.to receive(:visible?)
.and_return(parent_visible)
end
context 'when the user is allowed to see the parent' do
let(:parent_visible) { true }
it 'has the parent embedded' do
expect(generated)
.to be_json_eql('Project'.to_json)
.at_path("#{embedded_path}/_type")
expect(generated)
.to be_json_eql(parent_project.name.to_json)
.at_path("#{embedded_path}/name")
end
end
context 'when the user is forbidden to see the parent' do
let(:parent_visible) { false }
it 'hides the parent' do
expect(generated)
.not_to have_json_path(embedded_path)
end
end
end
end
describe 'caching' do
it 'is based on the representer\'s cache_key' do
allow(OpenProject::Cache)
.to receive(:fetch)
.and_call_original
representer.to_json
expect(OpenProject::Cache)
.to have_received(:fetch)
.with(representer.json_cache_key)
end
describe '#json_cache_key' do
let!(:former_cache_key) { representer.json_cache_key }
it 'includes the name of the representer class' do
expect(representer.json_cache_key)
.to include('API', 'V3', 'Projects', 'ProjectRepresenter')
end
it 'changes when the locale changes' do
I18n.with_locale(:fr) do
expect(representer.json_cache_key)
.not_to eql former_cache_key
end
end
it 'changes when the project is updated' do
project.updated_at = Time.now + 20.seconds
expect(representer.json_cache_key)
.not_to eql former_cache_key
end
it 'changes when the project status is updated' do
project.status.updated_at = Time.now + 20.seconds
expect(representer.json_cache_key)
.not_to eql former_cache_key
end
end
end
describe '.checked_permissions' do
it 'lists add_work_packages' do
expect(described_class.checked_permissions).to match_array([:add_work_packages])
end
end
end

@ -1,474 +0,0 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe ::API::V3::Projects::ProjectRepresenter do
include ::API::V3::Utilities::PathHelper
let(:project) do
FactoryBot.build_stubbed(:project,
parent: parent_project,
description: 'some description',
status: status).tap do |p|
allow(p)
.to receive(:available_custom_fields)
.and_return([int_custom_field, version_custom_field])
allow(p)
.to receive(:"custom_field_#{int_custom_field.id}")
.and_return(int_custom_value.value)
allow(p)
.to receive(:custom_value_for)
.with(version_custom_field)
.and_return(version_custom_value)
end
end
let(:status) do
FactoryBot.build_stubbed(:project_status)
end
let(:parent_project) { FactoryBot.build_stubbed(:project) }
let(:representer) { described_class.create(project, current_user: user) }
let(:user) do
FactoryBot.build_stubbed(:user).tap do |u|
allow(u)
.to receive(:allowed_to?) do |permission, context|
permissions.include?(permission) && context == project
end
end
end
let(:int_custom_field) { FactoryBot.build_stubbed(:int_project_custom_field, visible: false) }
let(:version_custom_field) { FactoryBot.build_stubbed(:version_project_custom_field, visible: true) }
let(:int_custom_value) do
CustomValue.new(custom_field: int_custom_field,
value: '1234',
customized: nil)
end
let(:version) { FactoryBot.build_stubbed(:version) }
let(:version_custom_value) do
CustomValue.new(custom_field: version_custom_field,
value: version.id,
customized: nil).tap do |cv|
allow(cv)
.to receive(:typed_value)
.and_return(version)
end
end
let(:permissions) { %i[add_work_packages view_members] }
context 'generation' do
subject(:generated) { representer.to_json }
it { is_expected.to include_json('Project'.to_json).at_path('_type') }
describe 'properties' do
it_behaves_like 'property', :_type do
let(:value) { 'Project' }
end
it_behaves_like 'property', :id do
let(:value) { project.id }
end
it_behaves_like 'property', :identifier do
let(:value) { project.identifier }
end
it_behaves_like 'property', :name do
let(:value) { project.name }
end
it_behaves_like 'property', :active do
let(:value) { project.active }
end
it_behaves_like 'property', :public do
let(:value) { project.public }
end
it_behaves_like 'formattable property', :description do
let(:value) { project.description }
end
context 'statusExplanation' do
it_behaves_like 'formattable property', 'statusExplanation' do
let(:value) { status.explanation }
end
end
it_behaves_like 'has UTC ISO 8601 date and time' do
let(:date) { project.created_at }
let(:json_path) { 'createdAt' }
end
it_behaves_like 'has UTC ISO 8601 date and time' do
let(:date) { project.updated_at }
let(:json_path) { 'updatedAt' }
end
context 'int custom field' do
context 'if the user is admin' do
before do
allow(user)
.to receive(:admin?)
.and_return(true)
end
it "has a property for the int custom field" do
is_expected
.to be_json_eql(int_custom_value.value.to_json)
.at_path("customField#{int_custom_field.id}")
end
end
context 'if the user is no admin' do
it "has no property for the int custom field" do
is_expected
.not_to have_json_path("customField#{int_custom_field.id}")
end
end
end
end
describe '_links' do
it { is_expected.to have_json_type(Object).at_path('_links') }
it 'links to self' do
expect(subject).to have_json_path('_links/self/href')
end
it 'has a title for link to self' do
expect(subject).to have_json_path('_links/self/title')
end
describe 'create work packages' do
context 'user allowed to create work packages' do
it 'has the correct path for a create form' do
is_expected
.to be_json_eql(api_v3_paths.create_project_work_package_form(project.id).to_json)
.at_path('_links/createWorkPackage/href')
end
it 'has the correct path to create a work package' do
is_expected.to be_json_eql(api_v3_paths.work_packages_by_project(project.id).to_json)
.at_path('_links/createWorkPackageImmediately/href')
end
end
context 'user not allowed to create work packages' do
let(:permissions) { [] }
it_behaves_like 'has no link' do
let(:link) { 'createWorkPackage' }
end
it_behaves_like 'has no link' do
let(:link) { 'createWorkPackageImmediately' }
end
end
end
describe 'parent' do
before do
allow(parent_project)
.to receive(:visible?)
.and_return(visible)
end
let(:visible) { true }
it_behaves_like 'has a titled link' do
let(:link) { 'parent' }
let(:href) { api_v3_paths.project(parent_project.id) }
let(:title) { parent_project.name }
end
context 'if lacking the permissions to see the parent' do
let(:visible) { false }
it_behaves_like 'has a titled link' do
let(:link) { 'parent' }
let(:href) { nil }
let(:title) { nil }
end
end
end
context 'status' do
it_behaves_like 'has a titled link' do
let(:link) { 'status' }
let(:href) { api_v3_paths.project_status(project.status.code) }
let(:title) { I18n.t(:"activerecord.attributes.projects/status.codes.#{project.status.code}") }
end
context 'if the status is nil' do
let(:status) { nil }
it_behaves_like 'has an untitled link' do
let(:link) { 'status' }
let(:href) { nil }
end
end
end
describe 'categories' do
it 'has the correct link to its categories' do
is_expected.to be_json_eql(api_v3_paths.categories_by_project(project.id).to_json)
.at_path('_links/categories/href')
end
end
describe 'versions' do
context 'with only manage_versions permission' do
let(:permissions) { [:manage_versions] }
it_behaves_like 'has an untitled link' do
let(:link) { 'versions' }
let(:href) { api_v3_paths.versions_by_project(project.id) }
end
end
context 'with only view_work_packages permission' do
let(:permissions) { [:view_work_packages] }
it_behaves_like 'has an untitled link' do
let(:link) { 'versions' }
let(:href) { api_v3_paths.versions_by_project(project.id) }
end
end
context 'without both permissions' do
let(:permissions) { [:add_work_packages] }
it_behaves_like 'has no link' do
let(:link) { 'versions' }
end
end
end
describe 'types' do
context 'for a user having the view_work_packages permission' do
let(:permissions) { [:view_work_packages] }
it 'links to the types active in the project' do
is_expected.to be_json_eql(api_v3_paths.types_by_project(project.id).to_json)
.at_path('_links/types/href')
end
it 'links to the work packages in the project' do
is_expected.to be_json_eql(api_v3_paths.work_packages_by_project(project.id).to_json)
.at_path('_links/workPackages/href')
end
end
context 'for a user having the manage_types permission' do
let(:permissions) { [:manage_types] }
it 'links to the types active in the project' do
is_expected.to be_json_eql(api_v3_paths.types_by_project(project.id).to_json)
.at_path('_links/types/href')
end
end
context 'for a user not having the necessary permissions' do
let(:permission) { [] }
it 'has no types link' do
is_expected.to_not have_json_path('_links/types/href')
end
it 'has no work packages link' do
is_expected.to_not have_json_path('_links/workPackages/href')
end
end
end
describe 'memberships' do
it_behaves_like 'has an untitled link' do
let(:link) { 'memberships' }
let(:href) { api_v3_paths.path_for(:memberships, filters: [{ project: { operator: "=", values: [project.id.to_s] } }]) }
end
context 'without the view_members permission' do
let(:permissions) { [] }
it_behaves_like 'has no link' do
let(:link) { 'memberships' }
end
end
end
context 'link custom field' do
context 'if the user is admin and the field is invisible' do
before do
allow(user)
.to receive(:admin?)
.and_return(true)
version_custom_field.visible = false
end
it 'links custom fields' do
is_expected
.to be_json_eql(api_v3_paths.version(version.id).to_json)
.at_path("_links/customField#{version_custom_field.id}/href")
end
end
context 'if the user is no admin and the field is invisible' do
before do
version_custom_field.visible = false
end
it "has no property for the int custom field" do
is_expected
.not_to have_json_path("links/customField#{version_custom_field.id}")
end
end
context 'if the user is no admin and the field is visible' do
it 'links custom fields' do
is_expected
.to be_json_eql(api_v3_paths.version(version.id).to_json)
.at_path("_links/customField#{version_custom_field.id}/href")
end
end
end
describe 'update' do
context 'for a user having the edit_project permission' do
let(:permissions) { [:edit_project] }
it_behaves_like 'has an untitled link' do
let(:link) { 'update' }
let(:href) { api_v3_paths.project_form project.id }
end
end
context 'for a user lacking the edit_project permission' do
let(:permissions) { [] }
it_behaves_like 'has no link' do
let(:link) { 'update' }
end
end
end
describe 'updateImmediately' do
context 'for a user having the edit_project permission' do
let(:permissions) { [:edit_project] }
it_behaves_like 'has an untitled link' do
let(:link) { 'updateImmediately' }
let(:href) { api_v3_paths.project project.id }
end
end
context 'for a user lacking the edit_project permission' do
let(:permissions) { [] }
it_behaves_like 'has no link' do
let(:link) { 'updateImmediately' }
end
end
end
describe 'delete' do
context 'for a user being admin' do
before do
allow(user)
.to receive(:admin?)
.and_return(true)
end
it_behaves_like 'has an untitled link' do
let(:link) { 'delete' }
let(:href) { api_v3_paths.project project.id }
end
end
context 'for a non admin user' do
let(:permissions) { [] }
it_behaves_like 'has no link' do
let(:link) { 'delete' }
end
end
end
end
describe 'caching' do
it 'is based on the representer\'s cache_key' do
expect(OpenProject::Cache)
.to receive(:fetch)
.with(representer.json_cache_key)
.and_call_original
representer.to_json
end
describe '#json_cache_key' do
let!(:former_cache_key) { representer.json_cache_key }
it 'includes the name of the representer class' do
expect(representer.json_cache_key)
.to include('API', 'V3', 'Projects', 'ProjectRepresenter')
end
it 'changes when the locale changes' do
I18n.with_locale(:fr) do
expect(representer.json_cache_key)
.not_to eql former_cache_key
end
end
it 'changes when the project is updated' do
project.updated_at = Time.now + 20.seconds
expect(representer.json_cache_key)
.not_to eql former_cache_key
end
it 'changes when the project status is updated' do
project.status.updated_at = Time.now + 20.seconds
expect(representer.json_cache_key)
.not_to eql former_cache_key
end
end
end
end
describe '.checked_permissions' do
it 'lists add_work_packages' do
expect(described_class.checked_permissions).to match_array([:add_work_packages])
end
end
end

@ -162,7 +162,7 @@ describe ::API::V3::Projects::Schemas::ProjectSchemaRepresenter do
it_behaves_like 'has basic schema properties' do
let(:type) { 'Boolean' }
let(:name) { I18n.t('attributes.public') }
let(:required) { true }
let(:required) { false }
let(:writable) { true }
end
end
@ -173,7 +173,7 @@ describe ::API::V3::Projects::Schemas::ProjectSchemaRepresenter do
it_behaves_like 'has basic schema properties' do
let(:type) { 'Boolean' }
let(:name) { I18n.t('attributes.active') }
let(:required) { true }
let(:required) { false }
let(:writable) { true }
end
end

@ -146,9 +146,9 @@ describe 'API v3 Project resource', type: :request, content_type: :json do
let!(:parent_memberships) do
end
it 'has no path to the parent' do
it 'shows the `undisclosed` uri' do
expect(subject.body)
.to be_json_eql(nil.to_json)
.to be_json_eql(API::V3::URN_UNDISCLOSED.to_json)
.at_path('_links/parent/href')
end
end

@ -737,6 +737,29 @@ describe 'API v3 Work package form resource', type: :request, with_mail: false d
expect(subject.body).to have_json_path('_embedded/validationErrors/responsible')
}
end
describe 'formattable custom field set to nil' do
let(:custom_field) do
FactoryBot.create :work_package_custom_field, field_format: 'text'
end
let(:cf_param) { { "customField#{custom_field.id}" => nil } }
let(:params) { valid_params.merge(cf_param) }
before do
project.work_package_custom_fields << custom_field
project.save!
work_package.type.custom_fields << custom_field
work_package.save!
login_as(current_user)
post post_path, (params ? params.to_json : nil), 'CONTENT_TYPE' => 'application/json'
end
it 'should respond with a valid body (Regression OP#37510)' do
expect(last_response.status).to eq(200)
end
end
end
end
end

Loading…
Cancel
Save