Extract base cache state from relations and work packages services

This handles the implicit expiry of items after a given time interval.
pull/5734/head
Oliver Günther 7 years ago
parent 841d9acabb
commit 355dc08fc5
  1. 1
      frontend/app/components/api/api-paths/api-paths.config.ts
  2. 12
      frontend/app/components/api/api-v3/hal-resource-dms/relations-dm.service.ts
  3. 132
      frontend/app/components/states/state-cache.service.ts
  4. 102
      frontend/app/components/work-packages/work-package-cache.service.ts
  5. 2
      frontend/app/components/wp-fast-table/builders/relation-cell-builder.ts
  6. 2
      frontend/app/components/wp-fast-table/builders/relations/relations-render-pass.ts
  7. 6
      frontend/app/components/wp-fast-table/state/wp-table-additional-elements.service.ts
  8. 4
      frontend/app/components/wp-relations/wp-relations.directive.ts
  9. 87
      frontend/app/components/wp-relations/wp-relations.service.ts
  10. 4
      frontend/app/components/wp-table/timeline/global-elements/wp-timeline-relations.directive.ts

@ -36,6 +36,7 @@ function apiPathsProviderConfig(apiPathsProvider:ApiPathsServiceProvider) {
}]; }];
const workPackages = ['work_packages{/wp}', { const workPackages = ['work_packages{/wp}', {
form: 'form', form: 'form',
relations: 'relations',
availableProjects: 'available_projects' availableProjects: 'available_projects'
}, { }, {
project: projects project: projects

@ -35,10 +35,22 @@ import {buildApiV3Filter} from '../api-v3-filter-builder';
export class RelationsDmService { export class RelationsDmService {
constructor(private halRequest:HalRequestService, constructor(private halRequest:HalRequestService,
private v3Path:any,
private $q:ng.IQService) { private $q:ng.IQService) {
} }
public load(workPackageId:string):ng.IPromise<RelationResource[]> {
return this.halRequest.get(
this.v3Path.wp.relations({wp: workPackageId}),
{},
{
caching: {enabled: false}
}).then((collection:CollectionResource) => {
return collection.elements;
});
}
public loadInvolved(workPackageIds:string[]):ng.IPromise<RelationResource[]> { public loadInvolved(workPackageIds:string[]):ng.IPromise<RelationResource[]> {
let validIds = _.filter(workPackageIds, id => /\d+/.test(id)); let validIds = _.filter(workPackageIds, id => /\d+/.test(id));

@ -0,0 +1,132 @@
// -- 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 {SchemaCacheService} from './../schemas/schema-cache.service';
import {InputState, MultiInputState, State} from 'reactivestates';
import {Observable, Subject} from 'rxjs';
import {opWorkPackagesModule} from '../../angular-modules';
import {
WorkPackageResourceInterface
} from '../api/api-v3/hal-resources/work-package-resource.service';
import {ApiWorkPackagesService} from '../api/api-work-packages/api-work-packages.service';
import {States} from '../states.service';
import {WorkPackageNotificationService} from './../wp-edit/wp-notification.service';
import IScope = angular.IScope;
import IPromise = angular.IPromise;
import {WorkPackageCollectionResourceInterface} from '../api/api-v3/hal-resources/wp-collection-resource.service';
import {SchemaResource} from '../api/api-v3/hal-resources/schema-resource.service';
export abstract class StateCacheService<T> {
private cacheDurationInMs:number;
constructor(private holdValuesForSeconds:number = 120) {
this.cacheDurationInMs = holdValuesForSeconds * 1000;
}
public state(id:string):State<T> {
return this.multiState.get(id);
}
/**
* Update the value due to application changes.
*
* @param id The value's identifier.
* @param val<T> The value.
*/
public updateValue(id:string, val:T) {
this.multiState.get(id).putValue(val);
}
/**
* Require the value to be loaded either when forced or the value is stale
* according to the cache interval specified for this service.
*
* @param id The value's identifier.
* @param force Load the value anyway.
*/
public require(id:string, force:boolean = false):Promise<T> {
const state = this.multiState.get(id);
// Refresh when stale or being forced
if (this.stale(state) || force) {
state.clear();
return this.load(id);
}
return Promise.resolve(state.value);
}
/**
* Require the states of the given ids to be loaded if they're empty or stale,
* or all when force is given.
* @param ids Ids to require
* @param force Load the values anyway
* @return {Promise<undefined>} An empty promise to mark when the set of states is filled.
*/
public requireAll(ids:string[], force:boolean = false):Promise<undefined> {
let idsToRequest:string[];
if (force) {
idsToRequest = ids;
} else {
idsToRequest = ids.filter((id:string) => this.stale(this.multiState.get(id)));
}
if (idsToRequest.length === 0) {
return Promise.resolve();
}
return this.loadAll(idsToRequest);
}
/**
* Returns whether the state
* @param state
* @return {boolean}
*/
protected stale(state:InputState<T>):boolean {
return state.isPristine() || state.isValueOlderThan(this.cacheDurationInMs);
}
/**
* Returns the internal state object
*/
protected abstract get multiState():MultiInputState<T>;
/**
* Load a single value into the cache state.
* Subclassses need to ensure it gets loaded and resolve or reject the promise
* @param id The identifier of the value object of type T.
*/
protected abstract load(id:string):Promise<T>;
/**
* Load a set of required values, fill the results into the appropriate states
* and return a promise when all values are inserted.
*/
protected abstract loadAll(ids:string[]):Promise<undefined>;
}

@ -39,12 +39,13 @@ import IScope = angular.IScope;
import IPromise = angular.IPromise; import IPromise = angular.IPromise;
import {WorkPackageCollectionResourceInterface} from '../api/api-v3/hal-resources/wp-collection-resource.service'; import {WorkPackageCollectionResourceInterface} from '../api/api-v3/hal-resources/wp-collection-resource.service';
import {SchemaResource} from '../api/api-v3/hal-resources/schema-resource.service'; import {SchemaResource} from '../api/api-v3/hal-resources/schema-resource.service';
import {StateCacheService} from '../states/state-cache.service';
function getWorkPackageId(id:number | string):string { function getWorkPackageId(id:number | string):string {
return (id || "__new_work_package__").toString(); return (id || "__new_work_package__").toString();
} }
export class WorkPackageCacheService { export class WorkPackageCacheService extends StateCacheService<WorkPackageResourceInterface> {
private newWorkPackageCreatedSubject = new Subject<WorkPackageResourceInterface>(); private newWorkPackageCreatedSubject = new Subject<WorkPackageResourceInterface>();
@ -54,6 +55,7 @@ export class WorkPackageCacheService {
private wpNotificationsService:WorkPackageNotificationService, private wpNotificationsService:WorkPackageNotificationService,
private schemaCacheService:SchemaCacheService, private schemaCacheService:SchemaCacheService,
private apiWorkPackages:ApiWorkPackagesService) { private apiWorkPackages:ApiWorkPackagesService) {
super();
} }
newWorkPackageCreated(wp:WorkPackageResourceInterface) { newWorkPackageCreated(wp:WorkPackageResourceInterface) {
@ -61,7 +63,7 @@ export class WorkPackageCacheService {
} }
updateWorkPackage(wp:WorkPackageResourceInterface) { updateWorkPackage(wp:WorkPackageResourceInterface) {
this.updateWorkPackageList([wp]); this.updateValue(wp.id, wp);
} }
updateWorkPackageList(list:WorkPackageResourceInterface[]) { updateWorkPackageList(list:WorkPackageResourceInterface[]) {
@ -101,52 +103,12 @@ export class WorkPackageCacheService {
} }
/** /**
* Load an array of work package ids into states, unless they already exist. * Wrapper around `require(id)`.
* *
* @param workPackageIds * @deprecated
*/ */
loadWorkPackages(workPackageIds:string[]):Promise<void> {
const needToLoad:string[] = [];
workPackageIds.forEach((id:string) => {
if (this.states.workPackages.get(id).isPristine()) {
needToLoad.push(id);
}
});
if (needToLoad.length === 0) {
return this.$q.resolve();
}
this.apiWorkPackages
.loadWorkPackagesCollectionsFor(_.uniq(workPackageIds))
.then((pagedResults:WorkPackageCollectionResourceInterface[]) => {
_.each(pagedResults, (results) => {
if (results.schemas) {
_.each(results.schemas.elements, (schema:SchemaResource) => {
this.states.schemas.get(schema.href as string).putValue(schema);
});
}
if (results.elements) {
this.updateWorkPackageList(results.elements);
}
});
});
// Wait until all desired IDs have a value in their respective state
return Observable
.forkJoin(needToLoad.map(id => this.states.workPackages.get(id).valuesPromise()))
.mapTo(undefined)
.toPromise();
}
loadWorkPackage(workPackageId:string, forceUpdate = false):State<WorkPackageResourceInterface> { loadWorkPackage(workPackageId:string, forceUpdate = false):State<WorkPackageResourceInterface> {
const state = this.states.workPackages.get(getWorkPackageId(workPackageId)); const state = this.state(workPackageId);
if (forceUpdate) {
state.clear();
}
// Several services involved in the creation of work packages // Several services involved in the creation of work packages
// use this method to resolve the latest created work package, // use this method to resolve the latest created work package,
@ -155,24 +117,50 @@ export class WorkPackageCacheService {
return state; return state;
} }
state.putFromPromiseIfPristine(() => { this.require(workPackageId, forceUpdate);
const deferred = this.$q.defer(); return state;
}
this.apiWorkPackages.loadWorkPackageById(workPackageId, forceUpdate) onNewWorkPackage():Observable<WorkPackageResourceInterface> {
.then((workPackage:WorkPackageResourceInterface) => { return this.newWorkPackageCreatedSubject.asObservable();
this.schemaCacheService.ensureLoaded(workPackage).then(() => { }
deferred.resolve(workPackage);
});
});
return deferred.promise; protected loadAll(ids:string[]) {
return new Promise<undefined>((resolve, reject) => {
this.apiWorkPackages
.loadWorkPackagesCollectionsFor(_.uniq(ids))
.then((pagedResults:WorkPackageCollectionResourceInterface[]) => {
_.each(pagedResults, (results) => {
if (results.schemas) {
_.each(results.schemas.elements, (schema:SchemaResource) => {
this.states.schemas.get(schema.href as string).putValue(schema);
});
}
if (results.elements) {
this.updateWorkPackageList(results.elements);
}
resolve(undefined);
});
}, reject);
}); });
}
return state; protected load(id:string) {
return new Promise<WorkPackageResourceInterface>((resolve, reject) => {
this.apiWorkPackages.loadWorkPackageById(id, true)
.then((workPackage:WorkPackageResourceInterface) => {
this.schemaCacheService.ensureLoaded(workPackage).then(() => {
this.updateValue(id, workPackage);
resolve(workPackage);
}, reject);
}, reject);
});
} }
onNewWorkPackage():Observable<WorkPackageResourceInterface> { protected get multiState() {
return this.newWorkPackageCreatedSubject.asObservable(); return this.states.workPackages;
} }
} }

@ -29,7 +29,7 @@ export class RelationCellbuilder {
// Get current expansion and value state // Get current expansion and value state
const expanded = this.wpTableRelationColumns.getExpandFor(workPackage.id) === column.id; const expanded = this.wpTableRelationColumns.getExpandFor(workPackage.id) === column.id;
const relationState = this.wpRelations.getRelationsForWorkPackage(workPackage.id).value; const relationState = this.wpRelations.state(workPackage.id).value;
const relations = this.wpTableRelationColumns.relationsForColumn(workPackage, const relations = this.wpTableRelationColumns.relationsForColumn(workPackage,
relationState, relationState,
column); column);

@ -51,7 +51,7 @@ export class RelationsRenderPass {
// If the work package has no relations, ignore // If the work package has no relations, ignore
const workPackage = row.workPackage; const workPackage = row.workPackage;
const fromId = workPackage.id; const fromId = workPackage.id;
const state = this.wpRelations.getRelationsForWorkPackage(fromId); const state = this.wpRelations.state(fromId);
if (!state.hasValue() || _.size(state.value!) === 0) { if (!state.hasValue() || _.size(state.value!) === 0) {
return; return;
} }

@ -68,7 +68,7 @@ export class WorkPackageTableAdditionalElementsService {
} }
private loadAdditional(wpIds:string[]) { private loadAdditional(wpIds:string[]) {
this.wpCacheService.loadWorkPackages(wpIds) this.wpCacheService.requireAll(wpIds)
.then(() => { .then(() => {
this.states.table.additionalRequiredWorkPackages.putValue(null, 'All required work packages are loaded'); this.states.table.additionalRequiredWorkPackages.putValue(null, 'All required work packages are loaded');
}); });
@ -84,10 +84,10 @@ export class WorkPackageTableAdditionalElementsService {
return Promise.resolve([]); return Promise.resolve([]);
} }
return this.wpRelations return this.wpRelations
.load(rows) .requireAll(rows, true)
.then(() => { .then(() => {
const ids = this.getInvolvedWorkPackages(rows.map(id => { const ids = this.getInvolvedWorkPackages(rows.map(id => {
return this.wpRelations.getRelationsForWorkPackage(id).value!; return this.wpRelations.state(id).value!;
})); }));
return _.flatten(ids); return _.flatten(ids);
}); });

@ -52,7 +52,7 @@ export class WorkPackageRelationsController {
protected wpCacheService:WorkPackageCacheService) { protected wpCacheService:WorkPackageCacheService) {
scopedObservable(this.$scope, scopedObservable(this.$scope,
this.wpRelations.getRelationsForWorkPackage(this.workPackage.id).values$()) this.wpRelations.state(this.workPackage.id).values$())
.subscribe((relations:RelationsStateValue) => { .subscribe((relations:RelationsStateValue) => {
this.loadedRelations(relations); this.loadedRelations(relations);
}); });
@ -62,7 +62,7 @@ export class WorkPackageRelationsController {
this.wpCacheService.loadWorkPackage(this.workPackage.id).values$()) this.wpCacheService.loadWorkPackage(this.workPackage.id).values$())
.subscribe((wp:WorkPackageResourceInterface) => { .subscribe((wp:WorkPackageResourceInterface) => {
this.workPackage = wp; this.workPackage = wp;
this.wpRelations.require(wp); this.wpRelations.require(wp.id);
}); });
} }

@ -9,14 +9,24 @@ import {RelationsDmService} from '../api/api-v3/hal-resource-dms/relations-dm.se
import {WorkPackageTableRefreshService} from '../wp-table/wp-table-refresh-request.service'; import {WorkPackageTableRefreshService} from '../wp-table/wp-table-refresh-request.service';
import {opServicesModule} from '../../angular-modules'; import {opServicesModule} from '../../angular-modules';
import {Observable} from 'rxjs'; import {Observable} from 'rxjs';
import {StateCacheService} from '../states/state-cache.service';
export type RelationsStateValue = { [relationId:number]:RelationResource }; export type RelationsStateValue = { [relationId:number]:RelationResource };
export class WorkPackageRelationsService extends StatesGroup { class RelationStateGroup extends StatesGroup {
name = 'WP-Relations'; name = 'WP-Relations';
private relations = multiInput<RelationsStateValue>(); relations = multiInput<RelationsStateValue>();
constructor() {
super();
this.initializeMembers();
}
}
export class WorkPackageRelationsService extends StateCacheService<RelationsStateValue> {
private relationStates:RelationStateGroup;
/*@ngInject*/ /*@ngInject*/
constructor(private relationsDm:RelationsDmService, constructor(private relationsDm:RelationsDmService,
@ -24,79 +34,50 @@ export class WorkPackageRelationsService extends StatesGroup {
private $q:ng.IQService, private $q:ng.IQService,
private PathHelper:any) { private PathHelper:any) {
super(); super();
this.initializeMembers(); this.relationStates = new RelationStateGroup();
} }
getRelationsForWorkPackage(workPackageId:string):State<RelationsStateValue> { protected get multiState() {
return this.relations.get(workPackageId); return this.relationStates.relations;
}
/**
* Require the relations of the given singular work package to be loaded into its state.
*/
require(workPackage:WorkPackageResourceInterface, force:boolean = false) {
const state = this.relations.get(workPackage.id);
if (force) {
state.clear();
}
if (state.isPristine()) {
workPackage.relations.$load(true).then((collection:CollectionResource) => {
if (collection.elements.length > 0) {
this.mergeIntoStates(collection.elements as RelationResource[]);
} else {
this.relations.get(workPackage.id).putValue({},
"Received empty response from singular relations");
}
});
}
} }
/** /**
* Load a set of work package ids into the states, regardless of them being loaded * Load a set of work package ids into the states, regardless of them being loaded
* @param workPackageIds * @param workPackageIds
*/ */
load(workPackageIds:string[]) { protected load(id:string) {
return new Promise((resolve, reject) => {
this.relationsDm
.load(id)
.then(elements => {
this.mergeIntoStates(elements);
resolve();
})
.catch((error) => reject(error));
});
}
protected loadAll(ids:string[]) {
const deferred = this.$q.defer<undefined>(); const deferred = this.$q.defer<undefined>();
this.relationsDm this.relationsDm
.loadInvolved(workPackageIds) .loadInvolved(ids)
.then((elements:RelationResource[]) => { .then((elements:RelationResource[]) => {
this.mergeIntoStates(elements); this.mergeIntoStates(elements);
this.initializeEmpty(workPackageIds); this.initializeEmpty(ids);
deferred.resolve(); deferred.resolve();
}); });
return deferred.promise; return deferred.promise;
} }
/**
* Require the relations of a set of involved work packages loaded into the states.
*/
requireLoaded(workPackageIds:string[]):ng.IPromise<undefined> {
const needToLoad:string[] = [];
workPackageIds.forEach((id:string) => {
if (this.relations.get(id).isPristine()) {
needToLoad.push(id);
}
});
if (needToLoad.length === 0) {
return this.$q.resolve();
}
return this.load(needToLoad);
}
/** /**
* Remove the given relation. * Remove the given relation.
*/ */
public removeRelation(relation:RelationResourceInterface) { public removeRelation(relation:RelationResourceInterface) {
return relation.delete().then(() => { return relation.delete().then(() => {
_.each(relation.ids, (member:string) => { _.each(relation.ids, (member:string) => {
const state = this.relations.get(member); const state = this.multiState.get(member);
const currentValue = state.value!; const currentValue = state.value!;
if (currentValue !== null) { if (currentValue !== null) {
@ -155,7 +136,7 @@ export class WorkPackageRelationsService extends StatesGroup {
* Merge an object of relations into the associated state or create it, if empty. * Merge an object of relations into the associated state or create it, if empty.
*/ */
private merge(workPackageId:string, newRelations:RelationResource[]) { private merge(workPackageId:string, newRelations:RelationResource[]) {
const state = this.relations.get(workPackageId); const state = this.multiState.get(workPackageId);
let relationsToInsert = _.keyBy(newRelations, r => r.id); let relationsToInsert = _.keyBy(newRelations, r => r.id);
state.putValue(relationsToInsert, "Initializing relations state."); state.putValue(relationsToInsert, "Initializing relations state.");
} }
@ -187,7 +168,7 @@ export class WorkPackageRelationsService extends StatesGroup {
private initializeEmpty(ids:string[]) { private initializeEmpty(ids:string[]) {
ids.forEach(id => { ids.forEach(id => {
const state = this.relations.get(id); const state = this.multiState.get(id);
if (state.isPristine()) { if (state.isPristine()) {
state.putValue({}); state.putValue({});
} }

@ -115,10 +115,10 @@ export class WorkPackageTableTimelineRelations {
.subscribe(list => { .subscribe(list => {
// ... make sure that the corresponding relations are loaded ... // ... make sure that the corresponding relations are loaded ...
const wps = _.compact(list.map(row => row.workPackageId) as string[]); const wps = _.compact(list.map(row => row.workPackageId) as string[]);
this.wpRelations.requireLoaded(wps); this.wpRelations.requireAll(wps);
wps.forEach(wpId => { wps.forEach(wpId => {
const relationsForWorkPackage = this.wpRelations.getRelationsForWorkPackage(wpId); const relationsForWorkPackage = this.wpRelations.state(wpId);
this.workPackagesWithRelations[wpId] = relationsForWorkPackage; this.workPackagesWithRelations[wpId] = relationsForWorkPackage;
// ... once they are loaded, display them. // ... once they are loaded, display them.

Loading…
Cancel
Save