src/app/core/basic-datatypes/entity/edit-entity/edit-entity.component.ts

Description

A form field to select among the entities of the given type(s). Can be configured as single or multi select.

Extends

CustomFormControlDirective<T>

Implements

OnInit DoCheck EditComponent

Example

Metadata

Relationships

Index

Properties
Methods
Inputs
Outputs

Inputs

accessor
Type : function
Default value : (e) => { if (e instanceof Entity) { return e.toString(); } if (typeof e === "string") { return e; } return "?"; }

The accessor used for filtering and when selecting a new entity.

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.

multi
Type : boolean
Default value : false

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.

showEntities
Type : boolean
Default value : true

Whether to show entities in the list.

additionalFilter
Default value : (_) => true
entityType
Default value : undefined

Explicitly define the entity type(s) to select among. Overrides the additional configuration of the FormFieldConfig if given.

formFieldConfig
Type : FormFieldConfig
aria-describedby
Type : string
disabled
Type : boolean
ngControl
Type : any
Default value : inject(NgControl, { optional: true, self: true })
required
Type : boolean
value
Type : T

Outputs

valueChange
Type : EventEmitter

Methods

Async createNewEntity
createNewEntity(input: string, type?: string)
Parameters :
Name Type Optional
input string No
type string Yes
Returns : Promise<E>
onContainerClick
onContainerClick(event: MouseEvent)
Parameters :
Name Type Optional
event MouseEvent No
Returns : void
recalculateMatchingInactive
recalculateMatchingInactive(newAutocompleteFilter?: (o?: Entity) => void)
Parameters :
Name Type Optional
newAutocompleteFilter function Yes
Returns : void
toggleIncludeInactive
toggleIncludeInactive()
Returns : void
blur
blur()
Returns : void
focus
focus()
Returns : void
registerOnChange
registerOnChange(fn: any)
Parameters :
Name Type Optional
fn any No
Returns : void
registerOnTouched
registerOnTouched(fn: any)
Parameters :
Name Type Optional
fn any No
Returns : void
setDescribedByIds
setDescribedByIds(ids: string[])
Parameters :
Name Type Optional
ids string[] No
Returns : void
setDisabledState
setDisabledState(isDisabled: boolean)
Parameters :
Name Type Optional
isDisabled boolean No
Returns : void
writeValue
writeValue(value: T, notifyFormControl: unknown)

Implementation for Angular ControlValueAccessor interface that links the form control value to the component value

Parameters :
Name Type Optional Default value Description
value T No

The new value to set

notifyFormControl unknown No false

Whether to notify the FormControl of this change (for internal updates)

Returns : void

Properties

autocompleteComponent
Type : BasicAutocompleteComponent<E | T>
Decorators :
@ViewChild(BasicAutocompleteComponent)
Readonly availableEntitiesResource
Type : Resource<E[]>
Default value : resourceWithRetention({ defaultValue: [], params: () => ({ allEntities: this.allEntities.value(), values: this.values(), includeInactive: this.includeInactive(), getEntity: (id: string) => this.getEntity(id), // we cannot directly access `this.` within the loader (see https://github.com/Aam-Digital/ndb-core/pull/3410#issuecomment-3438380605) }), loader: async ({ params }) => { const availableEntities = params.allEntities.filter( (e) => params.values.includes(e.getId()) || params.includeInactive || !e.inactive, ); for (const id of params.values) { if (id === null || id === undefined || id === "") { continue; } if (availableEntities.find((e) => id === e.getId())) { continue; } const additionalEntity = await params.getEntity(id); if (additionalEntity) { availableEntities.push(additionalEntity); } else { availableEntities.push({ getId: () => id, isHidden: true, } as unknown as E); } } return availableEntities; }, })
control
Type : Signal<FormControl<T>>
Default value : computed(() => { this.controlSourceRefresh(); let control = this.ngControl?.control as FormControl<T>; if (!control) { control = this._formControl ?? new FormControl(); } this._formControl = control; return this._formControl; })

The form control bound to the inner autocomplete. Recomputed when ngControl appears (it is not a signal and may be set after first render) so we stop using the fallback control. Named control to avoid clashing with the base formControl getter.

createNewEntityOptions
Type : Signal<CreateOptionConfig[]>
Default value : computed(() => { if (this.isCreateDisabled()) { return []; } return this.entityType().map((type) => ({ label: this.entityRegistry.get(type)?.label ?? type, create: (input: string) => this.createNewEntity(input, type), })); })

One create option per configured entity type, shown as separate "Add new [TypeLabel]" entries in the autocomplete dropdown.

currentlyMatchingInactive
Type : Signal<number>
Default value : computed(() => { return this.allEntities .value() .filter((e) => e.inactive && this.autocompleteFilter()(e)).length; })
entityToId
Type : unknown
Default value : () => {...}
entityType
Type : Signal<string[]>
Default value : computed(() => { let value = this.entityTypeInput(); if (!value || value.length === 0) { value = this.formFieldConfig()?.additional; } return asArray(value ?? []); })
Readonly hasInaccessible
Type : Signal<boolean>
Default value : computed(() => { const entities = this.availableEntitiesResource.value(); const currentValues = this.values(); return currentValues.some((id) => { if (!id) return false; const entity = entities.find((e) => e.getId() === id); return entity && (entity as any).isHidden === true; }); })
includeInactive
Type : unknown
Default value : signal<boolean>(false)
Readonly isCreateDisabled
Type : unknown
Default value : computed(() => { if (this.disableCreateNew === true) { return true; } 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())
Readonly loadingPlaceholder
Type : unknown
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.control) .pipe( switchMap((form) => { // Emit both the initial value and subsequent value changes return form.valueChanges.pipe(startWith(form.value)); }), ) .pipe(map((value) => (value === undefined ? [] : asArray(value)))), { initialValue: [] }, )

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

controlType
Type : string
Default value : "custom-control"
elementRef
Type : unknown
Default value : inject<ElementRef<HTMLElement>>(ElementRef)
Readonly enabled
Type : Signal<boolean>
Default value : computed(() => !this._disabled())

Whether the control is currently enabled, as a signal (tracks disabled).

errorStateMatcher
Type : unknown
Default value : inject(ErrorStateMatcher)
id
Type : unknown
Default value : `custom-form-control-${CustomFormControlDirective.nextId++}`
Static nextId
Type : number
Default value : 0
onChange
Type : unknown
Default value : () => {...}
onTouched
Type : unknown
Default value : () => {...}
parentForm
Type : unknown
Default value : inject(NgForm, { optional: true })
parentFormGroup
Type : unknown
Default value : inject(FormGroupDirective, { optional: true })
stateChanges
Type : unknown
Default value : new Subject<void>()
Readonly valueSignal
Type : Signal<T>
Default value : computed(() => this._value())

The current value of the control as a signal. Authoritative in both modes: it reflects the bound FormControl (synced in ngDoCheck) as well as [(value)] / writeValue updates.

import { resourceWithRetention } from "#src/app/utils/resourceWithRetention";
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  DoCheck,
  inject,
  Input,
  input,
  InputSignal,
  OnInit,
  Resource,
  Signal,
  signal,
  ViewChild,
} from "@angular/core";
import { toObservable, toSignal } from "@angular/core/rxjs-interop";
import { FormControl, FormsModule, ReactiveFormsModule } from "@angular/forms";
import { MatAutocompleteModule } from "@angular/material/autocomplete";
import { MatCheckboxModule } from "@angular/material/checkbox";
import { MatChipsModule } from "@angular/material/chips";
import { MatFormFieldControl } from "@angular/material/form-field";
import { MatInputModule } from "@angular/material/input";
import { MatSlideToggle } from "@angular/material/slide-toggle";
import { MatTooltipModule } from "@angular/material/tooltip";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { asArray } from "app/utils/asArray";
import { lastValueFrom, map, startWith, switchMap } from "rxjs";
import {
  BasicAutocompleteComponent,
  CreateOptionConfig,
} from "../../../common-components/basic-autocomplete/basic-autocomplete.component";
import { CustomFormControlDirective } from "../../../common-components/basic-autocomplete/custom-form-control.directive";
import { FormFieldConfig } from "../../../common-components/entity-form/FormConfig";
import { DynamicComponent } from "../../../config/dynamic-components/dynamic-component.decorator";
import { EntityRegistry } from "../../../entity/database-entity.decorator";
import { EditComponent } from "../../../entity/entity-field-edit/dynamic-edit/edit-component.interface";
import { EntityMapperService } from "../../../entity/entity-mapper/entity-mapper.service";
import { Entity } from "../../../entity/model/entity";
import { FormDialogService } from "../../../form-dialog/form-dialog.service";
import { Logging } from "../../../logging/logging.service";
import { EntityAbility } from "../../../permissions/ability/entity-ability";
import { EntityBlockComponent } from "../entity-block/entity-block.component";

/**
 * A form field to select among the entities of the given type(s).
 * Can be configured as single or multi select.
 */
@DynamicComponent("EditEntity")
@Component({
  selector: "app-edit-entity",
  templateUrl: "./edit-entity.component.html",
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrls: [
    "../../../common-components/basic-autocomplete/basic-autocomplete-dropdown.component.scss",
  ],
  imports: [
    ReactiveFormsModule,
    MatAutocompleteModule,
    MatChipsModule,
    EntityBlockComponent,
    FontAwesomeModule,
    MatTooltipModule,
    MatInputModule,
    MatCheckboxModule,
    BasicAutocompleteComponent,
    MatSlideToggle,
    FormsModule,
  ],
  providers: [
    { provide: MatFormFieldControl, useExisting: EditEntityComponent },
  ],
})
export class EditEntityComponent<
  T extends string[] | string = string[],
  E extends Entity = Entity,
>
  extends CustomFormControlDirective<T>
  implements OnInit, DoCheck, EditComponent
{
  formFieldConfig = input<FormFieldConfig>();

  @ViewChild(BasicAutocompleteComponent)
  autocompleteComponent: BasicAutocompleteComponent<E, T>;

  private readonly entityMapperService = inject(EntityMapperService);
  private readonly formDialog = inject(FormDialogService);
  private readonly entityRegistry = inject(EntityRegistry);
  private readonly ability = inject(EntityAbility);

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

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

  /**
   * 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 placeholder is what is seen when someone clicks into the input
   * field and adds new entities.
   */
  @Input() override placeholder: string;

  /**
   * Whether to show entities in the list.
   */
  @Input() showEntities = true;

  /**
   * The accessor used for filtering and when selecting a new entity.
   */
  @Input() accessor: (e: Entity | string) => string = (e) => {
    if (e instanceof Entity) {
      return e.toString();
    }

    if (typeof e === "string") {
      return e;
    }

    return "?";
  };
  entityToId = (option: E) => option.getId();

  additionalFilter: InputSignal<(e: E) => boolean> = input((_) => true);

  /**
   * The form control bound to the inner autocomplete.
   * Recomputed when {@link ngControl} appears (it is not a signal and may be set after
   * first render) so we stop using the fallback control. Named `control` to avoid clashing
   * with the base `formControl` getter.
   */
  control: Signal<FormControl<T>> = computed(() => {
    this.controlSourceRefresh();
    let control = this.ngControl?.control as FormControl<T>;
    if (!control) {
      control = this._formControl ?? new FormControl();
    }

    this._formControl = control;
    return this._formControl;
  });
  private _formControl: FormControl<T>;

  /**
   * Manual recompute trigger for `formControl`.
   * Needed because `ngControl` is not a signal and may appear after first render.
   */
  private readonly controlSourceRefresh = signal(0);

  /**
   * Explicitly define the entity type(s) to select among.
   * Overrides the `additional` configuration of the FormFieldConfig if given.
   */
  entityTypeInput: Signal<string | string[]> = input(undefined, {
    // eslint-disable-next-line @angular-eslint/no-input-rename
    alias: "entityType",
  });
  entityType: Signal<string[]> = computed(() => {
    let value = this.entityTypeInput();
    if (!value || value.length === 0) {
      value = this.formFieldConfig()?.additional;
    }

    return asArray(value ?? []);
  });

  private readonly allEntities: Resource<E[]> = resourceWithRetention({
    defaultValue: [],
    params: () => ({
      entityTypes: this.entityType(),
      additionalFilter: this.additionalFilter(),
      loadType: (type: string) => this.entityMapperService.loadType<E>(type), // we cannot directly access `this.` within the loader (see https://github.com/Aam-Digital/ndb-core/pull/3410#issuecomment-3438380605)
    }),
    loader: async ({ params }) => {
      if (params.entityTypes.length === 0) return [];

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

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

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

  readonly isCreateDisabled = computed(() => {
    if (this.disableCreateNew === true) {
      return true;
    }
    const entityTypes = this.entityType();
    if (entityTypes.length === 0) {
      return true;
    }
    const entityType = entityTypes[0];
    return !this.ability.can("create", entityType);
  });

  /**
   * One create option per configured entity type, shown as separate
   * "Add new [TypeLabel]" entries in the autocomplete dropdown.
   */
  createNewEntityOptions: Signal<CreateOptionConfig<E>[]> = computed(() => {
    if (this.isCreateDisabled()) {
      return [];
    }

    return this.entityType().map((type) => ({
      label: this.entityRegistry.get(type)?.label ?? type,
      create: (input: string) => this.createNewEntity(input, type),
    }));
  });

  loading: Signal<boolean> = computed(() => this.allEntities.isLoading());

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

  includeInactive = signal<boolean>(false);

  readonly availableEntitiesResource: Resource<E[]> = resourceWithRetention({
    defaultValue: [],
    params: () => ({
      allEntities: this.allEntities.value(),
      values: this.values(),
      includeInactive: this.includeInactive(),
      getEntity: (id: string) => this.getEntity(id), // we cannot directly access `this.` within the loader (see https://github.com/Aam-Digital/ndb-core/pull/3410#issuecomment-3438380605)
    }),
    loader: async ({ params }) => {
      const availableEntities = params.allEntities.filter(
        (e) =>
          params.values.includes(e.getId()) ||
          params.includeInactive ||
          !e.inactive,
      );

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

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

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

      return availableEntities;
    },
  });

  readonly hasInaccessible: Signal<boolean> = computed(() => {
    const entities = this.availableEntitiesResource.value();
    const currentValues = this.values();
    return currentValues.some((id) => {
      if (!id) return false;
      const entity = entities.find((e) => e.getId() === id);
      return entity && (entity as any).isHidden === true;
    });
  });

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

    const entity = await this.entityMapperService
      .load<E>(type, selectedId)
      .catch((err: any) => {
        if (err?.status === 404 || err?.status === 401) {
          Logging.debug(
            "[ENTITY_SELECT] Selected entity not found.",
            selectedId,
          );
        } else {
          Logging.warn(
            "[ENTITY_SELECT] Error loading selected entity.",
            selectedId,
            err,
          );
        }
        return undefined;
      });

    return entity;
  }

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

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

  recalculateMatchingInactive(newAutocompleteFilter?: (o: Entity) => boolean) {
    if (newAutocompleteFilter) {
      this.autocompleteFilter.set(newAutocompleteFilter);
    }
  }

  async createNewEntity(input: string, type?: string): Promise<E> {
    const entityTypes = this.entityType();
    const targetType = type ?? entityTypes[0];
    if (!targetType) {
      return;
    }

    const newEntity = new (this.entityRegistry.get(targetType))();
    applyTextToCreatedEntity(newEntity, input);

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

  ngOnInit() {
    this.multi = this.formFieldConfig()?.isArray ?? false;
  }

  override ngDoCheck() {
    super.ngDoCheck();
    const control = this.ngControl?.control;
    if (!control || control === this._formControl) {
      return;
    }

    // Force `formControl` recomputation so we stop using the fallback control
    // and bind to the real parent form control.
    this.controlSourceRefresh.update((v) => v + 1);
  }

  override onContainerClick(event: MouseEvent) {
    this.autocompleteComponent?.onContainerClick(event);
  }
}

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

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

  return entity;
}
<div class="flex-row gap-small">
  <app-basic-autocomplete
    #autocomplete
    (click)="onContainerClick($event)"
    class="flex-grow"
    display="chips"
    [formControl]="control()"
    [valueMapper]="entityToId"
    [optionToString]="accessor"
    (autocompleteFilterChange)="recalculateMatchingInactive($event)"
    [multi]="multi"
    [options]="availableEntitiesResource.value()"
    [placeholder]="loading() ? loadingPlaceholder : placeholder"
    [createOptions]="createNewEntityOptions()"
  >
    <ng-template let-item>
      @if (!item.isHidden) {
        <app-entity-block
          style="margin: auto"
          [entity]="item"
          [linkDisabled]="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>

  @if (enabled()) {
    <fa-icon
      icon="caret-down"
      class="form-field-icon-suffix"
      (click)="autocomplete.showAutocomplete()"
    ></fa-icon>
  }
</div>

@if (hasInaccessible()) {
  <div class="hint-text">
    <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
    >
  </div>
}

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