diff --git a/frontend/app/components/api/api-v3/hal-resource-types/hal-resource-types.config.ts b/frontend/app/components/api/api-v3/hal-resource-types/hal-resource-types.config.ts index 1b24f12c72..62e1c3c41a 100644 --- a/frontend/app/components/api/api-v3/hal-resource-types/hal-resource-types.config.ts +++ b/frontend/app/components/api/api-v3/hal-resource-types/hal-resource-types.config.ts @@ -55,6 +55,7 @@ function halResourceTypesConfig(halResourceTypes:HalResourceTypesService) { to: 'WorkPackage' } }, + Schema: 'SchemaResource', Error: 'ErrorResource', User: 'UserResource', Collection: 'CollectionResource' diff --git a/frontend/app/components/api/api-v3/hal-resources/hal-resource.service.ts b/frontend/app/components/api/api-v3/hal-resources/hal-resource.service.ts index f5b26dc7dc..489acf1c61 100644 --- a/frontend/app/components/api/api-v3/hal-resources/hal-resource.service.ts +++ b/frontend/app/components/api/api-v3/hal-resources/hal-resource.service.ts @@ -29,6 +29,7 @@ import {opApiModule} from '../../../../angular-modules'; import {HalLinkInterface} from '../hal-link/hal-link.service'; import {HalResourceFactoryService} from '../hal-resource-factory/hal-resource-factory.service'; +import {State} from './../../../../helpers/reactive-fassade'; const ObservableArray:any = require('observable-array'); @@ -100,7 +101,27 @@ export class HalResource { this.$initialize($source); } + /** + * Return the associated state to this HAL resource, if any. + */ + public get state():State|null { + return null; + } + public $load(force = false):ng.IPromise { + const state = this.state; + if (!this.state) { + return this.$loadResource(force); + } + + if (force) { + state.clear(); + } + + return > state.get(); + } + + protected $loadResource(force = false):ng.IPromise { if (!force) { if (this.$loaded) { return $q.when(this); diff --git a/frontend/app/components/api/api-v3/hal-resources/schema-resource.service.ts b/frontend/app/components/api/api-v3/hal-resources/schema-resource.service.ts new file mode 100644 index 0000000000..97a6cb56a2 --- /dev/null +++ b/frontend/app/components/api/api-v3/hal-resources/schema-resource.service.ts @@ -0,0 +1,55 @@ +//-- 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 './hal-resource.service'; +import {opApiModule} from '../../../../angular-modules'; +import {WorkPackageResource} from './work-package-resource.service'; +import {States} from '../../../states.service'; +import {State} from './../../../../helpers/reactive-fassade'; + +var states: States; + +export class SchemaResource extends HalResource { + + public get state() { + return states.schemas.get(this.$link.href); + } +} + +function schemaResource(...args) { + [ + states, + ] = args; + return SchemaResource; +} + +schemaResource.$inject = [ + 'states', +]; + +opApiModule.factory('SchemaResource', schemaResource); diff --git a/frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.ts b/frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.ts index 38b191130b..34f6062d96 100644 --- a/frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.ts +++ b/frontend/app/components/api/api-v3/hal-resources/work-package-resource.service.ts @@ -36,6 +36,8 @@ import {UploadFile} from '../../op-file-upload/op-file-upload.service'; import IQService = angular.IQService; import IPromise = angular.IPromise; import ITimeoutService = angular.ITimeoutService; +import {States} from '../../../states.service'; +import {SchemaResource} from './schema-resource.service'; interface WorkPackageResourceEmbedded { activities: CollectionResourceInterface; @@ -50,7 +52,7 @@ interface WorkPackageResourceEmbedded { project: HalResource|any; relations: CollectionResourceInterface; responsible: HalResource|any; - schema: HalResource|any; + schema: SchemaResource|any; status: HalResource|any; timeEntries: HalResource[]|any[]; type: HalResource|any; @@ -80,6 +82,7 @@ var $q: IQService; var $stateParams: any; var $timeout: ITimeoutService; var I18n: op.I18n; +var states: States; var apiWorkPackages: ApiWorkPackagesService; var wpCacheService: WorkPackageCacheService; var NotificationsService: any; @@ -113,7 +116,7 @@ export class WorkPackageResource extends HalResource { public $embedded: WorkPackageResourceEmbedded; public $links: WorkPackageResourceLinks; public id: number|string; - public schema; + public schema: SchemaResource|any; public $pristine: { [attribute: string]: any } = {}; public parentId: number; public subject: string; @@ -146,6 +149,10 @@ export class WorkPackageResource extends HalResource { return this.modifiedFields.length > 0; } + /** + * Returns a reference to the + */ + public get modifiedFields(): string[] { var modified = []; @@ -190,12 +197,8 @@ export class WorkPackageResource extends HalResource { * be done automatically, but the backend does not provide typed collections yet. */ protected $initialize(source) { - const oldSchema = this.schema; super.$initialize(source); - // Take over previously loaded schema resource - this.setExistingSchema(oldSchema); - var attachments = this.attachments || {$source: void 0, $loaded: void 0}; this.attachments = new AttachmentCollectionResource( attachments.$source, @@ -259,18 +262,6 @@ export class WorkPackageResource extends HalResource { }); } - public requiredValueFor(fieldName): boolean { - var fieldSchema = this.schema[fieldName]; - - // The field schema may be undefined if a custom field - // is used as a column, but not available for this type. - if (angular.isUndefined(fieldSchema)) { - return false; - } - - return !this[fieldName] && fieldSchema.writable && fieldSchema.required; - } - public allowedValuesFor(field): ng.IPromise { var deferred = $q.defer(); @@ -319,15 +310,10 @@ export class WorkPackageResource extends HalResource { this.form .then(form => { - // Override the current schema with - // the changes from API - this.schema = form.$embedded.schema; - // Take over new values from the form // this resource doesn't know yet. this.assignNewValues(form.$embedded.payload); - // Use the newest embedded form this.schema = form.$embedded.schema; deferred.resolve(form); @@ -340,11 +326,11 @@ export class WorkPackageResource extends HalResource { return deferred.promise; } - public getSchema() { + public loadFormSchema() { return this.getForm().then(form => { - const schema = form.$embedded.schema; + this.schema = form.$embedded.schema; - angular.forEach(schema, (field, name) => { + angular.forEach(this.schema, (field, name) => { // Assign only links from schema when an href is set // and field is writable. // (exclude plain properties and null values) @@ -355,11 +341,11 @@ export class WorkPackageResource extends HalResource { const hasAllowedValues = Array.isArray(field.allowedValues) && field.allowedValues.length > 0; if (isHalField && hasAllowedValues) { - this[name] = _.find(field.allowedValues, {href: this[name].href}); + this[name] = _.find(field.allowedValues, {href: this[name].href}) || this[name]; } }); - return schema; + return this.schema; }); } @@ -373,26 +359,30 @@ export class WorkPackageResource extends HalResource { const sentValues = Object.keys(this.$pristine); this.$links.updateImmediately(payload) - .then(workPackage => { - // Initialize any potentially new HAL values - this.$initialize(workPackage); - this.updateActivities(); - - if (wasNew) { - this.uploadPendingAttachments(); - wpCacheService.newWorkPackageCreated(this); - } - - // Remove only those pristine values that were submitted - angular.forEach(sentValues, (key) => { - delete this.$pristine[key]; - }); - + .then((workPackage:WorkPackageResource) => { // Remove the current form, otherwise old form data - // might still be used for the next edit field in #getSchema() + // might still be used for the next edit field to be edited this.form = null; - deferred.resolve(this); + // Ensure the schema is loaded before updating + workPackage.schema.$load().then((schema) => { + // Initialize any potentially new HAL values + this.$initialize(workPackage); + this.schema = schema; + this.updateActivities(); + + if (wasNew) { + this.uploadPendingAttachments(); + wpCacheService.newWorkPackageCreated(this); + } + + // Remove only those pristine values that were submitted + angular.forEach(sentValues, (key) => { + delete this.$pristine[key]; + }); + + deferred.resolve(this); + }); }) .catch(error => { deferred.reject(error); @@ -460,25 +450,6 @@ export class WorkPackageResource extends HalResource { }); } - private setExistingSchema(previous) { - // Only when existing schema was loaded - if (!(previous && previous.$loaded)) { - return; - } - - // If old schema was a regular schema and both href match - // assume they are matching - if (previous.href === this.schema.href) { - this.schema = previous; - } - - // If old schema was embedded into the WP form, decide - // based on its baseSchema href. - if (previous.baseSchema && previous.baseSchema.href === this.schema.href) { - this.schema = previous; - } - } - /** * Invalidate a set of linked resources of this work package. * And inform the cache service about the work package update. @@ -557,6 +528,7 @@ function wpResource(...args) { $stateParams, $timeout, I18n, + states, apiWorkPackages, wpCacheService, NotificationsService, @@ -570,6 +542,7 @@ wpResource.$inject = [ '$stateParams', '$timeout', 'I18n', + 'states', 'apiWorkPackages', 'wpCacheService', 'NotificationsService', diff --git a/frontend/app/components/api/api-work-packages/api-work-packages.service.ts b/frontend/app/components/api/api-work-packages/api-work-packages.service.ts index a782707e68..a865d24c1a 100644 --- a/frontend/app/components/api/api-work-packages/api-work-packages.service.ts +++ b/frontend/app/components/api/api-work-packages/api-work-packages.service.ts @@ -55,12 +55,21 @@ export class ApiWorkPackagesService { */ public loadWorkPackageById(id:number, force = false) { const url = this.v3Path.wp({wp: id}); + const deferred = this.$q.defer(); - return > this.halRequest.get(url, null, { + this.halRequest.get(url, null, { caching: { enabled: !force } - }); + }) + .then((workPackage:WorkPackageResource) => { + workPackage.schema.$load().then(() => { + deferred.resolve(workPackage); + }) + }) + .catch(deferred.reject); + + return deferred.promise; } /** diff --git a/frontend/app/components/routing/wp-list/wp-list.controller.test.ts b/frontend/app/components/routing/wp-list/wp-list.controller.test.ts index 3b3d50efbc..f511c65486 100644 --- a/frontend/app/components/routing/wp-list/wp-list.controller.test.ts +++ b/frontend/app/components/routing/wp-list/wp-list.controller.test.ts @@ -175,6 +175,12 @@ describe('WorkPackagesListController', () => { } }; + var workPackage = { + schema: { + '$load': () => { return $q.when(true) } + } + } + wpListServiceMock = { fromQueryParams() { return $q.when({ @@ -186,9 +192,7 @@ describe('WorkPackagesListController', () => { resource: { total: 10 }, - work_packages: [ - {} - ] + work_packages: [ workPackage ] }); } }; diff --git a/frontend/app/components/routing/wp-view-base/wp-view-base.controller.ts b/frontend/app/components/routing/wp-view-base/wp-view-base.controller.ts index 47970014bf..45ad80d196 100644 --- a/frontend/app/components/routing/wp-view-base/wp-view-base.controller.ts +++ b/frontend/app/components/routing/wp-view-base/wp-view-base.controller.ts @@ -100,9 +100,6 @@ export class WorkPackageViewController { * Initialize controller after workPackage resource has been loaded. */ protected init() { - // Ensure the schema is being loaded as soon as possible - this.workPackage.schema.$load(); - // Set elements this.workPackage.project.$load().then(() => { this.projectIdentifier = this.workPackage.project.identifier; diff --git a/frontend/app/components/states.service.ts b/frontend/app/components/states.service.ts index 8b76b53157..46487f3bc8 100644 --- a/frontend/app/components/states.service.ts +++ b/frontend/app/components/states.service.ts @@ -1,10 +1,12 @@ import {MultiState, initStates} from "../helpers/reactive-fassade"; import {WorkPackageResource} from "./api/api-v3/hal-resources/work-package-resource.service"; import {opServicesModule} from "../angular-modules"; +import {SchemaResource} from './api/api-v3/hal-resources/schema-resource.service'; export class States { workPackages = new MultiState(); + schemas = new MultiState(); constructor() { initStates(this, function (msg: any) { diff --git a/frontend/app/components/work-packages/work-package-cache.service.ts b/frontend/app/components/work-packages/work-package-cache.service.ts index 828f176265..116938b85d 100644 --- a/frontend/app/components/work-packages/work-package-cache.service.ts +++ b/frontend/app/components/work-packages/work-package-cache.service.ts @@ -64,7 +64,9 @@ export class WorkPackageCacheService { ? wpState.getCurrentValue() // dirty, use current wp : wp; // not dirty or unknown, use new wp - this.states.workPackages.put(workPackageId, wpForPublish); + wpForPublish.schema.$load().then(() => { + this.states.workPackages.put(workPackageId, wpForPublish); + }); } } diff --git a/frontend/app/components/work-packages/wp-display-attr/wp-display-attr.directive.ts b/frontend/app/components/work-packages/wp-display-attr/wp-display-attr.directive.ts index de35edc930..7a20632b4e 100644 --- a/frontend/app/components/work-packages/wp-display-attr/wp-display-attr.directive.ts +++ b/frontend/app/components/work-packages/wp-display-attr/wp-display-attr.directive.ts @@ -93,19 +93,17 @@ export class WorkPackageDisplayAttributeController { protected updateAttribute(wp) { this.workPackage = wp; - this.schema.$load().then(() => { - this.field = this.wpDisplayField.getField(this.workPackage, this.attribute, this.schema[this.attribute]); + this.field = this.wpDisplayField.getField(this.workPackage, this.attribute, this.schema[this.attribute]); - if (this.field.isManualRenderer) { - this.__d__renderer = this.__d__renderer || this.$element.find(".__d__renderer"); - this.field.render(this.__d__renderer, this); - } + if (this.field.isManualRenderer) { + this.__d__renderer = this.__d__renderer || this.$element.find(".__d__renderer"); + this.field.render(this.__d__renderer, this); + } - this.$element.attr("aria-label", this.label + " " + this.displayText); + this.$element.attr("aria-label", this.label + " " + this.displayText); - this.__d__cell = this.__d__cell || this.$element.find(".__d__cell"); - this.__d__cell.toggleClass("-placeholder", this.isEmpty); - }); + this.__d__cell = this.__d__cell || this.$element.find(".__d__cell"); + this.__d__cell.toggleClass("-placeholder", this.isEmpty); } } diff --git a/frontend/app/components/work-packages/wp-single-view/wp-single-view.directive.ts b/frontend/app/components/work-packages/wp-single-view/wp-single-view.directive.ts index b886fef97c..7108020165 100644 --- a/frontend/app/components/work-packages/wp-single-view/wp-single-view.directive.ts +++ b/frontend/app/components/work-packages/wp-single-view/wp-single-view.directive.ts @@ -124,27 +124,24 @@ export class WorkPackageSingleViewController { this.workPackage.attachments.updateElements(); } - this.workPackage.schema.$load().then(schema => { - this.setFocus(); + this.setFocus(); - var otherGroup: any = _.find(this.groupedFields, {groupName: 'other'}); - otherGroup.attributes = []; + var otherGroup: any = _.find(this.groupedFields, {groupName: 'other'}); + otherGroup.attributes = []; - angular.forEach(schema, (prop, propName) => { - if (propName.match(/^customField/)) { - otherGroup.attributes.push(propName); - } - }); + angular.forEach(this.workPackage.schema, (prop, propName) => { + if (propName.match(/^customField/)) { + otherGroup.attributes.push(propName); + } + }); - otherGroup.attributes.sort((leftField, rightField) => { - var getLabel = field => this.singleViewWp.getLabel(field); - var left = getLabel(leftField).toLowerCase(); - var right = getLabel(rightField).toLowerCase(); + otherGroup.attributes.sort((leftField, rightField) => { + var getLabel = field => this.singleViewWp.getLabel(field); + var left = getLabel(leftField).toLowerCase(); + var right = getLabel(rightField).toLowerCase(); - return left.localeCompare(right); - }); + return left.localeCompare(right); }); - } } diff --git a/frontend/app/components/wp-edit/wp-edit-field/wp-edit-field.directive.ts b/frontend/app/components/wp-edit/wp-edit-field/wp-edit-field.directive.ts index dcc94ef3ee..1a33a28699 100644 --- a/frontend/app/components/wp-edit/wp-edit-field/wp-edit-field.directive.ts +++ b/frontend/app/components/wp-edit/wp-edit-field/wp-edit-field.directive.ts @@ -116,40 +116,38 @@ export class WorkPackageEditFieldController { } public initializeField() { - // Activate field when creating a work package - // and the schema requires this field - if (this.workPackage.isNew && this.workPackage.requiredValueFor(this.fieldName)) { - this.activate(); - - var activeField = this.formCtrl.firstActiveField; - if (!activeField || this.formCtrl.fields[activeField].fieldIndex > this.fieldIndex) { - this.formCtrl.firstActiveField = this.fieldName; - } - } - // Mark the td field if it is inline-editable // We're resolving the non-form schema here since its loaded anyway for the table - this.workPackage.schema.$load().then(schema => { - var fieldSchema = schema[this.fieldName]; + const fieldSchema = this.workPackage.schema[this.fieldName]; + + this.editable = fieldSchema && fieldSchema.writable; + this.fieldType = fieldSchema && this.wpEditField.fieldType(fieldSchema.type); - this.editable = fieldSchema && fieldSchema.writable; - this.fieldType = fieldSchema && this.wpEditField.fieldType(fieldSchema.type); + this.updateDisplayAttributes(); - this.updateDisplayAttributes(); + if (fieldSchema) { + this.fieldLabel = this.fieldLabel || fieldSchema.name; - if (fieldSchema) { - this.fieldLabel = this.fieldLabel || fieldSchema.name; + // Activate field when creating a work package + // and the schema requires this field + if (this.workPackage.isNew && this.isRequired() && !this.workPackage[this.fieldName]) { + this.activate(); - // Activate the field automatically when in editAllMode - if (this.inEditMode && this.isEditable) { - // Set focus on the first field - if(this.fieldName === 'subject') - this.activate(true); - else - this.activate(); + var activeField = this.formCtrl.firstActiveField; + if (!activeField || this.formCtrl.fields[activeField].fieldIndex > this.fieldIndex) { + this.formCtrl.firstActiveField = this.fieldName; } } - }); + + // Activate the field automatically when in editAllMode + if (this.inEditMode && this.isEditable) { + // Set focus on the first field + if (this.fieldName === 'subject') + this.activate(true); + else + this.activate(); + } + } } public activateIfEditable(event) { @@ -314,7 +312,7 @@ export class WorkPackageEditFieldController { } protected buildEditField(): ng.IPromise { - return this.formCtrl.loadSchema().then(schema => { + return this.workPackage.loadFormSchema().then(schema => { this.field = this.wpEditField.getField(this.workPackage, this.fieldName, schema[this.fieldName]); this.workPackage.storePristine(this.fieldName); }); diff --git a/frontend/app/components/wp-edit/wp-edit-form.directive.ts b/frontend/app/components/wp-edit/wp-edit-form.directive.ts index e4f6245366..18a23e80bf 100644 --- a/frontend/app/components/wp-edit/wp-edit-form.directive.ts +++ b/frontend/app/components/wp-edit/wp-edit-form.directive.ts @@ -63,12 +63,6 @@ export class WorkPackageEditFormController { }); } - public isFieldRequired() { - return _.filter((this.fields as any), (name: string) => { - return !this.workPackage[name] && this.workPackage.requiredValueFor(name); - }); - } - public registerField(field) { this.fields[field.fieldName] = field; field.setErrors(this.errorsPerAttribute[field.fieldName] || []); @@ -105,10 +99,6 @@ export class WorkPackageEditFormController { return this.workPackage.isEditable; } - public loadSchema() { - return this.workPackage.getSchema(); - } - /** * Update the form and embedded schema. * In edit-all mode, this allows fields to cause changes to the form (e.g., type switch)