frontend/src/app/shared/components/fields/changeset/resource-changeset.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 {
input,
InputState,
} from '@openproject/reactivestates';
import { take } from 'rxjs/operators';
import { SchemaResource } from 'core-app/features/hal/resources/schema-resource';
import { FormResource } from 'core-app/features/hal/resources/form-resource';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import {
ChangeItem,
ChangeMap,
Changeset,
} from 'core-app/shared/components/fields/changeset/changeset';
import { IFieldSchema } from 'core-app/shared/components/fields/field.base';
import { debugLog } from 'core-app/shared/helpers/debug_output';
import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service';
import { SchemaProxy } from 'core-app/features/hal/schemas/schema-proxy';
import { IHalOptionalTitledLink } from 'core-app/core/state/hal-resource';
import isNewResource from 'core-app/features/hal/helpers/is-new-resource';
import { firstValueFrom } from 'rxjs';
export const PROXY_IDENTIFIER = '__is_changeset_proxy';
/**
* Temporary class living while a resource is being edited
* Maintains references to:
* - The source resource (a pristine base)
* - The open set of changes (a changeset object)
* - The current form (due to temporary type/project changes)
*
* Provides access to:
* - The projected resource with all changes applied as properties
*/
export class ResourceChangeset<T extends HalResource = HalResource> {
/** Maintain a single change set while editing */
protected changeset = new Changeset();
/** Reference and load promise for the current form */
protected form$ = input<FormResource>();
/** Request cache for objects within the changeset for the current form */
protected cache:{ [key:string]:Promise<unknown> } = {};
/** Flag whether this is currently being saved */
public inFlight = false;
/** Keep a reference to the original resource */
protected _pristineResource:T;
/** The projected resource, which will proxy values from the changeset */
public projectedResource:T;
/** The cache to all the schemas. Used to maintain the schema of the projectedResource which does not stem from a form.
* The schema of the form is kept inside the changeset.
* */
protected schemaCache:SchemaCacheService;
constructor(
pristineResource:T,
public readonly state?:InputState<ResourceChangeset<T>>,
loadedForm:FormResource|null = null,
) {
this.updatePristineResource(pristineResource);
this.schemaCache = (pristineResource.injector).get(SchemaCacheService);
if (loadedForm) {
this.form$.putValue(loadedForm);
}
}
/**
* Push the change to the editing state to notify others.
* This will happen internally on resource wide changes
*/
public push() {
if (this.state) {
this.state.putValue(this);
}
}
/**
* Build the request attributes against the fresh form
*/
public buildRequestPayload():Promise<Object> {
return this
.getForm()
.then(() => this.buildPayloadFromChanges());
}
/**
* Update the pristine resource in case it changed
*
* @param attribute
*/
public updatePristineResource(resource:T) {
// Ensure we're not passing in a proxy
if ((resource as any)[PROXY_IDENTIFIER]) {
throw new Error("You're trying to pass proxy object as a pristine resource. This will cause errors");
}
this._pristineResource = resource;
this.projectedResource = new Proxy(
this._pristineResource,
{
get: (_, key:string) => this.proxyGet(key),
set: (_, key:string, val:any) => {
this.setValue(key, val);
return true;
},
},
);
}
public get pristineResource():T {
return this._pristineResource;
}
/**
* Returns the cached form or loads it if necessary.
*/
public getForm(reload = false):Promise<FormResource> {
if ((this.form$.isPristine() || reload) && !this.form$.hasActivePromiseRequest()) {
return this.updateForm();
}
return firstValueFrom(this.form$.values$());
}
/**
* Cache some promised value in the course of this changeset.
* Will get cleared automatically by the changeset on destroy/submission
*/
/**
* Posts to the form with the current changes
* to get the up to date projected object.
*/
protected updateForm():Promise<FormResource> {
const payload = this.buildPayloadFromChanges();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (!this.pristineResource.$links.update) {
return Promise.reject();
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const promise = this.pristineResource
.$links
.update(payload)
.then((form:FormResource) => {
this.cache = {};
this.form$.putValue(form);
this.setNewDefaults(form);
this.push();
return form;
}) as Promise<FormResource>;
this.form$.putFromPromiseIfPristine(() => promise);
return promise;
}
/**
* Return whether no changes were made to the work package
*/
public isEmpty() {
return this.changeset.changed.length === 0;
}
/**
* Return the ID of the resource we're editing
*/
public get id():string {
return this.pristineResource.id!.toString();
}
/**
* Return the HAL href of the resource we're editing
*/
public get href():string {
return this.pristineResource.href as string;
}
/**
* Returns the changed `to` values of the ChangeMap
*/
public get changes():{ [key:string]:unknown } {
const changes:{ [key:string]:unknown } = {};
_.each(this.changeset.all, (item, key) => {
changes[key] = item.to;
});
return changes;
}
/**
* Returns the change map with from and to values
*/
public get changeMap():ChangeMap {
return { ...this.changeset.all };
}
/**
* Return the changed attributes in this change;
*/
public get changedAttributes():string[] {
return this.changeset.changed;
}
/**
* Return whether the element is writable
* given the current best schema.
*
* @param key
*/
public isWritable(key:string):boolean {
const fieldSchema = this.schema.ofProperty(key) as IFieldSchema|null;
return !!(fieldSchema && fieldSchema.writable);
}
/**
* Return the best humanized name for this attribute
* @param attribute
*/
public humanName(attribute:string):string {
return _.get(this.schema, `${attribute}.name`, attribute);
}
/**
* Returns whether the given attribute was changed
*/
public contains(key:string) {
return this.changeset.contains(key);
}
/**
* Proxy getters to base or changeset.
* @param key
*/
private proxyGet(key:string) {
if (key === '__is_proxy') {
return true;
}
return this.value(key);
}
/**
* Retrieve the editing value for the given attribute
*
* @param {string} key The attribute to read
* @return {any} Either the value from the overridden change, or the default value
*/
public value<R>(key:string):R {
// Overridden value by user?
if (this.changeset.contains(key)) {
return this.changeset.getValue(key) as R;
}
// Return whatever is on the base.
// TODO this needs to be typed
return this.pristineResource[key] as R;
}
/**
* Return whether the given value exists,
* even if its undefined.
*
* @param key
*/
public valueExists(key:string):boolean {
return this.changeset.contains(key) || this.pristineResource.hasOwnProperty(key);
}
/**
* Change the value of the projected resource to some value
*
* @param key
* @param val
*/
public setValue(key:string, val:any) {
this.changeset.set(key, val, this.pristineResource[key]);
}
/**
* Clear the changed value of the projected resource
*
* @param keys A set of keys to reset
*/
public clearValue(...keys:string[]) {
this.changeset.reset(...keys);
}
public clear() {
this.state && this.state.clear();
this.changeset.clear();
this.cache = {};
this.form$.clear();
}
/**
* Reset the given changed attribute
* @param key
*/
public reset(key:string) {
this.changeset.reset(key);
}
/**
* Return whether a change value exist for the given attribute key.
* @param {string} key
* @return {boolean}
*/
public isOverridden(key:string) {
return this.changes.hasOwnProperty(key);
}
/**
* Get the best schema currently available, either the default resource schema (must exist).
* If loaded, return the form schema, which provides better information on writable status
* and contains available values.
*/
public get schema():SchemaResource {
if (this.form$.hasValue()) {
return SchemaProxy.create(this.form$.value!.schema, this.projectedResource);
}
return this.schemaCache.of(this.pristineResource);
}
/**
* Access some promised value
* that should be cached for the lifetime duration of the form.
*/
public cacheValue<V>(key:string, request:() => Promise<V>):Promise<V> {
if (this.cache[key] !== undefined) {
return this.cache[key] as Promise<V>;
}
return this.cache[key] = request();
}
protected get minimalPayload() {
return { lockVersion: this.pristineResource.lockVersion, _links: {} };
}
/**
* Merge the current changes into the payload resource.
*
* @param {plainPayload:unknown} A set of attributes to merge into the payload
* @return {any}
*/
protected applyChanges(plainPayload:any) {
// Fall back to the last known state of the HalResource should the form not be loaded.
let reference = this.pristineResource.$source;
if (this.form$.value) {
reference = this.form$.value.payload.$source;
}
_.each(this.changeset.all, (val:ChangeItem, key:string) => {
if (!this.schema.isAttributeEditable(key)) {
debugLog(`Trying to write ${key} but is not writable in schema`);
return;
}
const fieldSchema:IFieldSchema|null = this.schema.ofProperty(key);
// Override in _links if it is a linked property
if (fieldSchema && reference._links[key]) {
plainPayload._links[key] = this.getLinkedValue(val.to, fieldSchema);
} else {
plainPayload[key] = val.to;
}
});
return plainPayload;
}
/**
* Create the payload from the current changes, and extend it with the current lock version.
* -- This is the place to add additional logic when the lockVersion changed in between --
*/
protected buildPayloadFromChanges() {
let payload:unknown&{ _links:{ attachments?:IHalOptionalTitledLink[], fileLinks?:IHalOptionalTitledLink[] } };
if (isNewResource(this.pristineResource)) {
// If the resource is new, we need to pass the entire form payload
// to let all default values be transmitted (type, status, etc.)
// We clone the object to avoid later manipulations to affect the original resource.
if (this.form$.value) {
payload = _.cloneDeep(this.form$.value.payload.$source);
} else {
payload = _.cloneDeep(this.pristineResource.$source);
}
// Add attachments to be assigned.
// They will already be created on the server, but now
// we need to claim them for the newly created work package.
if (this.pristineResource.attachments) {
payload._links.attachments = (this.pristineResource.attachments as unknown&{ elements:IHalOptionalTitledLink[] })
.elements
.map((a) => ({ href: a.href }));
}
// Add file links to be assigned.
if (this.pristineResource.fileLinks) {
payload._links.fileLinks = (this.pristineResource.fileLinks as unknown&{ elements:IHalOptionalTitledLink[] })
.elements
.map((fl) => ({ href: fl.href }));
}
} else {
// Otherwise, simply use the bare minimum
payload = this.minimalPayload;
}
return this.applyChanges(payload);
}
/**
* Extract the link(s) in the given changed value
*/
protected getLinkedValue(val:any, fieldSchema:IFieldSchema) {
// Links should always be nullified as { href: null }, but
// this wasn't always the case, so ensure null values are returned as such.
if (_.isNil(val)) {
return { href: null };
}
// Test if we either have a CollectionResource or a HAL array,
// or a single hal value.
const isArrayType = (fieldSchema.type || '').startsWith('[]');
let isArray = false;
if (val.forEach || val.elements) {
isArray = true;
}
if (isArray && isArrayType) {
const links:{ href:string }[] = [];
if (val) {
const elements = (val.forEach && val) || val.elements;
elements.forEach((link:{ href:string }) => {
if (link.href) {
links.push({ href: link.href });
}
});
}
return links;
}
return { href: _.get(val, 'href', null) };
}
/**
* When changing type or project, new custom fields may be present
* that we need to set.
*/
protected setNewDefaults(form:FormResource) {
_.each(form.payload, (val:unknown, key:string) => {
const fieldSchema:IFieldSchema|null = this.schema.ofProperty(key);
if (!fieldSchema?.writable) {
return;
}
this.setNewDefaultFor(key, val);
});
}
/**
* Set the default for the given attribute
*/
protected setNewDefaultFor(key:string, val:unknown) {
if (!this.valueExists(key)) {
debugLog(`Taking over default value from form for ${key}`);
this.setValue(key, val);
}
}
}