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 .icon-action-menu
@include icon-action-menu-rules @include icon-action-menu-rules
.icon-action-menu-post
@include icon-action-menu-rules
padding-left: 0.25rem
.icon-sub-menu .icon-sub-menu
@include icon-sub-menu-rules @include icon-sub-menu-rules

@ -45,4 +45,10 @@ class RowCell < RailsCell
def button_links def button_links
[] []
end end
def checkmark(condition)
if condition
op_icon 'icon icon-checkmark'
end
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 end
def writable_attributes 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}" } super + model.available_custom_fields.map { |cf| "custom_field_#{cf.id}" }
end end

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

@ -105,6 +105,22 @@ class Status < ActiveRecord::Base
def to_s; name end 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 private
def check_integrity def check_integrity

@ -144,7 +144,8 @@ class WorkPackage < ActiveRecord::Base
acts_as_attachable after_remove: :attachments_changed, acts_as_attachable after_remove: :attachments_changed,
order: "#{Attachment.table_name}.filename", order: "#{Attachment.table_name}.filename",
add_on_new_permission: :add_work_packages, 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, after_validation :set_attachments_error_details,
if: lambda { |work_package| work_package.errors.messages.has_key? :attachments } if: lambda { |work_package| work_package.errors.messages.has_key? :attachments }
@ -260,6 +261,12 @@ class WorkPackage < ActiveRecord::Base
status.nil? || status.is_closed? status.nil? || status.is_closed?
end 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 # Returns true if the work_package is overdue
def overdue? def overdue?
!due_date.nil? && (due_date < Date.today) && !closed? !due_date.nil? && (due_date < Date.today) && !closed?

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

@ -42,6 +42,17 @@ See docs/COPYRIGHT.rdoc for more details.
<div class="form--field"><%= f.check_box 'is_default' %></div> <div class="form--field"><%= f.check_box 'is_default' %></div>
<% end %> <% 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', <%= render partial: '/colors/color_autocomplete_field',
locals: { locals: {
form: f, form: f,

@ -48,104 +48,4 @@ See docs/COPYRIGHT.rdoc for more details.
<% end %> <% end %>
<% end %> <% end %>
<% if @statuses.any? %> <%= cell ::Statuses::TableCell, @statuses %>
<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 %>

@ -194,6 +194,11 @@ en:
statuses: statuses:
edit: 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: | status_color_text: |
Click to assign or change the color of this status. 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. 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" to: "Related work package"
status: status:
is_closed: "Work package closed" is_closed: "Work package closed"
is_readonly: "Work package read-only"
journal: journal:
notes: "Notes" notes: "Notes"
member: member:

@ -573,6 +573,7 @@ en:
message_successful_bulk_delete: Successfully deleted work packages. message_successful_bulk_delete: Successfully deleted work packages.
message_successful_show_in_fullscreen: "Click here to open this work package in fullscreen view." 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_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" no_value: "No value"
inline_create: inline_create:
title: 'Click here to add a new work package to this list' 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 | | position | Sort index of the status | Integer | | READ |
| isDefault | | Boolean | | READ | | isDefault | | Boolean | | READ |
| isClosed | are tickets of this status considered closed? | 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 | | defaultDoneRatio | The percentageDone being applied when changing to this status | Integer | 0 <= x <= 100 | READ |
## Statuses [/api/v3/statuses] ## Statuses [/api/v3/statuses]
@ -64,6 +65,7 @@
"position": 3, "position": 3,
"isDefault": false, "isDefault": false,
"isClosed": false, "isClosed": false,
"isReadonly": false,
"defaultDoneRatio": 75 "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 {CollectionResource} from 'core-app/modules/hal/resources/collection-resource';
import {IWorkPackageEditingServiceToken} from "../../wp-edit-form/work-package-editing.service.interface"; import {IWorkPackageEditingServiceToken} from "../../wp-edit-form/work-package-editing.service.interface";
import {Highlighting} from "core-components/wp-fast-table/builders/highlighting/highlighting.functions"; import {Highlighting} from "core-components/wp-fast-table/builders/highlighting/highlighting.functions";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
@Directive({ @Directive({
selector: '[wpStatusDropdown]' selector: '[wpStatusDropdown]'
@ -50,6 +51,7 @@ export class WorkPackageStatusDropdownDirective extends OpContextMenuTrigger {
readonly $state:StateService, readonly $state:StateService,
protected wpNotificationsService:WorkPackageNotificationService, protected wpNotificationsService:WorkPackageNotificationService,
@Inject(IWorkPackageEditingServiceToken) protected wpEditing:WorkPackageEditingService, @Inject(IWorkPackageEditingServiceToken) protected wpEditing:WorkPackageEditingService,
protected I18n:I18nService,
protected wpTableRefresh:WorkPackageTableRefreshService) { protected wpTableRefresh:WorkPackageTableRefreshService) {
super(elementRef, opContextMenu); super(elementRef, opContextMenu);
@ -89,6 +91,8 @@ export class WorkPackageStatusDropdownDirective extends OpContextMenuTrigger {
return { return {
disabled: false, disabled: false,
linkText: status.name, 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()), class: Highlighting.dotClass('status', status.getId()),
onClick: () => { onClick: () => {
this.updateStatus(status); this.updateStatus(status);

@ -17,6 +17,9 @@
[accessibleClickStopEvent]="false"> [accessibleClickStopEvent]="false">
<op-icon *ngIf="item.icon" icon-classes="icon-action-menu {{ item.icon }}"> </op-icon> <op-icon *ngIf="item.icon" icon-classes="icon-action-menu {{ item.icon }}"> </op-icon>
<span [textContent]="item.linkText"></span> <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> </a>
</li> </li>
</ng-container> </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 {WikiPageResource} from 'core-app/modules/hal/resources/wiki-page-resource';
import {PostResource} from 'core-app/modules/hal/resources/post-resource'; import {PostResource} from 'core-app/modules/hal/resources/post-resource';
import {HalResource} from 'core-app/modules/hal/resources/hal-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 { export class States extends StatesGroup {
[key:string]:any; [key:string]:any;
@ -37,6 +38,9 @@ export class States extends StatesGroup {
/* /api/v3/types */ /* /api/v3/types */
types = multiInput<TypeResource>(); types = multiInput<TypeResource>();
/* /api/v3/types */
statuses = multiInput<StatusResource>();
/* /api/v3/users */ /* /api/v3/users */
users = multiInput<UserResource>(); users = multiInput<UserResource>();

@ -27,6 +27,7 @@
// ++ // ++
import {InputState, MultiInputState, State} from 'reactivestates'; import {InputState, MultiInputState, State} from 'reactivestates';
import {Observable} from "rxjs";
export abstract class StateCacheService<T> { export abstract class StateCacheService<T> {
private cacheDurationInMs:number; private cacheDurationInMs:number;
@ -57,6 +58,13 @@ export abstract class StateCacheService<T> {
this.multiState.get(id).putValue(val); 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. * Clear a set of cached states.
* @param ids * @param ids

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

@ -28,33 +28,60 @@
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource'; import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {WorkPackageEditingService} from 'core-components/wp-edit-form/work-package-editing-service'; 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 {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import {IWorkPackageEditingServiceToken} from "../../wp-edit-form/work-package-editing.service.interface"; import {IWorkPackageEditingServiceToken} from "../../wp-edit-form/work-package-editing.service.interface";
import {Highlighting} from "core-components/wp-fast-table/builders/highlighting/highlighting.functions"; import {Highlighting} from "core-components/wp-fast-table/builders/highlighting/highlighting.functions";
import {HalResource} from "core-app/modules/hal/resources/hal-resource"; 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({ @Component({
selector: 'wp-status-button', selector: 'wp-status-button',
templateUrl: './wp-status-button.html' templateUrl: './wp-status-button.html'
}) })
export class WorkPackageStatusButtonComponent { export class WorkPackageStatusButtonComponent implements OnInit, OnDestroy {
@Input('workPackage') public workPackage:WorkPackageResource; @Input('workPackage') public workPackage:WorkPackageResource;
@Input('allowed') public allowed:boolean;
public text = { 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, constructor(readonly I18n:I18nService,
readonly wpCacheService:WorkPackageCacheService,
@Inject(IWorkPackageEditingServiceToken) protected wpEditing:WorkPackageEditingService) { @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() { public isDisabled() {
let changeset = this.wpEditing.changesetFor(this.workPackage); let changeset = this.wpEditing.changesetFor(this.workPackage);
return !this.allowed || changeset.inFlight; return !this.allowed || changeset.inFlight;
} }
public get buttonTitle() {
if (this.workPackage.isReadonly) {
return this.text.workPackageReadOnly;
} else {
return '';
}
}
public get statusHighlightClass() { public get statusHighlightClass() {
return Highlighting.inlineClass('status', this.status.getId()); return Highlighting.inlineClass('status', this.status.getId());
} }
@ -63,4 +90,8 @@ export class WorkPackageStatusButtonComponent {
let changeset = this.wpEditing.changesetFor(this.workPackage); let changeset = this.wpEditing.changesetFor(this.workPackage);
return changeset.value('status'); return changeset.value('status');
} }
public get allowed() {
return this.workPackage.isAttributeEditable('status');
}
} }

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

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

@ -127,7 +127,7 @@ export class WorkPackageEditFieldComponent implements OnInit {
public get isEditable() { public get isEditable() {
const fieldSchema = this.resource.schema[this.fieldName] as IFieldSchema; 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) { public activateIfEditable(event:JQueryEventObject) {

@ -64,7 +64,7 @@ export class TimelineCellRenderer {
} }
public canMoveDates(wp:WorkPackageResource) { 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) { public isEmpty(wp:WorkPackageResource) {

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

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

@ -32,7 +32,9 @@ import {Component, Input} from '@angular/core';
selector: 'op-icon', selector: 'op-icon',
host: { 'class': 'op-icon--wrapper' }, host: { 'class': 'op-icon--wrapper' },
template: ` template: `
<i [ngClass]="iconClasses" aria-hidden="true"></i> <i [ngClass]="iconClasses"
[title]="iconTitle"
aria-hidden="true"></i>
<span <span
class="hidden-for-sighted" class="hidden-for-sighted"
[textContent]="iconTitle" [textContent]="iconTitle"
@ -41,5 +43,5 @@ import {Component, Input} from '@angular/core';
}) })
export class OpIcon { export class OpIcon {
@Input('icon-classes') iconClasses:string; @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()); 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. * Return "<type name>: <subject>" if the type is known.
*/ */
@ -171,8 +175,24 @@ export class WorkPackageBaseResource extends HalResource {
return !(children && children.length > 0); 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[]) { private performUpload(files:UploadFile[]) {

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

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

@ -53,8 +53,13 @@ module API
end end
def writable?(property) 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 # 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 @writable_attributes ||= begin
contract.writable_attributes contract.writable_attributes
@ -69,6 +74,10 @@ module API
false false
end end
def readonly?
work_package.readonly_status?
end
private private
def contract def contract

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

@ -145,6 +145,16 @@ describe WorkPackages::BaseContract do
end end
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 describe 'estimated hours' do
it_behaves_like 'a parent unwritable property', :estimated_hours it_behaves_like 'a parent unwritable property', :estimated_hours

@ -30,6 +30,7 @@ FactoryBot.define do
factory :status do factory :status do
sequence(:name) do |n| "status #{n}" end sequence(:name) do |n| "status #{n}" end
is_closed false is_closed false
is_readonly false
factory :closed_status do factory :closed_status do
is_closed true 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('name') }
it { is_expected.to have_json_path('isClosed') } it { is_expected.to have_json_path('isClosed') }
it { is_expected.to have_json_path('isDefault') } 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('position') }
it { is_expected.to have_json_path('defaultDoneRatio') } 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.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_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_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.position.to_json).at_path('position') }
it { it {
is_expected.to be_json_eql(status.default_done_ratio.to_json).at_path('defaultDoneRatio') 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
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 describe '#assignable_statuses_for' do
let(:status_result) { double('status result') } 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) } let(:href) { api_v3_paths.attachments_by_work_package(work_package.id) }
end 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 it 'addAttachments is a post link' do
is_expected.to be_json_eql('post'.to_json).at_path('_links/addAttachment/method') is_expected.to be_json_eql('post'.to_json).at_path('_links/addAttachment/method')
end end

@ -98,6 +98,27 @@ describe Status, type: :model do
end end
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 describe '#cache_key' do
it 'updates when the updated_at field changes' do it 'updates when the updated_at field changes' do
old_cache_key = stubbed_status.cache_key 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) expect(WorkPackage.where(status_id: status.id).first).to eq(work_package)
end 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 describe '#new_statuses_allowed_to' do
let(:role) { FactoryBot.build_stubbed(:role) } let(:role) { FactoryBot.build_stubbed(:role) }
let(:type) { FactoryBot.build_stubbed(:type) } let(:type) { FactoryBot.build_stubbed(:type) }

@ -66,7 +66,8 @@ module Components
def drag_attachment(image_fixture, caption = 'Some caption') def drag_attachment(image_fixture, caption = 'Some caption')
in_editor do |container, editable| 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') images = editable.all('figure.image')
attachments.drag_and_drop_file(editable, image_fixture) attachments.drag_and_drop_file(editable, image_fixture)
@ -74,6 +75,7 @@ module Components
expect(page) expect(page)
.to have_selector('figure img[src^="/api/v3/attachments/"]', count: images.length + 1, wait: 10) .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') expect(page).not_to have_selector('notification-upload-progress')
# Besides testing caption functionality this also slows down clicking on the submit button # 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 # Activate the field and check it opened correctly
def activate! def activate!(expect_open: true)
retry_block do retry_block do
unless active? unless active?
scroll_to_and_click(display_element) scroll_to_and_click(display_element)
end end
unless active? if expect_open && !active?
raise "Expected WP field input type '#{field_type}' for attribute '#{property_name}'." raise "Expected WP field input type '#{field_type}' for attribute '#{property_name}'."
end end
end end

Loading…
Cancel
Save