File

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

Extends

ExtractResolveMethodMixin ExtractIsValueFunctionMixin ExtractToDisplayFunctionMixin AfterViewInit OnDestroy

Implements

AfterViewInit OnDestroy

Metadata

Index

Properties
Methods
Inputs

Inputs

rxapAutocompleteOptionsFromMethodCall
Type : Method<ControlOptions | Parameters>
rxapAutocompleteOptionsFromMethodIsValue
Type : function
rxapAutocompleteOptionsFromMethodMatAutocomplete
Type : MatAutocomplete
rxapAutocompleteOptionsFromMethodParameters
Type : Parameters
rxapAutocompleteOptionsFromMethodResetOnChange
Type : Value
rxapAutocompleteOptionsFromMethodResolve
Type : MethodWithParameters<ControlOption | >
rxapAutocompleteOptionsFromMethodToDisplay
Type : function

Methods

Protected loadOptions
loadOptions(parameters: Parameters)
Parameters :
Name Type Optional Default value
parameters Parameters No {} as Parameters
Returns : Promise<ControlOptions | null>
Static ngTemplateContextGuard
ngTemplateContextGuard(dir: OptionsFromMethodDirective, ctx: any)
Parameters :
Name Type Optional
dir OptionsFromMethodDirective No
ctx any No
Protected renderTemplate
renderTemplate()
Returns : void
Protected Async resolveValue
resolveValue(value: Value, parameters: Parameters)
Parameters :
Name Type Optional Default value
value Value No
parameters Parameters No {} as Parameters
Returns : unknown
Protected extractResolveMethod
extractResolveMethod(control: RxapFormControl, formDefinition)
Parameters :
Name Type Optional Default value
control RxapFormControl No this.extractControl()
formDefinition No this.extractFormDefinition(control)
Returns : any

Properties

Protected injector
Type : Injector
Default value : inject(INJECTOR)
Protected matFormField
Type : MatFormField | null
Default value : null
Protected ngControl
Type : NgControl | null
Default value : null
Protected settings
Type : AutocompleteOptionsFromMethodDirectiveSettings
Default value : {}

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