Пишу TreeView на Angular 2

Вдохновившись статьей «Порог вхождения в 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

    0
    Правильнее было бы добавить свойство isExpanded в модель (элемент массива Nodes), и указывать классы в шаблоне, а не удалять/добавлять классы в коде компонента. Как вы, например, сохранить состояние дереве в определенный момент? Например, чтобы после перезагрузки страницы можно было восстановить это саоме состояние
      0
      Не добавил isExpanded в модель по таким соображениям — это поле нужно только для UI, на сервере хранить состояние дерева пока не собирался. По этому этого поля нет в модели и я решил обойтись тем, что уже есть — проверкой наличия соответствующего класса.
      Задачи сохранять состояние дерева пока не ставил, возможно при решении такой задачи всё перекочует на сервер в какой нибудь asp.net Session...
        +1
        Любое состояние даже UI лучше хранить на клиенте. Т.к. вы можете в любой момент обратиться к нему, узнать чему оно равно, и отрендерить на его основе другой виджет. Плюс так гораздо проще сериализовывать. Надо помнить, что Angular 2 в первую очередь помогает рендерить верстку на основании данных. А не управлять данными на основе верстки. Это позволяет держать только один источник изменениий и уменьшает количество багов на клиенте.
          0
          Спасибо за ответ, в целом согласен. Хотел только уточнить про:

          А не управлять данными на основе верстки

          Это Вы в рамках теоретических рассуждений или в моем примере что-то такое заметили?
          +1
          Согласен с предыдущим комментатором, в Вашем примере логика вьюхи сидит в компоненте хардкодом, не надо так.
            0
            Я думал, что компонент — это и есть вьюха+контроллер, оно как-бы единое целое и использовать одно без другого не получится. По этому и не вижу разницы где писать логику. Или Вы что-то другое имели в виду и я не понял.
              0
              Вы не правы. Компонент — это самостоятельная часть целого, как компонент для приготовления блюда в кулинарии или компонент устройства для его сбора. Что подразумевает под собой компонент внутри системы — дело исключительно контекстное, но компонент не может состоять из двух фундаментальных сущностей. Мы можем сказать, например, что вьюха это компонент MVC, такой же равноценный как и контроллер или модель, часть целого.

              Так вот, в Вашем примере логика вьюхи, в данном случае название класса и принцип по котором он устанавливается/снимается с элемента, уехала в компонент (контроллер). Контроллер, в свою очередь, в js приложениях используется как VM и он не манипулирует вьюхой на прямую а является ее модельным представлением, то есть наличие класса active определяется свойством active или isActive. Мало того что это дает возможность сохранять состояния компонентов через сессию, так еще позволяет разделить работу между двумя людьми — верстальщиком и программистом.
                0
                Вот, теперь я врубился, что Вы имели ввиду. Спасибо. Все понял, постараюсь соблюдать.
                  0
                  Не за что. Если будут вопросы, пишите в личку.
                  0
                  Во втором ангуляре темплейт неотделим от компонента, и по сути просто его зависимая часть, поэтому с большего все равно где что делать. Минус первого подхода автора в том, что компонент получился не полностью data-driven, часть состояния залегла в ДОМе вместо модели.
                    0
                    Ровно как и в любом MVC веб-фремворке — вьюха рендерится через контроллер, но вот в чем прикол, когда у вас вся логика представления лежит в контроллере (VM), ну или компоненте, если брать в расчет сущности из Angular 2, то нам не важно какой темплейт отдать контроллеру/компоненту, это может быть xml, html, json и так далее. Как Вы правильно заметили, подход автора на data- атрибутах уложил состояния в DOM, что и является самой главной ошибкой. Да и вообще разделять логику на два экрана это моветон.
          0
          Правильнее было бы добавить свойство isExpanded в модель

          janitor, да, только это не модель, а ViewModel, туда как раз и надо размещать, а в текущем решении isExpanden() дергается на каждый digest цикл (ChangeDetector'a).

          А вообще, что-бы построить дерево — не обязательно писать компоненты или директивы, достаточно рекурсивного вызова шаблона.
          Вот пример на Angular Light.
            0
            Да какая разница, как это назвать, "ViewModel" или просто "модель", суть-то ясна, "модель данных" на клиенте имеется в виду
              0
              А вообще, что-бы построить дерево — не обязательно писать компоненты или директивы, достаточно рекурсивного вызова шаблона.

              Можно, но тогда очень страдает переиспользование. Компонент же я для того и пишу, чтобы переиспользовать его на любых страницах, где он понадобится, а дополнять/фиксить функционал только в компоненте.
                0
                1) дергается только при изменении скоупа одного уровня или выше
                2) можно использовать свойство вместо метода
                3) в новом ангуляре вместо дайджеста используется другое решение, которое похоже на дайджест из первого только в N*10 раз быстрее судя по их бенчмаркам, так что не критично
                  0
                  2) можно использовать свойство вместо метода
                  В том и дело.

                  1) дергается только при изменении скоупа одного уровня или выше
                  3) в новом ангуляре вместо дайджеста используется другое решение, которое похоже на дайджест из первого только в N*10 раз быстрее судя по их бенчмаркам, так что не критично
                  Дергается на каждый disgest как я и написал, только у каждого компонента свой changeDetector, и система компиляции навороченная (в положительном и отрицательном смысле), так же они предлагают несколько способов взаимодействия между компонентами и их changeDetector'ами, это все можно использовать что-бы ускорить dirty-checking в A2.
                    0
                    Это почти тоже самое что я написал выше. И я не вижу тут причины уносить toggleClass в контроллер, тем самым разрушая чистую архитектуру. В приложении, над которым я сейчас работаю, более 600 вотчеров на странице (ангуляр 1) и это нагружает двух-ядерный процессор ровно на 2%. Более того, как вы указали, каждый компонент имеет свой ChageDetector, который будет вызываться только если мы что то сделали внутри компонента, например использовали метод через ngClick, а это уже практически равноценно по нагрузке прямому jQuery-стайл коду. Так почему я не должен использовать ngClass и свойство isExpanded?

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое