Consolidate edit fields with their components (#6688)

* Consolidate edit fields with their components

Previously, we have edit field modules (typescript classes) and their component counterparts (projected into CDK portals). This was mainly due to the old AngularJS solution used one edit field component with a dynamic field for rendering, which is now no longer applicable. In Angular, we need a 1-to-1 mapping between component and template for edit fields.

This PR thus treats `EditFieldComponents` as a subclass of `Field` so we don't need to build both. This issue became apparent in the CKEditor instance.

* Better error handling to avoid JSON errors on submit

* Restore onSubmit we need to use to update field values

* Fix new spec by correctly rejecting save

* Fix demo data seeder spec

* Correctly read data from onSubmit handler before saving

Otherwise, the value saved may not be the absolute recent value.
pull/6776/head
Oliver Günther 6 years ago committed by GitHub
parent 650608e798
commit deb448f1fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      frontend/src/app/angular4-modules.ts
  2. 106
      frontend/src/app/components/work-packages/work-package-comment/work-package-comment-field-handler.ts
  3. 5
      frontend/src/app/components/work-packages/work-package-comment/work-package-comment.component.html
  4. 78
      frontend/src/app/components/work-packages/work-package-comment/work-package-comment.component.ts
  5. 49
      frontend/src/app/components/work-packages/work-package-comment/wp-comment-field.component.ts
  6. 7
      frontend/src/app/components/wp-activity/user/user-activity.component.html
  7. 58
      frontend/src/app/components/wp-activity/user/user-activity.component.ts
  8. 8
      frontend/src/app/components/wp-edit-form/single-view-edit-context.ts
  9. 7
      frontend/src/app/components/wp-edit-form/table-row-edit-context.ts
  10. 5
      frontend/src/app/components/wp-edit-form/work-package-edit-context.ts
  11. 38
      frontend/src/app/components/wp-edit-form/work-package-edit-field-handler.ts
  12. 49
      frontend/src/app/components/wp-edit-form/work-package-edit-form.ts
  13. 1
      frontend/src/app/components/wp-edit-form/work-package-editing.service.interface.ts
  14. 4
      frontend/src/app/components/wp-relations/wp-relation-row/wp-relation-row.component.ts
  15. 2
      frontend/src/app/modules/common/ckeditor/op-ckeditor.component.ts
  16. 2
      frontend/src/app/modules/common/notifications/notifications.service.spec.ts
  17. 4
      frontend/src/app/modules/fields/display/display-field.module.ts
  18. 4
      frontend/src/app/modules/fields/display/display-field.service.ts
  19. 82
      frontend/src/app/modules/fields/edit/edit-field.component.ts
  20. 42
      frontend/src/app/modules/fields/edit/edit-field.initializer.ts
  21. 32
      frontend/src/app/modules/fields/edit/edit-field.service.ts
  22. 86
      frontend/src/app/modules/fields/edit/edit.field.module.ts
  23. 47
      frontend/src/app/modules/fields/edit/editing-portal/edit-field-handler.ts
  24. 4
      frontend/src/app/modules/fields/edit/editing-portal/edit-form-portal.component.html
  25. 42
      frontend/src/app/modules/fields/edit/editing-portal/edit-form-portal.component.ts
  26. 15
      frontend/src/app/modules/fields/edit/editing-portal/edit-form-portal.injector.ts
  27. 15
      frontend/src/app/modules/fields/edit/editing-portal/wp-editing-portal-service.ts
  28. 2
      frontend/src/app/modules/fields/edit/field-controls/edit-field-controls.component.html
  29. 8
      frontend/src/app/modules/fields/edit/field-controls/edit-field-controls.component.ts
  30. 17
      frontend/src/app/modules/fields/edit/field-types/boolean-edit-field.component.ts
  31. 20
      frontend/src/app/modules/fields/edit/field-types/date-edit-field.component.ts
  32. 20
      frontend/src/app/modules/fields/edit/field-types/duration-edit-field.component.ts
  33. 14
      frontend/src/app/modules/fields/edit/field-types/float-edit-field.component.ts
  34. 98
      frontend/src/app/modules/fields/edit/field-types/formattable-edit-field.component.ts
  35. 83
      frontend/src/app/modules/fields/edit/field-types/formattable-edit-field.ts
  36. 15
      frontend/src/app/modules/fields/edit/field-types/integer-edit-field.component.ts
  37. 14
      frontend/src/app/modules/fields/edit/field-types/multi-select-edit-field.component.html
  38. 7
      frontend/src/app/modules/fields/edit/field-types/multi-select-edit-field.component.ts
  39. 6
      frontend/src/app/modules/fields/edit/field-types/select-edit-field.component.html
  40. 10
      frontend/src/app/modules/fields/edit/field-types/select-edit-field.component.ts
  41. 14
      frontend/src/app/modules/fields/edit/field-types/text-edit-field.component.ts
  42. 18
      frontend/src/app/modules/fields/edit/field-types/work-package-edit-field.component.ts
  43. 20
      frontend/src/app/modules/fields/field.base.ts
  44. 7
      frontend/src/app/modules/fields/field.service.ts
  45. 19
      frontend/src/app/modules/fields/openproject-fields.module.ts
  46. 4
      frontend/src/app/modules/hal/openproject-hal.module.ts
  47. 31
      frontend/src/app/modules/hal/services/hal-aware-error-handler.ts
  48. 6
      spec/features/work_packages/attachments/attachment_upload_spec.rb
  49. 2
      spec/features/work_packages/edit_work_package_spec.rb
  50. 4
      spec/features/work_packages/new/new_work_package_spec.rb

@ -219,6 +219,7 @@ import {WorkPackageTableHighlightingService} from "core-components/wp-fast-table
import {ChartsModule} from "ng2-charts";
import {WorkPackageEmbeddedGraphComponent} from "core-components/wp-table/embedded/wp-embedded-graph.component";
import {WorkPackageByVersionGraphComponent} from "core-components/wp-by-version-graph/wp-by-version-graph.component";
import {WorkPackageCommentFieldComponent} from "core-components/work-packages/work-package-comment/wp-comment-field.component";
@NgModule({
imports: [
@ -390,6 +391,7 @@ import {WorkPackageByVersionGraphComponent} from "core-components/wp-by-version-
// Activity Tab
NewestActivityOnOverviewComponent,
WorkPackageCommentComponent,
WorkPackageCommentFieldComponent,
ActivityEntryComponent,
UserActivityComponent,
RevisionActivityComponent,
@ -518,6 +520,7 @@ import {WorkPackageByVersionGraphComponent} from "core-components/wp-by-version-
// Single view
WorkPackageOverviewTabComponent,
WorkPackageEditFieldGroupComponent,
WorkPackageCommentFieldComponent,
// Searchbar
ExpandableSearchComponent,

@ -0,0 +1,106 @@
import {EditFieldHandler} from "core-app/modules/fields/edit/editing-portal/edit-field-handler";
import {ElementRef, Injector, OnInit} from "@angular/core";
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource";
import {WorkPackageChangeset} from "core-components/wp-edit-form/work-package-changeset";
import {IFieldSchema} from "core-app/modules/fields/field.base";
import {Subject} from "rxjs";
export abstract class WorkPackageCommentFieldHandler extends EditFieldHandler implements OnInit {
public fieldName = 'comment';
public handler = this;
public inEdit = false;
public inEditMode = false;
public inFlight = false;
public changeset:WorkPackageChangeset;
// Destroy events
public onDestroy = new Subject<void>();
constructor(protected elementRef:ElementRef,
protected injector:Injector) {
super();
}
/**
* Handle saving the comment
*/
public abstract handleUserSubmit():Promise<any>;
/**
* Required HTML id for the edit field
*/
public abstract get htmlId():string;
/**
* Required field label translation
*/
public abstract get fieldLabel():string;
public abstract get workPackage():WorkPackageResource;
ngOnInit() {
this.changeset = new WorkPackageChangeset(this.injector, this.workPackage);
}
public reset(withText:string = '') {
if (withText.length > 0) {
withText += '\n';
}
this.changeset.setValue('comment', { raw: withText });
}
public get schema():IFieldSchema {
return {
name: I18n.t('js.label_comment'),
writable: true,
required: false,
type: '_comment',
hasDefault: false
}
}
public get rawComment() {
return _.get(this.commentValue, 'raw', '');
}
public get commentValue() {
return this.changeset.value('comment');
}
public handleUserCancel() {
this.deactivate(true);
}
public get active() {
return this.inEdit;
}
public activate(withText?:string) {
this.inEdit = true;
this.reset(withText);
}
deactivate(focus:boolean):void {
this.inEdit = false;
this.onDestroy.next();
this.onDestroy.complete();
}
focus():void {
const trigger = this.elementRef.nativeElement.querySelector('.inplace-editing--trigger-container');
trigger && trigger.focus();
}
handleUserKeydown(event:JQueryEventObject, onlyCancel?:boolean):void {
}
isChanged():boolean {
return false;
}
stopPropagation(evt:JQueryEventObject):boolean {
return false;
}
}

@ -7,8 +7,9 @@
#commentContainer
*ngIf="canAddComment">
<div class="wp-edit-field inplace-edit">
<edit-form-portal *ngIf="active && field"
[editFieldInput]="field"
<edit-form-portal *ngIf="active"
[schemaInput]="schema"
[changesetInput]="changeset"
[editFieldHandler]="handler">
</edit-form-portal>
<div *ngIf="!active"

@ -27,7 +27,6 @@
// ++
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {WorkPackageCommentField} from './wp-comment-field.module';
import {ErrorResource} from 'core-app/modules/hal/resources/error-resource';
import {WorkPackageNotificationService} from '../../wp-edit/wp-notification.service';
import {WorkPackageCacheService} from '../work-package-cache.service';
@ -38,7 +37,7 @@ import {
Component,
ContentChild,
ElementRef,
Inject,
Inject, Injector,
Input,
OnDestroy,
OnInit,
@ -49,22 +48,20 @@ import {ConfigurationService} from "core-app/modules/common/config/configuration
import {NotificationsService} from "core-app/modules/common/notifications/notifications.service";
import {untilComponentDestroyed} from "ng2-rx-componentdestroyed";
import {IEditFieldHandler} from "core-app/modules/fields/edit/editing-portal/edit-field-handler.interface";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {WorkPackageChangeset} from "core-components/wp-edit-form/work-package-changeset";
import {WorkPackageCommentFieldHandler} from "core-components/work-packages/work-package-comment/work-package-comment-field-handler";
@Component({
selector: 'work-package-comment',
templateUrl: './work-package-comment.component.html'
})
export class WorkPackageCommentComponent implements IEditFieldHandler, OnInit, OnDestroy {
export class WorkPackageCommentComponent extends WorkPackageCommentFieldHandler implements OnInit, OnDestroy {
@Input() public workPackage:WorkPackageResource;
@ContentChild(TemplateRef) template:TemplateRef<any>;
@ViewChild('commentContainer') public commentContainer:ElementRef;
public field:WorkPackageCommentField;
public handler:IEditFieldHandler = this;
public text = {
editTitle: this.I18n.t('js.label_add_comment_title'),
addComment: this.I18n.t('js.label_add_comment'),
@ -73,11 +70,13 @@ export class WorkPackageCommentComponent implements IEditFieldHandler, OnInit, O
};
public fieldLabel:string = this.text.editTitle;
public editing = false;
public inFlight = false;
public canAddComment:boolean;
public showAbove:boolean;
public changeset:WorkPackageChangeset;
constructor(protected elementRef:ElementRef,
protected injector:Injector,
protected commentService:CommentService,
protected wpLinkedActivities:WorkPackagesActivityService,
protected ConfigurationService:ConfigurationService,
@ -86,10 +85,12 @@ export class WorkPackageCommentComponent implements IEditFieldHandler, OnInit, O
protected wpNotificationsService:WorkPackageNotificationService,
protected NotificationsService:NotificationsService,
protected I18n:I18nService) {
super(elementRef, injector);
}
public ngOnInit() {
super.ngOnInit();
this.canAddComment = !!this.workPackage.addComment;
this.showAbove = this.ConfigurationService.commentsSortedInDescendingOrder();
@ -124,57 +125,39 @@ export class WorkPackageCommentComponent implements IEditFieldHandler, OnInit, O
return 'wp-comment-field';
}
public get active() {
return this.editing;
}
public get inEditMode() {
return false;
}
public activate(withText?:string) {
this.editing = true;
this.reset(withText);
super.activate(withText);
if (!this.showAbove) {
this.scrollToBottom();
}
}
public get project() {
return this.workPackage.project;
}
public reset(withText?:string) {
this.field = this.field || new WorkPackageCommentField(this.workPackage);
this.field.initializeFieldValue(withText);
}
public deactivate(focus:boolean) {
focus && this.focus();
this.editing = false;
this.inEdit = false;
}
public handleUserSubmit() {
if (this.field.isBusy || this.field.isEmpty()) {
public async handleUserSubmit() {
if (this.inFlight || !this.rawComment) {
return Promise.resolve();
}
this.field.isBusy = true;
this.inFlight = true;
await this.onSubmit();
let indicator = this.loadingIndicator.wpDetails;
return indicator.promise = this.commentService.createComment(this.workPackage, this.field.value)
return indicator.promise = this.commentService.createComment(this.workPackage, this.commentValue)
.then(() => {
this.editing = false;
this.inEdit = false;
this.NotificationsService.addSuccess(this.I18n.t('js.work_packages.comment_added'));
this.wpLinkedActivities.require(this.workPackage, true);
this.wpCacheService.updateWorkPackage(this.workPackage);
this.field.isBusy = false;
this.inFlight = false;
this.focus();
})
.catch((error:any) => {
this.field.isBusy = false;
this.inFlight = false;
if (error instanceof ErrorResource) {
this.wpNotificationsService.showError(error, this.workPackage);
}
@ -184,27 +167,6 @@ export class WorkPackageCommentComponent implements IEditFieldHandler, OnInit, O
});
}
public handleUserCancel() {
this.deactivate(true);
}
focus():void {
const trigger = this.elementRef.nativeElement.querySelector('.inplace-editing--trigger-container');
trigger && trigger.focus();
}
handleUserKeydown(event:JQueryEventObject, onlyCancel?:boolean):void {
// We only save comments through field controls
}
isChanged():boolean {
return false;
}
stopPropagation(evt:JQueryEventObject):boolean {
return false;
}
scrollToBottom():void {
const scrollableContainer = jQuery(this.elementRef.nativeElement).scrollParent()[0];
if (scrollableContainer) {

@ -26,50 +26,31 @@
// See doc/COPYRIGHT.rdoc for more details.
// ++
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {WorkPackageChangeset} from '../../wp-edit-form/work-package-changeset';
import {ConfigurationService} from 'core-app/modules/common/config/configuration.service';
import {FormattableEditField} from "core-app/modules/fields/edit/field-types/formattable-edit-field";
export class WorkPackageCommentField extends FormattableEditField {
public _value:any;
import {Component, OnInit} from "@angular/core";
import {
FormattableEditFieldComponent,
formattableFieldTemplate
} from "core-app/modules/fields/edit/field-types/formattable-edit-field.component";
@Component({
template: formattableFieldTemplate
})
export class WorkPackageCommentFieldComponent extends FormattableEditFieldComponent implements OnInit {
public isBusy:boolean = false;
public ConfigurationService:ConfigurationService = this.$injector.get(ConfigurationService);
constructor(public workPackage:WorkPackageResource) {
super(
new WorkPackageChangeset(WorkPackageCommentField.$injector, workPackage),
'comment',
{name: I18n.t('js.label_comment')} as any
);
this.initializeFieldValue();
}
public get value() {
return this._value;
}
public set value(val:any) {
this._value = val;
public get name() {
return 'comment';
}
public get required() {
return true;
}
public initializeFieldValue(withText?:string):void {
if (!withText) {
this.rawValue = '';
return;
ngOnInit() {
super.ngOnInit();
this.rawValue = this.rawValue || '';
}
if (this.rawValue.length > 0) {
this.rawValue += '\n';
}
this.rawValue += withText;
}
}

@ -34,17 +34,18 @@
<op-icon icon-classes="action-icon icon-quote" [icon-title]="text.quote_comment"></op-icon>
</accessible-by-keyboard>
<accessible-by-keyboard *ngIf="userCanEdit"
(execute)="editComment()"
(execute)="activate()"
[linkTitle]="text.edit_comment"
class="edit-activity--{{activityNo}}">
<op-icon icon-classes="action-icon icon-edit" [icon-title]="text.edit_comment"></op-icon>
</accessible-by-keyboard>
</div>
</div>
<div class="user-comment wiki">
<div class="user-comment" [ngClass]="{ 'wiki': !inEdit }">
<div *ngIf="inEdit" class="inplace-edit">
<div class="user-comment--form inplace-edit--write-value">
<edit-form-portal [editFieldInput]="field"
<edit-form-portal [changesetInput]="changeset"
[schemaInput]="schema"
[editFieldHandler]="handler">
</edit-form-portal>
</div>

@ -30,29 +30,25 @@ import {UserResource} from 'core-app/modules/hal/resources/user-resource';
import {WorkPackageCacheService} from '../../work-packages/work-package-cache.service';
import {PathHelperService} from 'core-app/modules/common/path-helper/path-helper.service';
import {ConfigurationService} from 'core-app/modules/common/config/configuration.service';
import {WorkPackageCommentField} from 'core-components/work-packages/work-package-comment/wp-comment-field.module';
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {WorkPackagesActivityService} from 'core-components/wp-single-view-tabs/activity-panel/wp-activity.service';
import {AfterViewInit, Component, ElementRef, Inject, Input, OnInit} from "@angular/core";
import {AfterViewInit, Component, ElementRef, Injector, Input, OnInit} from "@angular/core";
import {UserCacheService} from "core-components/user/user-cache.service";
import {IEditFieldHandler} from "core-app/modules/fields/edit/editing-portal/edit-field-handler.interface";
import {CommentService} from "core-components/wp-activity/comment-service";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {WorkPackageCommentFieldHandler} from "core-components/work-packages/work-package-comment/work-package-comment-field-handler";
@Component({
selector: 'user-activity',
templateUrl: './user-activity.component.html'
})
export class UserActivityComponent implements IEditFieldHandler, OnInit, AfterViewInit {
export class UserActivityComponent extends WorkPackageCommentFieldHandler implements OnInit, AfterViewInit {
@Input() public workPackage:WorkPackageResource;
@Input() public activity:any;
@Input() public activityNo:number;
@Input() public activityLabel:string;
@Input() public isInitial:boolean;
public handler = this;
public inEdit = false;
public inEditMode = false;
public userCanEdit = false;
public userCanQuote = false;
@ -66,7 +62,6 @@ export class UserActivityComponent implements IEditFieldHandler, OnInit, AfterVi
public details:any[] = [];
public isComment:boolean;
public field:WorkPackageCommentField;
public focused = false;
public text = {
@ -80,6 +75,7 @@ export class UserActivityComponent implements IEditFieldHandler, OnInit, AfterVi
private $element:JQuery;
constructor(readonly elementRef:ElementRef,
readonly injector:Injector,
readonly PathHelper:PathHelperService,
readonly wpLinkedActivities:WorkPackagesActivityService,
readonly commentService:CommentService,
@ -87,9 +83,12 @@ export class UserActivityComponent implements IEditFieldHandler, OnInit, AfterVi
readonly ConfigurationService:ConfigurationService,
readonly userCacheService:UserCacheService,
readonly I18n:I18nService) {
super(elementRef, injector);
}
public ngOnInit() {
super.ngOnInit();
this.isComment = this.activity._type === 'Activity::Comment';
this.$element = jQuery(this.elementRef.nativeElement);
this.reset();
@ -137,37 +136,24 @@ export class UserActivityComponent implements IEditFieldHandler, OnInit, AfterVi
}
}
public reset(withText?:string) {
this.field = new WorkPackageCommentField(this.workPackage);
this.field.initializeFieldValue(withText);
public activate() {
super.activate(this.activity.comment.raw);
}
public handleUserSubmit() {
if (this.field.isBusy || this.field.isEmpty()) {
if (this.changeset.inFlight || !this.rawComment) {
return Promise.resolve();
}
return this.updateComment();
}
public handleUserCancel() {
this.deactivate(true);
}
public get active() {
return this.inEdit;
}
public editComment() {
this.inEdit = true;
this.reset(this.activity.comment.raw);
}
public quoteComment() {
this.commentService.quoteEvents.next(this.quotedText(this.activity.comment.raw));
}
public updateComment() {
return this.commentService.updateComment(this.activity, this.field.rawValue || '')
public async updateComment() {
await this.onSubmit();
return this.commentService.updateComment(this.activity, this.rawComment || '')
.then(() => {
this.wpLinkedActivities.require(this.workPackage, true);
this.wpCacheService.updateWorkPackage(this.workPackage);
@ -205,27 +191,11 @@ export class UserActivityComponent implements IEditFieldHandler, OnInit, AfterVi
return `user_activity_edit_field_${this.activityNo}`;
}
public get project() {
return this.workPackage.project;
}
deactivate(focus:boolean):void {
this.inEdit = false;
super.deactivate(focus);
if (focus) {
this.focusEditIcon();
}
}
handleUserKeydown(event:JQueryEventObject, onlyCancel?:boolean):void {
}
isChanged():boolean {
return false;
}
stopPropagation(evt:JQueryEventObject):boolean {
return false;
}
}

@ -39,8 +39,8 @@ import {WorkPackageTableRefreshService} from 'core-components/wp-table/wp-table-
import {WorkPackageEditForm} from 'core-components/wp-edit-form/work-package-edit-form';
import {WorkPackageEditFieldHandler} from 'core-components/wp-edit-form/work-package-edit-field-handler';
import {FocusHelperService} from 'core-app/modules/common/focus/focus-helper';
import {EditField} from "core-app/modules/fields/edit/edit.field.module";
import {WorkPackageEditingPortalService} from "core-app/modules/fields/edit/editing-portal/wp-editing-portal-service";
import {IFieldSchema} from "core-app/modules/fields/field.base";
export class SingleViewEditContext implements WorkPackageEditContext {
@ -60,14 +60,14 @@ export class SingleViewEditContext implements WorkPackageEditContext {
readonly fieldGroup:WorkPackageEditFieldGroupComponent) {
}
public async activateField(form:WorkPackageEditForm, field:EditField, fieldName:string, errors:string[]):Promise<WorkPackageEditFieldHandler> {
return this.fieldCtrl(field.name).then((ctrl) => {
public async activateField(form:WorkPackageEditForm, schema:IFieldSchema, fieldName:string, errors:string[]):Promise<WorkPackageEditFieldHandler> {
return this.fieldCtrl(fieldName).then((ctrl) => {
ctrl.active = true;
const container = ctrl.editContainer.nativeElement;
return this.wpEditingPortalService.create(
container,
form,
field,
schema,
fieldName,
errors
);

@ -37,9 +37,8 @@ import {WorkPackageEditFieldHandler} from './work-package-edit-field-handler';
import {WorkPackageEditForm} from './work-package-edit-form';
import {FocusHelperService} from 'core-app/modules/common/focus/focus-helper';
import {WorkPackageTable} from 'core-components/wp-fast-table/wp-fast-table';
import {EditField} from "core-app/modules/fields/edit/edit.field.module";
import {WorkPackageEditingPortalService} from "core-app/modules/fields/edit/editing-portal/wp-editing-portal-service";
import {asyncTimeOutput} from "core-app/helpers/debug_output";
import {IFieldSchema} from "core-app/modules/fields/field.base";
export class TableRowEditContext implements WorkPackageEditContext {
@ -71,7 +70,7 @@ export class TableRowEditContext implements WorkPackageEditContext {
return this.rowContainer.find(`.${tdClassName}.${fieldName}`).first();
}
public activateField(form:WorkPackageEditForm, field:EditField, fieldName:string, errors:string[]):Promise<WorkPackageEditFieldHandler> {
public activateField(form:WorkPackageEditForm, schema:IFieldSchema, fieldName:string, errors:string[]):Promise<WorkPackageEditFieldHandler> {
return this.waitForContainer(fieldName)
.then((cell) => {
@ -85,7 +84,7 @@ export class TableRowEditContext implements WorkPackageEditContext {
return this.wpEditingPortalService.create(
cell,
form,
field,
schema,
fieldName,
errors
);

@ -29,12 +29,13 @@
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {WorkPackageEditForm} from './work-package-edit-form';
import {WorkPackageEditFieldHandler} from './work-package-edit-field-handler';
import {EditField} from "core-app/modules/fields/edit/edit.field.module";
import {IFieldSchema} from "core-app/modules/fields/field.base";
export interface WorkPackageEditContext {
/**
* Activate the field, returning the element and associated field handler
*/
activateField(form:WorkPackageEditForm, field:EditField, fieldName:string, errors:string[]):Promise<WorkPackageEditFieldHandler>;
activateField(form:WorkPackageEditForm, schema:IFieldSchema, fieldName:string, errors:string[]):Promise<WorkPackageEditFieldHandler>;
/**
* Show this required field. E.g., add the necessary column

@ -32,15 +32,16 @@ import {WorkPackageEditContext} from './work-package-edit-context';
import {keyCodes} from 'core-app/modules/common/keyCodes.enum';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import {ConfigurationService} from 'core-app/modules/common/config/configuration.service';
import {ComponentRef, Injector} from '@angular/core';
import {Injector} from '@angular/core';
import {FocusHelperService} from 'core-app/modules/common/focus/focus-helper';
import {EditField} from "core-app/modules/fields/edit/edit.field.module";
import {IEditFieldHandler} from "core-app/modules/fields/edit/editing-portal/edit-field-handler.interface";
import {EditFieldHandler} from "core-app/modules/fields/edit/editing-portal/edit-field-handler";
import {ClickPositionMapper} from "core-app/modules/common/set-click-position/set-click-position";
import {debugLog} from "core-app/helpers/debug_output";
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
import {IFieldSchema} from "core-app/modules/fields/field.base";
import {Subject} from 'rxjs';
export class WorkPackageEditFieldHandler implements IEditFieldHandler {
export class WorkPackageEditFieldHandler extends EditFieldHandler {
// Injections
readonly FocusHelper:FocusHelperService = this.injector.get(FocusHelperService)
readonly ConfigurationService = this.injector.get(ConfigurationService);
@ -48,10 +49,9 @@ export class WorkPackageEditFieldHandler implements IEditFieldHandler {
// Other fields
public editContext:WorkPackageEditContext;
public schemaName:string;
// Reference to the active component, if any
public componentInstance:ComponentRef<EditFieldComponent>;
public componentInstance:EditFieldComponent;
// Current errors of the field
public errors:string[];
@ -59,13 +59,11 @@ export class WorkPackageEditFieldHandler implements IEditFieldHandler {
constructor(public injector:Injector,
public form:WorkPackageEditForm,
public fieldName:string,
public field:EditField,
public schema:IFieldSchema,
public element:HTMLElement,
protected onDestroy:() => void,
protected withErrors?:string[]) {
super();
this.editContext = form.editContext;
this.schemaName = field.name;
if (withErrors !== undefined) {
this.setErrors(withErrors);
@ -84,6 +82,10 @@ export class WorkPackageEditFieldHandler implements IEditFieldHandler {
return this.form.editMode;
}
public get inFlight() {
return this.form.changeset.inFlight;
}
public get active() {
return true;
}
@ -110,16 +112,19 @@ export class WorkPackageEditFieldHandler implements IEditFieldHandler {
this.element.classList.toggle('-error', this.isErrorenous);
}
/**
* Handle a user submitting the field (e.g, ng-change)
*/
public handleUserSubmit():Promise<any> {
if (this.field.inFlight || this.form.editMode) {
if (this.form.changeset.inFlight || this.form.editMode) {
return Promise.resolve();
}
this.field.onSubmit();
return this.form.submit();
return this
.onSubmit()
.then(() => this.form.submit());
}
/**
@ -169,7 +174,8 @@ export class WorkPackageEditFieldHandler implements IEditFieldHandler {
*/
public deactivate(focus:boolean = false) {
delete this.form.activeFields[this.fieldName];
this.onDestroy();
this.onDestroy.next();
this.onDestroy.complete();
this.editContext.reset(this.workPackage, this.fieldName, focus);
}
@ -184,7 +190,7 @@ export class WorkPackageEditFieldHandler implements IEditFieldHandler {
* Returns whether the field has been changed
*/
public isChanged():boolean {
return this.form.changeset.isOverridden(this.schemaName);
return this.form.changeset.isOverridden(this.fieldName);
}
/**
@ -213,7 +219,7 @@ export class WorkPackageEditFieldHandler implements IEditFieldHandler {
* Return the field label
*/
public get fieldLabel() {
return this.field.displayName;
return this.schema.name || this.fieldName;
}
public get errorMessageOnLabel() {

@ -31,7 +31,6 @@ import {ErrorResource} from 'core-app/modules/hal/resources/error-resource';
import {FormResource} from 'core-app/modules/hal/resources/form-resource';
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {Subscription} from 'rxjs';
import {debugLog} from '../../helpers/debug_output';
import {States} from '../states.service';
import {WorkPackageCacheService} from '../work-packages/work-package-cache.service';
import {WorkPackageNotificationService} from '../wp-edit/wp-notification.service';
@ -39,10 +38,8 @@ import {WorkPackageTableRefreshService} from '../wp-table/wp-table-refresh-reque
import {WorkPackageChangeset} from './work-package-changeset';
import {WorkPackageEditContext} from './work-package-edit-context';
import {WorkPackageEditFieldHandler} from './work-package-edit-field-handler';
import {WorkPackageEditingService} from './work-package-editing-service';
import {EditFieldService} from "core-app/modules/fields/edit/edit-field.service";
import {EditField} from "core-app/modules/fields/edit/edit.field.module";
import {IWorkPackageEditingServiceToken} from "core-components/wp-edit-form/work-package-editing.service.interface";
import {IFieldSchema} from "core-app/modules/fields/field.base";
export const activeFieldContainerClassName = 'wp-inline-edit--active-field';
export const activeFieldClassName = 'wp-inline-edit--field';
@ -52,7 +49,6 @@ export class WorkPackageEditForm {
public states:States = this.injector.get(States);
public wpCacheService = this.injector.get(WorkPackageCacheService);
public wpEditing = this.injector.get(IWorkPackageEditingServiceToken);
public wpEditField = this.injector.get(EditFieldService);
public wpTableRefresh = this.injector.get(WorkPackageTableRefreshService);
public wpNotificationsService = this.injector.get(WorkPackageNotificationService);
@ -114,14 +110,14 @@ export class WorkPackageEditForm {
*/
public activate(fieldName:string, noWarnings:boolean = false):Promise<WorkPackageEditFieldHandler> {
return new Promise<WorkPackageEditFieldHandler>((resolve, reject) => {
this.buildField(fieldName)
.then((field:EditField) => {
if (!field.writable && !noWarnings) {
this.wpNotificationsService.showEditingBlockedError(field.displayName);
this.loadFieldSchema(fieldName)
.then((schema:IFieldSchema) => {
if (!schema.writable && !noWarnings) {
this.wpNotificationsService.showEditingBlockedError(schema.name || fieldName);
reject();
}
this.renderField(fieldName, field)
this.renderField(fieldName, schema)
.then(resolve)
.catch(reject);
});
@ -161,7 +157,7 @@ export class WorkPackageEditForm {
* Save the active changeset.
* @return {any}
*/
public submit():Promise<WorkPackageResource> {
public async submit():Promise<WorkPackageResource> {
const isInitial = this.workPackage.isNew;
if (this.changeset.empty && !isInitial) {
@ -174,7 +170,9 @@ export class WorkPackageEditForm {
// Notify all fields of upcoming save
const openFields = _.keys(this.activeFields);
_.each(this.activeFields, (handler:WorkPackageEditFieldHandler) => handler.field.onSubmit());
// Call onSubmit handlers
await Promise.all(_.map(this.activeFields, (handler:WorkPackageEditFieldHandler) => handler.onSubmit()));
return new Promise<WorkPackageResource>((resolve, reject) => {
this.changeset.save()
@ -193,7 +191,8 @@ export class WorkPackageEditForm {
this.wpNotificationsService.handleRawError(error, this.workPackage);
if (error instanceof ErrorResource) {
this.handleSubmissionErrors(error, reject);
this.handleSubmissionErrors(error);
reject();
}
});
});
@ -230,10 +229,9 @@ export class WorkPackageEditForm {
});
}
protected handleSubmissionErrors(error:any, reject:Function) {
protected handleSubmissionErrors(error:any) {
// Process single API errors
this.handleErroneousAttributes(error);
return reject();
}
protected handleErroneousAttributes(error:any) {
@ -275,8 +273,13 @@ export class WorkPackageEditForm {
});
}
private buildField(fieldName:string):Promise<EditField> {
return new Promise<EditField>((resolve, reject) => {
/**
* Load the work package form to get the current field schema with all
* values loaded.
* @param fieldName
*/
private loadFieldSchema(fieldName:string):Promise<IFieldSchema> {
return new Promise<IFieldSchema>((resolve, reject) => {
this.changeset.getForm()
.then((form:FormResource) => {
const schemaName = this.changeset.getSchemaName(fieldName);
@ -286,13 +289,7 @@ export class WorkPackageEditForm {
return reject();
}
const field = this.wpEditField.getField(
this.changeset,
schemaName,
fieldSchema
);
resolve(field);
resolve(fieldSchema);
})
.catch((error) => {
console.error('Failed to build edit field: %o', error);
@ -301,9 +298,9 @@ export class WorkPackageEditForm {
});
}
private renderField(fieldName:string, field:EditField):Promise<WorkPackageEditFieldHandler> {
private renderField(fieldName:string, schema:IFieldSchema):Promise<WorkPackageEditFieldHandler> {
const promise:Promise<WorkPackageEditFieldHandler> = this.editContext.activateField(this,
field,
schema,
fieldName,
this.errorsPerAttribute[fieldName] || []);

@ -27,7 +27,6 @@
// ++
import {InjectionToken} from "@angular/core";
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource";
export const IWorkPackageEditingServiceToken = new InjectionToken<any>('IWorkPackageEditingService');

@ -34,10 +34,10 @@ export class WorkPackageRelationRowComponent implements OnInit {
// Create a quasi-field object
public fieldController = {
handler: {
active: true,
field: {
},
required: false
}
};
public relation:RelationResource;

@ -72,7 +72,7 @@ export class OpCkeditorComponent implements OnInit, OnDestroy {
// Debounce change listener for both CKE and codemirror
// to read back changes as they happen
private debouncedEmitter = _.debounce(
async () => {
() => {
this.getTransformedContent(false)
.then(val => {
this.onContentChange.emit(val);

@ -30,6 +30,7 @@ import {OpenprojectHalModule} from 'core-app/modules/hal/openproject-hal.module'
import {async, TestBed} from '@angular/core/testing';
import {NotificationsService} from 'core-app/modules/common/notifications/notifications.service';
import {ConfigurationService} from 'core-app/modules/common/config/configuration.service';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
describe('NotificationsService', function () {
var notificationsService:NotificationsService;
@ -42,6 +43,7 @@ describe('NotificationsService', function () {
],
providers: [
{ provide: ConfigurationService, useValue: { autoHidePopups: () => true } },
I18nService,
NotificationsService,
]
})

@ -36,11 +36,13 @@ export class DisplayField extends Field {
public mode:string | null = null;
public changeset:WorkPackageChangeset|null = null;
protected I18n:I18nService
constructor(public resource:any,
public name:string,
public schema:IFieldSchema,
public context:DisplayFieldContext) {
super(resource, name, schema);
super();
this.I18n = this.$injector.get(I18nService);
}
public get isFormattable():boolean {

@ -66,9 +66,7 @@ export class DisplayFieldService extends AbstractFieldService<DisplayField, IDis
* @returns {T}
*/
public getField(resource:any, fieldName:string, schema:IFieldSchema, context:DisplayFieldContext):DisplayField {
let type = this.fieldType(fieldName) || this.fieldType(schema.type) || this.defaultFieldType;
let fieldClass:IDisplayFieldType = this.classes[type];
const fieldClass = this.getClassFor(fieldName, schema.type);
return new fieldClass(resource, fieldName, schema, context);
}
}

@ -26,46 +26,63 @@
// See doc/COPYRIGHT.rdoc for more details.
// ++
import {ChangeDetectorRef, Component, Inject, InjectionToken, Injector, OnDestroy, OnInit} from "@angular/core";
import {WorkPackageEditFieldHandler} from "core-components/wp-edit-form/work-package-edit-field-handler";
import {EditField} from "core-app/modules/fields/edit/edit.field.module";
import {IFieldSchema} from "core-app/modules/fields/field.base";
import {IEditFieldHandler} from "core-app/modules/fields/edit/editing-portal/edit-field-handler.interface";
import {
ChangeDetectorRef,
Component,
ElementRef,
Inject,
InjectionToken,
Injector,
OnDestroy,
OnInit
} from "@angular/core";
import {EditFieldHandler} from "core-app/modules/fields/edit/editing-portal/edit-field-handler";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {IWorkPackageEditingServiceToken} from "core-components/wp-edit-form/work-package-editing.service.interface";
import {WorkPackageEditingService} from "core-components/wp-edit-form/work-package-editing-service";
import {untilComponentDestroyed} from "ng2-rx-componentdestroyed";
import {Field, IFieldSchema} from "core-app/modules/fields/field.base";
import {WorkPackageChangeset} from "core-components/wp-edit-form/work-package-changeset";
export const OpEditingPortalFieldToken = new InjectionToken('wp-editing-portal--field');
export const OpEditingPortalSchemaToken = new InjectionToken('wp-editing-portal--schema');
export const OpEditingPortalHandlerToken = new InjectionToken('wp-editing-portal--handler');
export const OpEditingPortalChangesetToken = new InjectionToken('wp-editing-portal--changeset');
@Component({
template: ''
})
export class EditFieldComponent implements OnDestroy {
export class EditFieldComponent extends Field implements OnDestroy {
/** Self reference */
public self = this;
constructor(readonly I18n:I18nService,
readonly elementRef:ElementRef,
@Inject(IWorkPackageEditingServiceToken) protected wpEditing:WorkPackageEditingService,
@Inject(OpEditingPortalFieldToken) readonly field:EditField,
@Inject(OpEditingPortalHandlerToken) readonly handler:IEditFieldHandler,
@Inject(OpEditingPortalChangesetToken) protected changeset:WorkPackageChangeset,
@Inject(OpEditingPortalSchemaToken) public schema:IFieldSchema,
@Inject(OpEditingPortalHandlerToken) readonly handler:EditFieldHandler,
readonly cdRef:ChangeDetectorRef,
readonly injector:Injector) {
super();
this.initialize();
this.wpEditing.state(this.field.resource.id)
this.wpEditing.state(this.changeset.workPackage.id)
.values$()
.pipe(
untilComponentDestroyed(this)
)
.subscribe((changeset) => {
if (!this.changeset.empty && this.changeset.wpForm.hasValue()) {
const fieldSchema = changeset.wpForm.value!.schema[this.field.name];
const fieldSchema = changeset.wpForm.value!.schema[this.name];
if (!fieldSchema) {
return handler.deactivate(false);
}
this.field.schema = fieldSchema;
this.field.resource = changeset.workPackage;
this.changeset = changeset;
this.schema = fieldSchema;
this.initialize();
this.cdRef.markForCheck();
}
@ -76,31 +93,46 @@ export class EditFieldComponent implements OnDestroy {
// Nothing to do
}
protected initialize() {
// Allow subclasses to create post-constructor initialization
public get inFlight() {
return this.handler.inFlight;
}
public get value() {
return this.field.value;
return this.changeset.value(this.name);
}
public set value(val:any) {
this.field.value = val;
public get name() {
// Get the mapped schema name, as this is not always the attribute
// e.g., startDate in table for milestone => date attribute
return this.changeset.getSchemaName(this.handler.fieldName);
}
public get name() {
return this.field.name;
public set value(value:any) {
this.changeset.setValue(this.name, this.parseValue(value));
}
public get schema():IFieldSchema {
return this.field.schema;
public get placeholder() {
if (this.name === 'subject') {
return this.I18n.t('js.placeholders.subject');
}
return '';
}
public get resource() {
return this.field.resource;
return this.changeset.workPackage;
}
/**
* Initialize the field after constructor was called.
*/
protected initialize() {
}
public get changeset() {
return this.field.changeset;
/**
* Parse the value from the model for setting
*/
protected parseValue(val:any) {
return val;
}
}

@ -27,26 +27,27 @@
// ++
import {EditFieldService} from "core-app/modules/fields/edit/edit-field.service";
import {TextEditField} from "core-app/modules/fields/edit/field-types/text-edit-field";
import {IntegerEditField} from "core-app/modules/fields/edit/field-types/integer-edit-field";
import {DurationEditField} from "core-app/modules/fields/edit/field-types/duration-edit-field";
import {SelectEditField} from "core-app/modules/fields/edit/field-types/select-edit-field";
import {MultiSelectEditField} from "core-app/modules/fields/edit/field-types/multi-select-edit-field";
import {FloatEditField} from "core-app/modules/fields/edit/field-types/float-edit-field";
import {WorkPackageEditField} from "core-app/modules/fields/edit/field-types/work-package-edit-field.module";
import {BooleanEditField} from "core-app/modules/fields/edit/field-types/boolean-edit-field";
import {DateEditField} from "core-app/modules/fields/edit/field-types/date-edit-field";
import {FormattableEditField} from "core-app/modules/fields/edit/field-types/formattable-edit-field";
import {TextEditFieldComponent} from "core-app/modules/fields/edit/field-types/text-edit-field.component";
import {IntegerEditFieldComponent} from "core-app/modules/fields/edit/field-types/integer-edit-field.component";
import {DurationEditFieldComponent} from "core-app/modules/fields/edit/field-types/duration-edit-field.component";
import {SelectEditFieldComponent} from "core-app/modules/fields/edit/field-types/select-edit-field.component";
import {MultiSelectEditFieldComponent} from "core-app/modules/fields/edit/field-types/multi-select-edit-field.component";
import {FloatEditFieldComponent} from "core-app/modules/fields/edit/field-types/float-edit-field.component";
import {BooleanEditFieldComponent} from "core-app/modules/fields/edit/field-types/boolean-edit-field.component";
import {WorkPackageEditFieldComponent} from "core-app/modules/fields/edit/field-types/work-package-edit-field.component";
import {DateEditFieldComponent} from "core-app/modules/fields/edit/field-types/date-edit-field.component";
import {FormattableEditFieldComponent} from "core-app/modules/fields/edit/field-types/formattable-edit-field.component";
import {WorkPackageCommentFieldComponent} from "core-components/work-packages/work-package-comment/wp-comment-field.component";
export function initializeCoreEditFields(editFieldService:EditFieldService) {
return () => {
editFieldService.defaultFieldType = 'text';
editFieldService
.addFieldType(TextEditField, 'text', ['String'])
.addFieldType(IntegerEditField, 'integer', ['Integer'])
.addFieldType(DurationEditField, 'duration', ['Duration'])
.addFieldType(SelectEditField, 'select', ['Priority',
.addFieldType(TextEditFieldComponent, 'text', ['String'])
.addFieldType(IntegerEditFieldComponent, 'integer', ['Integer'])
.addFieldType(DurationEditFieldComponent, 'duration', ['Duration'])
.addFieldType(SelectEditFieldComponent, 'select', ['Priority',
'Status',
'Type',
'User',
@ -54,14 +55,15 @@ export function initializeCoreEditFields(editFieldService:EditFieldService) {
'Category',
'CustomOption',
'Project'])
.addFieldType(MultiSelectEditField, 'multi-select', [
.addFieldType(MultiSelectEditFieldComponent, 'multi-select', [
'[]CustomOption',
'[]User'
])
.addFieldType(FloatEditField, 'float', ['Float'])
.addFieldType(WorkPackageEditField, 'workPackage', ['WorkPackage'])
.addFieldType(BooleanEditField, 'boolean', ['Boolean'])
.addFieldType(DateEditField, 'date', ['Date'])
.addFieldType(FormattableEditField, 'wiki-textarea', ['Formattable']);
.addFieldType(FloatEditFieldComponent, 'float', ['Float'])
.addFieldType(WorkPackageEditFieldComponent, 'workPackage', ['WorkPackage'])
.addFieldType(BooleanEditFieldComponent, 'boolean', ['Boolean'])
.addFieldType(DateEditFieldComponent, 'date', ['Date'])
.addFieldType(FormattableEditFieldComponent, 'wiki-textarea', ['Formattable'])
.addFieldType(WorkPackageCommentFieldComponent, '_comment', ['comment']);
};
}

@ -27,41 +27,17 @@
// ++
import {Injectable, Injector} from '@angular/core';
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource";
import {AbstractFieldService, IFieldType} from "core-app/modules/fields/field.service";
import {IFieldSchema} from "core-app/modules/fields/field.base";
import {EditField} from "core-app/modules/fields/edit/edit.field.module";
import {DisplayField} from "core-app/modules/fields/display/display-field.module";
import {DisplayFieldContext, IDisplayFieldType} from "core-app/modules/fields/display/display-field.service";
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
export interface IEditFieldType extends IFieldType<EditField> {
new(resource:WorkPackageResource, attributeType:string, schema:IFieldSchema):EditField;
export interface IEditFieldType extends IFieldType<EditFieldComponent> {
new():EditFieldComponent;
}
@Injectable()
export class EditFieldService extends AbstractFieldService<EditField, IEditFieldType> {
export class EditFieldService extends AbstractFieldService<EditFieldComponent, IEditFieldType> {
constructor(injector:Injector) {
super(injector);
}
/**
* Create an instance of the field type given the required arguments
* with either in descending order:
*
* 1. The registered field name (most specific)
* 2. The registered field for the schema attribute type
* 3. The default field type
*
* @param resource
* @param {string} fieldName
* @param {IFieldSchema} schema
* @returns {T}
*/
public getField(resource:any, fieldName:string, schema:IFieldSchema):EditField {
let type = this.fieldType(fieldName) || this.fieldType(schema.type) || this.defaultFieldType;
let fieldClass:IEditFieldType = this.classes[type];
return new fieldClass(resource, fieldName, schema);
}
}

@ -1,86 +0,0 @@
// -- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See doc/COPYRIGHT.rdoc for more details.
// ++
import {Field, IFieldSchema} from "core-app/modules/fields/field.base";
import {WorkPackageChangeset} from "core-components/wp-edit-form/work-package-changeset";
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
export class EditField extends Field {
readonly component:typeof EditFieldComponent;
constructor(public changeset:WorkPackageChangeset,
public name:string,
public schema:IFieldSchema) {
super(changeset.workPackage as any, name, schema);
this.initialize();
}
/**
* Called when the edit field is open and ready
* @param {HTMLElement} container
*/
public $onInit(container:HTMLElement) {
}
public onSubmit() {
}
public get inFlight() {
return this.changeset.inFlight;
}
public get value() {
return this.changeset.value(this.name);
}
public set value(value:any) {
this.changeset.setValue(this.name, this.parseValue(value));
}
public get placeholder() {
if (this.name === 'subject') {
return this.I18n.t('js.placeholders.subject');
}
return '';
}
/**
* Initialize the field after constructor was called.
*/
protected initialize() {
}
/**
* Parse the value from the model for setting
*/
protected parseValue(val:any) {
return val;
}
}

@ -26,9 +26,9 @@
// See doc/COPYRIGHT.rdoc for more details.
// ++
import {ProjectResource} from "core-app/modules/hal/resources/project-resource";
import {Subject} from 'rxjs';
export interface IEditFieldHandler {
export abstract class EditFieldHandler {
/**
* Whether the handler belongs to a larger edit mode form
* e.g., WP-create
@ -38,11 +38,20 @@ export interface IEditFieldHandler {
/** Whether the field is currently active */
active:boolean;
/** Whether the field is being saved */
inFlight:boolean;
/**
* Return a unique ID for this edit field
*/
htmlId:string;
/**
* The name of the attribute
*/
fieldName:string;
/**
* Accessibility label for the field
*/
@ -54,25 +63,39 @@ export interface IEditFieldHandler {
errorMessageOnLabel?:string;
/**
* Project resource
* On destroy observable
*/
public onDestroy = new Subject<void>();
// OnSubmit callbacks that may register from fields
protected _onSubmitHandlers:Array<() => Promise<void>> = [];
/**
* Call field submission callback handlers
*/
project:ProjectResource;
public onSubmit():Promise<any> {
return Promise.all(this._onSubmitHandlers.map((cb) => cb()));
}
public registerOnSubmit(callback:() => Promise<void>) {
this._onSubmitHandlers.push(callback);
}
/**
* Stop event propagation
*/
stopPropagation(evt:JQueryEventObject):boolean;
public abstract stopPropagation(evt:JQueryEventObject):boolean;
/**
* Focus on the active field.
* Optionally, try to set the click position to the given offset if the field is an input element.
*/
focus(setClickOffset?:number):void;
public abstract focus(setClickOffset?:number):void;
/**
* Handle a user submitting the field (e.g, ng-change)
*/
handleUserSubmit():Promise<any>;
public abstract handleUserSubmit():Promise<any>;
/**
* Handle users pressing enter inside an edit mode.
@ -80,25 +103,25 @@ export interface IEditFieldHandler {
* In an edit mode, we can't derive from a submit event wheteher the user pressed enter
* (and on what field he did that).
*/
handleUserKeydown(event:JQueryEventObject, onlyCancel?:boolean):void;
public abstract handleUserKeydown(event:JQueryEventObject, onlyCancel?:boolean):void;
/**
* Cancel edit
*/
handleUserCancel():void;
public abstract handleUserCancel():void;
/**
* Cancel any pending changes
*/
reset():void;
public abstract reset():void;
/**
* Close the field, resetting it with its display value.
*/
deactivate(focus:boolean):void;
public abstract deactivate(focus:boolean):void;
/**
* Returns whether the field has been changed
*/
isChanged():boolean;
public abstract isChanged():boolean;
}

@ -1,5 +1,5 @@
<div *ngIf="handler"
class="wp-inline-edit--active-field wp-edit-field inplace-edit {{ editField.name }}"
class="wp-inline-edit--active-field wp-edit-field inplace-edit {{ handler.fieldName }}"
[ngClass]="{'-error': handler.isErrorenous }">
<form (submit)="handler.handleUserSubmit()"
role="form"
@ -11,6 +11,6 @@
{{handler.errorMessageOnLabel}}
</label>
<ng-container *ngComponentOutlet="editField.component; injector: fieldInjector"></ng-container>
<ng-container *ngComponentOutlet="componentClass; injector: fieldInjector"></ng-container>
</form>
</div>

@ -1,52 +1,64 @@
import {
AfterViewInit,
EventEmitter,
Component,
ElementRef,
EventEmitter,
Injector,
Input,
OnDestroy,
OnInit,
Output
} from "@angular/core";
import {EditField} from "core-app/modules/fields/edit/edit.field.module";
import {IEditFieldHandler} from "core-app/modules/fields/edit/editing-portal/edit-field-handler.interface";
import {EditFieldHandler} from "core-app/modules/fields/edit/editing-portal/edit-field-handler";
import {
OpEditingPortalFieldToken,
OpEditingPortalHandlerToken
EditFieldComponent,
OpEditingPortalChangesetToken,
OpEditingPortalHandlerToken,
OpEditingPortalSchemaToken
} from "core-app/modules/fields/edit/edit-field.component";
import {createLocalInjector} from "core-app/modules/fields/edit/editing-portal/edit-form-portal.injector";
import {IFieldSchema} from "core-app/modules/fields/field.base";
import {WorkPackageChangeset} from "core-components/wp-edit-form/work-package-changeset";
import {EditFieldService, IEditFieldType} from "core-app/modules/fields/edit/edit-field.service";
@Component({
selector: 'edit-form-portal',
templateUrl: './edit-form-portal.component.html'
})
export class EditFormPortalComponent implements OnInit, OnDestroy, AfterViewInit {
@Input() editFieldInput:EditField;
@Input() editFieldHandler:IEditFieldHandler;
@Input() schemaInput:IFieldSchema;
@Input() changesetInput:WorkPackageChangeset;
@Input() editFieldHandler:EditFieldHandler;
@Output() public onEditFieldReady = new EventEmitter<void>();
public handler:IEditFieldHandler;
public editField:EditField;
public handler:EditFieldHandler;
public schema:IFieldSchema;
public changeset:WorkPackageChangeset;
public fieldInjector:Injector;
public componentClass:IEditFieldType;
public htmlId:string;
public label:string;
constructor(readonly injector:Injector,
readonly editField:EditFieldService,
readonly elementRef:ElementRef) {
}
ngOnInit() {
if (this.editFieldHandler && this.editFieldInput) {
if (this.editFieldHandler && this.schemaInput) {
this.handler = this.editFieldHandler;
this.editField = this.editFieldInput;
this.schema = this.schemaInput;
this.changeset = this.changesetInput;
} else {
this.handler = this.injector.get<IEditFieldHandler>(OpEditingPortalHandlerToken);
this.editField = this.injector.get<EditField>(OpEditingPortalFieldToken);
this.handler = this.injector.get<EditFieldHandler>(OpEditingPortalHandlerToken);
this.schema = this.injector.get<IFieldSchema>(OpEditingPortalSchemaToken);
this.changeset = this.injector.get<WorkPackageChangeset>(OpEditingPortalChangesetToken);
}
this.fieldInjector = createLocalInjector(this.injector, this.handler, this.editField);
this.componentClass = this.editField.getClassFor(this.handler.fieldName, this.schema.type);
this.fieldInjector = createLocalInjector(this.injector, this.changeset, this.handler, this.schema);
}
ngOnDestroy() {
@ -56,8 +68,6 @@ export class EditFormPortalComponent implements OnInit, OnDestroy, AfterViewInit
ngAfterViewInit() {
// Fire in a timeout to avoid same execution context in AfterViewInit
setTimeout(() => {
// Call $onInit once the field is ready
this.editField.$onInit(this.elementRef.nativeElement);
this.onEditFieldReady.emit();
});
}

@ -1,22 +1,25 @@
import {Injector} from "@angular/core";
import {
OpEditingPortalFieldToken,
OpEditingPortalHandlerToken
OpEditingPortalChangesetToken,
OpEditingPortalHandlerToken,
OpEditingPortalSchemaToken
} from "core-app/modules/fields/edit/edit-field.component";
import {PortalInjector} from "@angular/cdk/portal";
import {IEditFieldHandler} from "core-app/modules/fields/edit/editing-portal/edit-field-handler.interface";
import {EditField} from "core-app/modules/fields/edit/edit.field.module";
import {EditFieldHandler} from "core-app/modules/fields/edit/editing-portal/edit-field-handler";
import {IFieldSchema} from "core-app/modules/fields/field.base";
import {WorkPackageChangeset} from "core-components/wp-edit-form/work-package-changeset";
/**
* Creates an injector for the edit field portal to pass data into.
*
* @returns {PortalInjector}
*/
export function createLocalInjector(injector:Injector, fieldHandler:IEditFieldHandler, field:EditField):Injector {
export function createLocalInjector(injector:Injector, changeset:WorkPackageChangeset, fieldHandler:EditFieldHandler, schema:IFieldSchema):Injector {
const injectorTokens = new WeakMap();
injectorTokens.set(OpEditingPortalChangesetToken, changeset);
injectorTokens.set(OpEditingPortalHandlerToken, fieldHandler);
injectorTokens.set(OpEditingPortalFieldToken, field);
injectorTokens.set(OpEditingPortalSchemaToken, schema);
return new PortalInjector(injector, injectorTokens);
}

@ -5,10 +5,10 @@ import {WorkPackageEditFieldHandler} from "core-components/wp-edit-form/work-pac
import {WorkPackageEditForm} from "core-components/wp-edit-form/work-package-edit-form";
import {ApplicationRef, ComponentFactoryResolver, Injectable, Injector} from "@angular/core";
import {ComponentPortal, DomPortalOutlet} from "@angular/cdk/portal";
import {EditField} from "core-app/modules/fields/edit/edit.field.module";
import {EditFormPortalComponent} from "core-app/modules/fields/edit/editing-portal/edit-form-portal.component";
import {createLocalInjector} from "core-app/modules/fields/edit/editing-portal/edit-form-portal.injector";
import {take} from "rxjs/operators";
import {IFieldSchema} from "core-app/modules/fields/field.base";
@Injectable()
export class WorkPackageEditingPortalService {
@ -21,7 +21,7 @@ export class WorkPackageEditingPortalService {
public create(container:HTMLElement,
form:WorkPackageEditForm,
field:EditField,
schema:IFieldSchema,
fieldName:string,
errors:string[]):Promise<WorkPackageEditFieldHandler> {
@ -33,14 +33,19 @@ export class WorkPackageEditingPortalService {
this.injector,
form,
fieldName,
field,
schema,
container,
() => outlet.detach(), // Don't call .dispose() on the outlet, it destroys the DOM element
errors
);
fieldHandler
.onDestroy
.pipe(take(1))
// Don't call .dispose() on the outlet, it destroys the DOM element
.subscribe(() => outlet.detach());
// Create an injector that contains injectable reference to the edit field and handler
const injector = createLocalInjector(this.injector, fieldHandler, field);
const injector = createLocalInjector(this.injector, form.changeset, fieldHandler, schema);
// Create a portal for the edit-form/field
const portal = new ComponentPortal(EditFormPortalComponent, null, injector);

@ -1,5 +1,5 @@
<div class="inplace-edit--dashboard">
<div class="inplace-edit--controls" *ngIf="fieldController.active">
<div class="inplace-edit--controls" *ngIf="field.handler.active">
<accessible-by-keyboard (execute)="save()"
[attr.disabled]="(field.required && field.isEmpty()) || undefined"
[linkTitle]="saveTitle"

@ -26,8 +26,8 @@
// See doc/COPYRIGHT.rdoc for more details.
// ++
import {EditField} from "core-app/modules/fields/edit/edit.field.module";
import {Component, Input, Output, EventEmitter} from "@angular/core";
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
@Component({
selector: 'edit-field-controls',
@ -36,14 +36,10 @@ import {Component, Input, Output, EventEmitter} from "@angular/core";
export class EditFieldControlsComponent {
@Input() public cancelTitle:string;
@Input() public saveTitle:string;
@Input() public fieldController:any;
@Input('fieldController') public field:EditFieldComponent;
@Output() public onSave = new EventEmitter<void>();
@Output() public onCancel = new EventEmitter<void>();
public get field():EditField {
return this.fieldController.field;
}
public save() {
this.onSave.emit();
}

@ -28,30 +28,23 @@
import {Component} from "@angular/core";
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
import {EditField} from "core-app/modules/fields/edit/edit.field.module";
@Component({
template: `
<input type="checkbox"
class="wp-inline-edit--field wp-inline-edit--boolean-field"
[attr.aria-required]="field.required"
[checked]="field.value"
(change)="updateValue(!field.value)"
[attr.aria-required]="required"
[checked]="value"
(change)="updateValue(!value)"
(keydown)="handler.handleUserKeydown($event)"
[disabled]="field.inFlight"
[disabled]="inFlight"
[id]="handler.htmlId" />
`
})
export class BooleanEditFieldComponent extends EditFieldComponent {
public field:BooleanEditField;
public updateValue(newValue:boolean) {
this.field.value = newValue;
this.value = newValue;
this.handler.handleUserSubmit();
}
}
export class BooleanEditField extends EditField {
public component = BooleanEditFieldComponent;
}

@ -30,23 +30,22 @@ import {Component} from "@angular/core";
import * as moment from "moment";
import {TimezoneService} from "core-components/datetime/timezone.service";
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
import {EditField} from "core-app/modules/fields/edit/edit.field.module";
@Component({
template: `
<op-date-picker
tabindex="-1"
(onChange)="onValueSelected($event)"
[initialDate]="field.defaultDate">
[initialDate]="defaultDate">
<input [ngModel]="formatter(field.value)"
(ngModelChange)="field.value = parser($event);"
<input [ngModel]="formatter(value)"
(ngModelChange)="value = parser($event);"
type="text"
class="wp-inline-edit--field"
(keydown)="handler.handleUserKeydown($event)"
[attr.required]="field.required"
[disabled]="field.inFlight"
[attr.placeholder]="field.placeholder"
[attr.required]="required"
[disabled]="inFlight"
[attr.placeholder]="placeholder"
[id]="handler.htmlId" />
</op-date-picker>
@ -54,11 +53,10 @@ import {EditField} from "core-app/modules/fields/edit/edit.field.module";
`
})
export class DateEditFieldComponent extends EditFieldComponent {
public field:DateEditField;
readonly timezoneService = this.injector.get(TimezoneService);
public onValueSelected(data:string) {
this.field.value = this.parser(data);
this.value = this.parser(data);
this.handler.handleUserSubmit();
}
@ -78,10 +76,6 @@ export class DateEditFieldComponent extends EditFieldComponent {
return null;
}
}
}
export class DateEditField extends EditField {
public component = DateEditFieldComponent;
/**
* Return the default date for the datepicker instance.

@ -29,7 +29,6 @@
import {TimezoneService} from 'core-components/datetime/timezone.service';
import * as moment from 'moment';
import {Component} from "@angular/core";
import {EditField} from "core-app/modules/fields/edit/edit.field.module";
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
@Component({
@ -37,17 +36,17 @@ import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.compon
<input type="number"
step="0.01"
class="wp-inline-edit--field"
[attr.aria-required]="field.required"
[ngModel]="formatter(field.value)"
(ngModelChange)="field.value = parser($event)"
[attr.required]="field.required"
[attr.aria-required]="required"
[ngModel]="formatter(value)"
(ngModelChange)="value = parser($event)"
[attr.required]="required"
(keydown)="handler.handleUserKeydown($event)"
[disabled]="field.inFlight"
[disabled]="inFlight"
[id]="handler.htmlId" />
`
})
export class DurationEditFieldComponent extends EditFieldComponent {
public field:DurationEditField;
readonly TimezoneService:TimezoneService = this.$injector.get(TimezoneService);
public parser(value:any) {
if (!isNaN(value)) {
@ -61,14 +60,9 @@ export class DurationEditFieldComponent extends EditFieldComponent {
public formatter(value:any) {
return Number(moment.duration(value).asHours().toFixed(2));
}
}
export class DurationEditField extends EditField {
public component = DurationEditFieldComponent;
readonly TimezoneService:TimezoneService = this.$injector.get(TimezoneService);
protected parseValue(val:moment.Moment | null) {
return val === null ? null : val.toISOString();
}
}

@ -26,7 +26,6 @@
// ++
import {Component} from "@angular/core";
import {EditField} from "core-app/modules/fields/edit/edit.field.module";
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
@Component({
@ -34,10 +33,10 @@ import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.compon
<input type="number"
step="any"
class="wp-inline-edit--field"
[attr.aria-required]="field.required"
[attr.required]="field.required"
[disabled]="field.inFlight"
[(ngModel)]="field.value"
[attr.aria-required]="required"
[attr.required]="required"
[disabled]="inFlight"
[(ngModel)]="value"
(keydown)="handler.handleUserKeydown($event)"
[attr.lang]="locale"
[id]="handler.htmlId" />
@ -45,9 +44,4 @@ import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.compon
})
export class FloatEditFieldComponent extends EditFieldComponent {
public locale = I18n.locale;
public field:FloatEditField;
}
export class FloatEditField extends EditField {
public component = FloatEditFieldComponent;
}

@ -25,49 +25,54 @@
// See doc/COPYRIGHT.rdoc for more details.
// ++
import {Component, ViewChild} from "@angular/core";
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
import {FormattableEditField} from "core-app/modules/fields/edit/field-types/formattable-edit-field";
import {Component, OnInit, ViewChild} from "@angular/core";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {ICKEditorContext, ICKEditorInstance} from "core-app/modules/common/ckeditor/ckeditor-setup.service";
import {NotificationsService} from "core-app/modules/common/notifications/notifications.service";
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
import {OpCkeditorComponent} from "core-app/modules/common/ckeditor/op-ckeditor.component";
import {ICKEditorContext, ICKEditorInstance} from "core-app/modules/common/ckeditor/ckeditor-setup.service";
import {untilComponentDestroyed} from 'ng2-rx-componentdestroyed';
@Component({
template: `
export const formattableFieldTemplate = `
<div class="textarea-wrapper">
<div class="op-ckeditor--wrapper op-ckeditor-element">
<op-ckeditor [context]="context"
[content]="field.rawValue || ''"
<op-ckeditor [context]="ckEditorContext"
[content]="rawValue"
(onContentChange)="onContentChange($event)"
(onInitialized)="onCkeditorSetup($event)"
[ckEditorType]="editorType">
</op-ckeditor>
</div>
<edit-field-controls *ngIf="!handler.inEditMode"
[fieldController]="handler"
(onSave)="handleUserSubmit()"
[fieldController]="field"
(onSave)="handler.handleUserSubmit()"
(onCancel)="handler.handleUserCancel()"
[saveTitle]="field.text.save"
[cancelTitle]="field.text.cancel">
[saveTitle]="text.save"
[cancelTitle]="text.cancel">
</edit-field-controls>
</div>
`
@Component({
template: formattableFieldTemplate
})
export class FormattableEditFieldComponent extends EditFieldComponent {
public field:FormattableEditField;
private readonly pathHelper:PathHelperService = this.injector.get(PathHelperService);
private readonly Notifications = this.injector.get(NotificationsService);
export class FormattableEditFieldComponent extends EditFieldComponent implements OnInit {
readonly pathHelper:PathHelperService = this.$injector.get(PathHelperService);
public readonly field = this;
@ViewChild(OpCkeditorComponent) instance:OpCkeditorComponent;
public initialize() {
super.initialize();
this.field.instance = this;
}
// Values used in template
public isPreview:boolean = false;
public previewHtml:string = '';
public text = {
attachmentLabel: this.I18n.t('js.label_formattable_attachment_hint'),
save: this.I18n.t('js.inplace.button_save', {attribute: this.schema.name}),
cancel: this.I18n.t('js.inplace.button_cancel', {attribute: this.schema.name})
};
public onContentChange(value:string) {
this.field.rawValue = value;
ngOnInit() {
this.handler.registerOnSubmit(() => this.getCurrentValue());
}
public onCkeditorSetup(editor:ICKEditorInstance) {
@ -76,21 +81,28 @@ export class FormattableEditFieldComponent extends EditFieldComponent {
}
}
public getCurrentValue() {
return this.instance.getTransformedContent();
public getCurrentValue():Promise<void> {
return this.instance
.getTransformedContent()
.then((val) => {
this.rawValue = val;
});
}
public onContentChange(value:string) {
this.rawValue = value;
}
public handleUserSubmit() {
this.getCurrentValue()
.then((value:string) => {
this.field.rawValue = value;
.then(() => {
this.handler.handleUserSubmit();
});
return false;
}
public get context():ICKEditorContext {
public get ckEditorContext():ICKEditorContext {
return {
resource: this.resource,
macros: 'none' as 'none',
@ -99,18 +111,44 @@ export class FormattableEditFieldComponent extends EditFieldComponent {
}
public get editorType() {
if (this.field.name === 'description') {
if (this.name === 'description') {
return 'full';
} else {
return 'constrained';
}
}
public get previewContext() {
private get previewContext() {
if (this.resource.isNew && this.resource.project) {
return this.resource.project.href;
} else if (!this.resource.isNew) {
return this.pathHelper.api.v3.work_packages.id(this.resource.id).path;
}
}
public reset() {
if (this.instance) {
this.instance.content = this.rawValue;
}
}
public get rawValue() {
if (this.value && this.value.raw) {
return this.value.raw;
} else {
return '';
}
}
public set rawValue(val:string) {
this.value = {raw: val};
}
public isEmpty():boolean {
return !(this.value && this.value.raw);
}
public get isFormattable() {
return true;
}
}

@ -1,83 +0,0 @@
// -- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See doc/COPYRIGHT.rdoc for more details.
// ++
import {EditField} from "core-app/modules/fields/edit/edit.field.module";
import {FormattableEditFieldComponent} from "core-app/modules/fields/edit/field-types/formattable-edit-field.component";
export class FormattableEditField extends EditField {
// Values used in template
public instance:FormattableEditFieldComponent;
public isBusy:boolean = false;
public isPreview:boolean = false;
public previewHtml:string = '';
public text = {
attachmentLabel: this.I18n.t('js.label_formattable_attachment_hint'),
save: this.I18n.t('js.inplace.button_save', { attribute: this.schema.name }),
cancel: this.I18n.t('js.inplace.button_cancel', { attribute: this.schema.name })
};
public get component() {
return FormattableEditFieldComponent;
}
public onSubmit() {
this.instance
.getCurrentValue()
.then((value:string) => {
this.rawValue = value;
});
}
public get rawValue() {
if (this.value && this.value.raw) {
return this.value.raw;
} else {
return '';
}
}
public set rawValue(val:string) {
this.value = { raw: val };
}
public get isFormattable() {
return true;
}
public isEmpty():boolean {
return !(this.value && this.value.raw);
}
public submitUnlessInPreview(form:any) {
setTimeout(() => {
if (!this.isPreview) {
form.submit();
}
});
}
}

@ -25,29 +25,22 @@
// See doc/COPYRIGHT.rdoc for more details.
// ++
import {DurationEditField} from "core-app/modules/fields/edit/field-types/duration-edit-field";
import {Component} from "@angular/core";
import {EditField} from "core-app/modules/fields/edit/edit.field.module";
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
@Component({
template: `
<input type="number"
class="wp-inline-edit--field"
[attr.aria-required]="field.required"
[attr.required]="field.required"
[disabled]="field.inFlight"
[attr.aria-required]="required"
[attr.required]="required"
[disabled]="inFlight"
[attr.lang]="locale"
[(ngModel)]="field.value"
[(ngModel)]="value"
(keydown)="handler.handleUserKeydown($event)"
[id]="handler.htmlId" />
`
})
export class IntegerEditFieldComponent extends EditFieldComponent {
public locale = I18n.locale;
public field:DurationEditField;
}
export class IntegerEditField extends EditField {
public component = IntegerEditFieldComponent;
}

@ -4,9 +4,9 @@
*ngIf="!isMultiselect"
class="focus-input wp-inline-edit--field inplace-edit--field -animated form--select"
[(ngModel)]="selectedOption"
[attr.aria-required]="field.required"
[required]="field.required"
[disabled]="field.inFlight"
[attr.aria-required]="required"
[required]="required"
[disabled]="inFlight"
[id]="handler.htmlId"
(keydown)="handler.handleUserKeydown($event, true)"
(change)="handler.handleUserSubmit()"
@ -32,9 +32,9 @@
*ngIf="isMultiselect"
[(ngModel)]="selectedOption"
class="focus-input wp-inline-edit--field inplace-edit--textarea -animated form--select"
[attr.aria-required]="field.required"
[required]="field.required"
[disabled]="field.inFlight"
[attr.aria-required]="required"
[required]="required"
[disabled]="inFlight"
[id]="handler.htmlId"
(keydown)="handler.handleUserKeydown($event, true)"
(change)="submitOnSingleSelect()"
@ -68,7 +68,7 @@
</a>
</div>
<edit-field-controls [fieldController]="handler"
<edit-field-controls [fieldController]="self"
*ngIf="isMultiselect && !handler.inEditMode"
(onSave)="handler.handleUserSubmit()"
(onCancel)="handler.handleUserCancel()"

@ -31,8 +31,7 @@ import {HalResource} from 'core-app/modules/hal/resources/hal-resource';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import {Component, OnInit} from "@angular/core";
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
import {ValueOption} from "core-app/modules/fields/edit/field-types/select-edit-field";
import {EditField} from "core-app/modules/fields/edit/edit.field.module";
import {ValueOption} from "core-app/modules/fields/edit/field-types/select-edit-field.component";
@Component({
templateUrl: './multi-select-edit-field.component.html'
@ -215,7 +214,3 @@ export class MultiSelectEditFieldComponent extends EditFieldComponent implements
}
}
}
export class MultiSelectEditField extends EditField {
component = MultiSelectEditFieldComponent;
}

@ -1,9 +1,9 @@
<select [(ngModel)]="selectedOption"
[compareWith]="compareByHref"
class="wp-inline-edit--field form--select"
[attr.aria-required]="field.required"
[required]="field.required"
[disabled]="field.inFlight"
[attr.aria-required]="required"
[required]="required"
[disabled]="inFlight"
[id]="handler.htmlId"
(keydown)="handler.handleUserKeydown($event, true)"
(change)="handler.handleUserSubmit()"

@ -30,10 +30,6 @@ import {Component} from "@angular/core";
import {HalResourceSortingService} from "core-app/modules/hal/services/hal-resource-sorting.service";
import {CollectionResource} from "core-app/modules/hal/resources/collection-resource";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {EditField} from "core-app/modules/fields/edit/edit.field.module";
import {WorkPackageEditingService} from "../../../../components/wp-edit-form/work-package-editing-service";
import {componentDestroyed} from "ng2-rx-componentdestroyed";
import {takeUntil} from "rxjs/internal/operators";
import {EditFieldComponent} from "../edit-field.component";
import {AngularTrackingHelpers} from "core-components/angular/tracking-functions";
@ -74,7 +70,7 @@ export class SelectEditFieldComponent extends EditFieldComponent {
}
public get selectedOption() {
const href = this.field.value ? this.value.$href : null;
const href = this.value ? this.value.$href : null;
return _.find(this.valueOptions, o => o.$href === href)!;
}
@ -123,7 +119,3 @@ export class SelectEditFieldComponent extends EditFieldComponent {
}
}
}
export class SelectEditField extends EditField {
component = SelectEditFieldComponent;
}

@ -28,26 +28,20 @@
import {Component} from "@angular/core";
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
import {EditField} from "core-app/modules/fields/edit/edit.field.module";
@Component({
template: `
<input type="text"
class="wp-inline-edit--field"
[focus]="shouldFocus"
[attr.aria-required]="field.required"
[attr.required]="field.required"
[disabled]="field.inFlight"
[attr.aria-required]="required"
[attr.required]="required"
[disabled]="inFlight"
[(ngModel)]="value"
(keydown)="handler.handleUserKeydown($event)"
[id]="handler.htmlId" />
`
})
export class TextEditFieldComponent extends EditFieldComponent {
public field:TextEditField;
public shouldFocus = this.field.name === 'subject';
}
export class TextEditField extends EditField {
public component = TextEditFieldComponent;
public shouldFocus = this.name === 'subject';
}

@ -26,11 +26,17 @@
// See doc/COPYRIGHT.rdoc for more details.
// ++
import {TextEditField} from "core-app/modules/fields/edit/field-types/text-edit-field";
import {Component} from "@angular/core";
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
export class WorkPackageEditField extends TextEditField {
public get writable() {
return false;
}
@Component({
template: `
<input type="text"
class="wp-inline-edit--field"
disabled
[ngModel]="value"
[id]="handler.htmlId" />
`
})
export class WorkPackageEditFieldComponent extends EditFieldComponent {
}

@ -27,12 +27,12 @@
// ++
import {Injector} from '@angular/core';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {DisplayFieldContext} from "core-app/modules/fields/display/display-field.service";
export interface IFieldSchema {
type:string;
writable:boolean;
allowedValues:any;
allowedValues?:any;
required?:boolean;
hasDefault:boolean;
name?:string;
@ -41,6 +41,10 @@ export interface IFieldSchema {
export class Field {
public static type:string;
public static $injector:Injector;
public resource:any;
public name:string;
public schema:IFieldSchema;
public context:DisplayFieldContext;
public get displayName():string {
return this.schema.name || this.name;
@ -77,16 +81,4 @@ export class Field {
protected get $injector():Injector {
return (this.constructor as typeof Field).$injector;
}
protected initializer() {
}
protected I18n:I18nService
constructor(public resource:any,
public name:string,
public schema:IFieldSchema) {
this.I18n = this.$injector.get(I18nService);
this.initializer();
}
}

@ -65,8 +65,9 @@ export abstract class AbstractFieldService<T extends Field, C extends IFieldType
* @param {string} fieldName
* @returns {C}
*/
public getClassFor(fieldName:string):C {
return this.classes[fieldName] || this.classes[this.defaultFieldType];
public getClassFor(fieldName:string, type:string = 'unknown'):C {
let key = this.fieldType(fieldName) || this.fieldType(type) || this.defaultFieldType;
return this.classes[key];
}
/**
@ -96,7 +97,7 @@ export abstract class AbstractFieldService<T extends Field, C extends IFieldType
* @returns {this}
*/
public extendFieldType(fieldType:string, attributes:string[]) {
let fieldClass = this.getClassFor(fieldType);
let fieldClass = this.classes[fieldType] || this.getClassFor(fieldType);
return this.addFieldType(fieldClass, fieldType, attributes);
}

@ -33,21 +33,22 @@ import {DisplayFieldService} from "core-app/modules/fields/display/display-field
import {initializeCoreEditFields} from "core-app/modules/fields/edit/edit-field.initializer";
import {initializeCoreDisplayFields} from "core-app/modules/fields/display/display-field.initializer";
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
import {BooleanEditFieldComponent} from "core-app/modules/fields/edit/field-types/boolean-edit-field";
import {DateEditFieldComponent} from "core-app/modules/fields/edit/field-types/date-edit-field";
import {BooleanEditFieldComponent} from "core-app/modules/fields/edit/field-types/boolean-edit-field.component";
import {DateEditFieldComponent} from "core-app/modules/fields/edit/field-types/date-edit-field.component";
import {FormsModule} from "@angular/forms";
import {DurationEditFieldComponent} from "core-app/modules/fields/edit/field-types/duration-edit-field";
import {FloatEditFieldComponent} from "core-app/modules/fields/edit/field-types/float-edit-field";
import {IntegerEditFieldComponent} from "core-app/modules/fields/edit/field-types/integer-edit-field";
import {MultiSelectEditFieldComponent} from "core-app/modules/fields/edit/field-types/multi-select-edit-field";
import {SelectEditFieldComponent} from "core-app/modules/fields/edit/field-types/select-edit-field";
import {DurationEditFieldComponent} from "core-app/modules/fields/edit/field-types/duration-edit-field.component";
import {FloatEditFieldComponent} from "core-app/modules/fields/edit/field-types/float-edit-field.component";
import {IntegerEditFieldComponent} from "core-app/modules/fields/edit/field-types/integer-edit-field.component";
import {MultiSelectEditFieldComponent} from "core-app/modules/fields/edit/field-types/multi-select-edit-field.component";
import {SelectEditFieldComponent} from "core-app/modules/fields/edit/field-types/select-edit-field.component";
import {FormattableEditFieldComponent} from "core-app/modules/fields/edit/field-types/formattable-edit-field.component";
import {TextEditFieldComponent} from "core-app/modules/fields/edit/field-types/text-edit-field";
import {TextEditFieldComponent} from "core-app/modules/fields/edit/field-types/text-edit-field.component";
import {OpenprojectCommonModule} from "core-app/modules/common/openproject-common.module";
import {WorkPackageEditingPortalService} from "core-app/modules/fields/edit/editing-portal/wp-editing-portal-service";
import {EditFormPortalComponent} from "core-app/modules/fields/edit/editing-portal/edit-form-portal.component";
import {EditFieldControlsComponent,} from "core-app/modules/fields/edit/field-controls/edit-field-controls.component";
import {OpenprojectAccessibilityModule} from "core-app/modules/a11y/openproject-a11y.module";
import {WorkPackageEditFieldComponent} from "core-app/modules/fields/edit/field-types/work-package-edit-field.component";
@NgModule({
imports: [
@ -80,6 +81,7 @@ import {OpenprojectAccessibilityModule} from "core-app/modules/a11y/openproject-
SelectEditFieldComponent,
TextEditFieldComponent,
EditFieldControlsComponent,
WorkPackageEditFieldComponent,
],
entryComponents: [
EditFormPortalComponent,
@ -93,6 +95,7 @@ import {OpenprojectAccessibilityModule} from "core-app/modules/a11y/openproject-
MultiSelectEditFieldComponent,
SelectEditFieldComponent,
TextEditFieldComponent,
WorkPackageEditFieldComponent,
]
})
export class OpenprojectFieldsModule { }

@ -26,7 +26,7 @@
// See doc/COPYRIGHT.rdoc for more details.
// ++
import {APP_INITIALIZER, NgModule} from '@angular/core';
import {APP_INITIALIZER, ErrorHandler, NgModule} from '@angular/core';
import {HTTP_INTERCEPTORS, HttpClientModule} from '@angular/common/http';
import {BrowserModule} from '@angular/platform-browser';
import {
@ -45,6 +45,7 @@ import {OpenProjectHeaderInterceptor} from 'core-app/modules/hal/http/openprojec
import {UserDmService} from 'core-app/modules/hal/dm-services/user-dm.service';
import {ProjectDmService} from 'core-app/modules/hal/dm-services/project-dm.service';
import {HalResourceSortingService} from "core-app/modules/hal/services/hal-resource-sorting.service";
import {HalAwareErrorHandler} from "core-app/modules/hal/services/hal-aware-error-handler";
@NgModule({
imports: [
@ -52,6 +53,7 @@ import {HalResourceSortingService} from "core-app/modules/hal/services/hal-resou
HttpClientModule,
],
providers: [
{ provide: ErrorHandler, useClass: HalAwareErrorHandler },
HalResourceService,
HalResourceSortingService,
{ provide: HTTP_INTERCEPTORS, useClass: OpenProjectHeaderInterceptor, multi: true },

@ -0,0 +1,31 @@
import {ErrorHandler, Injectable} from "@angular/core";
import {ErrorResource} from "core-app/modules/hal/resources/error-resource";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
@Injectable()
export class HalAwareErrorHandler extends ErrorHandler {
private text = {
internal_error: this.I18n.t('js.errors.internal')
};
constructor(private readonly I18n:I18nService) {
super();
}
public handleError(error:any) {
let message:string = this.text.internal_error;
if (error instanceof ErrorResource) {
console.error("Returned error resource %O", error);
message += ` ${error.errorMessages.join("\n")}`;
} else if (error instanceof HalResource) {
console.error("Returned hal resource %O", error);
message += `Resource returned ${error.name}`;
} else {
message = error;
}
super.handleError(message);
}
}

@ -108,6 +108,9 @@ describe 'Upload attachment to work package', js: true do
target = find('.ck-content')
attachments.drag_and_drop_file(target, image_fixture)
sleep 2
expect(page).not_to have_selector('notification-upload-progress')
editor.in_editor do |container, editable|
expect(editable).to have_selector('img[src*="/api/v3/attachments/"]', wait: 20)
end
@ -118,8 +121,6 @@ describe 'Upload attachment to work package', js: true do
caption.click
caption.base.send_keys('Some image caption')
expect(page).not_to have_selector('notification-upload-progress')
click_on 'Save'
wp_page.expect_notification(
@ -127,6 +128,7 @@ describe 'Upload attachment to work package', js: true do
)
field = wp_page.edit_field :description
expect(field.display_element).to have_selector('img')
expect(field.display_element).to have_content('Some image caption')

@ -114,7 +114,7 @@ describe 'edit work package', js: true do
dueDate: '2013-03-20',
responsible: manager.name,
assignee: manager.name,
estimatedTime: '5.00',
estimatedTime: '5',
priority: priority2.name,
version: version.name,
category: category.name,

@ -184,8 +184,8 @@ describe 'new work package', js: true do
find(".customField#{ids.last} option", text: 'foo').select_option
save_work_package!(false)
# Its a known bug that custom fields validation errors do not contain their names
notification.expect_error("can't be blank.")
notification.expect_error("#{custom_field1.name} can't be blank.")
cf1.set 'Custom field content'
save_work_package!(true)

Loading…
Cancel
Save