import { InternalError, NotFoundError } from "@decaf-ts/db-decorators";
import {
HTML5DateFormat,
HTML5InputTypes,
UIKeys,
ValidatableByAttribute,
ValidatableByType,
} from "./constants";
import {
CrudOperationKeys,
FieldDefinition,
FieldProperties,
UIClassMetadata,
UIElementMetadata,
UIListItemElementMetadata,
UIListPropMetadata,
UIModelMetadata,
UIPropMetadata,
} from "./types";
import { RenderingError } from "./errors";
import { formatByType, generateUIModelID } from "./utils";
import { IPagedComponentProperties } from "./interfaces";
import { Constructor, Metadata } from "@decaf-ts/decoration";
import {
Model,
ModelConstructor,
ReservedModels,
ValidationKeys,
ValidationMetadata,
} from "@decaf-ts/decorator-validation";
/**
* @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.name.toLowerCase():
return HTML5InputTypes.TEXT;
case ReservedModels.NUMBER.name.toLowerCase():
case ReservedModels.BIGINT.name.toLowerCase():
return HTML5InputTypes.NUMBER;
case ReservedModels.BOOLEAN.name.toLowerCase():
return HTML5InputTypes.CHECKBOX;
case ReservedModels.DATE.name.toLowerCase():
return HTML5InputTypes.DATE;
}
} else {
switch (key) {
case HTML5InputTypes.SELECT:
case HTML5InputTypes.TEXT:
case HTML5InputTypes.EMAIL:
case HTML5InputTypes.COLOR:
case HTML5InputTypes.PASSWORD:
case HTML5InputTypes.TEL:
case HTML5InputTypes.URL:
case HTML5InputTypes.SEARCH:
case HTML5InputTypes.HIDDEN:
case HTML5InputTypes.TEXTAREA:
case HTML5InputTypes.RADIO:
return ReservedModels.STRING.name.toLowerCase();
case HTML5InputTypes.NUMBER:
return ReservedModels.NUMBER.name.toLowerCase();
case HTML5InputTypes.CHECKBOX:
return ReservedModels.BOOLEAN.name.toLowerCase();
case HTML5InputTypes.DATE:
case HTML5InputTypes.DATETIME_LOCAL:
case HTML5InputTypes.TIME:
return ReservedModels.DATE.name.toLowerCase();
}
}
return key;
}
/**
* @description Retrieves class decorator metadata for a model instance
* @summary Extracts UI-related class decorators from a model and returns them as an array
* This method collects metadata from various UI class decorators including @uimodel,
* @uilistmodel, @uihandlers, and @uilayout applied to the model class.
*
* @template M Type extending Model
* @param {M} model - The model instance to extract metadata from
* @returns {UIClassMetadata[]} Array of UI class metadata objects
*
* @private
*/
private getClassDecoratorsMetadata<M extends Model>(
model: M
): UIClassMetadata[] {
return [
Model.uiModelOf(model.constructor as Constructor<M>),
Model.uiListModelOf(model.constructor as Constructor<M>),
Model.uiHandlersFor(model.constructor as Constructor<M>),
Model.uiLayoutOf(model.constructor as Constructor<M>),
].filter(Boolean) as UIClassMetadata[];
}
/**
* @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 = this.getClassDecoratorsMetadata<M>(model);
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 = Model.uiPropertiesOf(
model.constructor as Constructor<M>
) as (keyof M)[];
// 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, any> = {};
const getPath = (parent: string | undefined, prop: string) => {
return parent ? [parent, prop].join(".") : prop;
};
if (uiDecorators) {
const validationDecorators: Record<string, any> =
Metadata.get(
model.constructor as Constructor,
ValidationKeys.REFLECT
) || {};
// const validationDecorators: Record<
// string,
// DecoratorMetadata<ValidationMetadata>[]
// > = Reflection.getAllPropertyDecorators(
// model,
// ValidationKeys.REFLECT
// ) as Record<string, DecoratorMetadata<ValidationMetadata>[]>;
for (const key of uiDecorators) {
const decs = Model.uiDecorationOf(
model.constructor as Constructor<M>,
key
);
let type: any;
try {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type = Model.uiTypeOf(model.constructor as Constructor<M>, key);
} catch (e: unknown) {
if (!(e instanceof NotFoundError)) throw e;
}
// const types = Object.values(decs).filter(({ key }) =>
// [UIKeys.PROP, UIKeys.ELEMENT, UIKeys.CHILD].includes(key)
// );
// if (!type)
// throw new RenderingError(
// `Only one type of decoration is allowed. Please choose between @uiprop, @uichild or @uielement`
// );
const hasHideOnDecorator = Model.uiIsHidden(
model.constructor as Constructor<M>,
key
);
if (hasHideOnDecorator) {
const hasUiElementDecorator = Model.uiElementOf(
model.constructor as Constructor<M>,
key
);
if (!hasUiElementDecorator)
throw new RenderingError(
`@uielement no found in "${key as string}". It is required to use hiddenOn decorator.`
);
}
const sorted = Object.entries(decs)
.map(([k, v]) => ({
key: k,
props: v,
}))
.sort((a) => {
return [UIKeys.ELEMENT, UIKeys.CHILD].includes(a.key) ? -1 : 1;
});
sorted.forEach((dec) => {
if (!dec) throw new RenderingError(`No decorator found`);
switch (dec.key) {
case UIKeys.PROP: {
childProps[key as any] = dec.props as UIPropMetadata;
break;
}
case UIKeys.CHILD: {
if (!Model.isPropertyModel(model, key as string))
throw new RenderingError(
`Child "${key as string}" must be a model.`
);
let Clazz;
const submodel = (model as Record<string, any>)[
key as any
] as Model;
const constructable =
typeof submodel === "object" &&
submodel !== null &&
!Array.isArray(submodel);
// create instance if undefined
if (!constructable) {
const clazzName = (
(dec.props as any).props as Record<string, any>
)?.name as string;
Clazz = new (Model.get(clazzName) as ModelConstructor<Model>)();
}
children = children || [];
const childrenGlobalProps = Object.assign(
{},
globalProps || {},
{ model: Clazz },
{
inheritProps: dec.props as UIModelMetadata,
childOf: getPath(
globalProps?.childOf as string,
key as string
),
}
);
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 || {};
if ((dec.props as UIListPropMetadata).name)
mapper[
(dec.props as UIListPropMetadata).name as keyof typeof mapper
] = key;
const props = Object.assign(
{},
classDecorator.props?.item || {},
item?.props || {},
(dec.props as UIListPropMetadata).props || {},
globalProps
);
childProps = {
tag: item?.tag || props.render || "",
props: Object.assign({}, childProps?.props, { mapper }, props),
};
break;
}
case UIKeys.EVENTS:
case UIKeys.HIDDEN:
case UIKeys.PAGE:
case UIKeys.ORDER:
case UIKeys.UILAYOUTPROP:
case UIKeys.ELEMENT: {
children = children || [];
const uiProps: UIElementMetadata = dec.props as UIElementMetadata;
const props = Object.assign(
{},
childProps?.props,
uiProps.props || {},
uiProps?.props?.name
? {
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, {name: uiProps.props?.name || key }
);
if (dec.key === UIKeys.ELEMENT) {
const childDefinition: FieldDefinition<Record<string, any>> = {
tag: uiProps.tag || childProps?.tag || tag || "",
props,
};
const validationDecs = validationDecorators[
key as any
] as ValidationMetadata[];
if (validationDecs) {
for (const dec of Object.entries(validationDecs).map(
([k, v]) => ({ key: k, props: v })
)) {
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 = Metadata.type(
model.constructor as Constructor,
key as 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);
} else {
const child = children.find((c) =>
c.props?.name === key ||
([UIKeys.UILAYOUTPROP, UIKeys.PAGE, UIKeys.EVENTS].includes(dec.key) &&
(c.props?.childOf === key || c.props?.childOf?.endsWith(`.${key as string}`)))
);
if (child) {
if (dec.key !== UIKeys.UILAYOUTPROP) {
child.props = Object.assign({}, child.props, {
[dec.key]: uiProps,
});
} else {
const { row, col, props } = dec.props as any;
child.props = Object.assign(
{},
props || {},
child.props,
row,
col
);
}
}
}
break;
}
default:
throw new RenderingError(`Invalid key: ${dec.key}`);
}
});
}
}
globalProps = Object.assign({}, props, globalProps, {
handlers: handlers || {},
});
const operation = globalProps?.operation;
children = children
?.sort((a, b) => (a?.props?.order ?? 0) - (b?.props?.order ?? 0))
.filter((item) => {
const hiddenOn = (item?.props?.hidden as CrudOperationKeys[]) || [];
if (!hiddenOn?.length) return item;
if (!hiddenOn.includes(operation as CrudOperationKeys)) return item;
});
const result: FieldDefinition<T> = {
tag: tag,
item: childProps as UIListItemElementMetadata,
props: globalProps as T & FieldProperties & IPagedComponentProperties,
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 = Model.renderedBy(model.constructor as Constructor<M>);
// @ts-expect-error for the var args type check
return RenderingEngine.get(flavour).render(model, ...args);
}
}
//
// Decoration.for(DBKeys.ID)
// .extend({
// decorator: function pk(options: { generated: boolean }) {
// return function innerPk(target: object, propertyKey?: any) {
// if (options.generated)
// hideOn(OperationKeys.CREATE, OperationKeys.UPDATE)(
// target,
// propertyKey
// );
// };
// },
// })
// .apply();
//
// Decoration.for(DBKeys.TIMESTAMP)
// .extend({
// decorator: function timestamp(ops: OperationKeys[]) {
// return hideOn(...(ops as any[]));
// },
// })
// .apply();
//
// Decoration.for(DBKeys.COMPOSED)
// .extend({
// decorator: function composed() {
// return hideOn(OperationKeys.CREATE, OperationKeys.UPDATE);
// },
// })
// .apply();
//
// Decoration.for("createdBy")
// .extend({
// decorator: function createdBy() {
// return hideOn(OperationKeys.CREATE, OperationKeys.UPDATE);
// },
// })
// .apply();
//
// Decoration.for("updatedBy")
// .extend({
// decorator: function createdBy() {
// return hideOn(OperationKeys.CREATE, OperationKeys.UPDATE);
// },
// })
// .apply();
Source