File

src/lib/directives/autocomplete-options-from-method.directive.ts

Extends

OptionsFromMethodDirectiveSettings

Index

Properties

Properties

filteredOptions
filteredOptions: boolean
Type : boolean
Optional

Autocomplete Table Select

With Rxap form System

Example :

<mat-form-field>
  <mat-label>Company</mat-label>
  <input type="text"
         placeholder="Enter Company Name"
         matInput
         formControlName="company"
         [matAutocomplete]="auto">
  <!-- The autocomplete input -->
  <mat-autocomplete #auto="matAutocomplete">
    <mat-option *rxapAutocompleteOptionsFromMethod="let option; matAutocomplete: auto"
                [value]="option.value">{{ option.display }}
    </mat-option>
  </mat-autocomplete>
  <button mat-icon-button rxapInputClearButton matSuffix>
    <mat-icon>clear</mat-icon>
  </button>
</mat-form-field>
Example :
@Injectable()
@RxapForm('form')
export class Form {

  // ensure the table data source is provided - in root or in the form component
  @UseAutocompleteOptionsMethod(SearchCompanyMethod)
  // ensure the table data source is provided - in root or in the form component
  @UseAutocompleteResolveMethod(GetCompanyMethod)
  @UseFormControl()
  company!: RxapFromControl<Company>;

}

The DataSource CompanyGuiTableDataSource is used to populate the table in the selection window. Ensure that this DataSource is of the type AbstractTableDataSource.

The Method SearchCompanyMethod is used by the autocomplete control to search for a list of matching selection options. The Method must accept a string as the first argument and return a list of ControlOption objects.

The Method GetCompanyMethod is used to resolve the selected value. The Method must accept a the value of the form control as the first argument and return a ControlOption object.

Example :
export class SearchCompanyMethod implements Method<ControlOptions, { parameters: { search?: string | null } }> { ... }
export class GetCompanyMethod implements Method<ControlOption, { parameters: { value: string } }> { ... }

Method Parameter Adopter

By default the following adopter function is used by the @UseAutocompleteOptionsMethod and @UseAutocompleteResolveMethod decorators.

Example :
(parameters) => ({parameters})

To override this default adopter function use the second argument of the decorator.

Example :
@UseAutocompleteOptionsMethod(SearchCompanyMethod, { adapter: { parameter: (parameters) => ({ search: parameters.search }) } })

The adapter function are called in an injection context, so it is possible to use the inject function

import {
  AfterViewInit,
  Directive,
  inject,
  Injectable,
  Injector,
  INJECTOR,
  Input,
  isDevMode,
  OnDestroy,
  ProviderToken,
} from '@angular/core';
import { MatAutocomplete } from '@angular/material/autocomplete';
import { ControlOption, ControlOptions } from '@rxap/utilities';
import { distinctUntilChanged, Subscription, tap } from 'rxjs';
import { isUUID } from '@rxap/validator';
import { Method, MethodWithParameters } from '@rxap/pattern';
import { Mixin } from '@rxap/mixin';
import { NgControl } from '@angular/forms';
import { MatFormField } from '@angular/material/form-field';
import { isDefined } from '@rxap/rxjs';
import { ExtractControlMixin } from '../mixins/extract-control.mixin';
import { ExtractFormDefinitionMixin } from '../mixins/extract-form-definition.mixin';
import {
  ExtractIsValueFunctionMixin,
  UseIsValueFunction,
} from '../mixins/extract-is-value-function.mixin';
import { ExtractMethodMixin } from '../mixins/extract-method.mixin';
import { UseMethodConfig } from '../mixins/extract-methods.mixin';
import { UseOptionsMethod } from '../mixins/extract-options-method.mixin';
import {
  ExtractResolveMethodMixin,
  UseResolveMethod,
} from '../mixins/extract-resolve-method.mixin';
import {
  ExtractToDisplayFunctionMixin,
  UseToDisplayFunction,
} from '../mixins/extract-to-display-function.mixin';
import { OptionsFromMethodDirective, OptionsFromMethodDirectiveSettings } from './options-from-method.directive';
import { OpenApiRemoteMethodParameter } from '@rxap/open-api/remote-method';
import { controlValueChanges$ } from '@rxap/forms';

export function UseAutocompleteOptionsMethod(
  method: ProviderToken<MethodWithParameters<ControlOptions, AutocompleteOptionsFromMethodDirectiveParameters>>,
): any;
export function UseAutocompleteOptionsMethod(
  method: ProviderToken<MethodWithParameters<ControlOptions, OpenApiRemoteMethodParameter<AutocompleteOptionsFromMethodDirectiveParameters>>>,
): any;
export function UseAutocompleteOptionsMethod(
  method: ProviderToken<MethodWithParameters>,
  config: UseMethodConfig<ControlOptions, AutocompleteOptionsFromMethodDirectiveParameters>,
): any;
export function UseAutocompleteOptionsMethod(
  method: ProviderToken<MethodWithParameters<ControlOptions, AutocompleteOptionsFromMethodDirectiveParameters | OpenApiRemoteMethodParameter<AutocompleteOptionsFromMethodDirectiveParameters>>>,
  config: UseMethodConfig = {},
) {
  config.adapter ??= {};
  config.adapter.parameter ??= (parameters) => ({parameters});
  return UseOptionsMethod(method as any, config);
}

export function UseAutocompleteResolveMethod<Value = unknown>(
  method: ProviderToken<MethodWithParameters<ControlOption<Value>, OpenApiRemoteMethodParameter<{ value: string }>>>,
  config: UseMethodConfig = {},
) {
  config.adapter ??= {};
  config.adapter.parameter ??= (parameters) => ({parameters});
  return UseResolveMethod(method, config);
}

export function UseAutocompleteIsValueFunction(
  isValue: (value: any) => boolean,
): any {
  return UseIsValueFunction(isValue);
}

export function UseAutocompleteToDisplayFunction(
  toDisplay: (value: any) => string,
): any {
  return UseToDisplayFunction(toDisplay);
}

export interface AutocompleteOptionsFromRemoteMethodTemplateContext {
  $implicit: ControlOption;
}

export interface AutocompleteOptionsFromMethodDirectiveSettings extends OptionsFromMethodDirectiveSettings {
  filteredOptions?: boolean;
}

export interface AutocompleteOptionsFromMethodDirectiveParameters<Value = any> {
  search?: Value | null;
}

// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface AutocompleteOptionsFromMethodDirective<Value = any, Parameters extends AutocompleteOptionsFromMethodDirectiveParameters<Value> = AutocompleteOptionsFromMethodDirectiveParameters<Value>>
  extends ExtractResolveMethodMixin, ExtractIsValueFunctionMixin, ExtractToDisplayFunctionMixin, AfterViewInit, OnDestroy {
}

@Injectable({ providedIn: 'root' })
export class NoopResolveMethod<Value> implements Method<ControlOption, { value: Value }> {

  call({ value } : { value: Value }): Promise<ControlOption> {
    return Promise.resolve({ value, display: value + '' });
  }

}

@Mixin(ExtractResolveMethodMixin, ExtractIsValueFunctionMixin, ExtractToDisplayFunctionMixin)
@Directive({
  // eslint-disable-next-line @angular-eslint/directive-selector
  selector: '[rxapAutocompleteOptionsFromMethod]',
  standalone: true,
})
export class AutocompleteOptionsFromMethodDirective<Value = any, Parameters extends AutocompleteOptionsFromMethodDirectiveParameters<Value> = AutocompleteOptionsFromMethodDirectiveParameters<Value>>
  extends OptionsFromMethodDirective<Value, Parameters>
  implements AfterViewInit, OnDestroy {

  static override ngTemplateContextGuard(
    dir: OptionsFromMethodDirective,
    ctx: any,
  ): ctx is AutocompleteOptionsFromRemoteMethodTemplateContext {
    return true;
  }

  @Input('rxapAutocompleteOptionsFromMethodParameters')
  public declare parameters?: Parameters;
  @Input('rxapAutocompleteOptionsFromMethodResetOnChange')
  public declare resetOnChange?: Value;
  @Input('rxapAutocompleteOptionsFromMethodMatAutocomplete')
  public matAutocomplete?: MatAutocomplete;
  // eslint-disable-next-line @angular-eslint/no-input-rename
  @Input('rxapAutocompleteOptionsFromMethodCall')
  public declare method: Method<ControlOptions, Parameters>;
  // eslint-disable-next-line @angular-eslint/no-input-rename
  @Input('rxapAutocompleteOptionsFromMethodResolve')
  public resolveMethod?: MethodWithParameters<ControlOption, { value: Value } & Parameters>;

  @Input('rxapAutocompleteOptionsFromMethodIsValue')
  public isValue?: (value: any) => boolean;

  @Input('rxapAutocompleteOptionsFromMethodToDisplay')
  public toDisplay?: (value: any) => string;

  protected override ngControl: NgControl | null                              = null;
  protected override matFormField: MatFormField | null                        = null;
  protected override injector: Injector                                       = inject(INJECTOR);
  protected override settings: AutocompleteOptionsFromMethodDirectiveSettings = {};
  /**
   * This flag is used to prevent the setValue is called for each refresh
   * of the options list. This is needed because the setValue method will
   * trigger a new refresh of the options list. This results in an endless
   * call stack. This flag is set to true if the setValue method is called
   * once.
   */
  private isAutocompleteToDisplayTriggered                                    = false;

  private _subscription?: Subscription;

  public ngOnDestroy() {
    this._subscription?.unsubscribe();
  }

  public override async ngAfterViewInit() {
    if (this.matAutocomplete) {
      this.settings ??= {};
      this.settings.filteredOptions ??= true;
    }
    await super.ngAfterViewInit();
    this.resolveMethod ??= this.extractResolveMethod();
    this.isValue ??= this.extractIsValueFunction((value: any) => typeof value === 'string' && isUUID(value));
    this.toDisplay ??= this.extractToDisplayFunction((value: any): string => {
      if (!value) {
        return '';
      }
      const option = this.findOptionByValue(value);
      return option?.display ?? (isDevMode() ? 'to display error' : '...');
    });
    if (this.matAutocomplete) {
      this.matAutocomplete.displayWith = this.toDisplay.bind(this);
    }
    if (!this.control) {
      throw new Error('The control is not yet defined');
    }
    const value$       = controlValueChanges$(this.control);
    this._subscription = value$.pipe(
      isDefined(),
      // only trigger the load options or resolve value if the value is changed
      // this is required because in the resolveValue method the control value is set
      // to trigger the toDisplay function in the mat-autocomplete
      distinctUntilChanged(),
      tap(async value => {
        this.setOptions(await this.loadOptions(this.parameters));
        if (this.isValue?.(value)) {
          this.triggerAutocompleteToDisplay();
        }
      }),
    ).subscribe();
  }

  protected override loadOptions(parameters: Parameters = {} as Parameters): Promise<ControlOptions | null> {
    if (!this.control) {
      throw new Error('The control is not yet defined');
    }
    const value        = this.control?.value;
    const c_parameters = {...parameters};
    if (this.isValue?.(value)) {
      return this.resolveValue(value, c_parameters);
    } else {
      c_parameters.search ??= value;
      if (!c_parameters.search) {
        return Promise.resolve([]);
      }
      return super.loadOptions(c_parameters);
    }
  }

  protected override renderTemplate() {
    super.renderTemplate();
    if (this.matAutocomplete && !this.isAutocompleteToDisplayTriggered) {
      this.isAutocompleteToDisplayTriggered = true;
      this.triggerAutocompleteToDisplay();
    }
  }

  protected async resolveValue(value: Value, parameters: Parameters = {} as Parameters) {
    if (!this.resolveMethod) {
      if (isDevMode()) {
        console.warn('The resolve method is not yet defined');
      }
      return null;
    }
    // only resolve the value if the option is not already loaded
    if (!this.findOptionByValue(value)) {
      const option = await this.resolveMethod!.call({...parameters, value});
      if (option.value !== value) {
        throw new Error('The resolved value is not the same as the input value');
      }
      return [ option ];
    }
    return this.options;
  }

  private triggerAutocompleteToDisplay() {
    // trigger a change detection after the options are rendered
    // this is needed to trigger the mat-autocomplete options to display function
    this.ngControl?.control?.setValue(this.ngControl?.control?.value);
  }

  private findOptionByValue(value: Value): ControlOption | null {
    return this.options?.find((option: ControlOption) => option.value === value) ?? null;
  }

}

results matching ""

    No results matching ""