File

src/app/core/common-components/entity-select/entity-select.component.ts

Implements

OnChanges

Metadata

Index

Properties
Methods
Inputs
Accessors

Constructor

constructor(entityMapperService: EntityMapperService, formDialog: FormDialogService, entityRegistry: EntityRegistry)
Parameters :
Name Type Optional
entityMapperService EntityMapperService No
formDialog FormDialogService No
entityRegistry EntityRegistry No

Inputs

accessor
Type : function
Default value : (e) => e.toString()

The accessor used for filtering and when selecting a new entity.
Per default, this filters for the name. If the entity has no name, this filters for the entity's id.

additionalFilter
Type : function
Default value : (_) => true
disableCreateNew
Type : boolean

Disable the option to type any text into the selection field and use a "Create new ..." link to open the form for a new entity.

entityType
Type : string | []

The entity-type (e.g. 'Child', 'School', e.t.c.) to set. that displays the entities. Can be an array giving multiple types.

form
Type : FormControl<T>
includeInactive
Type : boolean
Default value : false
label
Type : string

The label is what is seen above the list. For example when used in the note-details-view, this is "Children"

multi
Type : boolean
Default value : true

Whether users can select multiple entities.

placeholder
Type : string

The placeholder is what is seen when someone clicks into the input field and adds new entities. In the note-details-view, this is "Add children..." The placeholder is only displayed if loading === false

showEntities
Type : boolean
Default value : true

Whether to show entities in the list. Entities can still be selected using the autocomplete, and selection as well as selectionChange will still work as expected

Methods

recalculateMatchingInactive
recalculateMatchingInactive(newAutocompleteFilter?: (o?: Entity) => void)

Recalculates the number of inactive entities that match the current filter, and optionally updates the current filter function (otherwise reuses the filter previously set)

Parameters :
Name Type Optional
newAutocompleteFilter function Yes
Returns : void
Async toggleIncludeInactive
toggleIncludeInactive()
Returns : any

Properties

allEntities
Type : E[]
Default value : []
availableOptions
Default value : new BehaviorSubject<E[]>([])
createNewEntity
Default value : () => {...}
currentlyMatchingInactive
Type : number
Default value : 0
entityToId
Default value : () => {...}
loading
Default value : new BehaviorSubject(true)

true when this is loading and false when it's ready. This subject's state reflects the actual loading resp. the 'readiness'- state of this component. Will trigger once loading is done

Readonly loadingPlaceholder
Default value : $localize`:A placeholder for the input element when select options are not loaded yet:loading...`

Accessors

entityType
setentityType(type: string | string[])

The entity-type (e.g. 'Child', 'School', e.t.c.) to set. that displays the entities. Can be an array giving multiple types.

Parameters :
Name Type Optional Description
type string | string[] No

The ENTITY_TYPE of a Entity. This affects the entities which will be loaded and the component that displays the entities. Can be an array giving multiple types.

Returns : void
import { Component, Input, OnChanges, SimpleChanges } from "@angular/core";
import { Entity } from "../../entity/model/entity";
import { BehaviorSubject, lastValueFrom } from "rxjs";
import { FormControl, FormsModule, ReactiveFormsModule } from "@angular/forms";
import { MatChipsModule } from "@angular/material/chips";
import { MatAutocompleteModule } from "@angular/material/autocomplete";
import { UntilDestroy } from "@ngneat/until-destroy";
import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service";
import { MatFormFieldModule } from "@angular/material/form-field";
import { AsyncPipe, NgIf } from "@angular/common";
import { EntityBlockComponent } from "../../basic-datatypes/entity/entity-block/entity-block.component";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { MatTooltipModule } from "@angular/material/tooltip";
import { MatInputModule } from "@angular/material/input";
import { MatCheckboxModule } from "@angular/material/checkbox";
import { ErrorHintComponent } from "../error-hint/error-hint.component";
import { BasicAutocompleteComponent } from "../basic-autocomplete/basic-autocomplete.component";
import { MatSlideToggle } from "@angular/material/slide-toggle";
import { asArray } from "app/utils/asArray";
import { Logging } from "../../logging/logging.service";
import { FormDialogService } from "../../form-dialog/form-dialog.service";
import { EntityRegistry } from "../../entity/database-entity.decorator";

@Component({
  selector: "app-entity-select",
  templateUrl: "./entity-select.component.html",
  styleUrls: [
    "./entity-select.component.scss",
    "../../common-components/basic-autocomplete/basic-autocomplete-dropdown.component.scss",
  ],
  imports: [
    MatFormFieldModule,
    NgIf,
    ReactiveFormsModule,
    MatAutocompleteModule,
    MatChipsModule,
    EntityBlockComponent,
    FontAwesomeModule,
    MatTooltipModule,
    MatInputModule,
    MatCheckboxModule,
    AsyncPipe,
    ErrorHintComponent,
    BasicAutocompleteComponent,
    MatSlideToggle,
    FormsModule,
  ],
})
@UntilDestroy()
export class EntitySelectComponent<
  E extends Entity,
  T extends string[] | string = string[],
> implements OnChanges
{
  readonly loadingPlaceholder = $localize`:A placeholder for the input element when select options are not loaded yet:loading...`;

  @Input() form: FormControl<T>;

  /**
   * The entity-type (e.g. 'Child', 'School', e.t.c.) to set.
   * @param type The ENTITY_TYPE of a Entity. This affects the entities which will be loaded and the component
   *             that displays the entities. Can be an array giving multiple types.
   * @throws Error when `type` is not in the entity-map
   */
  @Input() set entityType(type: string | string[]) {
    if (type === undefined || type === null) {
      type = [];
    }

    this._entityType = Array.isArray(type) ? type : [type];
    this.loadAvailableEntities().then((_) => {});
  }

  private _entityType: string[];

  /**
   * Whether users can select multiple entities.
   */
  @Input() multi: boolean = true;

  /**
   * Disable the option to type any text into the selection field and use a "Create new ..." link to open the form for a new entity.
   */
  @Input() disableCreateNew: boolean;

  /**
   * The label is what is seen above the list. For example when used
   * in the note-details-view, this is "Children"
   */
  @Input() label: string;

  /**
   * The placeholder is what is seen when someone clicks into the input
   * field and adds new entities.
   * In the note-details-view, this is "Add children..."
   * The placeholder is only displayed if `loading === false`
   */
  @Input() placeholder: string;

  /**
   * Whether to show entities in the list.
   * Entities can still be selected using the autocomplete,
   * and {@link selection} as well as {@link selectionChange} will
   * still work as expected
   */
  @Input() showEntities: boolean = true;

  /**
   * true when this is loading and false when it's ready.
   * This subject's state reflects the actual loading resp. the 'readiness'-
   * state of this component. Will trigger once loading is done
   */
  loading = new BehaviorSubject(true);
  allEntities: E[] = [];
  availableOptions = new BehaviorSubject<E[]>([]);

  @Input() includeInactive: boolean = false;
  currentlyMatchingInactive: number = 0;

  constructor(
    private entityMapperService: EntityMapperService,
    private formDialog: FormDialogService,
    private entityRegistry: EntityRegistry,
  ) {}

  /**
   * The accessor used for filtering and when selecting a new
   * entity.
   * <br> Per default, this filters for the name. If the entity
   * has no name, this filters for the entity's id.
   */
  @Input() accessor: (e: Entity) => string = (e) => e.toString();
  entityToId = (option: E) => option.getId();

  @Input() additionalFilter: (e: E) => boolean = (_) => true;

  ngOnChanges(changes: SimpleChanges): void {
    if (changes["form"]) {
      this.form.valueChanges.subscribe((value) => {
        this.updateAvailableOptions().then((_) => {});
      });
    }
  }

  private async loadAvailableEntities() {
    this.loading.next(true);

    const entities = [];
    for (const type of this._entityType) {
      entities.push(...(await this.entityMapperService.loadType<E>(type)));
    }
    this.allEntities = entities
      .filter((e) => this.additionalFilter(e))
      .sort((a, b) => a.toString().localeCompare(b.toString()));

    await this.updateAvailableOptions();

    this.loading.next(false);
  }

  private async updateAvailableOptions() {
    const includeInactive = (entity: E) =>
      this.includeInactive || entity.isActive;
    const includeSelected = (entity: E) =>
      asArray(this.form.value).includes(entity.getId());

    const newAvailableEntities = this.allEntities.filter(
      (e) => includeInactive(e) || includeSelected(e),
    );

    await this.alignAvailableAndSelectedEntities(newAvailableEntities);

    this.availableOptions.next(newAvailableEntities);
    this.recalculateMatchingInactive();
  }

  /**
   * Edit form value (currently selected) and the given available Entities to be consistent:
   * Entities that do not exist should be removed from the form value
   * and availableEntities should contain all selected entities, even from other types.
   * @private
   */
  private async alignAvailableAndSelectedEntities(availableEntities: E[]) {
    if (this.form?.value === null || this.form?.value === undefined) {
      return;
    }

    let updatedValue: T = this.form.value;

    for (const id of asArray(this.form.value)) {
      if (availableEntities.find((e) => id === e.getId())) {
        // already available, nothing to do
        continue;
      }

      const additionalEntity = await this.getEntity(id);
      if (additionalEntity) {
        availableEntities.push(additionalEntity);
      } else {
        updatedValue = isMulti(this)
          ? ((updatedValue as string[]).filter((v) => v !== id) as T)
          : undefined;
      }
    }

    if (this.form.value !== updatedValue) {
      this.form.setValue(updatedValue);
    }
  }

  private async getEntity(selectedId: string): Promise<E | undefined> {
    const type = Entity.extractTypeFromId(selectedId);

    const entity = await this.entityMapperService
      .load<E>(type, selectedId)
      .catch((err: Error) => {
        Logging.warn(
          "[ENTITY_SELECT] Error loading selected entity.",
          this.label,
          selectedId,
          err.message,
        );
        return undefined;
      });

    return entity;
  }

  async toggleIncludeInactive() {
    this.includeInactive = !this.includeInactive;
    await this.updateAvailableOptions();
  }

  private autocompleteFilter: (o: E) => boolean = () => true;

  /**
   * Recalculates the number of inactive entities that match the current filter,
   * and optionally updates the current filter function (otherwise reuses the filter previously set)
   * @param newAutocompleteFilter
   */
  recalculateMatchingInactive(newAutocompleteFilter?: (o: Entity) => boolean) {
    if (newAutocompleteFilter) {
      this.autocompleteFilter = newAutocompleteFilter;
    }

    this.currentlyMatchingInactive = this.allEntities.filter(
      (e) => !e.isActive && this.autocompleteFilter(e),
    ).length;
  }

  createNewEntity = async (input: string): Promise<E> => {
    if (this._entityType?.length < 1) {
      return;
    }
    if (this._entityType?.length > 1) {
      Logging.warn(
        "EntitySelect with multiple types is always creating a new entity of the first listed type only.",
      );
      // TODO: maybe display an additional popup asking the user to select which type should be created?
    }

    const newEntity = new (this.entityRegistry.get(this._entityType[0]))();
    applyTextToCreatedEntity(newEntity, input);

    const dialogRef = this.formDialog.openFormPopup(newEntity);
    return lastValueFrom<E | undefined>(dialogRef.afterClosed());
  };
}

function isMulti(
  cmp: EntitySelectComponent<any, string | string[]>,
): cmp is EntitySelectComponent<any, string[]> {
  return cmp.multi;
}

/**
 * Update the given entity by applying the text entered by a user
 * to the most likely appropriate entity field, inferred from the toString representation.
 */
export function applyTextToCreatedEntity(entity: Entity, input: string) {
  const toStringFields = entity.getConstructor().toStringAttributes;
  if (!toStringFields || toStringFields.length < 1) {
    return;
  }

  const inputParts = input.split(/\s+/);
  for (let i = 0; i < inputParts.length; i++) {
    const targetProperty =
      toStringFields[i < toStringFields.length ? i : toStringFields.length - 1];

    entity[targetProperty] = (
      (entity[targetProperty] ?? "") +
      " " +
      inputParts[i]
    ).trim();
  }

  return entity;
}
<mat-form-field>
  <mat-label>{{ label }}</mat-label>
  <div class="autocomplete-container">
    <app-basic-autocomplete
      #autocomplete
      [formControl]="form"
      [valueMapper]="entityToId"
      [optionToString]="accessor"
      (autocompleteFilterChange)="recalculateMatchingInactive($event)"
      [multi]="multi"
      [display]="showEntities ? 'chips' : 'none'"
      [options]="availableOptions | async"
      [placeholder]="(loading | async) ? loadingPlaceholder : placeholder"
      [createOption]="disableCreateNew ? null : createNewEntity"
    >
      <ng-template let-item>
        <app-entity-block
          style="margin: auto"
          [entity]="item"
          [linkDisabled]="form.enabled"
        ></app-entity-block>
      </ng-template>

      <ng-container autocompleteFooter *ngIf="currentlyMatchingInactive > 0">
        <mat-slide-toggle
          [checked]="includeInactive"
          (toggleChange)="toggleIncludeInactive()"
          i18n="Label for checkbox|e.g. include inactive children"
          >Also show {{ currentlyMatchingInactive }} inactive
        </mat-slide-toggle>
      </ng-container>
    </app-basic-autocomplete>
    <div class="icon-container">
      <fa-icon
        *ngIf="form.enabled"
        icon="caret-down"
        class="form-field-icon-suffix"
        (click)="autocomplete.showAutocomplete()"
        matIconSuffix
      ></fa-icon>
    </div>
  </div>
  <mat-error *ngIf="form.errors">
    <app-error-hint [form]="form"></app-error-hint>
  </mat-error>
</mat-form-field>

./entity-select.component.scss

.chip {
  padding-top: 2px;
  padding-bottom: 2px;
}

../../common-components/basic-autocomplete/basic-autocomplete-dropdown.component.scss

.autocomplete-container {
  position: relative;
}

.icon-container {
  position: absolute;
  top: 0;
  right: 0;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""