Custom Editors with Reactive Forms

The TreeList enables you to use templates and customize its built-in editing functionality in Reactive Forms.

Setup

The following example demonstrates how to use the EditTemplateDirective column options and the Reactive Forms to build custom AutoComplete and NumericTextBox editors. It also demonstrates how to integrate custom validation messages, which utilize the built-in Kendo UI styling and the Kendo UI for Angular Popup component.

To align the validation popup with the editor, use a custom directive to expose the ElementRef of the custom editor component and link it to the anchor property of the Popup.

import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { Observable } from 'rxjs';
import { take } from 'rxjs/operators';

import {
    AddEvent,
    CancelEvent,
    EditEvent,
    RemoveEvent,
    SaveEvent,
    TreeListComponent
} from '@progress/kendo-angular-treelist';

import { EmployeeEditService } from './employee-edit.service';
import { Employee } from './employee';
import { positions } from './positions';

@Component({
  selector: 'my-app',
  template: `
    <kendo-treelist
        kendoTreeListExpandable
        [data]="rootData | async"
        idField="EmployeeId"
        [fetchChildren]="fetchChildren"
        [hasChildren]="hasChildren"
        (add)="addHandler($event)"
        (edit)="editHandler($event)"
        (remove)="removeHandler($event)"
        (save)="saveHandler($event)"
        (cancel)="cancelHandler($event)"
        [height]="533"
    >
        <ng-template kendoTreeListToolbarTemplate>
            <button kendoTreeListAddCommand type="button">Add new</button>
        </ng-template>
        <kendo-treelist-column [expandable]="true" field="FirstName" title="First Name">
        </kendo-treelist-column>
        <kendo-treelist-column field="LastName" title="Last Name">
        </kendo-treelist-column>
        <kendo-treelist-column field="Position" title="Position">
            <ng-template kendoTreeListEditTemplate
              let-dataItem="dataItem"
              let-column="column"
              let-formGroup="formGroup">
              <kendo-autocomplete
                #anchor="popupAnchor"
                popupAnchor
                placeholder="Select position..."
                [data]="suggestions"
                [formControl]="formGroup.get('Position')"
              >
              </kendo-autocomplete>
              <kendo-popup
                  [anchor]="anchor.element"
                  *ngIf="formGroup.get(column.field).invalid && !(isNew && formGroup.get(column.field).untouched)"
                  popupClass="k-widget k-tooltip k-tooltip-validation k-invalid-msg"
                 >
                  <span class="k-icon k-i-warning"></span>
                  Position is required
                </kendo-popup>
            </ng-template>
        </kendo-treelist-column>
        <kendo-treelist-column field="Extension" title="Extension" editor="numeric" format="#">
            <ng-template kendoTreeListEditTemplate let-column="column" let-formGroup="formGroup" let-isNew="isNew">
                <kendo-numerictextbox
                  #anchor="popupAnchor"
                  popupAnchor
                  format="#"
                  [formControl]="formGroup.get(column.field)"></kendo-numerictextbox>
                <kendo-popup
                  [anchor]="anchor.element"
                  *ngIf="formGroup.get(column.field).invalid && !(isNew && formGroup.get(column.field).untouched)"
                  popupClass="k-widget k-tooltip k-tooltip-validation k-invalid-msg"
                 >
                  <span class="k-icon k-i-warning"></span>
                  Extension must be a positive number
                </kendo-popup>
              </ng-template>
        </kendo-treelist-column>
        <kendo-treelist-command-column width="140">
            <ng-template kendoTreeListCellTemplate let-isNew="isNew" let-cellContext="cellContext">
                <!-- "Add Child" command directive, will not be visible in edit mode -->
                <button [kendoTreeListAddCommand]="cellContext"
                        icon="filter-add-expression" title="Add Child">
                </button>

                <!-- "Edit" command directive, will not be visible in edit mode -->
                <button [kendoTreeListEditCommand]="cellContext"
                        icon="edit" title="Edit" [primary]="true">
                </button>

                <!-- "Remove" command directive, will not be visible in edit mode -->
                <button [kendoTreeListRemoveCommand]="cellContext"
                        icon="delete" title="Remove">
                </button>

                <!-- "Save" command directive, will be visible only in edit mode -->
                <button [kendoTreeListSaveCommand]="cellContext"
                        [disabled]="formGroup?.invalid"
                        icon="save" title="{{ isNew ? 'Add' : 'Update' }}">
                </button>

                <!-- "Cancel" command directive, will be visible only in edit mode -->
                <button [kendoTreeListCancelCommand]="cellContext"
                        icon="cancel" title="{{ isNew ? 'Discard changes' : 'Cancel' }}">
                </button>
            </ng-template>
        </kendo-treelist-command-column>
    </kendo-treelist>
  `
})
export class AppComponent implements OnInit {
    public rootData: Observable<Employee[]>;
    public formGroup: FormGroup;
    public editedItem: Employee;
    public suggestions = positions;

    constructor(private editService: EmployeeEditService) {}

    public ngOnInit(): void {
        this.rootData = this.editService;
        this.editService.read();
    }

    public fetchChildren = (item: Employee): Observable<Employee[]> => {
        return this.editService.fetchChildren(item.EmployeeId);
    }

    public hasChildren = (item: Employee): boolean => {
        return item.hasChildren;
    }

    public addHandler({ sender, parent }: AddEvent): void {
        // Close the current edited row, if any.
        this.closeEditor(sender);

        // Expand the parent.
        if (parent) {
            sender.expand(parent);
        }

        // Define all editable fields validators and default values
        this.formGroup = new FormGroup({
            'ReportsTo': new FormControl(parent ? parent.EmployeeId : null),
            'FirstName': new FormControl('', Validators.required),
            'LastName': new FormControl('', Validators.required),
            'Position': new FormControl('', Validators.required),
            'Extension': new FormControl('', Validators.compose([Validators.required, Validators.min(0)]))
        });

        // Show the new row editor, with the `FormGroup` build above
        sender.addRow(this.formGroup, parent);
    }

    public editHandler({ sender, dataItem }: EditEvent): void {
        // Close the current edited row, if any.
        this.closeEditor(sender, dataItem);

        // Define all editable fields validators and default values
        this.formGroup = new FormGroup({
            'EmployeeId': new FormControl(dataItem.EmployeeId),
            'ReportsTo': new FormControl(dataItem.ReportsTo),
            'FirstName': new FormControl(dataItem.FirstName, Validators.required),
            'LastName': new FormControl(dataItem.LastName, Validators.required),
            'Position': new FormControl(dataItem.Position, Validators.required),
            'Extension': new FormControl(dataItem.Extension, Validators.compose([Validators.required, Validators.min(0)]))
        });

        this.editedItem = dataItem;

        // Put the row in edit mode, with the `FormGroup` build above
        sender.editRow(dataItem, this.formGroup);
    }

    public cancelHandler({ sender, dataItem, isNew }: CancelEvent): void {
        // Close the editor for the given row
        this.closeEditor(sender, dataItem, isNew);
    }

    public saveHandler({ sender, dataItem, parent, formGroup, isNew }: SaveEvent): void {
        // Collect the current state of the form.
        // The `formGroup` argument is the same as was provided when calling `editRow`.
        const employee: Employee = formGroup.value;

        if (!isNew) {
            // Reflect changes immediately
            Object.assign(dataItem, employee);
        } else if (parent) {
            // Update the hasChildren field on the parent node
            parent.hasChildren = true;
        }

        this.editService
            // Publish the update to the remote service.
            .save(employee, parent, isNew)
            .pipe(take(1))
            .subscribe(() => {
                if (parent) {
                    // Reload the parent node to reflect the changes.
                    sender.reload(parent);
                }
            });

        sender.closeRow(dataItem, isNew);
    }

    public removeHandler({ sender, dataItem, parent }: RemoveEvent): void {
        this.editService
            // Publish the update to the remote service.
            .remove(dataItem, parent)
            .pipe(take(1))
            .subscribe(() => {
                if (parent) {
                    // Reload the parent node to reflect the changes.
                    sender.reload(parent);
                }
            });
    }

    private closeEditor(treelist: TreeListComponent, dataItem: any = this.editedItem, isNew: boolean = false): void {
        treelist.closeRow(dataItem, isNew);
        this.editedItem = undefined;
        this.formGroup = undefined;
    }
}
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, BehaviorSubject } from 'rxjs';
import { tap, take } from 'rxjs/operators';

import { Employee } from './employee';

const CREATE_ACTION = 'Create';
const UPDATE_ACTION = 'Update';
const REMOVE_ACTION = 'Destroy';

function noop() { /* */ }

@Injectable()
export class EmployeeEditService extends BehaviorSubject<Employee[]> {
    constructor(private http: HttpClient) {
        super(null);
    }

    public read(): void {
        this.fetch('')
            .pipe(take(1))
            .subscribe(data => this.next(data));
    }

    public fetchChildren(reportsTo: number = null): Observable<Employee[]> {
        return this.fetch('', null, reportsTo);
    }

    public update(item: Employee): void {
        this.save(item, null, false)
            .pipe(take(1))
            .subscribe(noop);
    }

    public save(item: Employee, parent: Employee, isNew: boolean): Observable<Employee[]> {
        const action = isNew ? CREATE_ACTION : UPDATE_ACTION;

        return this.fetch(action, item).pipe(tap(() => {
            if (!parent && isNew) {
                this.read();
            }
        }));
    }

    public remove(item: any, parent?: Employee): Observable<Employee[]> {
        return this.fetch(REMOVE_ACTION, item).pipe(tap(() => {
            if (!parent) {
                this.read();
            }
        }));
    }

    private fetch(action: string = '', data?: any, id?: any): Observable<Employee[]> {
        let params = new HttpParams();

        if (typeof id !== 'undefined') {
            params = params.set('id', id);
        }

        if (data) {
            params = params.set('models', JSON.stringify([data]));
        }

        return this.http.jsonp<Employee[]>(
            `https://demos.telerik.com/kendo-ui/service/EmployeeDirectory/${action}?${params.toString()}`,
            'callback'
        );
    }
}
export interface Employee {
    EmployeeId: number;
    ReportsTo: number;
    FirstName: string;
    LastName: string;
    Position: string;
    Extension: string;
    hasChildren?: boolean;
}
export const positions = [
    'CEO',
    'Chief Technical Officer',
    'VP, Engineering',
    'Team Lead',
    'Director, Engineering',
    'Software Architect',
    'Software Developer',
    'Junior Software Developer'
];
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule, HttpClientJsonpModule } from '@angular/common/http';

import { TreeListModule } from '@progress/kendo-angular-treelist';
import { PopupModule } from '@progress/kendo-angular-popup';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { AutoCompleteModule } from '@progress/kendo-angular-dropdowns';

import { AppComponent } from './app.component';
import { EmployeeEditService } from './employee-edit.service';
import { PopupAnchorDirective } from './popup.anchor-target.directive';

@NgModule({
    declarations: [
        AppComponent,
        PopupAnchorDirective
    ],
    imports: [
        BrowserModule,
        BrowserAnimationsModule,
        ReactiveFormsModule,
        HttpClientModule,
        HttpClientJsonpModule,
        TreeListModule,
        AutoCompleteModule,
        PopupModule,
        InputsModule
    ],
    providers: [
        EmployeeEditService
    ],
    bootstrap: [AppComponent]
})
export class AppModule {}
import { Directive, ElementRef } from '@angular/core';

@Directive({
  selector: '[popupAnchor]',
  exportAs: 'popupAnchor'
})
export class PopupAnchorDirective {
  constructor(public element: ElementRef) {}
}
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app.module';

enableProdMode();

const platform = platformBrowserDynamic();
platform.bootstrapModule(AppModule);

In this article