/**
* @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);
}
});
}
}
Source