opf/openproject

View on GitHub
frontend/src/app/features/hal/resources/hal-resource.ts

Summary

Maintainability
A
2 hrs
Test Coverage
// -- 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 { InputState } from '@openproject/reactivestates';
import { Injector } from '@angular/core';
import { States } from 'core-app/core/states/states.service';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator';
import { HalLinkInterface } from 'core-app/features/hal/hal-link/hal-link';
import { ICKEditorContext } from 'core-app/shared/components/editor/components/ckeditor/ckeditor.types';
import idFromLink from 'core-app/features/hal/helpers/id-from-link';
import isNewResource from 'core-app/features/hal/helpers/is-new-resource';

export interface HalResourceClass<T extends HalResource = HalResource> {
  new(injector:Injector,
    source:any,
    $loaded:boolean,
    halInitializer:(halResource:T) => void,
    $halType:string):T;
}

export type HalSourceLink = { href:string|null, title?:string };

export type HalSourceLinks = {
  [key:string]:HalSourceLink
};

export type HalSource = {
  [key:string]:string|number|boolean|null|HalSourceLinks,
  _links:HalSourceLinks
};

export class HalResource {
  // TODO this is the source of many issues in the frontend
  // because it no longer properly type checks stuff
  // Since 2019-10-21 I'm documenting what bugs this caused:
  // https://community.openproject.com/wp/31462
  [attribute:string]:any;

  // The API type reported from API
  public _type:string;

  // Internal initialization time for objects
  // created in the frontend
  public __initialized_at:number;

  // The HalResource that this type maps to
  // This will almost always be equal to _type, however may be different for dynamic types
  // e.g., { _type: 'StatusFilterInstance', $halType: 'QueryFilterInstance' }.
  //
  // This is required for attributes to be correctly mapped according to their configuration.
  public $halType:string;

  @InjectField() states:States;

  @InjectField() I18n!:I18nService;

  /**
   * Constructs and initializes the HalResource. For this, the halResoureFactory is required.
   *
   * However, We can't inject the HalResourceFactory here because it itself depends on this class.
   * So if you need to initialize a HalResource, use +HalResourceFactory.createHalResource+ instead.
   *
   * @param {Injector} injector
   * @param $halType The HalResource type that this instance maps to
   * @param $source
   * @param {boolean} $loaded
   * @param {Function} initializer The initializer callback to HAL-transform all linked and embedded resources.
   *
   */
  public constructor(
    public injector:Injector,
    public $source:any,
    public $loaded:boolean,
    public halInitializer:(halResource:any) => void,
    $halType:string,
  ) {
    this.$halType = $halType;
    this.$initialize($source);
  }

  public static getEmptyResource(self:{ href:string|null } = { href: null }):any {
    return { _links: { self } };
  }

  public $links:any = {};

  public $embedded:any = {};

  public $self:Promise<this>;

  public _name:string;

  public static matchFromLink(href:string, expectedResource:string):string|null {
    const match = new RegExp(`/api/v3/${expectedResource}/(\\d+)`).exec(href);
    return match && match[1];
  }

  public $initialize(source:any) {
    this.$source = source.$source || source;
    this.halInitializer(this);
  }

  /**
   * Override toString to ensure the resource can
   * be printed nicely on console and in errors
   */
  public toString() {
    if (this.href) {
      return `[HalResource href=${this.href}]`;
    }
    return `[HalResource id=${this.id}]`;
  }

  /**
   * Returns the ID and ensures it's a string, null.
   * Returns a string when:
   *  - The embedded ID is actually set
   *  - The self link is terminated by a number.
   */
  public get id():string|null {
    if (this.$source.id) {
      return this.$source.id.toString();
    }

    const id = idFromLink(this.href);
    if (/^\d+$/.exec(id)) {
      return id;
    }

    return null;
  }

  public set id(val:string|null) {
    this.$source.id = val;
  }

  /**
   * Retain the internal tracking identifier from the given other work package.
   * This is due to us needing to identify a work package beyond its actual ID,
   * because that changes upon saving.
   *
   * @param other
   */
  public retainFrom(other:HalResource) {
    this.__initialized_at = other.__initialized_at;
  }

  /**
   * Create a HalResource from the copied source of the given, other HalResource.
   *
   * @param {HalResource} other
   * @returns A HalResource with the identitical copied source of other.
   */
  public $copy<T extends HalResource = HalResource>(source:Object = {}):T {
    const clone:HalResourceClass<T> = this.constructor as any;

    return new clone(this.injector, _.merge(this.$plain(), source), this.$loaded, this.halInitializer, this.$halType);
  }

  public $plain():any {
    return _.cloneDeep(this.$source);
  }

  public get $isHal():boolean {
    return true;
  }

  public get $link():HalLinkInterface {
    return this.$links.self.$link;
  }

  public get name():string {
    return this._name || this.$link.title || '';
  }

  public set name(name:string) {
    this._name = name;
  }

  public get href():string|null {
    return this.$link.href;
  }

  /**
   * Return the associated state to this HAL resource, if any.
   */
  public get state():InputState<this>|null {
    return null;
  }

  /**
   * Update the state
   */
  public push(newValue:this):Promise<unknown> {
    if (this.state) {
      this.state.putValue(newValue);
    }

    return Promise.resolve();
  }

  public previewPath():string|undefined {
    if (isNewResource(this) && this.project) {
      return this.project.href;
    }

    return undefined;
  }

  public getEditorContext(fieldName:string):ICKEditorContext {
    return { type: 'constrained' };
  }

  public $load(force = false):Promise<this> {
    if (!this.state) {
      return this.$loadResource(force);
    }

    const { state } = this;

    if (force) {
      state.clear();
    }

    // If nobody has asked yet for the resource to be $loaded, do it ourselves.
    // Otherwise, we risk returning a promise, that will never be resolved.
    state.putFromPromiseIfPristine(() => this.$loadResource(force));

    return <Promise<this>>state.valuesPromise().then((source:any) => {
      this.$initialize(source);
      this.$loaded = true;
      return this;
    });
  }

  protected $loadResource(force = false):Promise<this> {
    if (!force) {
      if (this.$loaded) {
        return Promise.resolve(this);
      }

      if (!this.$loaded && this.$self) {
        return this.$self;
      }
    }

    // Reset and load this resource
    this.$loaded = false;
    this.$self = this.$links.self({}).then((source:any) => {
      this.$loaded = true;
      this.$initialize(source.$source);
      return this;
    });

    return this.$self;
  }

  /**
   * Update the resource ignoring the cache.
   */
  public $update() {
    return this.$load(true);
  }

  /**
   * Specify this resource's embedded keys that should be transformed with resources.
   * Use this to restrict, e.g., links that should not be made properties if you have a custom get/setter.
   */
  public $embeddableKeys():string[] {
    const properties = Object.keys(this.$source);
    return _.without(properties, '_links', '_embedded', 'id');
  }

  /**
   * Specify this resource's keys that should not be transformed with resources.
   * Use this to restrict, e.g., links that should not be made properties if you have a custom get/setter.
   */
  public $linkableKeys():string[] {
    const properties = Object.keys(this.$links);
    return _.without(properties, 'self');
  }
}