basic_as-base.js

import {Eventing_A} from './as-event.js';
import state from '../services/processing-queue.js';
import {ErrorCode} from '../../exception/exceptionDesc.js';

/**
 * @ignore
 */
const logModule = 'as-base';

/**
 * @description Base class for handling objects
 * @class TComponents.Base_A
 * @extends TComponents.Eventing_A
 * @memberof TComponents
 * @param {object} [props={}] - Initial properties for the object
 * @throws Will throw an error if props is not an object
 * @example
 * const baseInstance = new TComponents.Base_A({prop1: 'value1', prop2: 42});
 * baseInstance.on('event1', (data) => {
 *   console.log('Event 1 triggered with data:', data);
 * });
 */
export class Base_A extends Eventing_A {
  constructor(props = {}) {
    super();

    if (typeof props !== 'object') {
      Logger.e(logModule, ErrorCode.InvalidComponentProp, 'The props should be an object.', props);
      throw ErrorCode.InvalidComponentProp;
    }

    this.initialized = false;
    this.noCheck = [];

    this._initPropsDependencies = [];

    this._props = this._getAllProps(props);
    this._prevProps = Object.assign({}, this._props);
  }

  /**
   * @description Returns an object with expected input properties together with their initial value.
   * Every child class shall have a {@link TComponents.Base_A#defaultProps} to register its corresponding input properties.
   * @member TComponents.Base_A#defaultProps
   * @method
   * @protected
   * @returns {object}
   * @example
   * class MyComponent extends TComponents.Component_A {
   *  constructor(parent, props){}
   *  defaultProps(){
   *    return {
   *      myProp1: '',
   *      myProp2: 0,
   *      myProp3: false,
   *      myProp4: { a: 'A', b: 'B'}
   *    }
   *  }
   * }
   */
  defaultProps() {
    return {options: {async: false}};
  }

  /**
   * @description Register the properties that trigger an onInit when changed with setProps().
   * Input value is a string or array of strings with the name of the corresponding props
   * @member TComponents.Base_A#initPropsDep
   * @method
   * @protected
   * @param {string|string[]} props - The properties that trigger an onInit when changed
   * @throws Will throw an error if the new value is not a string or an array of strings
   * @example
   *     this.initPropsDep(['module', 'variable']);
   */
  initPropsDep(props) {
    if (typeof props === 'string') {
      props = [props];
    } else if (!Array.isArray(props)) {
      Logger.e(logModule, ErrorCode.InvalidNewPropName, props);
      throw ErrorCode.InvalidNewPropName;
    }

    this._initPropsDependencies = [...this._initPropsDependencies, ...props];
  }

  /**
   * @description Method used to update one or multiple component input properties. A change of property using this method
   * will trigger at least a {@link TComponent.Base_A#render()} call. If at least one of the given properties is listed in
   * the {@link TComponent.Base_A#initPropsDep} array, then a {@link TComponent.Base_A#init()} is called before the {@link TComponent.Base_A#render()}.
   * @member TComponents.Base_A#initPropsDep
   * @method
   * @param {object} newProps - Object including the property or properties to be updated.
   * @param {Function | null} [onRender=null] - Function to be executed once after the component has been rendered.
   * @param {boolean} [sync=false] - Whether to update synchronously or not
   * @param {boolean} [force=false] - Whether to force the update or not
   * @returns {Promise<boolean>} - true if the component has been updated, false otherwise
   */
  async setProps(newProps, onRender = null, sync = false, force = false) {
    const {props, modified} = Base_A._updateProps(newProps, this._props, false, this.noCheck);

    /**
     * Internal element containing the component properties. A copy of it can be obtained
     * outside the component with {@link TComponent.Base_A#getProps} method. To modify the props from outside, the method
     * {@link TComponent.Base_A#setProps} can be used.
     * @private
     */
    this._props = props;

    // if onRender is a function, register event listener to be executed after render
    if (onRender && typeof onRender === 'function') this.once('render', onRender);

    if ((force || modified) && this.initialized) {
      if (sync) {
        await this._componentDidUpdate();
      } else {
        // Put the update in the queue so that every rendering is done synchronously
        // one after the other
        state.q.push(this._componentDidUpdate.bind(this));
      }

      return true;
    }

    return false;
  }

  /**
   * @description Updates the component properties using the internal merge logic
   * and synchronizes the previous properties snapshot.
   *
   * This method applies the given properties directly to the internal `_props`
   * object without triggering the component update lifecycle
   * (`init()` or `render()`).
   *
   * After the update, `_prevProps` is synchronized with the new `_props`,
   * establishing a new baseline state for future change detection.
   *
   * ⚠️ This method should be used only for internal or controlled updates.
   * To trigger a normal component update flow, use {@link TComponents.Base_A#setProps}.
   *
   * @member TComponents.Base_A#commitProps
   * @method
   * @param {object} newProps - Object containing the properties to update.
   * @returns {object} An object containing the updated properties and a flag indicating
   * whether any property value changed.
   */
  commitProps(newProps) {
    const result = Base_A._updateProps(newProps, this._props, false, this.noCheck);
    this._props = result.props;
    this._prevProps = Object.assign({}, this._props);
    return result;
  }

  /**
   * @description Returns a copy of the component properties. Notice that the returning value does not have a
   * reference to the internal properties of the component. i.e. changing a value in that object
   * does not affect the component itself. To change the properties of the component use the {@link TComponent.Base_A#setProps} method.
   * @member TComponents.Base_A#getProps
   * @method
   * @returns {object}
   */
  getProps() {
    return Base_A._deepClone(this._props);
  }

  /**
   * @description Abstract function for asynchronous initialization of the component. This function is overwritten in {@link TComponents.Component_A}
   * @member TComponents.Base_A#init
   * @method
   * @async
   * @abstract
   * @protected
   * @returns {Promise<object>} The TComponents instance on which this method was called.
   */
  async init() {}

  /**
   * @description Abstract function for DOM rendering. This function is overwritten in {@link TComponents.Component_A}
   * @member TComponents.Base_A#render
   * @method
   * @abstract
   * @protected
   * @async
   */
  async render() {}

  /**
   * @return {object}
   */
  get props() {
    return this.getProps();
  }

  /**
   * @description The properties of the component.
   * @member {object} TComponents.Base_A#props
   * @instance
   * @example
   * const base = new TComponents.Base_A();
   * base.props = { key: 'value' };
   */
  set props(props) {
    this.setProps(props);
  }

  /**
   * @description Get all properties, including default properties from the prototype chain.
   * @member TComponents.Base_A~_getAllProps
   * @method
   * @private
   * @param {object} p - Initial properties
   * @param {boolean} [restError=true] - Whether to throw error for unexpected props
   * @returns {object}
   */
  _getAllProps(p, restError = true) {
    const {props} = Base_A._updateProps(p, this._getAllDefaultProps(), restError, this.noCheck);
    return props;
  }

  /**
   * @description Get all default properties from the prototype chain.
   * @member TComponents.Base_A~_getAllDefaultProps
   * @method
   * @private
   * @returns {object}
   */
  _getAllDefaultProps() {
    let props = {};
    let proto = this;

    const noCheck = [];

    // Traverse up the prototype chain and merge all defaultProps
    while (proto) {
      if (proto.defaultProps) {
        props = Object.assign({}, proto.defaultProps(), props);
        if (proto.noCheck) {
          proto.noCheck.forEach((element) => {
            if (!noCheck.includes(element)) {
              noCheck.push(element);
            }
          });
        }
      }
      proto = Object.getPrototypeOf(proto);
    }
    this.noCheck = noCheck;

    return props;
  }

  /**
   * @description Update properties with new values and determine if any properties were modified.
   * @member TComponents.Base_A._updateProps
   * @method
   * @static
   * @private
   * @param {object} [newProps={}] - New properties to update
   * @param {object} [prevProps={}] - Previous properties
   * @param {boolean} [restError=false] - Whether to throw error for unexpected props
   * @param {string[]} [noCheck=[]] - Properties to exclude from modification check
   * @returns {object} An object containing the updated properties and a boolean indicating if any properties were modified
   */
  static _updateProps(newProps = {}, prevProps = {}, restError = false, noCheck = []) {
    let modified = false;
    let props = Object.keys(prevProps).reduce((acc, key) => {
      if (Object.prototype.hasOwnProperty.call(newProps, key)) {
        if (
          !Array.isArray(prevProps[key]) &&
          !noCheck.includes(key) &&
          typeof prevProps[key] === 'object' &&
          prevProps[key] !== null &&
          !(prevProps[key] instanceof HTMLElement)
        ) {
          if (newProps[key] && newProps[key].constructor !== Object) {
            // If the key in newProps is a class instance, replace it entirely
            acc[key] = newProps[key];
            modified = modified || newProps[key] !== prevProps[key];
          } else {
            const nestedProps = Base_A._updateProps(newProps[key], prevProps[key], restError, noCheck);
            modified = modified || nestedProps.modified;
            acc[key] = nestedProps.props;
          }
        } else {
          acc[key] = newProps[key];
          modified = modified || newProps[key] !== prevProps[key];
        }
      } else {
        // key not existing in new prop, so the value is the same as before
        acc[key] = prevProps[key];
      }
      return acc;
    }, {});

    const rest = Object.keys(newProps).reduce((acc, key) => {
      if (!Object.prototype.hasOwnProperty.call(prevProps, key)) {
        acc[key] = newProps[key];
      }
      return acc;
    }, {});

    if (restError && Object.keys(rest).length !== 0) {
      // Logger.w('TComponents.Base_A', `Unexpected props: ${JSON.stringify(rest)}`);
      // throw new Error(`Unexpected props: ${JSON.stringify(rest)}`);
    }

    return {props, modified};
  }

  /**
   * @description Deeply checks if two values are equal.
   * Works with objects and arrays by recursively comparing keys and values.
   *
   * Limitations:
   * - Does not handle special objects like Date, Map, Set, RegExp properly.
   * - Does not handle circular references.
   *
   * @member TComponents.Base_A._deepEqual
   * @method
   * @static
   * @private
   * @param {any} a - First value to compare
   * @param {any} b - Second value to compare
   * @returns {boolean} - True if values are deeply equal, otherwise false
   */
  static _deepEqual(a, b) {
    // If both values are strictly equal (covers primitives and identical references)
    if (a === b) return true;

    // If either value is not an object (null included), they are not equal
    if (typeof a !== 'object' || typeof b !== 'object' || a == null || b == null) {
      return false;
    }

    // Compare the number of keys in both objects
    var keysA = Object.keys(a);
    var keysB = Object.keys(b);
    if (keysA.length !== keysB.length) return false;

    // Recursively compare each key's value
    for (var i = 0; i < keysA.length; i++) {
      var key = keysA[i];
      // Use indexOf instead of includes (better ES5 compatibility)
      if (keysB.indexOf(key) === -1 || !Base_A._deepEqual(a[key], b[key])) {
        return false;
      }
    }

    return true;
  }

  /**
   * @description Handle component updates and determine if dependencies have changed.
   * @member TComponents.Base_A~_componentDidUpdate
   * @method
   * @private
   * @async
   * @returns {Promise<void>}
   */
  async _componentDidUpdate() {
    function checkDepsDiff(deps, props, prevProps) {
      for (let i = 0; i < deps.length; i++) {
        const dep = deps[i];
        if (!Base_A._deepEqual(props[dep], prevProps[dep])) {
          return true;
        }
      }
      return false;
    }

    const isDepsDiff =
      this._initPropsDependencies && checkDepsDiff(this._initPropsDependencies, this._props, this._prevProps);

    // Update previous props
    this._prevProps = Object.assign({}, this._props);

    // Trigger update of the component
    if (isDepsDiff) {
      await this.init();
    } else {
      await this.render();
    }
  }

  /**
   * @description Creates a clone of an object, including objects with circular references,
   * functions, and non-enumerable properties.
   * @member TComponents.Base_A._deepClone
   * @method
   * @static
   * @private
   * @param {object} obj - The object to clone
   * @returns {object} The cloned object
   */
  static _deepClone(obj) {
    if (obj === null || typeof obj !== 'object') {
      return obj;
    }

    let clone;

    // ⚠️ Note: directly returns the TComponent instance.
    // The reason for this change can be found in Section `View Type Component Design.2.2`.
    if (typeof obj === 'object' && obj._isTComponent) {
      return obj;
    } else if (obj instanceof Date) {
      clone = new Date(obj.getTime());
    } else if (obj instanceof RegExp) {
      clone = new RegExp(obj.source, obj.flags);
    } else if (obj instanceof Set) {
      clone = new Set(obj);
    } else if (obj instanceof Map) {
      clone = new Map(obj);
    } else if (obj instanceof Error) {
      clone = new Error(obj.message);
    } else if (obj instanceof Array) {
      clone = [];
      for (let i = 0; i < obj.length; i++) {
        clone[i] = Base_A._deepClone(obj[i]);
      }
    } else if (obj instanceof HTMLElement) {
      // clone = obj.cloneNode(true);
      // if (obj.id && clone.id) {
      //   clone.id = API.generateUUID(); // Replace generateUniqueID() with your own logic to generate a unique ID
      // }
      // The reason for this change can be found in Section `View Type Component Design.2.2`.
      return obj;
    } else {
      // clone = Object.create(Object.getPrototypeOf(obj));
      clone = {};
      for (let key in obj) {
        if (Object.prototype.hasOwnProperty.call(obj, key)) {
          clone[key] = Base_A._deepClone(obj[key]);
        }
        // clone[key] = Base_A._deepClone(obj[key]);
      }
    }

    return clone;
  }
}