import { InternalError } from "@decaf-ts/db-decorators";
import {
Constructor,
Model,
ModelConstructor,
ReservedModels,
ValidationKeys,
ValidationMetadata,
} from "@decaf-ts/decorator-validation";
import {
HTML5DateFormat,
HTML5InputTypes,
UIKeys,
ValidatableByAttribute,
ValidatableByType,
} from "./constants";
import {
FieldDefinition,
FieldProperties,
UIElementMetadata,
UIListItemElementMetadata,
UIListItemModelMetadata,
UIModelMetadata,
UIPropMetadata,
} from "./types";
import { RenderingError } from "./errors";
import { DecoratorMetadata, Reflection } from "@decaf-ts/reflection";
import { formatByType, generateUIModelID } from "./utils";
/**
* @description Abstract class for rendering UI components based on model metadata.
* @summary The RenderingEngine class provides a framework for converting model metadata into UI field definitions.
* It handles the translation of model properties to UI elements, applies validation rules, and manages different rendering flavors.
* This class is designed to be extended by specific rendering implementations.
*
* @template T The type of the rendering result, defaults to void
* @template R The type of the field definition, defaults to FieldDefinition<T>
*
* @param {string} flavour - The flavor of the rendering engine.
*
* @class RenderingEngine
*/
export abstract class RenderingEngine<T = void, R = FieldDefinition<T>> {
/**
* @description Cache for storing rendering engine instances or constructors.
* @private
* @static
*/
private static cache: Record<
string,
| Constructor<RenderingEngine<unknown, unknown>>
| RenderingEngine<unknown, unknown>
> = {};
/**
* @description The currently active rendering engine.
* @private
* @static
*/
private static current:
| Constructor<RenderingEngine<unknown, unknown>>
| RenderingEngine<unknown, unknown>;
/**
* Flag indicating whether the rendering engine has been initialized.
*/
protected initialized: boolean = false;
protected constructor(readonly flavour: string) {
RenderingEngine.register(this);
console.log(`decaf's ${flavour} rendering engine loaded`);
}
/**
* @description Initializes the rendering engine.
* @summary Abstract method to be implemented by subclasses for specific initialization logic.
*
* @param {...any[]} args - Any additional arguments needed for initialization.
* @returns {Promise<void>} A promise that resolves when initialization is complete.
*
* @abstract
*/
abstract initialize(...args: any[]): Promise<void>;
/**
* @description Translates between model types and HTML input types.
* @summary Converts model data types to appropriate HTML input types and vice versa.
*
* @param {string} key - The key to translate.
* @param {boolean} [toView=true] - Direction of translation (true for model to view, false for view to model).
* @returns {string} The translated type.
*/
translate(key: string, toView: boolean = true): string {
if (toView) {
switch (key) {
case ReservedModels.STRING:
return HTML5InputTypes.TEXT;
case ReservedModels.NUMBER:
case ReservedModels.BIGINT:
return HTML5InputTypes.NUMBER;
case ReservedModels.BOOLEAN:
return HTML5InputTypes.CHECKBOX;
case ReservedModels.DATE:
return HTML5InputTypes.DATE;
}
} else {
switch (key) {
case HTML5InputTypes.TEXT:
case HTML5InputTypes.EMAIL:
case HTML5InputTypes.COLOR:
case HTML5InputTypes.PASSWORD:
case HTML5InputTypes.TEL:
case HTML5InputTypes.URL:
return ReservedModels.STRING;
case HTML5InputTypes.NUMBER:
return ReservedModels.NUMBER;
case HTML5InputTypes.CHECKBOX:
return ReservedModels.BOOLEAN;
case HTML5InputTypes.DATE:
case HTML5InputTypes.DATETIME_LOCAL:
case HTML5InputTypes.TIME:
return ReservedModels.DATE;
}
}
return key;
}
/**
* @description Checks if a type is validatable by its nature.
* @summary Determines if a given UI key represents a type that is inherently validatable.
*
* @param {string} key - The UI key to check.
* @returns {boolean} True if the type is validatable, false otherwise.
*/
protected isValidatableByType(key: string): boolean {
return Object.keys(ValidatableByType).includes(key);
}
/**
* @description Checks if a type is validatable by attribute.
* @summary Determines if a given UI key represents a validation that can be applied as an attribute.
*
* @param {string} key - The UI key to check.
* @returns {boolean} True if the type is validatable by attribute, false otherwise.
*/
protected isValidatableByAttribute(key: string): boolean {
return Object.keys(ValidatableByAttribute).includes(key);
}
/**
* @description Converts validation metadata to an attribute value.
* @summary Transforms validation metadata into a value suitable for use as an HTML attribute.
*
* @param {string} key - The validation key.
* @param {ValidationMetadata} value - The validation metadata.
* @returns {string | number | boolean} The converted attribute value.
* @throws {Error} If the given key is not validatable by attribute.
*/
protected toAttributeValue(
key: string,
value: ValidationMetadata
): string | number | boolean {
if (!Object.keys(ValidatableByAttribute).includes(key))
throw new Error(
`Invalid attribute key "${key}". Expected one of: ${Object.keys(ValidatableByAttribute).join(", ")}.`
);
return key === UIKeys.REQUIRED ? true : value[key];
}
/**
* @description Converts a model to a field definition.
* @summary Processes a model instance, extracting UI-related metadata and validation rules to create a field definition.
*
* @template M Type extending Model
* @template T Type referencing the specific Rendering engine field properties/inputs
* @param {M} model - The model instance to convert.
* @param {Record<string, unknown>} [globalProps={}] - Global properties to apply to all child elements.
* @param {boolean} [generateId=true] - Flag indicating whether to populate the rendererId property.
* @returns {FieldDefinition<T>} A field definition object representing the UI structure of the model.
* @throws {RenderingError} If no UI definitions are set for the model or if there are invalid decorators.
*
* @mermaid
* sequenceDiagram
* participant C as Client
* participant RE as RenderingEngine
* participant R as Reflection
* participant M as Model
* C->>RE: toFieldDefinition(model, globalProps)
* RE->>R: getMetadata(UIKeys.UIMODEL, model.constructor)
* R-->>RE: UIModelMetadata
* RE->>R: getAllPropertyDecorators(model, UIKeys.REFLECT)
* R-->>RE: Record<string, DecoratorMetadata[]>
* RE->>R: getAllPropertyDecorators(model, ValidationKeys.REFLECT)
* R-->>RE: Record<string, DecoratorMetadata<ValidationMetadata>[]>
* loop For each property
* RE->>RE: Process UI decorators
* RE->>RE: Apply validation rules
* end
* RE-->>C: FieldDefinition<T>
*/
protected toFieldDefinition<M extends Model>(
model: M,
globalProps: Record<string, unknown> = {},
generateId: boolean = true
): FieldDefinition<T> {
const { inheritProps, ...globalPropsWithoutInherits } = globalProps;
globalProps = globalPropsWithoutInherits;
const classDecorators: UIModelMetadata[] | UIListItemModelMetadata[] = [
Reflect.getMetadata(
RenderingEngine.key(UIKeys.UIMODEL),
model.constructor
) ||
Reflect.getMetadata(
RenderingEngine.key(UIKeys.UIMODEL),
Model.get(model.constructor.name) as any
),
Reflect.getMetadata(
RenderingEngine.key(UIKeys.UILISTITEM),
model.constructor
) ||
Reflect.getMetadata(
RenderingEngine.key(UIKeys.UILISTITEM),
Model.get(model.constructor.name) as any
),
Reflect.getMetadata(
RenderingEngine.key(UIKeys.HANDLERS),
model.constructor
) ||
Reflect.getMetadata(
RenderingEngine.key(UIKeys.HANDLERS),
Model.get(model.constructor.name) as any
),
].filter(Boolean);
if (!classDecorators.length)
throw new RenderingError(
`No ui definitions set for model ${model.constructor.name}. Did you use @uimodel?`
);
const classDecorator = Object.assign(
{},
...classDecorators,
inheritProps ? inheritProps : {} // override tag and properties when it is a component that should inherit properties from its parent.
);
const { tag, props, item, handlers } = classDecorator;
const uiDecorators: Record<string, DecoratorMetadata[]> =
Reflection.getAllPropertyDecorators(model, UIKeys.REFLECT) as Record<
string,
DecoratorMetadata[]
>;
let children: FieldDefinition<Record<string, any>>[] | undefined;
let childProps: Record<string, any> = item?.props || {};
let mapper: Record<string, string> = {};
const getPath = (parent: string | undefined, prop: string) => {
return parent ? [parent, prop].join(".") : prop;
};
if (uiDecorators) {
const validationDecorators: Record<
string,
DecoratorMetadata<ValidationMetadata>[]
> = Reflection.getAllPropertyDecorators(
model,
ValidationKeys.REFLECT
) as Record<string, DecoratorMetadata<ValidationMetadata>[]>;
for (const key in uiDecorators) {
const decs = uiDecorators[key];
const types = Object.values(decs).filter(
(item) => item.key === UIKeys.PROP || item.key === UIKeys.ELEMENT
);
if (types?.length > 1)
throw new RenderingError(
`Only one type of decoration is allowed. Please choose between @uiprop and @uielement`
);
decs.shift();
decs.forEach((dec) => {
if (!dec) throw new RenderingError(`No decorator found`);
switch (dec.key) {
case UIKeys.PROP: {
childProps[key] = dec.props as UIPropMetadata;
break;
}
case UIKeys.CHILD: {
if (!Model.isPropertyModel(model, key))
throw new RenderingError(`Child "${key}" must be a model.`);
let Clazz;
const submodel = (model as Record<string, any>)[key] as Model;
const constructable =
typeof submodel === "object" &&
submodel !== null &&
!Array.isArray(submodel);
// create instance if undefined
if (!constructable) {
const clazzName = (dec.props.props as Record<string, any>)
?.name as string;
Clazz = new (Model.get(clazzName) as ModelConstructor<Model>)();
}
children = children || [];
const childrenGlobalProps = Object.assign({}, globalProps || {}, {
inheritProps: dec.props as UIModelMetadata,
childOf: getPath(globalProps?.childOf as string, key),
});
const childDefinition = this.toFieldDefinition(
submodel || Clazz, // Must avoid undefined values — an instance is required to retrieve properties.
childrenGlobalProps,
false
);
children.push(
childDefinition as FieldDefinition<Record<string, any>>
);
break;
}
case UIKeys.UILISTPROP: {
mapper = mapper || {};
mapper[dec.props?.name as string] = key;
const props = Object.assign(
{},
classDecorator.props?.item || {},
item?.props || {},
dec.props?.props || {},
globalProps
);
childProps = {
tag: item?.tag || props.render || "",
props: Object.assign({}, childProps?.props, { mapper }, props),
};
break;
}
case UIKeys.ELEMENT: {
children = children || [];
const uiProps: UIElementMetadata = dec.props as UIElementMetadata;
const props = Object.assign(
{},
uiProps.props as any,
{
path: getPath(
globalProps?.childOf as string,
uiProps.props!.name
),
childOf: undefined, // The childOf prop is passed by globalProps when it is a nested prop
},
globalProps
);
const childDefinition: FieldDefinition<Record<string, any>> = {
tag: uiProps.tag,
props,
};
const validationDecs: DecoratorMetadata<ValidationMetadata>[] =
validationDecorators[
key
] as DecoratorMetadata<ValidationMetadata>[];
const typeDec: DecoratorMetadataObject =
validationDecs.shift() as DecoratorMetadata;
for (const dec of validationDecs) {
if (this.isValidatableByAttribute(dec.key)) {
childDefinition.props[this.translate(dec.key)] =
this.toAttributeValue(dec.key, dec.props);
continue;
}
if (this.isValidatableByType(dec.key)) {
if (dec.key === HTML5InputTypes.DATE) {
childDefinition.props[UIKeys.FORMAT] =
dec.props.format || HTML5DateFormat;
}
childDefinition.props[UIKeys.TYPE] = dec.key;
continue;
}
}
if (!childDefinition.props[UIKeys.TYPE]) {
const basicType = (typeDec.props as { name: string }).name;
childDefinition.props[UIKeys.TYPE] = this.translate(
basicType.toLowerCase(),
true
);
}
childDefinition.props.value = formatByType(
childDefinition.props[UIKeys.TYPE],
model[key as keyof M],
childDefinition.props[UIKeys.FORMAT]
);
children.push(childDefinition);
break;
}
default:
throw new RenderingError(`Invalid key: ${dec.key}`);
}
});
}
}
const result: FieldDefinition<T> = {
tag: tag,
item: childProps as UIListItemElementMetadata,
props: Object.assign({}, props, globalProps, {
handlers: handlers || {},
}) as T & FieldProperties,
children: children as FieldDefinition<any>[],
};
if (generateId) result.rendererId = generateUIModelID(model);
return result;
}
/**
* @description Renders a model with global properties and additional arguments.
* @summary Abstract method to be implemented by subclasses to define specific rendering behavior.
*
* @template M Type extending Model
* @template R Rendering engine implementation specific output type
* @param {M} model - The model to be rendered.
* @param {Record<string, unknown>} globalProps - Global properties to be applied to all elements during rendering.
* @param {...any[]} args - Additional arguments that may be required for specific rendering implementations.
* @returns {R} The rendered result, type depends on the specific implementation.
*
* @abstract
*/
abstract render<M extends Model>(
model: M,
globalProps: Record<string, unknown>,
...args: any[]
): R;
/**
* @description Registers a rendering engine instance.
* @summary Adds a rendering engine to the static cache and sets it as the current engine.
*
* @param {RenderingEngine<unknown, unknown>} engine - The rendering engine to register.
* @throws {InternalError} If an engine with the same flavor already exists.
*
* @static
*/
static register(engine: RenderingEngine<unknown, unknown>) {
if (engine.flavour in this.cache)
throw new InternalError(
`Rendering engine under ${engine.flavour} already exists`
);
this.cache[engine.flavour] = engine;
this.current = engine;
}
/**
* @description Retrieves or initializes a rendering engine.
* @summary Gets an existing engine instance or creates and initializes a new one if given a constructor.
*
* @template O The type of the rendering engine output
* @param {Constructor<RenderingEngine<O>> | RenderingEngine<O>} obj - The engine instance or constructor.
* @returns {RenderingEngine<O>} The initialized rendering engine.
*
* @private
* @static
*/
private static getOrBoot<O>(
obj: Constructor<RenderingEngine<O>> | RenderingEngine<O>
): RenderingEngine<O> {
if (obj instanceof RenderingEngine) return obj as RenderingEngine<O>;
const engine: RenderingEngine<O> = new obj();
engine.initialize(); // make the booting async. use the initialized flag to control it
return engine as RenderingEngine<O>;
}
/**
* @description Retrieves a rendering engine by flavor.
* @summary Gets the current rendering engine or a specific one by flavor.
*
* @template O The type of the rendering engine output
* @param {string} [flavour] - The flavor of the rendering engine to retrieve.
* @returns {RenderingEngine<O>} The requested rendering engine.
* @throws {InternalError} If the requested flavor does not exist.
*
* @static
*/
static get<O>(flavour?: string): RenderingEngine<O> {
if (!flavour)
return this.getOrBoot<O>(
this.current as Constructor<RenderingEngine<O>> | RenderingEngine<O>
);
if (!(flavour in this.cache))
throw new InternalError(
`Rendering engine under ${flavour} does not exist`
);
return this.getOrBoot<O>(
this.cache[flavour] as
| Constructor<RenderingEngine<O>>
| RenderingEngine<O>
);
}
/**
* @description Renders a model using the appropriate rendering engine.
* @summary Determines the correct rendering engine for a model and invokes its render method.
*
* @template M Type extending Model
* @param {M} model - The model to render.
* @param {...any[]} args - Additional arguments to pass to the render method.
* @returns {any} The result of the rendering process.
* @throws {InternalError} If no registered model is found.
*
* @static
*/
static render<M extends Model>(model: M, ...args: any[]): any {
const constructor =
Model.get(model.constructor.name) || Model.fromObject(model);
if (!constructor) throw new InternalError("No model registered found");
const flavour = Reflect.getMetadata(
RenderingEngine.key(UIKeys.RENDERED_BY),
constructor as ModelConstructor<Model>
);
// @ts-expect-error for the var args type check
return RenderingEngine.get(flavour).render(model, ...args);
}
/**
* @description Generates a metadata key for UI-related properties.
* @summary Prefixes a given key with the UI reflection prefix.
*
* @param {string} key - The key to prefix.
* @returns {string} The prefixed key.
*
* @static
*/
static key(key: string): string {
return `${UIKeys.REFLECT}${key}`;
}
}
Source