Source

lib/engine/NgxModelPageDirective.ts

import { Directive, Input } from '@angular/core';
import {
  CrudOperations,
  InternalError,
  IRepository,
  NotFoundError,
  OperationKeys,
} from '@decaf-ts/db-decorators';
import { EventIds, Repository } from '@decaf-ts/core';
import { Model, Primitives } from '@decaf-ts/decorator-validation';
import { NgxPageDirective } from './NgxPageDirective';
import { ComponentEventNames } from './constants';
import { IBaseCustomEvent, IModelPageCustomEvent } from './interfaces';
import { KeyValue, DecafRepository } from './types';
import { Constructor, Metadata } from '@decaf-ts/decoration';
import { getModelAndRepository } from '../for-angular-common.module';

@Directive()
export abstract class NgxModelPageDirective extends NgxPageDirective {
  /**
   * @description Primary key value of the current model instance.
   * @summary Specifies the primary key value for the current model record being displayed or
   * manipulated by the component. This identifier is used for CRUD operations that target
   * specific records, such as read, update, and delete operations. The value corresponds to
   * the field designated as the primary key in the model definition.
   * @type {EventIds}
   * @memberOf module:lib/engine/NgxComponentDirective
   */
  @Input()
  override modelId!: EventIds;

  /**
   * @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 The name of the model class to operate on.
   * @summary Identifies which registered model class this component should work with.
   * This name is used to resolve the model constructor from the global model registry
   * and instantiate the appropriate repository for data operations. The model must
   * be properly registered using the @Model decorator for resolution to work.
   *
   * @type {string}
   *  @memberOf ModelPage
   */
  @Input()
  modelName!: string;

  /**
   * @description Array of operations allowed for the current model instance.
   * @summary Dynamically determined list of operations that are permitted based on
   * the current context and model state. Initially contains CREATE and READ operations,
   * with UPDATE and DELETE added when a modelId is present. This controls which
   * action buttons are displayed and which operations are accessible to the user.
   *
   * @type {OperationKeys[]}
   * @default [OperationKeys.CREATE, OperationKeys.READ]
   * @memberOf ModelPage
   */
  allowedOperations: OperationKeys[] = [
    OperationKeys.CREATE,
    OperationKeys.READ,
  ];

  /**
   * @description Current model data loaded from the repository.
   * @summary Stores the raw data object representing the current model instance retrieved
   * from the repository. This property holds the actual data values for the model being
   * displayed or edited, and is set to undefined when no data is available or when an
   * error occurs during data loading.
   * @type {KeyValue | undefined}
   * @default undefined
   * @memberOf NgxModelPageDirective
   */
  modelData: KeyValue | undefined = undefined;

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

  override get pageTitle(): string {
    if (!this.modelName && this.model instanceof Model)
      this.modelName = this.model?.constructor?.name || '';
    if (!this.operation)
      return this.title ? this.title : `Listing ${this.modelName}`;
    const operation =
      this.operation.charAt(0).toUpperCase() +
      this.operation.slice(1).toLowerCase();
    return this.modelName ? `${operation} ${this.modelName}` : this.title;
  }
  /**
   * @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> | undefined {
    try {
      if (!this._repository) {
        const constructor = Model.get(this.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: ${this.modelName}. ${(error as Error).message}`
      );
      this._repository = undefined;
      // throw new InternalError((error as Error)?.message || (error as string));
    }
    return this._repository as DecafRepository<Model>;
  }

  /**
   * @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();
    if (this.modelId)
      this.allowedOperations = this.allowedOperations.concat([
        OperationKeys.UPDATE,
        OperationKeys.DELETE,
      ]);
    this.getLocale(this.modelName as string);
    await this.refresh(this.modelId);
    this.initialized = true;
  }

  /**
   * @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.handleGet(uid || this.modelId)) as Model;
          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[] = []): void {
    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));
    }
  }

  /**
   * @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,
    repository?: DecafRepository<Model>
  ): Promise<void> {
    const { name } = event;
    switch (name) {
      case ComponentEventNames.SUBMIT:
        await this.submit(event, repository);
        break;
    }
  }

  /**
   * @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<IModelPageCustomEvent|void>} Promise that resolves on success or throws on error
   */
  override async submit(
    event: IBaseCustomEvent,
    repository?: DecafRepository<Model>,
    redirect: boolean = false
  ): Promise<IModelPageCustomEvent | void> {
    try {
      if (!repository) repository = this._repository as DecafRepository<Model>;

      // const pk = this.pk || Model.pk(repository.class as Constructor<Model>);
      const operation = this.operation;
      const { data } = event;
      if (data) {
        const model = this.parseData(data || {}, operation, repository);
        let result;
        switch (operation) {
          case OperationKeys.CREATE:
            result = await (!Array.isArray(model)
              ? repository.create(model as unknown as Model)
              : repository.createAll(model as unknown as Model[]));
            break;
          case OperationKeys.UPDATE:
            result = await (!Array.isArray(model)
              ? repository.update(model as unknown as Model)
              : repository.updateAll(model as unknown as Model[]));
            break;
          case OperationKeys.DELETE:
            result = await (!Array.isArray(model)
              ? repository.delete(model as string | number)
              : repository.deleteAll(model as string[] | number[]));
            break;
        }

        const message = await this.translate(
          !Array.isArray(result)
            ? `operations.${operation}.${result ? 'success' : 'error'}`
            : `operations.multiple`
        );

        if (result) {
          // repository.refresh(this.modelName, this.operation, this.modelId as EventIds);
          if (redirect) this.location.back();
        }
        return {
          ...event,
          success: result ? true : false,
          message,
        };
      }
    } catch (error: unknown) {
      this.log.for(this.submit).error(
        `Error during ${this.operation} operation: ${
          error instanceof Error ? error.message : (error as string)
        }`
      );
      return {
        ...event,
        success: false,
        message: error instanceof Error ? error.message : (error as string),
      };
    }
  }


  /**
   * @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 handleGet(
    uid?: EventIds,
    repository?: IRepository<Model>,
    modelName?: string
  ): Promise<Model | undefined> {
    if (!uid) {
      this.log.info(
        'No key passed to model page read operation, backing to last page'
      );
      this.location.back();
      return undefined;
    }

    if(!modelName)
      modelName = this.modelName;

    const getRepository = async (
      modelName: string,
      parent?: string,
      model?: KeyValue
    ): Promise<DecafRepository<Model> | undefined> => {
      if (this._repository) return this._repository as DecafRepository<Model>;
      const constructor = Model.get(modelName);
      if (constructor) {
        const properties = Metadata.properties(
          constructor as Constructor<Model>
        ) as string[];
        // if (!model) model = {} as KeyValue;
        for (const prop of properties) {
          const type = Metadata.type(
            constructor as Constructor<Model>,
            prop
          ).name;
          const context = getModelAndRepository(type as string);
          if (!context) return getRepository(type, prop, model);
          const { repository } = context;
          if (modelName === this.modelName) {
            const data = await this.handleGet(uid, repository, modelName);
            this.model = Model.build({ [prop]: data }, modelName);
          }

          // else {
          //   model[prop as string] = Model.build({}, type);
          // }
        }
        // (this.model as KeyValue)[parent as string] = Model.build(
        //   model,
        //   modelName
        // );
      }
    };

    repository = (repository ||
      (await getRepository(modelName as string))) as IRepository<Model>;
    if (!repository) return this.model as Model;
    const type = Metadata.type(repository.class, Model.pk(repository.class) as string).name;
    try {
      const result = await (repository).read(
        ([Primitives.NUMBER, Primitives.BIGINT].includes(type.toLowerCase())
          ? Number(uid)
          : uid) as string
      );
      return result;
    } catch (error: unknown) {
      this.log
        .for(this.handleGet)
        .info(
          `Error getting model instance with id ${uid}: ${(error as Error).message}`
        );
      return undefined;
    }
  }

  /**
   * @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 parseData(
    data: KeyValue | KeyValue[],
    operation: OperationKeys,
    repository: DecafRepository<Model>
  ): Model | Model[] | EventIds {
    operation = (
      operation === OperationKeys.READ
        ? OperationKeys.DELETE
        : operation.toLowerCase()
    ) as OperationKeys;

    if (Array.isArray(data)) {
      data = data.map((item) =>
        this.parseData(item, operation, repository as DecafRepository<Model>)
      );
      return data as Model[];
    }

    let uid = this.modelId as EventIds;
    const pk = Model.pk(repository.class as Constructor<Model>);
    const type = Metadata.type(repository.class as Constructor<Model>, pk).name;
    uid = [Primitives.NUMBER, Primitives.BIGINT].includes(type.toLowerCase())
      ? Number(uid)
      : uid;
    if (operation !== OperationKeys.DELETE)
      return Model.build(
        this.modelId ? Object.assign(data, { [pk]: uid }) : data,
        repository.class.name
      ) as Model;
    return uid as EventIds;
  }
}