src/app/core/common-components/edit-text-with-autocomplete/edit-text-with-autocomplete.component.ts

Description

This component creates a normal text input with autocomplete. Compared to the EditEntityComponent this does not just assign the ID to the form control but instead completely overwrites the form with the values taken from the selected entity. This is especially useful when instead of creating a new entity, an existing one can also be selected (and extended).

When a value is already present the autocomplete is disabled, and it works like a normal text input.

E.g.

Example :
{
    "id": "title",
    "editComponent": "EditTextWithAutocomplete",
    "additional": {
      "entityType": "RecurringActivity",
      "relevantProperty": "linkedGroups",
      "relevantValue": "some-id",
    },
  }

Extends

CustomFormControlDirective<string>

Implements

EditComponent

Example

Metadata

Relationships

Used by

No results matching.

Depends on

Index

Properties
Methods
Inputs
Outputs

Constructor

constructor()

Inputs

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

Outputs

valueChange
Type : EventEmitter

Methods

Async initAutocomplete
initAutocomplete()
Returns : any
keyup
keyup()
Returns : void
Async resetForm
resetForm()
Returns : any
Async selectEntity
selectEntity(selected: Entity)
Parameters :
Name Type Optional
selected Entity No
Returns : any
updateAutocomplete
updateAutocomplete()
Returns : void
blur
blur()
Returns : void
focus
focus()
Returns : void
onContainerClick
onContainerClick(event: MouseEvent)
Parameters :
Name Type Optional
event MouseEvent No
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

addedFormControls
Type : []
Default value : []
additional
Type : { entityType: string; relevantProperty?: string; relevantValue?: string; relatedEntitiesParent?: Entity }

Config passed using component

autocompleteDisabled
Type : unknown
Default value : signal(true)
autocompleteEntities
Type : unknown
Default value : signal<Entity[]>([])
currentValues
Type : unknown
entities
Type : unknown
Default value : signal<Entity[]>([])
lastValue
Type : string
Default value : ""
originalValues
Type : unknown
selectedEntity
Type : unknown
Default value : signal<Entity | null>(null)
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.

Accessors

import { FormFieldConfig } from "#src/app/core/common-components/entity-form/FormConfig";
import {
  ChangeDetectionStrategy,
  Component,
  effect,
  inject,
  input,
  signal,
} from "@angular/core";
import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms";
import { MatAutocompleteModule } from "@angular/material/autocomplete";
import { MatFormFieldControl } from "@angular/material/form-field";
import { MatInputModule } from "@angular/material/input";
import { MatTooltipModule } from "@angular/material/tooltip";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { EntityBlockComponent } from "../../basic-datatypes/entity/entity-block/entity-block.component";
import { DynamicComponent } from "../../config/dynamic-components/dynamic-component.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 { CustomFormControlDirective } from "../basic-autocomplete/custom-form-control.directive";
import { ConfirmationDialogService } from "../confirmation-dialog/confirmation-dialog.service";

/**
 * This component creates a normal text input with autocomplete.
 * Compared to the {@link EditEntityComponent} this does not just assign the ID to the form control
 * but instead completely overwrites the form with the values taken from the selected entity.
 * This is especially useful when instead of creating a new entity, an existing one can also be selected (and extended).
 *
 * When a value is already present the autocomplete is disabled, and it works like a normal text input.
 *
 * E.g.
 * ```json
 * {
 *     "id": "title",
 *     "editComponent": "EditTextWithAutocomplete",
 *     "additional": {
 *       "entityType": "RecurringActivity",
 *       "relevantProperty": "linkedGroups",
 *       "relevantValue": "some-id",
 *     },
 *   }
 * ```
 */
@DynamicComponent("EditTextWithAutocomplete")
@Component({
  selector: "app-edit-text-with-autocomplete",
  templateUrl: "./edit-text-with-autocomplete.component.html",
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    ReactiveFormsModule,
    MatInputModule,
    MatAutocompleteModule,
    EntityBlockComponent,
    FontAwesomeModule,
    MatTooltipModule,
  ],
  providers: [
    {
      provide: MatFormFieldControl,
      useExisting: EditTextWithAutocompleteComponent,
    },
  ],
})
export class EditTextWithAutocompleteComponent
  extends CustomFormControlDirective<string>
  implements EditComponent
{
  private entityMapperService = inject(EntityMapperService);
  private confirmationDialog = inject(ConfirmationDialogService);

  formFieldConfig = input.required<FormFieldConfig>();

  get parent(): FormGroup {
    return this.formControl.parent as FormGroup;
  }

  /**
   * Config passed using component
   */
  declare additional: {
    /**
     * The entity type for which autofill should be created.
     * This should be the same type as for which the form was created.
     */
    entityType: string;

    /**
     * (optional) a property which should be filled with certain value, if an entity is selected.
     */
    relevantProperty?: string;

    /**
     * @deprecated use `relatedEntitiesParent` instead.
     *
     * (optional) required if `relevantProperty` is set.
     * The value to be filled in `selectedEntity[relevantProperty]`.
     */
    relevantValue?: string;

    /**
     * Reference to the "parent" entity within which this is listed as a related entity.
     * (automatically assigned by RelatedEntitiesComponent)
     */
    relatedEntitiesParent?: Entity;
  };

  entities = signal<Entity[]>([]);
  autocompleteEntities = signal<Entity[]>([]);
  selectedEntity = signal<Entity | null>(null);
  currentValues;
  originalValues;
  autocompleteDisabled = signal(true);
  lastValue = "";
  addedFormControls = [];

  constructor() {
    super();
    effect(() => {
      this.additional = this.formFieldConfig().additional;
      if (!this.formControl.value) {
        void this.initAutocomplete();
      }
    });
  }

  keyup() {
    this.lastValue = this.formControl.value;
    this.updateAutocomplete();
  }

  updateAutocomplete() {
    let val = this.formControl.value;
    if (
      !this.autocompleteDisabled() &&
      val !== this.currentValues[this.formFieldConfig().id]
    ) {
      let filteredEntities = this.entities();
      if (val) {
        filteredEntities = this.entities().filter(
          (entity) =>
            entity !== this.selectedEntity() &&
            entity.toString().toLowerCase().includes(val.toLowerCase()),
        );
      }
      this.autocompleteEntities.set(filteredEntities);
    }
  }

  async initAutocomplete() {
    const entityType = this.additional.entityType;
    const entities = await this.entityMapperService.loadType(entityType);
    const sortedEntities = [...entities].sort((e1, e2) =>
      e1.toString().localeCompare(e2.toString()),
    );
    this.entities.set(sortedEntities);
    this.autocompleteEntities.set(sortedEntities);
    this.autocompleteDisabled.set(false);
    this.currentValues = this.parent.getRawValue();
    this.originalValues = this.currentValues;
  }

  async selectEntity(selected: Entity) {
    if (await this.userConfirmsOverwriteIfNecessary(selected)) {
      this.selectedEntity.set(selected);
      this.addRelevantValueToRelevantProperty(selected);
      this.setAllFormValues(selected);
      this.currentValues = this.parent.getRawValue();
      this.autocompleteEntities.set([]);
    } else {
      this.formControl.setValue(this.lastValue);
    }
  }

  private async userConfirmsOverwriteIfNecessary(entity: Entity) {
    return (
      !this.valuesChanged() ||
      this.confirmationDialog.getConfirmation(
        $localize`:Discard changes header:Discard Changes?`,
        $localize`Do you want to discard the changes made to '${entity}'?`,
      )
    );
  }

  private valuesChanged() {
    return Object.entries(this.currentValues).some(
      ([prop, value]) =>
        prop !== this.formFieldConfig().id &&
        value !== this.parent.controls[prop].value,
    );
  }

  private addRelevantValueToRelevantProperty(selected: Entity) {
    const relevantValue =
      this.additional.relevantValue ??
      this.additional?.relatedEntitiesParent?.getId();

    if (!selected[this.additional.relevantProperty]) {
      selected[this.additional.relevantProperty] = [];
    }

    if (
      this.additional.relevantProperty &&
      relevantValue &&
      !selected[this.additional.relevantProperty].includes(relevantValue)
    ) {
      selected[this.additional.relevantProperty].push(relevantValue);
    }
  }

  private setAllFormValues(selected: Entity) {
    Object.keys(selected)
      .filter((key) => selected.getSchema().has(key))
      .forEach((key) => {
        if (this.parent.controls.hasOwnProperty(key)) {
          this.parent.controls[key].setValue(selected[key]);
        } else {
          // adding missing controls so saving does not lose any data
          this.parent.addControl(key, new FormControl(selected[key]));
          this.addedFormControls.push(key);
        }
      });
  }

  async resetForm() {
    const selectedEntity = this.selectedEntity();
    if (!selectedEntity) return;
    if (await this.userConfirmsOverwriteIfNecessary(selectedEntity)) {
      this.addedFormControls.forEach((control) =>
        this.parent.removeControl(control),
      );
      this.addedFormControls = [];
      this.formControl.reset();
      this.parent.patchValue(this.originalValues);
      this.selectedEntity.set(null);
      this.currentValues = this.originalValues;
    }
  }
}
<input
  matInput
  [readonly]="!!selectedEntity()"
  [formControl]="formControl"
  [matAutocomplete]="autoSuggestions"
  [matAutocompleteDisabled]="autocompleteDisabled()"
  (keyup)="keyup()"
  (focusin)="updateAutocomplete()"
/>

<mat-autocomplete #autoSuggestions="matAutocomplete">
  @for (entity of autocompleteEntities(); track entity.getId()) {
    <mat-option
      [value]="entity[formFieldConfig().id]"
      (onSelectionChange)="$event.source.selected ? selectEntity(entity) : null"
    >
      <app-entity-block
        [entity]="entity"
        [linkDisabled]="true"
      ></app-entity-block>
    </mat-option>
  }
</mat-autocomplete>

@if (!autocompleteDisabled()) {
  <div style="font-size: 0.75em; color: rgba(0, 0, 0, 0.6); margin-top: 4px">
    @if (!selectedEntity()) {
      <span i18n>Creating new record.</span>
    } @else {
      <span i18n>Editing existing record.</span>
    }
  </div>
}

@if (!autocompleteDisabled() && !selectedEntity()) {
  <fa-icon
    (click)="tooltipElement.show()"
    #tooltipElement="matTooltip"
    icon="cogs"
    i18n-matTooltip="Tooltip help text"
    matTooltip="You can create a new or load an existing record. Start to type an existing name and then select it from the dropdown to edit the existing record. Type any new text to create a new record."
    style="
      position: absolute;
      right: 0;
      top: 50%;
      transform: translateY(-50%);
      cursor: pointer;
    "
    class="tooltip-suffix"
  ></fa-icon>
} @else if (!autocompleteDisabled() && !!selectedEntity()) {
  <fa-icon
    (click)="resetForm()"
    icon="circle-xmark"
    i18n-matTooltip="Tooltip for button to reset form"
    matTooltip="Unload existing record and reset form."
    class="tooltip-suffix"
    style="
      position: absolute;
      right: 0;
      top: 50%;
      transform: translateY(-50%);
      cursor: pointer;
    "
  ></fa-icon>
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""