Search code examples
angulartypescriptangular2-templateangular2-directivesangular2-components

How Does One Properly Pass Functions to Custom Directives in Angular2/Typescript?


Im fairly new to Angular 2. I am upgrading my application from AngularJS and I'm now in the finishing stages of UI/UX development. I have one final issue I need help with. Thank you in advance.

Current Plan

  1. I have a custom directive TspFieldWidgetDirective that takes multiple inputs, one being a function @Input('onRefresh') public refresh: Function;.
  2. The TspFieldWidgetDirective element can be used inside various components to re-engineer standard form fields.
  3. I am using the current directive as a select box inside the TspHeaderComponent template.
  4. When the user changes the value of the select box, the OnRefresh function is called to refresh the application to update the view for a new company.

What's Not Working

  1. Every time the TspHeaderComponent template is loaded in the browser, it produces an infinite loop of calls to the OnRefresh function, onCompanyChange, when it should only be called once, when the select box changes values.

  2. When I change the value of the select box from the browser (inside the TspFieldWidgetDirective template - (change)="refresh({'id': fieldData[fieldKey]})", the following error is generated. ERROR TypeError: _co.refresh is not a function

Please Note

  1. The functionality has never worked in Angular 5, it worked perfectly fine in AngularJS.
  2. Everything else, except passing a function as input to the directive, works.

Code Snippets Below:

tsp-header.component.ts

/*
    Method to change company of the company selector

    @param: id - string
    @return: none
 */
public onCompanyChange(id: string): void {
    if ((__env.debug && __env.verbose)) {
        console.log('Setting current_company_id to ' + id);
    }

    if (id !== undefined && id !== null)
    {
        // @TODO: Update user preference on server IF they have permission to change their default company
        this.cookies.set('preferences.current_company_id', id);
        this.core.app.current_user.preferences.current_company_id = id;
        this.core.initApp();

        this.router.navigate(['/app/dashboard']);
    }
}

tsp-header.html

<!-- company changer -->
<li>
  <tsp-field-widget 
      type="company-select" 
      [onRefresh]="onCompanyChange(id)" 
      [showAvatar]="true"
      [fieldData]="core.app.current_user.preferences"
      fieldKey="current_company_id" 
      [hideLabel]="true"
      [optionData]="core.app.session.base_companies">
  </tsp-field-widget>
</li>

tsp-field-widget.component.ts

// Component class
@Component({
  selector: 'tsp-field-widget',
  templateUrl: './templates/tsp-field-widget.html'
})
export class TspFieldWidgetComponent implements OnInit {
  public lang:any; // for i18n

  @Input() public type: string; // the field type
  @Input() public isRequired: boolean; // is the field required
  @Input() public isReadonly: boolean;

  @Input() public index: any; // index of ng-repeat
  @Input() public filterBy: any; // used in conjunction with ng-repeat
  @Input() public orderBy: any; // used in conjunction with ng-repeat
  @Input() public fieldData: any; // the record of ng-repeat
  @Input() public fieldKey: string; // the index of record - record[fieldKey]
  @Input() public placeVal: string; // the placeholder value of the field, usually used for selects and other dropdowns
  @Input() public pattern: string; // used for ng-pattern
  @Input() public prefix: string; // Text to display before title listings

  @Input() public recordId: any; // the ID of the record
  @Input() public label: string; // the label for the field for <label> tag
  @Input() public suffix: string; // sub label, usually placed below some label or title
  @Input() public optionData: any[]; // array of data used to populate selects or to store data values
  @Input() public showAvatar: boolean; // show the fields avatar
  @Input() public hideLabel: boolean; // show the fields <label>
  @Input() public showAdd: boolean; // show the add button (to add new record)
  @Input() public showTitle: boolean; // avatar type: show the name of the user
  @Input() public showDesc: boolean; // avatar type: show the name of the user
  // @Input() public isHighlighted:boolean; // default type: highlight text
  @Input() public numRows: string; // textarea: the number of rows of the text area
  @Input() public idKey: string; // select: the default ID key for option data - defaults to _id
  @Input() public titleKey: string; // select: the default title key for option data - defaults to title
  @Input() public descKey: string; // select: the default description key for option data - defaults to description

  @Input() public sliderType: string; // percent, amount or default, slider type
  @Input() public sliderMinValue: string; // slider type
  @Input() public sliderMaxValue: string; // slider type
  @Input() public sliderStepValue: string; // slider type
  @Input() public sliderOrientation: string; // slider type

  @Input() public dzOptions: any; // dropzone options
  @Input() public dzCallbacks: any; // dropzone callbacks
  @Input() public dzMethods: any; // dropzone methods

  @Input() public decimalPlaces: string; // tspFormat type

  @Input() public locale: string; // for dates and currency
  @Input() public format: string; // for dates
  @Input() public formatHours: string; // for dates
  @Input() public intervalMins: number; // for dates
  @Input() public owner: string; // used for module windows to determine the type of record to add

  @Input('onAdd') public add: Function;
  @Input('onEdit') public edit: Function;
  @Input('onToggle') public toggle: Function;
  @Input('onDelete') public delete: Function;
  @Input('onRefresh') public refresh: Function;

  constructor(private el: ElementRef,
    private cookies: TspCookiesService,
    public core: TspCoreService,
    private object: TspObjectService,
    public date: TspDateService) {
    this.lang = core.lang;
    this.date = date;
  }
}

tsp-field-widget.html

<div *ngIf="type=='company-select'">
  <select class="company-select" 
      class="form-control" 
      [(ngModel)]="fieldData[fieldKey]" 
      (change)="refresh({'id': fieldData[fieldKey]})" 
      data-parsley-trigger="change">
    <option [selected]="x[idKey] === fieldData[fieldKey]" *ngFor="let x of optionData" [ngValue]="x[idKey]">
      {{x[titleKey]}}
    </option>
  </select>
</div>

Solution

  • Complete Fix

    tsp-field-widget.component.ts - Changed all @Inputs of type Function to @Ouput and initialized as EventEmitters. Replaced all on prefixes with evt as on is not allowed as a prefix for @Output. Added four new methods that will be called once events are fired. Each of the new methods need to have an interface assigned to the args argument for consistentency

    // Component class
    @Component({
      selector: 'tsp-field-widget',
      templateUrl: './templates/tsp-field-widget.html'
    })
    export class TspFieldWidgetComponent implements OnInit {
      public lang:any; // for i18n
    
      @Input() public type: string; // the field type
      @Input() public isRequired: boolean; // is the field required
      @Input() public isReadonly: boolean;
    
      @Input() public index: any; // index of ng-repeat
      @Input() public filterBy: any; // used in conjunction with ng-repeat
      @Input() public orderBy: any; // used in conjunction with ng-repeat
      @Input() public fieldData: any; // the record of ng-repeat
      @Input() public fieldKey: string; // the index of record - record[fieldKey]
      @Input() public placeVal: string; // the placeholder value of the field, usually used for selects and other dropdowns
      @Input() public pattern: string; // used for ng-pattern
      @Input() public prefix: string; // Text to display before title listings
    
      @Input() public recordId: any; // the ID of the record
      @Input() public label: string; // the label for the field for <label> tag
      @Input() public suffix: string; // sub label, usually placed below some label or title
      @Input() public optionData: any[]; // array of data used to populate selects or to store data values
      @Input() public showAvatar: boolean; // show the fields avatar
      @Input() public hideLabel: boolean; // show the fields <label>
      @Input() public showAdd: boolean; // show the add button (to add new record)
      @Input() public showTitle: boolean; // avatar type: show the name of the user
      @Input() public showDesc: boolean; // avatar type: show the name of the user
      // @Input() public isHighlighted:boolean; // default type: highlight text
      @Input() public numRows: string; // textarea: the number of rows of the text area
      @Input() public idKey: string; // select: the default ID key for option data - defaults to _id
      @Input() public titleKey: string; // select: the default title key for option data - defaults to title
      @Input() public descKey: string; // select: the default description key for option data - defaults to description
    
      @Input() public sliderType: string; // percent, amount or default, slider type
      @Input() public sliderMinValue: string; // slider type
      @Input() public sliderMaxValue: string; // slider type
      @Input() public sliderStepValue: string; // slider type
      @Input() public sliderOrientation: string; // slider type
    
      @Input() public dzOptions: any; // dropzone options
      @Input() public dzCallbacks: any; // dropzone callbacks
      @Input() public dzMethods: any; // dropzone methods
    
      @Input() public decimalPlaces: string; // tspFormat type
    
      @Input() public locale: string; // for dates and currency
      @Input() public format: string; // for dates
      @Input() public formatHours: string; // for dates
      @Input() public intervalMins: number; // for dates
      @Input() public owner: string; // used for module windows to determine the type of record to add
    
      @Output() public evtAdd = new EventEmitter();
      @Output() public evtEdit = new EventEmitter();
      @Output() public evtToggle = new EventEmitter();
      @Output() public evtDelete = new EventEmitter();
      @Output() public evtRefresh = new EventEmitter();
    
      constructor(private el: ElementRef,
        private cookies: TspCookiesService,
        public core: TspCoreService,
        private object: TspObjectService,
        public date: TspDateService) {
        this.lang = core.lang;
        this.date = date;
      }
      add(args: IAdd){
        this.evtAdd.emit(args);
      }
      edit(args: IEdit){
        this.evtEdit.emit(args);
      }
      toggle(args: IToggle){
        this.evtToggle.emit(args);
      }
      delete(args: IDelete){
        this.evtDelete.emit(args);
      }
      refresh(args: IRefresh){
        this.evtRefresh.emit(args);
      }
    }
    

    tsp-field-widget.html - No Changes required

    tsp-header.component.ts - Updated to pass in an object which contain the values needed by the function.

    public onCompanyChange(args: IRefresh):void {
        if (args !== undefined){
            if (args.id !== undefined && args.id !== null)
            {
                if ((__env.debug && __env.verbose)) {
                    console.log('Setting current_company_id to ' + args.id);
                }
    
                // @TODO: Update user preference on server IF they have permission to change their default company
                this.cookies.set('preferences.current_company_id', args.id);
                this.core.app.current_user.preferences.current_company_id = args.id;
                this.core.initApp();
    
                this.router.navigate(['/app/dashboard']);
            }
        }
    }
    

    tsp-header.html - Renamed onRefresh attribute to evtRefresh. To prevent the infinite loop, I wrapped evtRefresh attribute with parenthesis rather than brackets to denote that the attribute is an event and NOT an object. Also the function argument always has to be $event.

    <!-- company changer -->
    <li>
      <tsp-field-widget 
          type="company-select" 
          (evtRefresh)="onCompanyChange($event)" 
          [showAvatar]="true"
          [fieldData]="core.app.current_user.preferences"
          fieldKey="current_company_id" 
          [hideLabel]="true"
          [optionData]="core.app.session.base_companies">
      </tsp-field-widget>
    </li>