Вдохновившись статьей «Порог вхождения в Angular 2 — теория и практика», решил тоже написать статью про свои муки творчества.

У меня есть большой проект, написанный на ASP.NET WebForms. В нем намешано много всякого, и постепенно мне это всё перестало нравиться. Решил я попробовать переписать всё на чем-нибудь современном. Angular 2 мне приглянулся сразу, и я решил пробовать его. Задача определилась такая: написать новый frontend, прикрутив его к существующему backend, с минимальными переделками последнего. Новый frontend должен быть UI-совместимым со старым, чтобы конечный пользователь ничего не заметил.

Итого имеем такой стэк: backend — ASP.NET Web API, Entity Framework, MS SQL; frontend — Angular 2; тема Bootstrap 3.

Сразу покажу результат TreeView:

image

Процесс настройки Angular 2 в Visual Studio описывать не буду, на просторах этого полно. Единственное, что пришлось добавить, это настройку в web.config для редиректа route-запросов на index.html:

кусок web.config
<system.webServer>
	  <modules runAllManagedModulesForAllRequests="true"/>
	  <rewrite>
		  <rules>
			  <rule name="IndexRule" stopProcessing="true">
				  <match url=".*"/>
				  <conditions logicalGrouping="MatchAll">
					  <add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true"/>
					  <add input="{REQUEST_URI}" matchType="Pattern" pattern="^/api/" negate="true"/>
				  </conditions>
				  <action type="Rewrite" url="/index.html"/>
			  </rule>
		  </rules>
	  </rewrite>
</system.webServer>


Все успешно взлетело. Статик файлы грузятся правильно, api отрабатывают контроллеры web api, остальные маршруты всегда обрабатывает index.html.

Прежде чем начинать писать конечные точки, решил сначала написать некоторые контролы-аналоги WebForm's. Чаще всего конечно используется ListView и FormView. Но начать я решил с простенького TreeView, он тоже нужен в нескольких формах.

Для уменьшения трафика решил сделать загрузку только необходимых узлов дерева. При инициализации запрашиваю только верхний ур��вень.

При раскрытии узла проверяем наличие потомков, при отсутствии генерируем событие onRequestNodes. При выделении пользователем узла — генерируем событие onSelectedChanged. Иконки fontawesome.

Компонент имеет два входящих параметра: Nodes — список узлов на данном уровне, SelectedNode — выбранный пользователем узел. Два события: onSelectedChanged — смена выбранного пользователем узла, onRequestNodes — запрос узлов, при необходимости. @Input параметры распространяются от родителя к потомкам (вглубь иерархии). @Output() события распростаняются от потомков к родителям (наружу иерархии). Компонент рекурсивный — каждый новый уровень иерархии обрабатывает свой экземпляр компонента.

treeview.component.ts
import {Component, Input, Output, EventEmitter} from 'angular2/core';

export interface ITreeNode {
	id: number;
	name: string;
	children: Array<ITreeNode>;
}

@Component({
	selector: "tree-view",
	templateUrl: "/app/components/treeview/treeview.html",
	directives: [TreeViewComponent]
})
export class TreeViewComponent {

	@Input() Nodes: Array<ITreeNode>;
	@Input() SelectedNode: ITreeNode;

	@Output() onSelectedChanged: EventEmitter<ITreeNode> = new EventEmitter();
	@Output() onRequestNodes: EventEmitter<ITreeNode> = new EventEmitter();

	constructor() { }

	onSelectNode(node: ITreeNode) {
		this.onSelectedChanged.emit(node);
	}

	onExpand(li: HTMLLIElement, node: ITreeNode) {
		if (this.isExpanden(li)) {
			li.classList.remove('expanded');
		}
		else {
			li.classList.add('expanded');

			if (node.children.length == 0) {
				this.onRequest(node);
			}
		}
	}

	onRequest(parent: ITreeNode) {
		this.onRequestNodes.emit(parent);
	}

	isExpanden(li: HTMLLIElement) {
		return li.classList.contains('expanded');
	}
}


treeview.html
<ul class="treenodes">
	<li #li *ngFor="#node of Nodes" class="treenode">
		<i class="nodebutton fa"
		   (click)="onExpand(li, node)"
		   [ngClass]="{'fa-minus-square-o': isExpanden(li), 'fa-plus-square-o': !isExpanden(li)}">
		</i>

		<span class="nodetext"
			  [ngClass]="{'bg-info': node == SelectedNode}"
			  (click)="onSelectNode(node)">
			{{node.name}}
		</span>

		<tree-view [Nodes]="node.children"
				   [SelectedNode]="SelectedNode"
				   (onSelectedChanged)="onSelectNode($event)"
				   (onRequestNodes)="onRequest($event)"
				   *ngIf="isExpanden(li)">
		</tree-view>
	</li>
</ul>


Стили сделал отдельным файлом.

treeview.css
tree-view .treenodes {
	list-style-type: none;
	padding-left: 0;
}

tree-view tree-view .treenodes {
	list-style-type: none;
	padding-left: 16px;
}

tree-view .nodebutton {
	cursor: pointer;
}

tree-view .nodetext {
	padding-left: 3px;
	padding-right: 3px;
	cursor: pointer;
}


Как использовать:

sandbox.component.ts
import {Component, OnInit} from 'angular2/core';
import {NgClass} from 'angular2/common';
import {TreeViewComponent, ITreeNode} from '../treeview/treeview.component';
import {TreeService} from '../../services/tree.service';

@Component({
	templateUrl: '/app/components/sandbox/sandbox.html',
	directives: [NgClass, TreeViewComponent]
})
export class SandboxComponent implements OnInit {

	Nodes: Array<ITreeNode>;
	selectedNode: ITreeNode; // нужен для отображения детальной информации по выбранному узлу.

	constructor(private treeService: TreeService) {
	}

	// начальное заполнение верхнего уровня иерархии
	ngOnInit() {
		this.treeService.GetNodes(0).subscribe(
			res => this.Nodes = res,
			error => console.log(error)
		);
	}
	// обработка события смены выбранного узла
	onSelectNode(node: ITreeNode) {
		this.selectedNode = node;
	}
	// обработка события вложенных узлов
	onRequest(parent: ITreeNode) {
		this.treeService.GetNodes(parent.id).subscribe(
			res => parent.children = res,
			error=> console.log(error));
	}
}


sandbox.html
Напоминаю, у меня bootstrap 3.

<div class="col-lg-3">
	<div class="panel panel-info">
		<div class="panel-body">
			<tree-view [Nodes]="Nodes"
					   [SelectedNode]="selectedNode"
					   (onSelectedChanged)="onSelectNode($event)"
					   (onRequestNodes)="onRequest($event)">
			</tree-view>
		</div>
	</div>
</div>


tree.service.ts
Самый примитивный сервис
import {Injectable} from 'angular2/core';
import {Http} from 'angular2/http';
import 'rxjs/Rx';

@Injectable()
export class TreeService {
	constructor(public http: Http) {

	}
	GetNodes(parentId: number) {
		return this.http.get("/api/tree/" + parentId.toString())
			.map(res=> res.json());
	}
}


Получился вот такой «каркас» treeview. В дальнейшем можно сделать свойства для иконок, для выделения, чтобы отвязать treeview от bootstrap 3.

Backend описывать не буду, там ничего интересного, обычный web api контроллер и entity framework.

Следующий подопытный будет asp:ListView. В моём проекте он используется повсюду и по всякому. С встроенными Insert, Update templates и без, с множественной сортировкой, с пейджингом, с фильтрами…

Update 1:
Всем спасибо за комментарии. На их основании немного доработал компонент.
Добавил поле isExpanded и его обработку. Сократил кол-во методов.

treeview.component.ts ver:0.2
import {Component, Input, Output, EventEmitter} from 'angular2/core';

export interface ITreeNode {
	id: number;
	name: string;
	children: Array<ITreeNode>;
	isExpanded: boolean;
}

@Component({
	selector: "tree-view",
	templateUrl: "/app/components/treeview/treeview.html",
	directives: [TreeViewComponent]
})
export class TreeViewComponent {

	@Input() Nodes: Array<ITreeNode>;
	@Input() SelectedNode: ITreeNode;

	@Output() onSelectedChanged: EventEmitter<ITreeNode> = new EventEmitter();
	@Output() onRequestNodes: EventEmitter<ITreeNode> = new EventEmitter();

	constructor() { }

	onSelectNode(node: ITreeNode) {
		this.onSelectedChanged.emit(node);
	}

	onExpand(node: ITreeNode) {

		node.isExpanded = !node.isExpanded;

		if (node.isExpanded && node.children.length == 0) {
			this.onRequestNodes.emit(parent);
		}
	}
}


treeview.html ver:0.2
<ul class="treenodes">
	<li *ngFor="#node of Nodes" class="treenode">
		<i class="nodebutton fa fa-{{node.isExpanded ? 'minus' : 'plus'}}-square-o"
		   (click)="onExpand(node)">
		</i>

		<span class="nodetext {{node == SelectedNode ? 'bg-info' : ''}}"
			  (click)="onSelectNode(node)">
			{{node.name}}
		</span>

		<tree-view [Nodes]="node.children"
				   [SelectedNode]="SelectedNode"
				   (onSelectedChanged)="onSelectNode($event)"
				   (onRequestNodes)="onRequest($event)"
				   *ngIf="node.isExpanded">
		</tree-view>
	</li>
</ul>



Update 2:
В связи с выходом в релиз Angular 2, уже аж 2.2.0 текущая версия, решил выложить текущую версию компонента.
Основные изменения:
  • шаблон и стили переехали из отдельных файлов в код компонента
  • в ITreeNode добавлены необходимые проекту поля
  • у корневых узлов шрифт другой. намеренно.


treeview.html ver:0.3
import {Component, Input, Output, EventEmitter} from "@angular/core";

export interface ITreeNode {
	id: number;
	name: string;
	children: Array<ITreeNode>;
	isExpanded: boolean;
	badge: number;
	parent: ITreeNode;
	isLeaf: boolean;
}

@Component({
	selector: "tree-view",
	template: `
		<ul class="treenodes">
			<li *ngFor="let node of Nodes" class="treenode">
				<i *ngIf="!node.isLeaf" class="nodebutton fa fa-{{node.isExpanded ? 'minus' : 'plus'}}-square-o"
				   (click)="onExpand(node)">
				</i>
				<div class="nodeinfo">
					<i *ngIf="node.isLeaf" class="nodeicon fa fa-file-o"></i>
					<i *ngIf="!node.isLeaf" class="nodeicon fa fa-tags"></i>

					<span class="nodetext {{node == SelectedNode ? 'bg-info' : ''}} {{node.parent ? '' : 'text-root'}}"
						  (click)="onSelectNode(node)">
						{{node.name}}
					</span>
					<span *ngIf="node.badge > 0" class="nodebage badge">{{node.badge}}</span>

					<tree-view [Nodes]="node.children"
							   [SelectedNode]="SelectedNode"
							   (onSelectedChanged)="onSelectNode($event)"
							   (onRequestNodes)="onRequestLocal($event)"
							   *ngIf="node.isExpanded">
					</tree-view>
				</div>
			</li>
		</ul>
	`,
	styles: [
		'.treenodes {display:table; list-style-type: none; padding-left: 16px;}',
		':host .treenodes { padding-left: 0; }',
		'.treenode { display: table-row; list-style-type: none; }',
		'.nodebutton { display:table-cell; cursor: pointer; }',
		'.nodeinfo { display:table-cell; padding-left: 5px; list-style-type: none; }',
		'.nodetext { color: #31708f; padding-left: 3px; padding-right: 3px; cursor: pointer; }',
		'.nodetext.bg-info { font-weight: bold; }',
		'.nodetext.text-root { font-size: 16px; font-weight: bold; }'
	]
})
export class TreeView {

	@Input() Nodes: Array<ITreeNode>;
	@Input() SelectedNode: ITreeNode;

	@Output() onSelectedChanged: EventEmitter<ITreeNode> = new EventEmitter<ITreeNode>();
	@Output() onRequestNodes: EventEmitter<ITreeNode> = new EventEmitter<ITreeNode>();

	constructor() { }

	onSelectNode(node: ITreeNode) {
		this.onSelectedChanged.emit(node);
	}

	onExpand(node: ITreeNode) {

		node.isExpanded = !node.isExpanded;

		if (node.isExpanded && (!node.children || node.children.length === 0)) {
			this.onRequestNodes.emit(node);
		}
	}

	onRequestLocal(node: ITreeNode) {
		this.onRequestNodes.emit(node);
	}
}



Готов к конструктивной критике.