src/app/core/entity-details/related-entities/related-entities.component.ts

Description

Load and display a list of entity subrecords (entities related to the current entity details view).

Example

Metadata

Relationships

Index

Properties
Methods
Inputs
Outputs

Constructor

constructor()

Inputs

clickMode
Type : "popup" | "navigate" | "popup-details"
Default value : "popup"
columns
Type : ColumnConfig[]
Default value : []

Columns to be displayed in the table

dataSource
Type : DataSourceType
editable
Type : boolean
Default value : true
entity
Type : Entity

currently viewed/main entity for which related entities are displayed in this component

entityType
Type : string

entity type of the related entities to be displayed

filter
Type : DataFilter<E>

This filter is applied before displaying the data.

loaderMethod
Type : LoaderMethod

The special service or method to load data via an index or other special method.

property
Type : string | string[]

Property name of the related entities (type given in this.entityType) that holds the entity id to be matched with the id of the current main entity (given in this.entity). If not explicitly set, this will be inferred based on the defined relations between the entities.

manually setting this is only necessary if you have multiple properties referencing the same entity type and you want to list only records related to one of them. For example: if you set entityType = "Project" (to display a list of projects here) and the Project entities have a properties "participants" and "supervisors" both storing references to User entities, you can set property = "supervisors" to only list those projects where the current User is supervisors, not participant.

showInactive
Type : boolean | undefined
Default value : undefined

Whether inactive/archived records should be shown.

Outputs

showInactive
Type : boolean | undefined

Whether inactive/archived records should be shown.

Methods

createNewRecordFactory
createNewRecordFactory()
Returns : () => any
Protected getColumns
getColumns(value: ColumnConfig[] | undefined)
Parameters :
Name Type Optional
value ColumnConfig[] | undefined No
Returns : FormFieldConfig[]
Protected getDefaultColumns
getDefaultColumns()
Returns : FormFieldConfig[]
Protected getProperty
getProperty()

Auto-detect which properties of the related entity type reference the current entity. Returns dot-paths for nested references (e.g. "attendance.participant").

Returns : string | []
Protected initFilter
initFilter()
Returns : DataFilter<E>

Properties

Readonly _columns
Type : unknown
Default value : computed(() => { const entity = this.entity(); const rawCols = this.getColumns(this.columns()); if (!entity) return rawCols; return rawCols.map((column) => { if (typeof column.additional === "object" && column.additional !== null) { return { ...column, additional: { ...column.additional, relatedEntitiesParent: entity }, }; } return column; }); })
Readonly columnsToDisplay
Type : unknown
Default value : computed(() => this._columns() .filter((column) => { if (column?.hideFromTable) return false; const numericValue = ScreenSize[column?.visibleFrom]; if (numericValue === undefined) return true; return this.currentScreenSize() >= numericValue; }) .map((c) => c.id), )
Protected Readonly entityCtr
Type : unknown
Default value : computed<EntityConstructor<E> | undefined>( () => { const entityType = this.entityType(); if (!entityType) { return undefined; } return this.entityRegistry.get(entityType) as EntityConstructor<E>; }, )
Readonly filterObj
Type : unknown
Default value : signal<DataFilter<E>>({})
Protected filterService
Type : unknown
Default value : inject(FilterService)
recordsDataSource
Type : unknown
Default value : computed(() => resolveDataSource<E>(this.injector, this.dataSource(), this.loaderMethod()), )
Protected Readonly relationProperty
Type : unknown
Default value : computed<string | string[]>(() => { const entity = this.entity(); const entityCtr = this.entityCtr(); if (!entity || !entityCtr) { return []; } const resolvedProperty = this.property() ?? this.getProperty(); return typeof resolvedProperty === "string" ? this.resolvePropertyPath(resolvedProperty) : resolvedProperty.map((p) => this.resolvePropertyPath(p)); })
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  Injector,
  input,
  model,
  signal,
} from "@angular/core";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { CustomFormLinkButtonComponent } from "app/features/public-form/custom-form-link-button/custom-form-link-button.component";
import {
  ScreenSize,
  ScreenWidthObserver,
} from "../../../utils/media/screen-size-observer.service";
import { EntityDatatype } from "../../basic-datatypes/entity/entity.datatype";
import { EntitiesTableComponent } from "../../common-components/entities-table/entities-table.component";
import {
  ColumnConfig,
  FormFieldConfig,
  toFormFieldConfig,
} from "../../common-components/entity-form/FormConfig";
import { DynamicComponent } from "../../config/dynamic-components/dynamic-component.decorator";
import { EntityRegistry } from "../../entity/database-entity.decorator";
import { LoaderMethod } from "../../entity/entity-special-loader/entity-special-loader.service";
import { Entity, EntityConstructor } from "../../entity/model/entity";
import { EntitySchemaField } from "../../entity/schema/entity-schema-field";
import { FilterService } from "../../filter/filter.service";
import { DataFilter } from "../../filter/filters/filters";
import { DataSourceType } from "#src/app/core/common-components/entities-table/data-source/available-data-sources";
import { LoadRecordConfig } from "#src/app/core/common-components/entities-table/data-source/entities-table-data-source";
import { resolveDataSource } from "#src/app/core/common-components/entities-table/data-source/data-source-resolver";

/**
 * Load and display a list of entity subrecords (entities related to the current entity details view).
 */
@DynamicComponent("RelatedEntities")
@UntilDestroy()
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-related-entities",
  templateUrl: "./related-entities.component.html",
  imports: [EntitiesTableComponent, CustomFormLinkButtonComponent],
})
export class RelatedEntitiesComponent<E extends Entity> {
  private entityRegistry = inject(EntityRegistry);
  private screenWidthObserver = inject(ScreenWidthObserver);
  protected filterService = inject(FilterService);
  private readonly injector = inject(Injector);

  /** currently viewed/main entity for which related entities are displayed in this component */
  entity = input<Entity>();

  /** entity type of the related entities to be displayed */
  entityType = input<string>();

  /**
   * Property name of the related entities (type given in this.entityType) that holds the entity id
   * to be matched with the id of the current main entity (given in this.entity).
   * If not explicitly set, this will be inferred based on the defined relations between the entities.
   *
   * manually setting this is only necessary if you have multiple properties referencing the same entity type
   * and you want to list only records related to one of them.
   * For example: if you set `entityType = "Project"` (to display a list of projects here) and the Project entities have a properties "participants" and "supervisors" both storing references to User entities,
   * you can set `property = "supervisors"` to only list those projects where the current User is supervisors, not participant.
   */
  property = input<string | string[]>();

  /**
   * The special service or method to load data via an index or other special method.
   */
  loaderMethod = input<LoaderMethod>();

  /**
   * Columns to be displayed in the table
   * @param value
   */
  columns = input<ColumnConfig[]>([]);

  dataSource = input<DataSourceType>();
  recordsDataSource = computed(() =>
    resolveDataSource<E>(this.injector, this.dataSource(), this.loaderMethod()),
  );

  readonly _columns = computed(() => {
    const entity = this.entity();
    const rawCols = this.getColumns(this.columns());
    if (!entity) return rawCols;
    return rawCols.map((column) => {
      if (typeof column.additional === "object" && column.additional !== null) {
        return {
          ...column,
          additional: { ...column.additional, relatedEntitiesParent: entity },
        };
      }
      return column;
    });
  });

  readonly columnsToDisplay = computed(() =>
    this._columns()
      .filter((column) => {
        if (column?.hideFromTable) return false;
        const numericValue = ScreenSize[column?.visibleFrom];
        if (numericValue === undefined) return true;
        return this.currentScreenSize() >= numericValue;
      })
      .map((c) => c.id),
  );

  /**
   * This filter is applied before displaying the data.
   */
  filter = input<DataFilter<E>>();

  /**
   * Whether inactive/archived records should be shown.
   */
  showInactive = model<boolean | undefined>(undefined);

  clickMode = input<"popup" | "navigate" | "popup-details">("popup");
  editable = input<boolean>(true);

  protected readonly entityCtr = computed<EntityConstructor<E> | undefined>(
    () => {
      const entityType = this.entityType();
      if (!entityType) {
        return undefined;
      }
      return this.entityRegistry.get(entityType) as EntityConstructor<E>;
    },
  );
  protected readonly relationProperty = computed<string | string[]>(() => {
    const entity = this.entity();
    const entityCtr = this.entityCtr();
    if (!entity || !entityCtr) {
      return [];
    }

    const resolvedProperty = this.property() ?? this.getProperty();
    return typeof resolvedProperty === "string"
      ? this.resolvePropertyPath(resolvedProperty)
      : resolvedProperty.map((p) => this.resolvePropertyPath(p));
  });
  readonly filterObj = signal<DataFilter<E>>({});
  private readonly currentScreenSize = signal(
    this.screenWidthObserver.currentScreenSize(),
  );

  constructor() {
    this.screenWidthObserver
      .shared()
      .pipe(untilDestroyed(this))
      .subscribe(() => {
        this.currentScreenSize.set(
          this.screenWidthObserver.currentScreenSize(),
        );
      });

    effect(() => {
      this.filterObj.set(this.initFilter());
    });

    effect(() => {
      if (this.showInactive() === undefined) {
        this.showInactive.set(this.entity().anonymized);
      }
    });

    effect(() => {
      // each loader decides for itself how to handle an ambiguous relation property
      const config: LoadRecordConfig<E> = {
        entityCtr: this.entityCtr(),
        forEntity: this.entity(),
        relationProperty: this.relationProperty(),
        loaderMethod: this.loaderMethod(),
      };
      this.recordsDataSource().loadRecordConfig.set(config);
    });
  }

  /**
   * Auto-detect which properties of the related entity type reference the current entity.
   * Returns dot-paths for nested references (e.g. "attendance.participant").
   */
  protected getProperty(): string | string[] {
    const entity = this.entity();
    const entityCtr = this.entityCtr();
    if (!entity || !entityCtr) {
      return [];
    }
    const relType = entity.getType();
    const found: string[] = [];

    for (const [key, field] of entityCtr.schema) {
      if (this.fieldReferencesType(field, relType)) {
        found.push(key);
        continue;
      }

      for (const innerProp of this.findEmbeddedEntityRefs(field, relType)) {
        found.push(`${key}.${innerProp}`);
      }
    }

    return found.length === 1 ? found[0] : found;
  }

  /**
   * Resolve shorthand property names that point to an embedded schema
   * into dot-paths (e.g. "attendance" → "attendance.participant").
   */
  private resolvePropertyPath(property: string): string {
    if (property.includes(".")) {
      return property;
    }
    const entityCtr = this.entityCtr();
    if (!entityCtr) {
      return property;
    }
    const field = entityCtr.schema.get(property);
    if (!field) {
      return property;
    }
    const entity = this.entity();
    if (!entity) {
      return property;
    }
    const refs = this.findEmbeddedEntityRefs(field, entity.getType());
    return refs.length === 1 ? `${property}.${refs[0]}` : property;
  }

  protected initFilter(): DataFilter<E> {
    const filter: DataFilter<E> = { ...(this.filter() ?? {}) };
    const relationProperty = this.relationProperty();
    if (relationProperty) {
      // only show related entities
      if (typeof relationProperty === "string") {
        Object.assign(filter, this.getFilterForProperty(relationProperty));
      } else if (relationProperty.length > 0) {
        filter["$or"] = relationProperty.map((prop) =>
          this.getFilterForProperty(prop),
        );
      }
    }

    return filter;
  }

  private getFilterForProperty(property: string) {
    const relatedEntityId = this.entity()?.getId();
    const entityCtr = this.entityCtr();
    if (!relatedEntityId || !entityCtr) {
      return {};
    }
    if (property.includes(".")) {
      const [outerProp, innerProp] = property.split(".", 2);
      const outerIsArray = entityCtr.schema.get(outerProp)?.isArray;
      return outerIsArray
        ? {
            [outerProp]: { $elemMatch: { [innerProp]: relatedEntityId } },
          }
        : { [`${outerProp}.${innerProp}`]: relatedEntityId };
    }

    const isArray = entityCtr.schema.get(property)?.isArray;
    return {
      [property]: isArray
        ? { $elemMatch: { $eq: relatedEntityId } }
        : relatedEntityId,
    };
  }

  /** Check whether a schema field directly references the given entity type. */
  private fieldReferencesType(
    field: EntitySchemaField,
    entityType: string,
  ): boolean {
    if (field.dataType !== EntityDatatype.dataType) {
      return false;
    }
    return Array.isArray(field.additional)
      ? field.additional.includes(entityType)
      : field.additional === entityType;
  }

  /**
   * Find inner field names in an embedded schema's `additional` config
   * that reference the given entity type.
   */
  private findEmbeddedEntityRefs(
    field: EntitySchemaField,
    entityType: string,
  ): string[] {
    const additional = field?.additional;
    if (
      typeof additional !== "object" ||
      additional === null ||
      Array.isArray(additional)
    ) {
      return [];
    }
    return Object.entries<any>(additional)
      .filter(
        ([, inner]) =>
          inner?.dataType === EntityDatatype.dataType &&
          this.fieldReferencesType(inner, entityType),
      )
      .map(([key]) => key);
  }

  createNewRecordFactory() {
    return () => {
      const entityCtr = this.entityCtr();
      if (!entityCtr) {
        throw new Error("Cannot create related record without entity type");
      }

      const rec = new entityCtr();
      this.filterService.alignEntityWithFilter(rec, this.filterObj());
      return rec;
    };
  }

  protected getDefaultColumns(): FormFieldConfig[] {
    return [];
  }

  protected getColumns(value: ColumnConfig[] | undefined): FormFieldConfig[] {
    if (!Array.isArray(value) || value.length === 0) {
      return this.getDefaultColumns();
    }
    return value.map((column) => toFormFieldConfig(column));
  }
}
<app-entities-table
  [entityType]="entityCtr()!"
  [recordsDataSource]="recordsDataSource()"
  [filter]="filterObj()"
  [customColumns]="_columns()"
  [columnsToDisplay]="columnsToDisplay()"
  [newRecordFactory]="createNewRecordFactory()"
  [showInactive]="showInactive() ?? false"
  [clickMode]="clickMode()"
  [editable]="editable()"
></app-entities-table>

<app-custom-form-link-button
  [linkedEntity]="entity()"
  [formEntityType]="entityCtr()!"
>
</app-custom-form-link-button>
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""