frontend/src/app/features/work-packages/components/wp-new/wp-create.service.ts
// -- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2024 the OpenProject GmbH
//
// 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 COPYRIGHT and LICENSE files for more details.
//++
import {
Injectable,
Injector,
} from '@angular/core';
import {
firstValueFrom,
Observable,
Subject,
} from 'rxjs';
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { HookService } from 'core-app/features/plugins/hook-service';
import { WorkPackageFilterValues } from 'core-app/features/work-packages/components/wp-edit-form/work-package-filter-values';
import {
HalResourceEditingService,
ResourceChangesetCommit,
} from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service';
import { WorkPackageChangeset } from 'core-app/features/work-packages/components/wp-edit/work-package-changeset';
import { filter } from 'rxjs/operators';
import { IsolatedQuerySpace } from 'core-app/features/work-packages/directives/query-space/isolated-query-space';
import { FormResource } from 'core-app/features/hal/resources/form-resource';
import { HalEventsService } from 'core-app/features/hal/services/hal-events.service';
import { AuthorisationService } from 'core-app/core/model-auth/model-auth.service';
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import {
HalResource,
HalSource,
HalSourceLink,
} from 'core-app/features/hal/resources/hal-resource';
import idFromLink from 'core-app/features/hal/helpers/id-from-link';
import { SchemaResource } from 'core-app/features/hal/resources/schema-resource';
import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service';
import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service';
import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset';
import { AttachmentsResourceService } from 'core-app/core/state/attachments/attachments.service';
export const newWorkPackageHref = '/api/v3/work_packages/new';
@Injectable()
export class WorkPackageCreateService extends UntilDestroyedMixin {
protected form:Promise<FormResource>|undefined;
// Allow callbacks to happen on newly created work packages
protected newWorkPackageCreatedSubject = new Subject<WorkPackageResource>();
constructor(
protected injector:Injector,
protected hooks:HookService,
protected apiV3Service:ApiV3Service,
protected halResourceService:HalResourceService,
protected querySpace:IsolatedQuerySpace,
protected authorisationService:AuthorisationService,
protected halEditing:HalResourceEditingService,
protected schemaCache:SchemaCacheService,
protected halEvents:HalEventsService,
protected attachmentsService:AttachmentsResourceService,
) {
super();
this.halEditing
.committedChanges
.pipe(
this.untilDestroyed(),
filter((commit) => commit.resource._type === 'WorkPackage' && commit.wasNew),
)
.subscribe((commit:ResourceChangesetCommit<WorkPackageResource>) => {
this.newWorkPackageCreated(commit.resource);
});
this.halEditing
.changes$(newWorkPackageHref)
.pipe(
this.untilDestroyed(),
filter((changeset) => !changeset),
)
.subscribe(() => {
this.reset();
});
}
protected newWorkPackageCreated(wp:WorkPackageResource):void {
this.reset();
this.newWorkPackageCreatedSubject.next(wp);
}
public onNewWorkPackage():Observable<WorkPackageResource> {
return this.newWorkPackageCreatedSubject.asObservable();
}
public createNewWorkPackage(projectIdentifier:string|undefined|null, payload:HalSource):Promise<WorkPackageChangeset> {
return this
.apiV3Service
.withOptionalProject(projectIdentifier)
.work_packages
.form
.forPayload(payload)
.toPromise()
.then((form:FormResource) => this.fromCreateForm(form));
}
public fromCreateForm(form:FormResource):WorkPackageChangeset {
const wp = this.initializeNewResource(form);
const change = this.halEditing.edit<WorkPackageResource, WorkPackageChangeset>(wp, form);
// Call work package initialization hook
this.hooks.call('workPackageNewInitialization', change);
return change;
}
public copyWorkPackage(copyFrom:WorkPackageChangeset):Promise<WorkPackageChangeset> {
const request = copyFrom.pristineResource.$source;
// Ideally we would make an empty request before to get the create schema (cannot use the update schema of the source changeset)
// to get all the writable attributes and only send those.
// But as this would require an additional request, we don't.
return this
.apiV3Service
.work_packages
.form
.post(request)
.toPromise()
.then((form:FormResource) => {
const changeset = this.fromCreateForm(form);
return changeset;
});
}
/**
* Create a copy resource from other and the new work package form
* @param form Work Package create form
*/
private copyFrom(form:FormResource) {
const wp = this.initializeNewResource(form);
return this.halEditing.edit(wp, form);
}
public getEmptyForm(projectIdentifier:string|null|undefined):Promise<FormResource> {
if (!this.form) {
this.form = firstValueFrom(
this
.apiV3Service
.withOptionalProject(projectIdentifier)
.work_packages
.form
.post({}),
);
}
return this.form;
}
public cancelCreation():void {
this.halEditing.stopEditing({ href: newWorkPackageHref });
this.reset();
}
public changesetUpdates$():Observable<ResourceChangeset> {
return this
.halEditing
.state(newWorkPackageHref)
.values$();
}
public createOrContinueWorkPackage(projectIdentifier:string|null|undefined, type?:number, defaults?:HalSource):Promise<WorkPackageChangeset> {
let changePromise = this.continueExistingEdit(type);
if (!changePromise) {
changePromise = this.createNewWithDefaults(projectIdentifier, defaults);
}
return changePromise.then((change:WorkPackageChangeset) => {
this.authorisationService.initModelAuth('work_package', change.pristineResource);
this.halEditing.updateValue(newWorkPackageHref, change);
this
.apiV3Service
.work_packages
.cache
.updateWorkPackage(change.pristineResource, true);
return change;
});
}
protected reset():void {
this
.apiV3Service
.work_packages
.cache
.clearSome('new');
this
.attachmentsService
.clear('new');
this.form = undefined;
}
protected continueExistingEdit(type?:number):Promise<WorkPackageChangeset>|null {
const change = this.halEditing.state(newWorkPackageHref).value as WorkPackageChangeset;
if (change !== undefined) {
const changeType = change.projectedResource.type;
const hasChanges = !change.isEmpty();
const typeEmpty = !changeType && !type;
const typeMatches = type && changeType && idFromLink(changeType.href) === type.toString();
if (hasChanges && (typeEmpty || typeMatches)) {
return Promise.resolve(change);
}
}
return null;
}
/**
* Initializes a new work package. The work package is not yet persisted.
* The properties of the work package are initialized from two sources:
* * The default values provided
* * The filter values that might exist in the query space
*
* The first can be employed to e.g. provide the type or the parent of the work package.
* The later can be employed to create a work package that adheres to the filter values.
*
* @param projectIdentifier The project the work package is to be created in.
* @param defaults Values the new work package should possess on creation.
*/
protected createNewWithDefaults(projectIdentifier:string|null|undefined, defaults?:HalSource):Promise<WorkPackageChangeset> {
return this
.withFiltersPayload(projectIdentifier, defaults)
.then((filterDefaults) => {
const mergedPayload = _.merge({ _links: {} }, filterDefaults, defaults);
return this.createNewWorkPackage(projectIdentifier, mergedPayload).then((change:WorkPackageChangeset) => {
if (!change) {
throw new Error('No new work package was created');
}
// We need to apply the defaults again (after them being applied in the form requests)
// here as the initial form requests might have led to some default
// values not being carried over. This can happen when custom fields not available in one type are filter values.
// The defaults should be applied to the customFields only, hence we ignore the other filters.
const ignoreFiltersFn = (id:string):boolean => /customField\d+/.exec(id) === null;
this.defaultsFromFilters(change, defaults, ignoreFiltersFn);
return change;
});
});
}
/**
* Fetches all values of filters applicable to work as default values (e.g. assignee = 123).
* If defaults already contain the type, that filter is ignored.
*
* The ignoring functionality could be generalized.
*
* @param object
* @param defaults
*/
private defaultsFromFilters(
object:HalSource|WorkPackageChangeset,
defaults?:HalSource,
ignoreFiltersFn?:(id:string) => boolean,
):void {
// Not using WorkPackageViewFiltersService here as the embedded table does not load the form
// which will result in that service having empty current filters.
const query = this.querySpace.query.value;
if (query) {
let except = defaults?._links ? Object.keys(defaults._links) : [];
if (ignoreFiltersFn !== undefined) {
except = except.concat(query.filters.map((f) => f.id).filter(ignoreFiltersFn));
}
new WorkPackageFilterValues(this.injector, query.filters, except)
.applyDefaultsFromFilters(object);
}
}
/**
* Returns valid payload based on the filters active in the query space validated by the backend via a form
* request. In case no filters are active, the (empty) filters payload is just passed through.
*
* If there are filters applied, we need the additional form request to turn the defaults of the filters into
* a valid payload in the sense that all properties are at their correct place and are in the right format. That means
* HalResources are in the _links section and follow the { href: some_link } format while simple properties stay on the
* top level.
*/
private withFiltersPayload(projectIdentifier:string|null|undefined, defaults?:HalSource):Promise<HalSource> {
const fromFilter = { _links: {} };
this.defaultsFromFilters(fromFilter, defaults);
const filtersApplied = Object.keys(fromFilter).length > 1 || Object.keys(fromFilter._links).length > 0;
if (filtersApplied) {
return this
.apiV3Service
.withOptionalProject(projectIdentifier)
.work_packages
.form
.forTypePayload(defaults || { _links: {} })
.toPromise()
.then((form:FormResource) => {
this.toApiPayload(fromFilter, form.schema);
return fromFilter;
});
}
return Promise.resolve(fromFilter);
}
private toApiPayload(payload:HalSource, schema:SchemaResource) {
const links:string[] = [];
Object.keys(schema.$source).forEach((attribute) => {
if (!['Integer',
'Float',
'Date',
'DateTime',
'Duration',
'Formattable',
'Boolean',
'String',
'Text',
undefined].includes(schema.$source[attribute].type)) {
links.push(attribute);
}
});
links.forEach((attribute) => {
const value = payload[attribute];
if (value === undefined) {
// nothing
} else if (value instanceof HalResource) {
payload._links[attribute] = { href: value.$links.self.href };
} else if (!value) {
payload._links[attribute] = { href: null };
} else {
payload._links[attribute] = value as unknown as HalSourceLink;
}
delete payload[attribute];
});
}
/**
* Assign values from the form for a newly created work package resource.
* @param form
*/
private initializeNewResource(form:FormResource) {
const payload = form.payload.$plain() as object&{ _links:{ schema:{ href:string } } };
// maintain the reference to the schema
payload._links.schema = { href: 'new' };
const wp = this.halResourceService.createHalResourceOfType<WorkPackageResource>('WorkPackage', payload);
wp.$source.id = 'new';
// Ensure type is set to identify the resource
wp._type = 'WorkPackage';
// Since the ID will change upon saving, keep track of the WP
// with the actual creation date
wp.__initialized_at = Date.now();
// Set update link to form
wp.update = wp.$links.update = form.$links.self;
// Use POST /work_packages for saving link
wp.updateImmediately = (data:object) => firstValueFrom(this.apiV3Service.work_packages.post(data));
wp.$links.updateImmediately = (data:object) => firstValueFrom(this.apiV3Service.work_packages.post(data));
// We need to provide the schema to the cache so that it is available in the html form to e.g. determine
// the editability.
// It would be better if the edit field could simply rely on the changeset if it exists.
this.schemaCache.update(wp, form.schema);
return wp;
}
}