Как стать автором
Обновить

Пишу TreeView на Angular 2

Время на прочтение8 мин
Количество просмотров23K
Вдохновившись статьей «Порог вхождения в 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);
	}
}



Готов к конструктивной критике.
Теги:
Хабы:
Всего голосов 17: ↑10 и ↓7+3
Комментарии17

Публикации

Истории

Работа

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань