File

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

Metadata

Index

Properties
Methods
Inputs

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.

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

entityType
Default value : [], { transform: (type: string | string[] | undefined): string[] => { return asArray(type ?? []); }, }

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<string[] | string | undefined>

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
toggleIncludeInactive
toggleIncludeInactive()
Returns : void

Properties

availableOptions
Type : Signal<E[]>
Default value : computed(() => this.availableOptionsResource.value(), )

Entities visible to the user, considering current values and filters.

createNewEntity
Default value : () => {...}
currentlyMatchingInactive
Type : Signal<number>
Default value : computed(() => { return this.allEntities .value() .filter((e) => !e.isActive && this.autocompleteFilter()(e)).length; })
entityToId
Default value : () => {...}
hasInaccessibleEntities
Type : Boolean
Default value : false
includeInactive
Default value : signal<boolean>(false)
Readonly isCreateDisabled
Default value : computed(() => { if (this.disableCreateNew === true) { return true; } //calculate based on permissions and entity type const entityTypes = this.entityType(); if (entityTypes.length === 0) { return true; } const entityType = entityTypes[0]; return !this.ability.can("create", entityType); })
loading
Type : Signal<boolean>
Default value : computed(() => this.allEntities.isLoading())

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...`
values
Type : Signal<string[]>
Default value : toSignal( toObservable(this.form) .pipe(switchMap((form) => form.valueChanges)) .pipe(map((value) => (value === undefined ? [] : asArray(value)))), { initialValue: [] }, )

The currently selected values (IDs) of the form control.

import {
  Component,
  Input,
  Resource,
  Signal,
  computed,
  inject,
  input,
  signal,
} from "@angular/core";
import { toObservable, toSignal } from "@angular/core/rxjs-interop";
import { lastValueFrom, map, switchMap } from "rxjs";
import { Entity } from "../../entity/model/entity";
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 { 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";
import { resourceWithRetention } from "#src/app/utils/resourceWithRetention";
import { EntityAbility } from "../../permissions/ability/entity-ability";

@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,
    ReactiveFormsModule,
    MatAutocompleteModule,
    MatChipsModule,
    EntityBlockComponent,
    FontAwesomeModule,
    MatTooltipModule,
    MatInputModule,
    MatCheckboxModule,
    ErrorHintComponent,
    BasicAutocompleteComponent,
    MatSlideToggle,
    FormsModule,
  ],
})
@UntilDestroy()
export class EntitySelectComponent<E extends Entity> {
  private entityMapperService = inject(EntityMapperService);
  private formDialog = inject(FormDialogService);
  private entityRegistry = inject(EntityRegistry);
  private ability = inject(EntityAbility);

  readonly loadingPlaceholder = $localize`:A placeholder for the input element when select options are not loaded yet:loading...`;

  form = input<FormControl<string[] | string | undefined>>();

  /**
   * 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
   */
  entityType: Signal<string[]> = input([], {
    transform: (type: string | string[] | undefined): string[] => {
      return asArray(type ?? []);
    },
  });

  /**
   * 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;

  hasInaccessibleEntities: Boolean = false;

  /**
   * 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;

  private allEntities: Resource<E[]> = resourceWithRetention({
    defaultValue: [],
    params: () => ({
      entityTypes: this.entityType(),
    }),
    loader: async ({ params }) => {
      if (params.entityTypes.length === 0) return [];

      const entities: E[] = [];
      for (const type of params.entityTypes) {
        entities.push(...(await this.entityMapperService.loadType<E>(type)));
      }

      return entities
        .filter((e) => this.additionalFilter(e))
        .sort((a, b) => a.toString().localeCompare(b.toString()));
    },
  });

  currentlyMatchingInactive: Signal<number> = computed(() => {
    return this.allEntities
      .value()
      .filter((e) => !e.isActive && this.autocompleteFilter()(e)).length;
  });

  readonly isCreateDisabled = computed(() => {
    if (this.disableCreateNew === true) {
      return true;
    }
    //calculate based on permissions and entity type
    const entityTypes = this.entityType();
    if (entityTypes.length === 0) {
      return true;
    }

    const entityType = entityTypes[0];
    return !this.ability.can("create", entityType);
  });

  /**
   * 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: Signal<boolean> = computed(() => this.allEntities.isLoading());

  /**
   * The currently selected values (IDs) of the form control.
   */
  values: Signal<string[]> = toSignal(
    toObservable(this.form)
      .pipe(switchMap((form) => form.valueChanges))
      .pipe(map((value) => (value === undefined ? [] : asArray(value)))),
    { initialValue: [] },
  );

  includeInactive = signal<boolean>(false);

  private availableOptionsResource: Resource<E[]> = resourceWithRetention({
    defaultValue: [],
    params: () => ({
      allEntities: this.allEntities.value(),
      values: this.values(),
      includeInactive: this.includeInactive(),
    }),
    loader: async ({ params }) => {
      const availableEntities = params.allEntities.filter(
        (e) =>
          params.values.includes(e.getId()) ||
          params.includeInactive ||
          e.isActive,
      );

      for (const id of params.values) {
        if (id === null || id === undefined || id === "") {
          continue;
        }

        if (availableEntities.find((e) => id === e.getId())) {
          continue;
        }

        const additionalEntity = await this.getEntity(id);
        if (additionalEntity) {
          availableEntities.push(additionalEntity);
        } else {
          this.hasInaccessibleEntities = true;
          availableEntities.push({
            getId: () => id,
            isHidden: true,
          } as unknown as E);
        }
      }

      return availableEntities;
    },
  });

  /**
   * Entities visible to the user, considering current values and filters.
   */
  availableOptions: Signal<E[]> = computed(() =>
    this.availableOptionsResource.value(),
  );

  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;
  }

  toggleIncludeInactive() {
    this.includeInactive.set(!this.includeInactive());
  }

  private autocompleteFilter = signal<(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.set(newAutocompleteFilter);
    }
  }

  createNewEntity = async (input: string): Promise<E> => {
    const entityTypes = this.entityType();
    if (entityTypes.length < 1) {
      return;
    }
    if (entityTypes.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(entityTypes[0]))();
    applyTextToCreatedEntity(newEntity, input);

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

/**
 * 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 #formField>
  <mat-label>
    <span [matTooltip]="label" matTooltipPosition="before">{{ label }}</span>
  </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()"
      [placeholder]="loading() ? loadingPlaceholder : placeholder"
      [createOption]="isCreateDisabled() ? null : createNewEntity"
    >
      <ng-template let-item>
        @if (!item.isHidden) {
          <app-entity-block
            style="margin: auto"
            [entity]="item"
            [linkDisabled]="form().enabled"
          ></app-entity-block>
        }
      </ng-template>

      @if (currentlyMatchingInactive() > 0) {
        <ng-container autocompleteFooter>
          <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"
      role="combobox"
      [attr.aria-labelledby]="formField.getLabelId()"
    >
      @if (form().enabled) {
        <fa-icon
          icon="caret-down"
          class="form-field-icon-suffix"
          (click)="autocomplete.showAutocomplete()"
          matIconSuffix
        ></fa-icon>
      }
    </div>
  </div>

  @if (hasInaccessibleEntities) {
    <mat-hint class="hint">
      <fa-icon
        icon="warning"
        class="standard-icon-with-text warning-icon"
      ></fa-icon>
      <span i18n
        >Some records are hidden because you do not have permission to access
        them (or they could not be found for other reasons).</span
      >
    </mat-hint>
  }

  @if (form().errors) {
    <mat-error>
      <app-error-hint [form]="form()"></app-error-hint>
    </mat-error>
  }
</mat-form-field>

./entity-select.component.scss

@use "variables/colors";

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

.warning-icon {
  color: colors.$error;
}

../../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 ""