Merge pull request #6804 from opf/feature/28866/work-packages-read-only

Implement read-only work package attributes based on status
pull/6822/head
ulferts 6 years ago committed by GitHub
commit 1728da9d1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      app/assets/stylesheets/content/_action_menu_main.sass
  2. 6
      app/cells/row_cell.rb
  3. 47
      app/cells/statuses/row_cell.rb
  4. 50
      app/cells/statuses/table_cell.rb
  5. 6
      app/contracts/work_packages/base_contract.rb
  6. 1
      app/models/permitted_params.rb
  7. 16
      app/models/status.rb
  8. 9
      app/models/work_package.rb
  9. 1
      app/services/authorization/enterprise_service.rb
  10. 11
      app/views/statuses/_form.html.erb
  11. 102
      app/views/statuses/index.html.erb
  12. 6
      config/locales/en.yml
  13. 1
      config/locales/js-en.yml
  14. 5
      db/migrate/20181101132712_add_read_only_to_statuses.rb
  15. 2
      docs/api/apiv3/endpoints/statuses.apib
  16. 4
      frontend/src/app/components/op-context-menu/handlers/wp-status-dropdown-menu.directive.ts
  17. 3
      frontend/src/app/components/op-context-menu/op-context-menu.html
  18. 4
      frontend/src/app/components/states.service.ts
  19. 8
      frontend/src/app/components/states/state-cache.service.ts
  20. 4
      frontend/src/app/components/work-packages/wp-single-view/wp-single-view.html
  21. 39
      frontend/src/app/components/wp-buttons/wp-status-button/wp-status-button.component.ts
  22. 7
      frontend/src/app/components/wp-buttons/wp-status-button/wp-status-button.html
  23. 4
      frontend/src/app/components/wp-edit-form/display-field-renderer.ts
  24. 2
      frontend/src/app/components/wp-edit/wp-edit-field/wp-edit-field.component.ts
  25. 2
      frontend/src/app/components/wp-table/timeline/cells/timeline-cell-renderer.ts
  26. 2
      frontend/src/app/components/wp-table/timeline/cells/timeline-milestone-cell-renderer.ts
  27. 2
      frontend/src/app/components/wp-table/timeline/cells/wp-timeline-cell.ts
  28. 6
      frontend/src/app/modules/common/icon/op-icon.ts
  29. 39
      frontend/src/app/modules/hal/resources/status-resource.ts
  30. 24
      frontend/src/app/modules/hal/resources/work-package-resource.ts
  31. 5
      frontend/src/app/modules/hal/services/hal-resource.config.ts
  32. 1
      lib/api/v3/statuses/status_representer.rb
  33. 11
      lib/api/v3/work_packages/schema/base_work_package_schema.rb
  34. 18
      lib/plugins/acts_as_attachable/lib/acts_as_attachable.rb
  35. 10
      spec/contracts/work_packages/base_contract_spec.rb
  36. 1
      spec/factories/status_factory.rb
  37. 98
      spec/features/statuses/read_only_statuses_spec.rb
  38. 50
      spec/features/statuses/statuses_administration_spec.rb
  39. 2
      spec/lib/api/v3/statuses/status_representer_spec.rb
  40. 20
      spec/lib/api/v3/work_packages/schema/specific_work_package_schema_spec.rb
  41. 10
      spec/lib/api/v3/work_packages/work_package_representer_spec.rb
  42. 21
      spec/models/status_spec.rb
  43. 16
      spec/models/work_package/work_package_status_spec.rb
  44. 4
      spec/support/components/wysiwyg/wysiwyg_editor.rb
  45. 4
      spec/support/work_packages/work_package_field.rb

@ -63,5 +63,8 @@
.icon-action-menu
@include icon-action-menu-rules
.icon-action-menu-post
@include icon-action-menu-rules
padding-left: 0.25rem
.icon-sub-menu
@include icon-sub-menu-rules

@ -45,4 +45,10 @@ class RowCell < RailsCell
def button_links
[]
end
def checkmark(condition)
if condition
op_icon 'icon icon-checkmark'
end
end
end

@ -0,0 +1,47 @@
module Statuses
class RowCell < ::RowCell
include ::IconsHelper
include ::ColorsHelper
include ReorderLinksHelper
def status
model
end
def name
link_to status.name, edit_status_path(status)
end
def is_default
checkmark(status.is_default?)
end
def is_closed
checkmark(status.is_closed?)
end
def is_readonly
checkmark(status.is_readonly?)
end
def color
icon_for_color status.color
end
def done_ratio
h(status.default_done_ratio)
end
def sort
reorder_links 'status',
{ action: 'update', id: status },
method: :patch
end
def button_links
[
delete_link(status_path(status))
]
end
end
end

@ -0,0 +1,50 @@
require_dependency 'statuses/row_cell'
module Statuses
class TableCell < ::TableCell
def initial_sort
%i[id asc]
end
def sortable?
false
end
def columns
headers.map(&:first)
end
def inline_create_link
link_to new_status_path,
aria: { label: t(:label_work_package_status_new) },
class: 'wp-inline-create--add-link',
title: t(:label_work_package_status_new) do
op_icon('icon icon-add')
end
end
def empty_row_message
I18n.t :no_results_title_text
end
def row_class
::Statuses::RowCell
end
def headers
[
[:name, caption: Status.human_attribute_name(:name)],
[:color, caption: Status.human_attribute_name(:color)],
[:is_default, caption: Status.human_attribute_name(:is_default)],
[:is_closed, caption: Status.human_attribute_name(:is_closed)],
[:is_readonly, caption: Status.human_attribute_name(:is_readonly)],
[:sort, caption: I18n.t(:label_sort)]
].tap do |default|
if WorkPackage.use_status_for_done_ratio?
default.insert 2, [:done_ratio, caption: WorkPackage.human_attribute_name(:done_ratio)]
end
end
end
end
end

@ -128,6 +128,12 @@ module WorkPackages
end
def writable_attributes
# If we're in a readonly status and did not move into that status right now
# only allow other status transitions
if model.readonly_status? && !model.status_id_change
return %w[status status_id]
end
super + model.available_custom_fields.map { |cf| "custom_field_#{cf.id}" }
end

@ -582,6 +582,7 @@ class PermittedParams
default_done_ratio
is_closed
is_default
is_readonly
move_to
),
type: [

@ -105,6 +105,22 @@ class Status < ActiveRecord::Base
def to_s; name end
def is_readonly
return false unless can_readonly?
super
end
alias :is_readonly? :is_readonly
##
# Overrides cache key so that changes to EE state are reflected
def cache_key
super + '/' + can_readonly?.to_s
end
def can_readonly?
EnterpriseToken.allows_to?(:readonly_work_packages)
end
private
def check_integrity

@ -144,7 +144,8 @@ class WorkPackage < ActiveRecord::Base
acts_as_attachable after_remove: :attachments_changed,
order: "#{Attachment.table_name}.filename",
add_on_new_permission: :add_work_packages,
add_on_persisted_permission: :edit_work_packages
add_on_persisted_permission: :edit_work_packages,
modification_blocked: ->(*) { readonly_status? }
after_validation :set_attachments_error_details,
if: lambda { |work_package| work_package.errors.messages.has_key? :attachments }
@ -260,6 +261,12 @@ class WorkPackage < ActiveRecord::Base
status.nil? || status.is_closed?
end
# Return true if the work_package's status is_readonly
# Careful not to use +readonly?+ which is AR internals!
def readonly_status?
status.present? && status.is_readonly?
end
# Returns true if the work_package is overdue
def overdue?
!due_date.nil? && (due_date < Date.today) && !closed?

@ -41,6 +41,7 @@ class Authorization::EnterpriseService
custom_fields_in_projects_list
custom_actions
conditional_highlighting
readonly_work_packages
attachment_filters).freeze
def initialize(token)

@ -42,6 +42,17 @@ See docs/COPYRIGHT.rdoc for more details.
<div class="form--field"><%= f.check_box 'is_default' %></div>
<% end %>
<div class="form--field">
<% disabled = !EnterpriseToken.allows_to?(:readonly_work_packages) %>
<%= f.check_box :is_readonly, disabled: disabled %>
<div class="form--field-instructions">
<p><%= t('statuses.edit.status_readonly_html') %></p>
<% if disabled %>
<p><%= render(partial: 'enterprise/locked_note') %></p>
<% end %>
</div>
</div>
<%= render partial: '/colors/color_autocomplete_field',
locals: {
form: f,

@ -48,104 +48,4 @@ See docs/COPYRIGHT.rdoc for more details.
<% end %>
<% end %>
<% if @statuses.any? %>
<div class="generic-table--container">
<div class="generic-table--results-container">
<table class="generic-table">
<colgroup>
<col highlight-col>
<% if WorkPackage.use_status_for_done_ratio? %>
<col highlight-col>
<% end %>
<col highlight-col>
<col highlight-col>
<col highlight-col>
<col highlight-col>
<col>
</colgroup>
<thead>
<tr>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= Status.model_name.human %>
</span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= Status.human_attribute_name :color %>
</span>
</div>
</div>
</th>
<% if WorkPackage.use_status_for_done_ratio? %>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= WorkPackage.human_attribute_name(:done_ratio) %>
</span>
</div>
</div>
</th>
<% end %>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= Status.human_attribute_name(:is_default) %>
</span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= Status.human_attribute_name(:is_closed) %>
</span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%=l(:button_sort)%>
</span>
</div>
</div>
</th>
<th><div class="generic-table--empty-header"></div></th>
</tr>
</thead>
<tbody>
<% for status in @statuses %>
<tr>
<td><%= link_to h(status.name), edit_status_path(status) %></td>
<td><%= icon_for_color status.color %></td>
<% if WorkPackage.use_status_for_done_ratio? %>
<td><%= h status.default_done_ratio %></td>
<% end %>
<td><%= checked_image status.is_default? %></td>
<td ><%= checked_image status.is_closed? %></td>
<td>
<%= reorder_links('status', {action: 'update', id: status}, method: :patch) %>
</td>
<td class="buttons"><%= delete_link status_path(status) %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<%= pagination_links_full @statuses %>
<% else %>
<%= no_results_box(action_url: new_status_path, display_action: true) %>
<% end %>
<%= cell ::Statuses::TableCell, @statuses %>

@ -194,6 +194,11 @@ en:
statuses:
edit:
status_readonly_html: |
Check this option to mark work packages with this status as read-only.
No attributes can be changed with the exception of the status.
<br/>
<strong>Note</strong>: Inherited values (e.g., from children or relations) will still apply.
status_color_text: |
Click to assign or change the color of this status.
It is shown in the status button and can be used for highlighting work packages in the table.
@ -360,6 +365,7 @@ en:
to: "Related work package"
status:
is_closed: "Work package closed"
is_readonly: "Work package read-only"
journal:
notes: "Notes"
member:

@ -573,6 +573,7 @@ en:
message_successful_bulk_delete: Successfully deleted work packages.
message_successful_show_in_fullscreen: "Click here to open this work package in fullscreen view."
message_view_spent_time: "Show spent time for this work package"
message_work_package_read_only: "Work package is locked in this status. No attribute other than status can be altered."
no_value: "No value"
inline_create:
title: 'Click here to add a new work package to this list'

@ -0,0 +1,5 @@
class AddReadOnlyToStatuses < ActiveRecord::Migration[5.1]
def change
add_column :statuses, :is_readonly, :boolean, default: false
end
end

@ -13,6 +13,7 @@
| position | Sort index of the status | Integer | | READ |
| isDefault | | Boolean | | READ |
| isClosed | are tickets of this status considered closed? | Boolean | | READ |
| isReadonly | are tickets of this status read only? | Boolean | | READ |
| defaultDoneRatio | The percentageDone being applied when changing to this status | Integer | 0 <= x <= 100 | READ |
## Statuses [/api/v3/statuses]
@ -64,6 +65,7 @@
"position": 3,
"isDefault": false,
"isClosed": false,
"isReadonly": false,
"defaultDoneRatio": 75
},
{

@ -38,6 +38,7 @@ import {HalResource} from 'core-app/modules/hal/resources/hal-resource';
import {CollectionResource} from 'core-app/modules/hal/resources/collection-resource';
import {IWorkPackageEditingServiceToken} from "../../wp-edit-form/work-package-editing.service.interface";
import {Highlighting} from "core-components/wp-fast-table/builders/highlighting/highlighting.functions";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
@Directive({
selector: '[wpStatusDropdown]'
@ -50,6 +51,7 @@ export class WorkPackageStatusDropdownDirective extends OpContextMenuTrigger {
readonly $state:StateService,
protected wpNotificationsService:WorkPackageNotificationService,
@Inject(IWorkPackageEditingServiceToken) protected wpEditing:WorkPackageEditingService,
protected I18n:I18nService,
protected wpTableRefresh:WorkPackageTableRefreshService) {
super(elementRef, opContextMenu);
@ -89,6 +91,8 @@ export class WorkPackageStatusDropdownDirective extends OpContextMenuTrigger {
return {
disabled: false,
linkText: status.name,
postIcon: status.isReadonly ? 'icon-locked' : null,
postIconTitle: this.I18n.t('js.work_packages.message_work_package_read_only'),
class: Highlighting.dotClass('status', status.getId()),
onClick: () => {
this.updateStatus(status);

@ -17,6 +17,9 @@
[accessibleClickStopEvent]="false">
<op-icon *ngIf="item.icon" icon-classes="icon-action-menu {{ item.icon }}"> </op-icon>
<span [textContent]="item.linkText"></span>
<op-icon *ngIf="item.postIcon"
[icon-title]="item.postIconTitle || ''"
icon-classes="icon-action-menu-post {{ item.postIcon }}"> </op-icon>
</a>
</li>
</ng-container>

@ -13,6 +13,7 @@ import {QueryColumn} from './wp-query/query-column';
import {WikiPageResource} from 'core-app/modules/hal/resources/wiki-page-resource';
import {PostResource} from 'core-app/modules/hal/resources/post-resource';
import {HalResource} from 'core-app/modules/hal/resources/hal-resource';
import {StatusResource} from "core-app/modules/hal/resources/status-resource";
export class States extends StatesGroup {
[key:string]:any;
@ -37,6 +38,9 @@ export class States extends StatesGroup {
/* /api/v3/types */
types = multiInput<TypeResource>();
/* /api/v3/types */
statuses = multiInput<StatusResource>();
/* /api/v3/users */
users = multiInput<UserResource>();

@ -27,6 +27,7 @@
// ++
import {InputState, MultiInputState, State} from 'reactivestates';
import {Observable} from "rxjs";
export abstract class StateCacheService<T> {
private cacheDurationInMs:number;
@ -57,6 +58,13 @@ export abstract class StateCacheService<T> {
this.multiState.get(id).putValue(val);
}
/**
* Observe the value of the given id
*/
public observe(id:string):Observable<T> {
return this.state(id).values$();
}
/**
* Clear a set of cached states.
* @param ids

@ -9,8 +9,8 @@
<div class="wp-info-wrapper">
<wp-status-button *ngIf="!workPackage.isNew"
[workPackage]="workPackage"
[allowed]="workPackage.isEditable"></wp-status-button>
[workPackage]="workPackage">
</wp-status-button>
<attribute-help-text [attribute]="'status'"
[attributeScope]="'WorkPackage'"
*ngIf="!workPackage.isNew"></attribute-help-text>

@ -28,33 +28,60 @@
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {WorkPackageEditingService} from 'core-components/wp-edit-form/work-package-editing-service';
import {Component, Inject, Input} from '@angular/core';
import {Component, Inject, Input, OnDestroy, OnInit} from '@angular/core';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import {IWorkPackageEditingServiceToken} from "../../wp-edit-form/work-package-editing.service.interface";
import {Highlighting} from "core-components/wp-fast-table/builders/highlighting/highlighting.functions";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {WorkPackageCacheService} from "core-components/work-packages/work-package-cache.service";
import {untilComponentDestroyed} from "ng2-rx-componentdestroyed";
@Component({
selector: 'wp-status-button',
templateUrl: './wp-status-button.html'
})
export class WorkPackageStatusButtonComponent {
export class WorkPackageStatusButtonComponent implements OnInit, OnDestroy {
@Input('workPackage') public workPackage:WorkPackageResource;
@Input('allowed') public allowed:boolean;
public text = {
explanation: this.I18n.t('js.label_edit_status')
explanation: this.I18n.t('js.label_edit_status'),
workPackageReadOnly: this.I18n.t('js.work_packages.message_work_package_read_only')
};
constructor(readonly I18n:I18nService,
readonly wpCacheService:WorkPackageCacheService,
@Inject(IWorkPackageEditingServiceToken) protected wpEditing:WorkPackageEditingService) {
}
ngOnInit() {
this.wpCacheService
.observe(this.workPackage.id)
.pipe(
untilComponentDestroyed(this)
)
.subscribe((wp) => {
this.workPackage = wp;
this.workPackage.status.$load();
});
}
ngOnDestroy():void {
// Nothing to do
}
public isDisabled() {
let changeset = this.wpEditing.changesetFor(this.workPackage);
return !this.allowed || changeset.inFlight;
}
public get buttonTitle() {
if (this.workPackage.isReadonly) {
return this.text.workPackageReadOnly;
} else {
return '';
}
}
public get statusHighlightClass() {
return Highlighting.inlineClass('status', this.status.getId());
}
@ -63,4 +90,8 @@ export class WorkPackageStatusButtonComponent {
let changeset = this.wpEditing.changesetFor(this.workPackage);
return changeset.value('status');
}
public get allowed() {
return this.workPackage.isAttributeEditable('status');
}
}

@ -2,12 +2,17 @@
<button class="button"
[ngClass]="statusHighlightClass"
[disabled]="isDisabled()"
[attr.title]="buttonTitle"
[attr.aria-label]="text.explanation"
wpStatusDropdown
[wpStatusDropdown-workPackage]="workPackage">
<span class="button--text "
<span *ngIf="workPackage.isReadonly"
class="button--text">
<op-icon icon-classes="button--icon icon-locked"></op-icon>
</span>
<span class="button--text"
aria-hidden="true"
[textContent]="status.name"></span>
<op-icon icon-classes="button--icon icon-small icon-pulldown"></op-icon>

@ -118,7 +118,7 @@ export class DisplayFieldRenderer {
span.classList.add(placeholderClassName);
}
if (field.writable && workPackage.isEditable) {
if (field.writable && workPackage.isAttributeEditable(field.name)) {
span.classList.add(editableClassName);
span.setAttribute('role', 'button');
} else {
@ -142,7 +142,7 @@ export class DisplayFieldRenderer {
titleContent = labelContent;
}
if (field.writable && workPackage.isEditable) {
if (field.writable && workPackage.isAttributeEditable(field.name)) {
return this.I18n.t('js.inplace.button_edit', { attribute: `${field.displayName} ${titleContent}` });
} else {
return `${field.displayName} ${titleContent}`;

@ -127,7 +127,7 @@ export class WorkPackageEditFieldComponent implements OnInit {
public get isEditable() {
const fieldSchema = this.resource.schema[this.fieldName] as IFieldSchema;
return this.resource.isEditable && fieldSchema && fieldSchema.writable;
return this.resource.isAttributeEditable(this.fieldName) && fieldSchema && fieldSchema.writable;
}
public activateIfEditable(event:JQueryEventObject) {

@ -64,7 +64,7 @@ export class TimelineCellRenderer {
}
public canMoveDates(wp:WorkPackageResource) {
return wp.schema.startDate.writable && wp.schema.dueDate.writable;
return wp.schema.startDate.writable && wp.schema.dueDate.writable && wp.isAttributeEditable('startDate');
}
public isEmpty(wp:WorkPackageResource) {

@ -32,7 +32,7 @@ export class TimelineMilestoneCellRenderer extends TimelineCellRenderer {
}
public canMoveDates(wp:WorkPackageResource) {
return wp.schema.date.writable;
return wp.schema.date.writable && wp.isAttributeEditable('date');
}
public displayPlaceholderUnderCursor(ev:MouseEvent, renderInfo:RenderInfo):HTMLElement {

@ -147,7 +147,7 @@ export class WorkPackageTimelineCell {
cell.append(this.wpElement);
// Allow editing if editable
if (renderInfo.workPackage.isEditable) {
if (renderer.canMoveDates(renderInfo.workPackage)) {
this.wpElement.classList.add('-editable');
registerWorkPackageMouseHandler(

@ -32,7 +32,9 @@ import {Component, Input} from '@angular/core';
selector: 'op-icon',
host: { 'class': 'op-icon--wrapper' },
template: `
<i [ngClass]="iconClasses" aria-hidden="true"></i>
<i [ngClass]="iconClasses"
[title]="iconTitle"
aria-hidden="true"></i>
<span
class="hidden-for-sighted"
[textContent]="iconTitle"
@ -41,5 +43,5 @@ import {Component, Input} from '@angular/core';
})
export class OpIcon {
@Input('icon-classes') iconClasses:string;
@Input('icon-title') iconTitle:string;
@Input('icon-title') iconTitle:string = '';
}

@ -0,0 +1,39 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See doc/COPYRIGHT.rdoc for more details.
//++
import {HalResource} from 'core-app/modules/hal/resources/hal-resource';
import {CollectionResource} from 'core-app/modules/hal/resources/collection-resource';
import {InputState} from 'reactivestates';
export class StatusResource extends HalResource {
public get state():InputState<this> {
return this.states.statuses.get(this.href as string) as any;
}
}

@ -147,6 +147,10 @@ export class WorkPackageBaseResource extends HalResource {
return ancestors.map((el:WorkPackageResource) => el.id.toString());
}
public get isReadonly():boolean {
return this.status && this.status.isReadonly;
}
/**
* Return "<type name>: <subject>" if the type is known.
*/
@ -171,8 +175,24 @@ export class WorkPackageBaseResource extends HalResource {
return !(children && children.length > 0);
}
public get isEditable():boolean {
return !!this.$links.update || this.isNew;
/**
* Return whether the user in general has permission to edit the work package.
* This check is required, but not sufficient to check all attribute restrictions.
*
* Use +isAttributeEditable(property)+ for this case.
*/
public get isEditable() {
return this.isNew || !!this.$links.update;
}
/**
* Return whether the work package is editable with the user's permission
* on the given work package attribute.
*
* @param property
*/
public isAttributeEditable(property:string):boolean {
return this.isEditable && (!this.isReadonly || property === 'status');
}
private performUpload(files:UploadFile[]) {

@ -52,6 +52,7 @@ import {Injectable} from '@angular/core';
import {HalResource} from 'core-app/modules/hal/resources/hal-resource';
import {WikiPageResource} from "core-app/modules/hal/resources/wiki-page-resource";
import {PostResource} from "core-app/modules/hal/resources/post-resource";
import {StatusResource} from "core-app/modules/hal/resources/status-resource";
const halResourceDefaultConfig:{ [typeName:string]:HalResourceFactoryConfigInterface } = {
WorkPackage: {
@ -62,6 +63,7 @@ const halResourceDefaultConfig:{ [typeName:string]:HalResourceFactoryConfigInter
children: 'WorkPackage',
relations: 'Relation',
schema: 'Schema',
status: 'Status',
type: 'Type'
}
},
@ -96,6 +98,9 @@ const halResourceDefaultConfig:{ [typeName:string]:HalResourceFactoryConfigInter
Type: {
cls: TypeResource
},
Status: {
cls: StatusResource
},
SchemaDependency: {
cls: SchemaDependencyResource
},

@ -42,6 +42,7 @@ module API
getter: -> (*) { color.hexcode if color },
render_nil: true
property :is_default, render_nil: true
property :is_readonly, render_nil: true
property :default_done_ratio, render_nil: true
property :position, render_nil: true

@ -53,8 +53,13 @@ module API
end
def writable?(property)
property = property.to_sym
# Special case for readonly: Only status is allowed
return property == :status if readonly?
# Special case for milestones + date property
property = :start_date if property.to_sym == :date && milestone?
property = :start_date if property == :date && milestone?
@writable_attributes ||= begin
contract.writable_attributes
@ -69,6 +74,10 @@ module API
false
end
def readonly?
work_package.readonly_status?
end
private
def contract

@ -60,14 +60,16 @@ module Redmine
view_permission: view_permission(options),
delete_permission: delete_permission(options),
add_on_new_permission: add_on_new_permission(options),
add_on_persisted_permission: add_on_persisted_permission(options)
add_on_persisted_permission: add_on_persisted_permission(options),
modification_blocked: options[:modification_blocked]
}
options.except!(:view_permission,
:delete_permission,
:add_on_new_permission,
:add_on_persisted_permission,
:add_permission)
:add_permission,
:modification_blocked)
end
def view_permission(options)
@ -114,15 +116,27 @@ module Redmine
end
module InstanceMethods
def modification_blocked?
if policy = self.class.attachable_options[:modification_blocked]
return instance_eval &policy
end
false
end
def attachments_visible?(user = User.current)
allowed_to_on_attachment?(user, self.class.attachable_options[:view_permission])
end
def attachments_deletable?(user = User.current)
return false if modification_blocked?
allowed_to_on_attachment?(user, self.class.attachable_options[:delete_permission])
end
def attachments_addable?(user = User.current)
return false if modification_blocked?
(new_record? && allowed_to_on_attachment?(user, self.class.attachable_options[:add_on_new_permission])) ||
(persisted? && allowed_to_on_attachment?(user, self.class.attachable_options[:add_on_persisted_permission]))
end

@ -145,6 +145,16 @@ describe WorkPackages::BaseContract do
end
end
describe 'locked status' do
before do
allow(work_package).to receive(:readonly_status?).and_return true
end
it 'only sets status to allowed' do
expect(contract.writable_attributes).to eq(%w[status status_id])
end
end
describe 'estimated hours' do
it_behaves_like 'a parent unwritable property', :estimated_hours

@ -30,6 +30,7 @@ FactoryBot.define do
factory :status do
sequence(:name) do |n| "status #{n}" end
is_closed false
is_readonly false
factory :closed_status do
is_closed true

@ -0,0 +1,98 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2018 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe 'Read-only statuses affect work package editing',
with_ee: %i[readonly_work_packages],
type: :feature,
js: true do
let(:locked_status) { FactoryBot.create :status, name: 'Locked', is_readonly: true }
let(:unlocked_status) { FactoryBot.create :status, name: 'Unlocked', is_readonly: false }
let(:type) { FactoryBot.create :type_bug }
let(:project) { FactoryBot.create :project, types: [type] }
let!(:work_package) do
FactoryBot.create :work_package,
project: project,
type: type,
status: unlocked_status
end
let(:role) { FactoryBot.create :role, permissions: %i[edit_work_packages view_work_packages] }
let(:user) do
FactoryBot.create :user,
member_in_project: project,
member_through_role: role
end
let!(:workflow1) do
FactoryBot.create :workflow,
type_id: type.id,
old_status: unlocked_status,
new_status: locked_status,
role: role
end
let!(:workflow2) do
FactoryBot.create :workflow,
type_id: type.id,
old_status: locked_status,
new_status: unlocked_status,
role: role
end
let(:wp_page) { Pages::FullWorkPackage.new(work_package) }
before do
login_as(user)
wp_page.visit!
end
it 'locks the work package on a read only status' do
expect(page).to have_selector '.work-package--attachments--drop-box'
subject_field = wp_page.edit_field :subject
subject_field.activate!
subject_field.cancel_by_escape
status_field = wp_page.edit_field :status
status_field.expect_state_text 'Unlocked'
status_field.update 'Locked'
wp_page.expect_and_dismiss_notification(message: 'Successful update.')
status_field.expect_state_text 'Locked'
subject_field = wp_page.edit_field :subject
subject_field.activate! expect_open: false
subject_field.expect_read_only
# Expect attachments not available
expect(page).to have_no_selector '.work-package--attachments--drop-box'
end
end

@ -0,0 +1,50 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2018 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe 'Statuses administration', type: :feature do
let(:admin) { FactoryBot.create :admin }
before do
login_as(admin)
visit new_status_path
end
describe 'with EE token', with_ee: %i[readonly_work_packages] do
it 'allows to set readonly status' do
expect(page).to have_field 'status[is_readonly]', disabled: false
end
end
describe 'without EE token' do
it 'does not allow to set readonly status' do
expect(page).to have_field 'status[is_readonly]', disabled: true
end
end
end

@ -42,6 +42,7 @@ describe ::API::V3::Statuses::StatusRepresenter do
it { is_expected.to have_json_path('name') }
it { is_expected.to have_json_path('isClosed') }
it { is_expected.to have_json_path('isDefault') }
it { is_expected.to have_json_path('isReadonly') }
it { is_expected.to have_json_path('position') }
it { is_expected.to have_json_path('defaultDoneRatio') }
@ -50,6 +51,7 @@ describe ::API::V3::Statuses::StatusRepresenter do
it { is_expected.to be_json_eql(status.name.to_json).at_path('name') }
it { is_expected.to be_json_eql(status.is_closed.to_json).at_path('isClosed') }
it { is_expected.to be_json_eql(status.is_default.to_json).at_path('isDefault') }
it { is_expected.to be_json_eql(status.is_readonly.to_json).at_path('isReadonly') }
it { is_expected.to be_json_eql(status.position.to_json).at_path('position') }
it {
is_expected.to be_json_eql(status.default_done_ratio.to_json).at_path('defaultDoneRatio')

@ -68,6 +68,26 @@ describe ::API::V3::WorkPackages::Schema::SpecificWorkPackageSchema do
end
end
describe '#readonly?' do
it "modifies the writable attributes" do
allow(work_package)
.to receive(:readonly_status?)
.and_return(true)
is_expected.to be_readonly
expect(subject.writable?(:status)).to be_truthy
expect(subject.writable?(:subject)).to be_falsey
allow(work_package)
.to receive(:readonly_status?)
.and_return(false)
is_expected.to_not be_readonly
expect(subject.writable?(:status)).to be_truthy
expect(subject.writable?(:subject)).to be_truthy
end
end
describe '#assignable_statuses_for' do
let(:status_result) { double('status result') }

@ -553,6 +553,16 @@ describe ::API::V3::WorkPackages::WorkPackageRepresenter do
let(:href) { api_v3_paths.attachments_by_work_package(work_package.id) }
end
context 'when work package blocked' do
before do
allow(work_package).to receive(:readonly_status?).and_return true
end
it_behaves_like 'has no link' do
let(:link) { 'addAttachment' }
end
end
it 'addAttachments is a post link' do
is_expected.to be_json_eql('post'.to_json).at_path('_links/addAttachment/method')
end

@ -98,6 +98,27 @@ describe Status, type: :model do
end
end
describe '#is_readonly' do
let!(:status) { FactoryBot.build(:status, is_readonly: true) }
context 'when EE enabled', with_ee: %i[readonly_work_packages] do
it 'is still marked read only' do
expect(status.is_readonly).to be_truthy
expect(status.is_readonly?).to be_truthy
end
end
context 'when EE no longer enabled', with_ee: %i[] do
it 'is still marked read only' do
expect(status.is_readonly).to be_falsey
expect(status.is_readonly?).to be_falsey
# But DB attribute is still correct to keep the state
# whenever user reactivates
expect(status.read_attribute(:is_readonly)).to be_truthy
end
end
end
describe '#cache_key' do
it 'updates when the updated_at field changes' do
old_cache_key = stubbed_status.cache_key

@ -40,6 +40,22 @@ describe WorkPackage, 'status', type: :model do
expect(WorkPackage.where(status_id: status.id).first).to eq(work_package)
end
describe '#readonly' do
let(:status) { FactoryBot.create(:status, is_readonly: true) }
context 'with EE', with_ee: %i[readonly_work_packages] do
it 'marks work package as read only' do
expect(work_package).to be_readonly_status
end
end
context 'without EE' do
it 'is not marked as read only' do
expect(work_package).not_to be_readonly_status
end
end
end
describe '#new_statuses_allowed_to' do
let(:role) { FactoryBot.build_stubbed(:role) }
let(:type) { FactoryBot.build_stubbed(:type) }

@ -66,7 +66,8 @@ module Components
def drag_attachment(image_fixture, caption = 'Some caption')
in_editor do |container, editable|
editable.base.send_keys(:page_up, 'some text', :enter, :enter, :enter)
sleep 0.5
editable.base.send_keys(:enter, 'some text', :enter, :enter, :enter)
images = editable.all('figure.image')
attachments.drag_and_drop_file(editable, image_fixture)
@ -74,6 +75,7 @@ module Components
expect(page)
.to have_selector('figure img[src^="/api/v3/attachments/"]', count: images.length + 1, wait: 10)
sleep 0.5
expect(page).not_to have_selector('notification-upload-progress')
# Besides testing caption functionality this also slows down clicking on the submit button

@ -53,13 +53,13 @@ class WorkPackageField
##
# Activate the field and check it opened correctly
def activate!
def activate!(expect_open: true)
retry_block do
unless active?
scroll_to_and_click(display_element)
end
unless active?
if expect_open && !active?
raise "Expected WP field input type '#{field_type}' for attribute '#{property_name}'."
end
end

Loading…
Cancel
Save