Хабрахабр

Давайте сделаем переиспользуемый компонент tree view в Angular

Я разрабатываю несколько Angular-библиотек, поэтому люблю делать простые и легко переиспользуемые решения для разработчиков. Недавно один из подписчиков в Твиттере спросил меня, как сделать компонент, который выводил бы его данные в виде иерархического дерева — tree view. 

Я люблю подобные задачи, потому что они дают возможность покрыть много различных юзкейсов с минимальным количеством логики внутри. В этой статье опишу, как я размышляю, когда решаю подобные задачи.

Дисклеймер: эта статья-туториал рассчитана на аудиторию изучающих Angular. Если вы понимаете, как сделать рекурсивный тип, рекурсивный компонент и преобразовать в нем данные, переданные функцией-обработчиком, можете ее пропустить.

Итак, что нам нужно?

В первую очередь нам надо понять, с какими данными мы будем работать. Что описывает такую древовидную структуру?

Здесь первым приходит в голову многомерный массив: если мы встретили в нем элемент, то просто покажем его. Если встретили вложенный массив, то погружаемся на уровень ниже.

Давайте опишем такой тип в TypeScript:

export type MultidimensionalArray<string> =| string| ReadonlyArray<MultidimensionalArray<string>>;

Это будет работать благодаря TypeScript recursive type references и позволит нам использовать подобную структуру в качестве данных:

readonly items: MultidimensionalArray<string> = [ "Hello", ["here", "is", ["some", "structured"], "Data"], "Bye"];

Каждый элемент это («строка» или массив из («строка» или массив из («строка» или …)))… Добро пожаловать в рекурсию!

Что ж, готово? Но здесь можно найти одну небольшую проблему. Мы планировали сделать легко переиспользуемый компонент, и, скорее всего, пользователи нашего компонента используют в своих приложениях не только строки в качестве данных.

Но эта проблема легко решается. Давайте воспользуемся TypeScript generics:

export type MultidimensionalArray<T> =| T| ReadonlyArray<MultidimensionalArray<T>>;

Теперь у нас есть крепкая типизация и мы можем начать кодить что-нибудь настоящее!

Рекурсивный Angular-компонент

Angular поддерживает рекурсию в компонентах. Эта фича позволит нам нарисовать tree view, строя из компонентов ровно такую же структуру, которую имеет наш массив.

Давайте создадим компонент для отображения tree view:

В классе компонента нам определенно нужен инпут для значения — тот самый элемент или массив элементов или массивов элементов и так далее

Кроме того, я сделаю еще один геттер isArray Его можно будет использовать в компоненте для проверки, а также завязать на него HostBinding, чтобы можно было легко разделить случаи массива и отдельного элемента в стилях.

@Component()export class MultidimensionalViewComponent<T> { @Input() value: MultidimensionalArray<T> = []; @HostBinding("class._array") get isArray(): boolean { return Array.isArray(this.value); }}

В шаблоне же нам нужно рассмотреть два кейса с помощью isArray-геттера и *ngIf

Если у нас массив, то мы можем проитерировать по каждому его элементу через *ngFor передав элемент в m-dimensional-view следующего уровня, — так мы и получим необходимую нам рекурсию. 

Если у нас отдельный элемент, давайте просто отобразим его, заодно покинув рекурсию. 

<ng-container *ngIf="isArray; else itemView"><m-dimensional-view *ngFor="let item of value" [value]="item"></m-dimensional-view></ng-container><ng-template #itemView> {{ value }}</ng-template>

На этой стадии мы можем написать простейшие стили, чтобы убедиться, что наша задумка работает.

:host { display: block; &._array { margin-left: 20px; }}

Просто margin-left для каждого уровня вложенности, написано на LESS

Давайте взглянем, что мы получили:

Компонент работает корректно и может показывать строки или любой произвольный объект с методом toString (интерполяция {{value}}приводит значение к строчному виду по умолчанию).

Но все же разработчики, которые будут переиспользовать наш компонент, редко имеют данные с реализованными toString-методами. Если они будут орудовать обычными объектами, то их дерево будет состоять исключительно из [object Object]

Поддержка данных любого типа 

Проблема предыдущего решения может быть легко устранена с помощью хендлеров — функций-обработчиков. Это такие функции, которые принимают в себя элемент и отвечают на какой-то вопрос. В нашем случае вопрос будет звучать так: «Какое строчное представление этого элемента?».

Давайте добавим еще один инпут к нашему компоненту с подобным хендлером:

@Component({})export class MultidimensionalViewComponent<T> { // ... @Input() stringify: (item: T) => string = (item: T) => String(item); // ...}

Разработчик может передать функцию, которая приведет элемент к строке. По умолчанию же будет нативный String.

Также не забудем добавить обработку значения в шаблон:

<ng-container *ngIf="isArray; else itemView"><m-dimensional-view *ngFor="let item of value" [stringify]="stringify" [value]="item"></m-dimensional-view></ng-container><ng-template #itemView> {{stringify(value)}}</ng-template>

Теперь мы используем stringify для значения, если в нашем компоненте отдельный элемент, или передаем ее для значения на следующем уровне вложенности.

Мы можем добавить любые стили для нашего дерева. Мой пример будет немного необычным: подписчику, по запросу которого я готовил этот компонент, было нужно дерево, которое стартует сверху и отрисовывает структуру линиями из точек.

Вот по этой ссылке можно увидеть весь получившийся код в действии: Открыть Stackblitz

Отдельное спасибо Waterplea за щепотку CSS-магии, чтобы сделать пример более лаконичным:

Хотя постойте…

А вдруг мы захотим добавить к пункту ссылку или иконку?

Мы можем пойти еще дальше и позволить кастомизировать компонент шаблонами ng-polymorheus. Они тоже поддерживают строки и обработчики, но еще позволяют представить значение как любой кастомный шаблон или компонент.

Давайте установим ng-polymorheus:

npm i @tinkoff/ng-polymorpheus

В нем содержится специальный тип для «строка», или «обработчик», или «шаблон», или «компонент». Импортируем его и немного перепишем класс:

import { PolymorpheusContent } from "@tinkoff/ng-polymorpheus"; // ... @Component({ selector: "m-dimensional-view", templateUrl: "./m-dimensional-view.template.html", styleUrls: ["./m-dimensional-view.styles.less"], changeDetection: ChangeDetectionStrategy.OnPush})export class MultidimensionalViewComponent<T> { @Input() value: MultidimensionalArray<T> = []; @Input() content: PolymorpheusContent = ""; @HostBinding("class._array") get isArray(): boolean { return Array.isArray(this.value); }}

В шаблоне компонента нам нужно заменить функцию stringify на polymorpheus-outlet. Этот компонент создаст блок с контентом. Если контент будет строкой или числом, то блок покажет их значение. Если контент — функция, шаблон или компонент, то мы сможем получить значение благодаря context и кастомизировать контент под каждый конкретный элемент.

Теперь мы готовы создать более хитрый пример. Давайте посмотрим на массив из папок и файлов с различными иконками:

readonly itemsWithIcons: MultidimensionalArray<Node> = [ { title: "Documents", icon: "https://www.flaticon.com/svg/static/icons/svg/210/210086.svg" }, [ { title: "hello.doc", icon: "https://www.flaticon.com/svg/static/icons/svg/2306/2306060.svg" }, { title: "table.csv", icon: "https://www.flaticon.com/svg/static/icons/svg/2306/2306046.svg" } ]];

Добавим шаблон polymorheus для кастомизации, он будет передаваться как контент в компонент вывода дерева:

<m-dimensional-view [value]="itemsWithIcons" [content]="itemView"></m-dimensional-view> <ng-template #itemView let-icon="icon" let-title="title"> <img alt="icon" width="16" [src]="icon" /> {{title}}</ng-template>

В этом шаблоне у нас есть доступ к полям объекта элемента из контекста, который пробрасывается внутри tree view компонента. Когда мы пишем let-icon мы получаем локальную переменную со строкой, которую можем использовать внутри этого ng-template. Самим шаблоном будет картинка с иконкой и название папки или файла:

Вот три примера с ng-polymorheus: Открыть Stackblitz

Итого

Итак, мы написали один тип, один компонент с двумя инпутами и несколько строк HTML. Да, изначальная проблема не очень сложна, но наше маленькое решение содержит множество идей, которые можно использовать при проектировании других компонентов.

Я же просто показал, как размышляю, когда делаю подобные решения, чтобы их мог переиспользовать любой разработчик для своего конкретного кейса.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»