import { AfterViewInit, Directive, Input } from '@angular/core';
import {
CrudOperations,
InternalError,
IRepository,
NotFoundError,
OperationKeys,
PrimaryKeyType,
} from '@decaf-ts/db-decorators';
import { Condition, EventIds, Repository } from '@decaf-ts/core';
import { Model, Primitives } from '@decaf-ts/decorator-validation';
import { ComponentEventNames } from '@decaf-ts/ui-decorators';
import { NgxPageDirective } from './NgxPageDirective';
import {
IBaseCustomEvent,
ICrudFormEvent,
ILayoutModelContext,
IModelComponentSubmitEvent,
} from './interfaces';
import { DecafRepository, KeyValue } from './types';
import { Constructor, Metadata } from '@decaf-ts/decoration';
import { getModelAndRepository } from './helpers';
@Directive()
export abstract class NgxModelPageDirective extends NgxPageDirective implements AfterViewInit {
/**
* @description The CRUD operation type to be performed on the model.
* @summary Specifies which operation (Create, Read, Update, Delete) this component instance
* should perform. This determines the UI behavior, form configuration, and available actions.
* The operation affects form validation, field availability, and the specific repository
* method called during data submission.
*
* @type {OperationKeys.CREATE | OperationKeys.READ | OperationKeys.UPDATE | OperationKeys.DELETE}
* @default OperationKeys.READ
* @memberOf ModelPage
*/
@Input()
override operation:
| OperationKeys.CREATE
| OperationKeys.READ
| OperationKeys.UPDATE
| OperationKeys.DELETE = OperationKeys.READ;
/**
* @description Error message from failed operations.
* @summary Stores error messages that occur during repository operations such as
* data loading, creation, update, or deletion. When set, this indicates an error
* state that should be displayed to the user. Cleared on successful operations.
* @type {string | undefined}
* @default undefined
* @memberOf NgxModelPageDirective
*/
errorMessage: string | undefined = undefined;
// constructor(@Inject(CPTKN) hm: boolean = true, @Inject(CPTKN) protected toastController?: ToastController) {
// super("NgxModelPageDirective");
// }
/**
* @description Lazy-initialized repository getter with model resolution.
* @summary Creates and returns a repository instance for the specified model name.
* Resolves the model constructor from the global registry, instantiates the repository,
* and creates a new model instance. Throws an InternalError if the model is not
* properly registered with the @Model decorator.
*
* @return {DecafRepository<Model>} The repository instance for the current model
*
* @throws {InternalError} When the model is not found in the registry
*/
override get repository(): DecafRepository<Model> {
const modelName = this.modelName || this.model?.constructor?.name;
try {
if (!this._repository && modelName) {
const constructor = Model.get(modelName);
if (!constructor)
throw new InternalError('Cannot find model. was it registered with @model?');
this._repository = Repository.forModel(constructor);
if (!this.pk) this.pk = Model.pk(constructor) as string;
this.model = new constructor() as Model;
}
} catch (error: unknown) {
this.log.warn(
`Error getting repository for model: ${modelName}. ${(error as Error).message}`,
);
this._repository = undefined;
// throw new InternalError((error as Error)?.message || (error as string));
}
return this._repository as DecafRepository<Model>;
}
override async initialize(): Promise<void> {
await super.initialize();
await this.refresh(this.modelId);
this.changeDetectorRef.detectChanges();
this.getLocale(this.modelName as string);
}
// /**
// * @description Angular lifecycle hook for component initialization.
// * @summary Initializes the component by setting up the logger instance using the getLogger
// * utility. This ensures that logging is available throughout the component's lifecycle
// * for error tracking and debugging purposes.
// */
// async ionViewWillEnter(): Promise<void> {
// // await super.ionViewWillEnter();
// }
/**
* @description Refreshes the component data by loading the specified model instance.
* @summary Loads model data from the repository based on the current operation type.
* For READ, UPDATE, and DELETE operations, fetches the existing model data using
* the provided unique identifier. Handles errors gracefully by logging them through
* the logger instance.
*
* @param {string} [uid] - The unique identifier of the model to load; defaults to modelId
*/
override async refresh(uid?: EventIds): Promise<void> {
if (!uid) {
uid = this.modelId;
}
try {
this._repository = this.repository;
switch (this.operation) {
case OperationKeys.READ:
case OperationKeys.UPDATE:
case OperationKeys.DELETE:
{
this.model = await this.handleRead(uid);
this.changeDetectorRef.detectChanges();
}
break;
}
} catch (error: unknown) {
if (error instanceof NotFoundError) {
this.errorMessage = error.message;
}
this.log.error(error as Error | string);
}
}
/**
* @description Enables CRUD operations except those specified.
* @summary Sets the allowed CRUD operations for the component, excluding any operations provided in the 'except' array.
*
* @param {OperationKeys[]} except - Array of operations to exclude from the allowed operations.
*/
protected enableCrudOperations(except: OperationKeys[] = []): CrudOperations[] {
const operations = [
OperationKeys.CREATE,
OperationKeys.READ,
OperationKeys.UPDATE,
OperationKeys.DELETE,
] as CrudOperations[];
if (!except?.length) {
this.operations = [...operations];
} else {
this.operations = operations.filter((op) => !except.includes(op));
}
return this.operations;
}
/**
* @description Generic event handler for component events.
* @summary Processes incoming events from child components and routes them to appropriate
* handlers based on the event name. Currently handles SUBMIT events by delegating to
* the submit method. This centralized event handling approach allows for easy
* extension and consistent event processing.
*
* @param {IBaseCustomEvent} event - The event object containing event data and metadata
*/
override async handleEvent(
event: IBaseCustomEvent & ICrudFormEvent & CustomEvent,
repository?: DecafRepository<Model>,
): Promise<void> {
const { name, role, handler, data, handlers } = event;
if (handler && role) {
this.handlers = handlers || {};
return await handler.bind(this)(event, data || {}, role);
}
switch (name) {
case ComponentEventNames.Submit:
await this.submit(event, true, repository);
break;
default:
this.listenEvent.emit(event as IBaseCustomEvent | ICrudFormEvent);
}
}
/**
* @description Parses and transforms form data for repository operations.
* @summary Converts raw form data into the appropriate format for repository operations.
* For DELETE operations, returns the primary key value (string or number). For CREATE
* and UPDATE operations, builds a complete model instance using the Model.build method
* with proper primary key assignment for updates.
*
* @param {Partial<Model>} data - The raw form data to be processed
* @return {Model | string | number} Processed data ready for repository operations
* @private
*/
// private buildModel<M extends Model>(
// data: KeyValue | KeyValue[],
// repository: DecafRepository<M>,
// operation?: OperationKeys,
// ): M | M[] | EventIds {
// if (!operation) operation = this.operation;
// operation = (
// [OperationKeys.READ, OperationKeys.DELETE].includes(operation)
// ? OperationKeys.DELETE
// : operation.toLowerCase()
// ) as OperationKeys;
// if (Array.isArray(data))
// return data.map((item) => this.buildModel(item, repository, operation)) as M[];
// const pk = Model.pk(repository.class as Constructor<M>);
// const pkType = Metadata.type(repository.class as Constructor<M>, pk as string).name;
// const modelId = (this.modelId || data[pk as string]) as Primitives;
// if (!this.modelId) this.modelId = modelId;
// const uid = this.parsePkValue(
// operation === OperationKeys.DELETE ? data[pk as string] : modelId,
// pkType,
// );
// if (operation !== OperationKeys.DELETE) {
// const properties = Metadata.properties(repository.class as Constructor<M>) as string[];
// const relation =
// pk === this.pk
// ? {}
// : properties.includes(this.pk as string) && !data[this.pk as string]
// ? { [this.pk as string]: modelId }
// : {};
// return Model.build(
// Object.assign(
// data || {},
// relation,
// modelId && !data[this.pk] ? { [this.pk]: modelId } : {},
// ),
// repository.class.name,
// ) as M;
// }
// return uid as EventIds;
// }
async getTransactionRepository<M extends Model>(
event: ICrudFormEvent,
repo: DecafRepository<M>,
): Promise<DecafRepository<M>> {
if (!repo) {
repo = this._repository as DecafRepository<M>;
}
if (!repo || repo.class.name !== this.model?.constructor.name) {
const { context } = (await this.process(
event,
this.model as Model,
false,
)) as ILayoutModelContext;
if (context) {
// parse data from main model to event
event.data = context.data;
return context.repository as DecafRepository<M>;
}
}
if (!repo) {
repo = this.repository as DecafRepository<M>;
}
return repo;
}
// async beginTransaction<M extends Model>(
// data: M,
// repository: DecafRepository<M>,
// operation: CrudOperations,
// ): Promise<M | M[] | EventIds> {
// const hook = `before${operation.charAt(0).toUpperCase() + operation.slice(1)}`;
// const handler = this.handlers?.[hook] || undefined;
// const model = this.buildModel(data || {}, repository, operation);
// if (handler && typeof handler === 'function') {
// (await handler.bind(this)(model, repository, this.modelId)) as M;
// }
// return model as M;
// }
/**
* @description Retrieves a model instance from the repository by unique identifier.
* @summary Fetches a specific model instance using the repository's read method.
* Handles both string and numeric identifiers by automatically converting numeric
* strings to numbers. If no identifier is provided, logs an informational message
* and navigates back to the previous page. Returns undefined for missing instances.
*
* @param {string} uid - The unique identifier of the model instance to retrieve
* @return {Promise<Model | undefined>} Promise resolving to the model instance or undefined
*/
async handleRead<M extends Model>(
uid?: EventIds,
repository?: IRepository<M>,
modelName?: string,
): Promise<Model | undefined> {
if (!uid) {
this.log.info('No key passed to model page read operation, backing to last page');
return undefined;
}
if (!modelName) {
modelName = this.modelName;
if (!modelName && this.model?.constructor)
this.modelName = modelName = this.model.constructor.name;
}
const getRepository = async (
modelName: string,
acc: KeyValue = {},
parent: string = '',
): Promise<DecafRepository<Model> | void> => {
if (this._repository) return this._repository as DecafRepository<Model>;
const constructor = Model.get(modelName);
if (constructor) {
const properties = this.getModelProperties(constructor);
for (const prop of properties) {
const type = this.getModelPropertyType(constructor as Constructor<Model>, prop);
const context = getModelAndRepository(type as string);
if (!context) {
acc[prop] = {};
return getRepository(type, acc, prop);
}
const { repository, model, pk, pkType } = context;
if (!this.pk) {
this.pk = pk;
this.pkType = pkType;
}
uid = this.parsePkValue(uid as PrimaryKeyType, this.pkType);
if (!this.modelId) this.modelId = uid as PrimaryKeyType;
const query = await repository
.select()
.where(Condition.attribute<Model>(this.pk as keyof Model).eq(uid))
.execute();
if (modelName === this.modelName) {
const data = query?.length ? (query?.length === 1 ? query[0] : query) : undefined;
acc[prop] = data;
const model = await this.transactionEnd(data as Model, repository, OperationKeys.READ);
this.name = prop;
this.model = Model.build({ [prop]: model ? model : data }, modelName);
} else {
// model[parent] = {
// ...model[parent],
// [prop]: data,
// };
}
}
// this._data = model;
// this.changeDetectorRef.detectChanges();
// this.model = Model.build(model, this.modelName as string);
// this.changeDetectorRef.detectChanges();
}
};
repository = (repository || (await getRepository(modelName as string))) as IRepository<M>;
if (!repository) {
return this.model as M;
}
try {
if (!this.pk) this.pk = Model.pk(repository.class) as string;
const res = await repository.read(
this.parsePkValue(uid as Primitives, this.getModelPkType(repository.class)),
);
return res;
} catch (error: unknown) {
this.log
.for(this.handleRead)
.info(`Error getting model instance with id ${uid}: ${(error as Error).message}`);
return undefined;
}
}
async process<M extends Model>(
event: ICrudFormEvent,
model?: M,
submit: boolean = false,
): Promise<ILayoutModelContext | IModelComponentSubmitEvent<M>> {
const result = { models: {} } as ILayoutModelContext;
const iterate = async (evt: ICrudFormEvent, model: string | M, parent?: string) => {
const constructor = this.getModelConstrutor(model);
if (constructor) {
const properties = Metadata.properties(constructor) as string[];
const promises = properties.map(async (prop) => {
const type = Metadata.type(constructor as Constructor<Model>, prop).name;
let data =
(evt.data as KeyValue)[prop] ||
(parent
? (event.data as KeyValue)[parent as string][prop]
: (event.data as KeyValue)[prop]);
if (data) {
if (parent || Array.isArray(data)) data = [...Object.values(data)];
const context = getModelAndRepository(type);
evt = { ...evt, data };
if (!context) {
await iterate(evt, type, prop);
} else {
const { repository, model, pk, pkType } = context;
if (!this.pk) this.pk = pk;
if (!result.context) {
result.context = {
repository,
model,
pk: pk,
pkType: pkType,
data,
};
if (!this.modelId) this.modelId = (data as KeyValue)[pk];
} else {
Object.assign(result.context.data, {
[prop]: Array.isArray(data) ? this.buildTransactionModel(data, repository) : data,
});
}
Object.assign(result.models, {
[prop]: {
model,
data,
repository: repository as IRepository<Model>,
pk,
},
});
delete (evt.data as KeyValue)?.[prop];
evt = { ...evt, data: evt.data };
}
}
});
await Promise.all(promises);
}
};
if (!model) {
model = this.model as M;
}
await iterate(event, model);
if (!submit) {
return result;
}
return (await this.batchOperation(result)) as IModelComponentSubmitEvent<M>;
}
/**
* @description Handles form submission events for CRUD operations.
* @summary Processes form submission by executing the appropriate repository operation
* based on the current operation type. Handles CREATE, UPDATE, and DELETE operations,
* processes the form data, refreshes the repository cache, navigates back to the previous
* page, and displays success notifications. Comprehensive error handling ensures robust
* operation with detailed logging.
*
* @param {IBaseCustomEvent} event - The submit event containing form data
* @return {Promise<IModelComponentSubmitEvent|void>} Promise that resolves on success or throws on error
*/
override async submit<M extends Model>(
event: ICrudFormEvent,
redirect: boolean = false,
repo?: DecafRepository<M>,
pk?: string,
): Promise<IModelComponentSubmitEvent<M>> {
let success = false;
let message = '';
let result = null;
try {
const repository = await this.getTransactionRepository(event, repo as DecafRepository<M>);
const { data, role } = event;
const operation = (role || this.operation) as CrudOperations;
if (data) {
if (!pk) pk = Model.pk(repository.class) as string;
if (!this.modelId) this.modelId = (data as M)[pk as keyof M] as PrimaryKeyType;
const model = await this.transactionBegin(data as M, repository, operation);
if (!model) {
return {
success: false,
aborted: true,
model: null,
message: 'Operation aborted by before hook',
};
}
switch (operation) {
case OperationKeys.CREATE:
result = await (!Array.isArray(model)
? repository.create(model as M)
: repository.createAll(model as M[]));
break;
case OperationKeys.UPDATE: {
const models = (!Array.isArray(model) ? [model] : model) as M[];
for (const m of models) {
const uid = m[pk as keyof M];
const check = uid ? await repository.read(uid as PrimaryKeyType) : false;
result = await (!check ? repository.create(m as M) : repository.update(m as M));
}
break;
}
case OperationKeys.DELETE:
result = await (!Array.isArray(model)
? repository.delete(model as PrimaryKeyType)
: repository.deleteAll(model as []));
break;
}
const pkValue = (model as KeyValue)[pk] || (model as KeyValue)[this.pk] || model || '';
message = await this.translate(`operations.${operation}.${result ? 'success' : 'error'}`, {
'0': repository.class.name || pk,
'1': pkValue,
});
success = result ? true : false;
if (success) {
if ((result as KeyValue)?.[this.pk]) this.modelId = (result as KeyValue)[this.pk];
if (redirect) this.location.back();
}
}
} catch (error: unknown) {
this.log
.for(this.submit)
.error(
`Error during ${this.operation} operation: ${
error instanceof Error ? error.message : (error as string)
}`,
);
message = error instanceof Error ? error.message : (error as string);
}
return { ...event, success, message, model: result, aborted: false };
}
async batchOperation(context: ILayoutModelContext, redirect: boolean = false): Promise<any> {
// const { data, repository, pk } = context.context;
// return data;
// return await this.submit({data}, false, repository, pk);
// const result: boolean[] = [];
// let resultMessage = '';
// const promises = Object.keys(models).map(async(m) => {
// const {model, repository} = models[m];
// const {success} = await this.submit({data: model}, false, repository as IRepository<Model>);
// if(success)
// resultMessage = await this.translate('operations.multiple.success');
// result.push(success);
// })
// await Promise.all(promises);
// const success = result.every((r: boolean) => r);
// if (success && redirect)
// this.location.back();
// return {...{data: models}, success, message: resultMessage};
}
}
Source