Source

lib/engine/NgxRenderingEngine.ts

/**
 * @module lib/engine/NgxRenderingEngine
 * @description Angular rendering engine for Decaf model-driven UIs.
 * @summary Implements NgxRenderingEngine which converts model decorator metadata
 * into Angular components, manages component registration, and orchestrates
 * dynamic component creation and input mapping.
 * @link {@link NgxRenderingEngine}
 */
import { FieldDefinition, HTML5InputTypes, RenderingEngine } from '@decaf-ts/ui-decorators';
import { AngularFieldDefinition, KeyValue } from './types';
import { AngularDynamicOutput, IFormComponentProperties } from './interfaces';
import { AngularEngineKeys } from './constants';
import { Model, ModelKeys, Primitives } from '@decaf-ts/decorator-validation';
import { Constructor } from '@decaf-ts/decoration';
import { InternalError, OperationKeys } from '@decaf-ts/db-decorators';
import {
  ComponentMirror,
  ComponentRef,
  createEnvironmentInjector,
  EnvironmentInjector,
  Injector,
  reflectComponentType,
  runInInjectionContext,
  TemplateRef,
  Type,
  ViewContainerRef,
  createComponent,
} from '@angular/core';
import { NgxFormService } from '../services/NgxFormService';
import { isDevelopmentMode } from '../utils';
import { FormParent } from './types';
import { getLogger } from '../for-angular-common.module';

/**
 * @description Angular implementation of the RenderingEngine for Decaf components.
 * @summary This class extends the base RenderingEngine to provide Angular-specific rendering capabilities.
 * It handles the conversion of field definitions to Angular components, manages component registration,
 * and provides utilities for component creation and input handling. The engine converts model decorator
 * metadata into dynamically created Angular components with proper input binding and lifecycle management.
 * @template AngularFieldDefinition - Type for Angular-specific field definitions
 * @template AngularDynamicOutput - Type for Angular-specific component output
 * @class NgxRenderingEngine
 * @extends {RenderingEngine<AngularFieldDefinition, AngularDynamicOutput>}
 * @example
 * ```typescript
 * const engine = NgxRenderingEngine.get();
 * engine.initialize();
 * const output = engine.render(myModel, {}, viewContainerRef, injector, templateRef);
 * ```
 * @mermaid
 * sequenceDiagram
 *   participant Client
 *   participant Engine as NgxRenderingEngine
 *   participant Components as RegisteredComponents
 *
 *   Client->>Engine: get()
 *   Client->>Engine: initialize()
 *   Client->>Engine: render(model, props, vcr, injector, tpl)
 *   Engine->>Engine: toFieldDefinition(model, props)
 *   Engine->>Engine: fromFieldDefinition(fieldDef, vcr, injector, tpl)
 *   Engine->>Components: components(fieldDef.tag)
 *   Components-->>Engine: component constructor
 *   Engine->>Engine: createComponent(component, inputs, metadata, vcr, injector, template)
 *   Engine-->>Client: return AngularDynamicOutput
 * @memberOf module:lib/engine/NgxRenderingEngine
 */
export class NgxRenderingEngine extends RenderingEngine<
  AngularFieldDefinition,
  AngularDynamicOutput
> {
  private static _injector?: Injector;

  /**
   * @description Registry of components available for dynamic rendering.
   * @summary Static registry that stores all registered components indexed by their selector name.
   * Each component entry contains a constructor reference that can be used to instantiate
   * the component during the rendering process. This registry is shared across all instances
   * of the rendering engine and is populated through the registerComponent method.
   * @private
   * @static
   * @type {Record<string, { constructor: Constructor<unknown> }>}
   * @memberOf module:lib/engine/NgxRenderingEngine
   */
  private static _components: Record<
    string,
    { constructor: Constructor<unknown> }
  >;

  /**
   * @description Currently active model being rendered by the engine.
   * @summary Stores a reference to the model instance that is currently being processed
   * by the rendering engine. This property is set during the render method execution
   * and is used throughout the rendering lifecycle to access model data and metadata.
   * The definite assignment assertion (!) is used because this property is always
   * initialized before use within the render method.
   * @private
   * @type {Model}
   * @memberOf module:lib/engine/NgxRenderingEngine
   */
  private _model!: Model;

  /**
   * @description Current operation context for component visibility control.
   * @summary Static property that stores the current operation being performed,
   * which is used to determine component visibility through the 'hidden' property.
   * Components can specify operations where they should be hidden, and this property
   * provides the context for those visibility checks. The value is typically extracted
   * from the global properties during the rendering process.
   * @private
   * @static
   * @type {string | undefined}
   * @memberOf module:lib/engine/NgxRenderingEngine
   */
  private static _operation: string | undefined = undefined;

  /**
   * @description Reference to the currently active component instance.
   * @summary Static property that maintains a reference to the most recently created
   * component instance. This is used internally for component lifecycle management
   * and can be cleared through the destroy method. The reference allows access to
   * the active component instance for operations that need to interact with the
   * currently rendered component.
   * @private
   * @static
   * @type {Type<unknown> | undefined}
   * @memberOf module:lib/engine/NgxRenderingEngine
   */
  private static _instance: Type<unknown> | undefined;

  // private static _projectable: boolean = true

  /**
   * @description Parent component properties for child component inheritance.
   * @summary Static property that stores parent component properties that should be
   * inherited by child components. This is particularly used for passing page configuration
   * down to child components in multi-page forms. The property is cleared after rendering
   * to prevent property leakage between unrelated component trees.
   * @private
   * @static
   * @type {KeyValue | undefined}
   * @memberOf module:lib/engine/NgxRenderingEngine
   */
  private static _parentProps: KeyValue | undefined = undefined;

  /**
   * @description Constructs a new NgxRenderingEngine instance.
   * @summary Initializes a new instance of the Angular rendering engine by calling the parent
   * constructor with the 'angular' engine type identifier. This constructor sets up the base
   * rendering engine functionality with Angular-specific configurations and prepares the
   * instance for component registration and rendering operations.
   * @constructor
   * @memberOf module:lib/engine/NgxRenderingEngine
   */
  constructor() {
    super(AngularEngineKeys.FLAVOUR);
  }

  /**
   * @description Converts a field definition to an Angular component output.
   * @summary This private method takes a field definition and creates the corresponding Angular component.
   * It handles component instantiation, input property mapping, child component rendering, and visibility
   * control. The method validates input properties against the component's metadata and processes
   * child components recursively.
   * @param {FieldDefinition<AngularFieldDefinition>} fieldDef - The field definition to convert
   * @param {ViewContainerRef} vcr - The view container reference for component creation
   * @param {Injector} injector - The Angular injector for dependency injection
   * @param {TemplateRef<any>} tpl - The template reference for content projection
   * @param {string} registryFormId - Form identifier for the component renderer
   * @param {boolean} createComponent - Whether to create the component instance
   * @param {FormParent} [formGroup] - Optional form group for form components
   * @return {AngularDynamicOutput} The Angular component output with component reference and inputs
   * @mermaid
   * sequenceDiagram
   *   participant Method as fromFieldDefinition
   *   participant Components as NgxRenderingEngine.components
   *   participant Angular as Angular Core
   *   participant Process as processChild
   *
   *   Method->>Components: components(fieldDef.tag)
   *   Components-->>Method: component constructor
   *   Method->>Angular: reflectComponentType(component)
   *   Angular-->>Method: componentMetadata
   *   Method->>Method: Validate input properties
   *   Method->>Method: Create result object
   *   alt Has children
   *     Method->>Process: Process children recursively
   *     Process->>Method: Return processed children
   *     Method->>Angular: Create embedded view
   *     Method->>Method: Create component instance
   *   end
   *   Method-->>Caller: return AngularDynamicOutput
   * @private
   * @memberOf module:lib/engine/NgxRenderingEngine
   */
  private fromFieldDefinition(
    fieldDef: FieldDefinition<AngularFieldDefinition>,
    vcr: ViewContainerRef,
    injector: Injector,
    tpl: TemplateRef<unknown>,
    registryFormId: string = Date.now().toString(36).toUpperCase(),
    createComponent: boolean = true,
    formGroup?: FormParent
  ): AngularDynamicOutput {
    const cmp =
      (fieldDef as KeyValue)?.['component'] ||
      NgxRenderingEngine.components(fieldDef.tag);
    const component = cmp.constructor as unknown as Type<unknown>;

    const componentMetadata = reflectComponentType(component);
    if (!componentMetadata) {
      throw new InternalError(
        `Metadata for component ${fieldDef.tag} not found.`
      );
    }

    const { inputs: possibleInputs } = componentMetadata;
    const inputs = { ...fieldDef.props };

    const unmappedKeys = Object.keys(inputs).filter((input) => {
      const isMapped = possibleInputs.find(
        ({ propName }) => propName === input
      );
      if (!isMapped) delete inputs[input];
      return !isMapped;
    });

    if (unmappedKeys.length > 0 && isDevelopmentMode())
      getLogger(this).warn(
        `Unmapped input properties for component ${fieldDef.tag}: ${unmappedKeys.join(', ')}`
      );

    const operation = NgxRenderingEngine._operation;

    const hiddenOn = inputs?.hidden || [];
    if ((hiddenOn as string[]).includes(operation as string))
      return { inputs, injector };

    // const customTypes = (inputs as KeyValue)?.['customTypes'] || [];
    // const hasFormRoot = Object.values(possibleInputs).some(({propName}) => propName ===  AngularEngineKeys.PARENT_FORM);
    // if (hasFormRoot && !inputs?.[AngularEngineKeys.PARENT_FORM] && formGroup)
    //   inputs[AngularEngineKeys.PARENT_FORM] = formGroup;
    if(operation !== OperationKeys.CREATE && (hiddenOn as string[]).includes(OperationKeys.CREATE)) {
       fieldDef.props = {...fieldDef.props, ... {readonly: true, type: HTML5InputTypes.TEXT} };
    }

    const result: AngularDynamicOutput = {
      component,
      inputs,
      injector,
    };

    if (fieldDef.rendererId)
      (result.inputs as Record<string, unknown>)['rendererId'] =
        fieldDef.rendererId;
    // process children
    // generating DOM
    // const projectable = NgxRenderingEngine._projectable;
    // const template = !projectable ? [] : vcr.createEmbeddedView(tpl, injector).rootNodes;
    // const template = [];
    const hasChildren = Object.values(possibleInputs).some(
      ({ propName }) => propName === AngularEngineKeys.CHILDREN
    );
    const hasModel = Object.values(possibleInputs).some(
      ({ propName }) => propName === ModelKeys.MODEL
    );
    const componentInputs = Object.assign(
      inputs,
      hasModel ? { model: this._model } : {},
      hasChildren ? { children: fieldDef?.['children'] || [] } : {}
    );
    if (createComponent) {
      vcr.clear();
      const componentInstance = NgxRenderingEngine.createComponent(
        component,
        componentInputs,
        injector,
        componentMetadata,
        vcr,
        []
      );
      result.component = NgxRenderingEngine._instance =
        componentInstance as Type<unknown>;
    }
    if (fieldDef.children?.length) {
      if (!NgxRenderingEngine._parentProps && inputs?.['pages']) {
        NgxRenderingEngine._parentProps = { pages: inputs?.['pages'] };
        //  NgxRenderingEngine._projectable = false;
      }

      result.children = fieldDef.children.map((child) => {
        const readonly = operation === OperationKeys.UPDATE && ((child?.props?.hidden || []) as string[]).includes(OperationKeys.CREATE);
        // const hiddenOn = (child?.props?.hidden || []) as string[];
        // // moved to ui decorators
        // if (child?.children?.length) {
        //   child.children = child.children.filter(c => {
        //     const hiddenOn = c?.props?.hidden || [];
        //     if (!(hiddenOn as string[]).includes(operation as string))
        //       return c
        //   })
        // }
        // if (!hiddenOn?.length || !(hiddenOn as CrudOperations[]).includes(operation as CrudOperations))
        if(!readonly) {
          NgxFormService.addControlFromProps(registryFormId, child.props, {
            ...inputs,
            ...(NgxRenderingEngine._parentProps || {}),
          });
        } else {
          child.props = {...child.props, ... {readonly: true} };
        }
        return this.fromFieldDefinition(
          child,
          vcr,
          injector,
          tpl,
          registryFormId,
          false,
          formGroup
        );
      });
    }
    return result;
  }

  /**
   * @description Creates an Angular component instance with inputs and template projection.
   * @summary This static utility method creates an Angular component instance with the specified
   * inputs and template. It uses Angular's component creation API to instantiate the component
   * and then sets the input properties using the provided metadata.
   * @param {Type<unknown>} component - The component type to create
   * @param {KeyValue} [inputs={}] - The input properties to set on the component
   * @param {ComponentMirror<unknown>} metadata - The component metadata for input validation
   * @param {ViewContainerRef} vcr - The view container reference for component creation
   * @param {Injector} injector - The Angular injector for dependency injection
   * @param {Node[]} [template=[]] - The template nodes to project into the component
   * @return {ComponentRef<unknown>} The created component reference
   * @static
   * @memberOf module:lib/engine/NgxRenderingEngine
   */
  static createComponent<C>(
    component: Type<unknown>,
    inputs: KeyValue = {},
    injector?: Injector,
    metadata?: ComponentMirror<unknown>,
    vcr?: ViewContainerRef,
    template?: Node[]
  ): C {
    if (vcr && metadata && injector)
      return NgxRenderingEngine.createViewComponent(
        component,
        inputs,
        metadata,
        vcr,
        injector,
        template || []
      );
    return NgxRenderingEngine.createHostComponent(component, inputs, injector);
  }

  static createViewComponent<C>(
    component: Type<unknown>,
    inputs: KeyValue = {},
    metadata: ComponentMirror<unknown>,
    vcr: ViewContainerRef,
    injector: Injector,
    template: Node[] = []
  ): C {
    const cmp = vcr.createComponent(component as Type<unknown>, {
      environmentInjector: injector as EnvironmentInjector,
      projectableNodes: [template],
    });
    this.setInputs(cmp, inputs, metadata);
    return cmp.instance as C;
  }

  static createHostComponent<C>(
    component: Type<C | unknown> | string,
    props: KeyValue = {},
    injector?: Injector
  ): C {
    if (!injector)
      injector =
        NgxRenderingEngine._injector ||
        Injector.create({ providers: [], parent: Injector.NULL });
    const envInjector: EnvironmentInjector = createEnvironmentInjector(
      [],
      injector as EnvironmentInjector
    );

    let cmp: ComponentRef<unknown> = {} as ComponentRef<C>;

    runInInjectionContext(envInjector, () => {
      const host = document.createElement('div');
      component =
        typeof component === Primitives.STRING
          ? (NgxRenderingEngine.components(
              component as string
            ) as Type<unknown>)
          : component;
      if (!host) throw new Error('Cant create host element for component');

      cmp = createComponent(component as Type<unknown>, {
        environmentInjector: envInjector,
        hostElement: host,
      });

      const metadata = reflectComponentType(component as Type<unknown>);
      if (!metadata)
        throw new InternalError(
          `Metadata for component ${component} not found.`
        );

      const { inputs: possibleInputs } = metadata;
      const inputs = { ...props };

      const unmappedKeys = Object.keys(inputs).filter((input) => {
        const isMapped = possibleInputs.find(
          ({ propName }) => propName === input
        );
        if (!isMapped) delete inputs[input];
        return !isMapped;
      });

      if (unmappedKeys.length > 0 && isDevelopmentMode())
        getLogger(this.createHostComponent).warn(
          `Unmapped input properties for component ${component}: ${unmappedKeys.join(', ')}`
        );

      if (metadata)
        this.setInputs(
          cmp as ComponentRef<unknown>,
          inputs,
          metadata as ComponentMirror<unknown>
        );
      document.body.querySelector('ion-app')?.appendChild(host);
    });

    return cmp.instance as C;
  }

  /**
   * @description Extracts decorator metadata from a model.
   * @summary This method provides access to the field definition generated from a model's
   * decorators. It's a convenience wrapper around the toFieldDefinition method that
   * converts a model to a field definition based on its decorators and the provided
   * global properties.
   * @param {Model} model - The model to extract decorators from
   * @param {Record<string, unknown>} globalProps - Global properties to include in the field definition
   * @return {FieldDefinition<AngularFieldDefinition>} The field definition generated from the model
   * @memberOf module:lib/engine/NgxRenderingEngine
   */
  getDecorators(
    model: Model,
    globalProps: Record<string, unknown>
  ): FieldDefinition<AngularFieldDefinition> {
    return this.toFieldDefinition(model, globalProps);
  }

  /**
   * @description Destroys the current engine instance and cleans up resources.
   * @summary This static method clears the current instance reference and parent props,
   * effectively destroying the singleton instance of the rendering engine. Optionally
   * removes the form registry for the specified form ID. This can be used to reset the
   * engine state or to prepare for a new instance creation.
   * @param {string} [formId] - Optional form ID to remove from registry
   * @return {Promise<void>} A promise that resolves when the instance is destroyed
   * @static
   * @memberOf module:lib/engine/NgxRenderingEngine
   */
  static async destroy(formId?: string): Promise<void> {
    if (formId) NgxFormService.removeRegistry(formId);
    NgxRenderingEngine._instance = undefined;
    NgxRenderingEngine._parentProps = undefined;
  }

  /**
   * @description Renders a model into an Angular component output.
   * @summary This method takes a model and converts it to an Angular component output.
   * It first stores a reference to the model, then converts it to a field definition
   * using the base RenderingEngine's toFieldDefinition method, and finally converts
   * that field definition to an Angular component output using fromFieldDefinition.
   * @template M - Type extending Model
   * @param {M} model - The model to render
   * @param {Record<string, unknown>} globalProps - Global properties to pass to the component
   * @param {ViewContainerRef} vcr - The view container reference for component creation
   * @param {Injector} injector - The Angular injector for dependency injection
   * @param {TemplateRef<any>} tpl - The template reference for content projection
   * @return {AngularDynamicOutput} The Angular component output with component reference and inputs
   * @mermaid
   * sequenceDiagram
   *   participant Client as Client Code
   *   participant Render as render method
   *   participant ToField as toFieldDefinition
   *   participant FromField as fromFieldDefinition
   *
   *   Client->>Render: render(model, globalProps, vcr, injector, tpl)
   *   Render->>Render: Store model reference
   *   Render->>ToField: toFieldDefinition(model, globalProps)
   *   ToField-->>Render: fieldDef
   *   Render->>FromField: fromFieldDefinition(fieldDef, vcr, injector, tpl)
   *   FromField-->>Render: AngularDynamicOutput
   *   Render-->>Client: return AngularDynamicOutput
   * @override
   * @memberOf module:lib/engine/NgxRenderingEngine
   */
  override render<M extends Model>(
    model: M,
    globalProps: Record<string, unknown>,
    vcr: ViewContainerRef,
    injector: Injector,
    tpl: TemplateRef<unknown>
  ): AngularDynamicOutput {
    let result: AngularDynamicOutput;
    if (!NgxRenderingEngine._injector) NgxRenderingEngine._injector = injector;
    try {
      this._model = model;
      const formId = Date.now().toString(36).toUpperCase();
      const fieldDef = this.toFieldDefinition(model, globalProps);
      const props = fieldDef.props as Partial<IFormComponentProperties>;
      if (!NgxRenderingEngine._operation)
        NgxRenderingEngine._operation = props?.operation || undefined;
      const isArray =
        (props?.pages && (props?.pages as number) >= 1) ||
        props?.multiple === true;
      const formGroup = NgxFormService.createForm(formId, isArray);
      result = this.fromFieldDefinition(
        fieldDef,
        vcr,
        injector,
        tpl,
        formId,
        true,
        formGroup
      );
      if (result.component)
        (result.component as KeyValue)['formGroup'] = formGroup;
      NgxRenderingEngine.destroy(formId);
    } catch (e: unknown) {
      throw new InternalError(
        `Failed to render Model ${model.constructor.name}: ${e}`
      );
    }

    return result;
  }

  /**
   * @description Initializes the rendering engine.
   * @summary This method initializes the rendering engine. It checks if the engine is already initialized
   * and sets the initialized flag to true. This method is called before the engine is used
   * to ensure it's properly set up for rendering operations.
   * @return {Promise<void>} A promise that resolves when initialization is complete
   * @override
   * @memberOf module:lib/engine/NgxRenderingEngine
   */
  override async initialize(): Promise<void> {
    if (!this.initialized) this.initialized = true;
  }

  /**
   * @description Registers a component with the rendering engine.
   * @summary This static method registers a component constructor with the rendering engine
   * under a specific name. It initializes the components registry if needed and throws
   * an error if a component is already registered under the same name to prevent
   * accidental overrides.
   * @param {string} name - The name to register the component under
   * @param {Constructor<unknown>} constructor - The component constructor
   * @return {void}
   * @static
   * @memberOf module:lib/engine/NgxRenderingEngine
   */
  static registerComponent(
    name: string,
    constructor: Constructor<unknown>
  ): void {
    if (!this._components) this._components = {};
    if (name in this._components)
      throw new InternalError(`Component already registered under ${name}`);
    this._components[name] = {
      constructor: constructor,
    };
  }

  /**
   * @description Retrieves registered components from the rendering engine.
   * @summary This static method retrieves either all registered components or a specific component
   * by its selector. When called without a selector, it returns an array of all registered
   * components. When called with a selector, it returns the specific component if found,
   * or throws an error if the component is not registered.
   * @param {string} [selector] - Optional selector to retrieve a specific component
   * @return {Object|Array} Either a specific component or an array of all components
   * @static
   * @memberOf module:lib/engine/NgxRenderingEngine
   */
  static components(selector?: string): object | string[] {
    if (!selector) return Object.values(this._components);
    if (!(selector in this._components))
      throw new InternalError(`No Component registered under ${selector}`);
    return this._components[selector];
  }

  /**
   * @description Sets input properties on a component instance.
   * @summary This static utility method sets input properties on a component instance
   * based on the provided inputs object and component metadata. It handles both simple
   * values and nested objects, recursively processing object properties. The method
   * validates each input against the component's metadata to ensure only valid inputs
   * are set.
   * @param {ComponentRef<unknown>} component - The component reference to set inputs on
   * @param {KeyValue} inputs - The input properties to set
   * @param {ComponentMirror<unknown>} metadata - The component metadata for input validation
   * @return {void}
   * @mermaid
   * sequenceDiagram
   *   participant Caller
   *   participant SetInputs as setInputs
   *   participant Parse as parseInputValue
   *   participant Component as ComponentRef
   *
   *   Caller->>SetInputs: setInputs(component, inputs, metadata)
   *   SetInputs->>SetInputs: Iterate through inputs
   *   loop For each input
   *     SetInputs->>SetInputs: Check if input exists in metadata
   *     alt Input is 'props'
   *       SetInputs->>Parse: parseInputValue(component, value)
   *       Parse->>Parse: Recursively process nested objects
   *       Parse->>Component: setInput(key, value)
   *     else Input is valid
   *       SetInputs->>Component: setInput(key, value)
   *     end
   *   end
   * @static
   * @memberOf module:lib/engine/NgxRenderingEngine
   */
  static setInputs(
    component: ComponentRef<unknown>,
    inputs: KeyValue,
    metadata: ComponentMirror<unknown>
  ): void {
    function parseInputValue(
      component: ComponentRef<unknown>,
      input: KeyValue
    ) {
      Object.keys(input).forEach((key) => {
        const value = input[key];
        if (typeof value === 'object' && !!value)
          return parseInputValue(component, value);
        component.setInput(key, value);
      });
    }

    Object.entries(inputs).forEach(([key, value]) => {
      const prop = metadata.inputs.find(
        (item: { propName: string }) => item.propName === key
      );
      if (prop) {
        if (key === 'props') {
          component.setInput(key, value);
          parseInputValue(component, value);
        }

        // if (key === 'locale' && !value)
        //   value = getLocaleFromClassName(this._componentName);
        component.setInput(key, value);
      }
    });
  }
}