Давайте сделаем переиспользуемый компонент 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({     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> = [];      @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. Да, изначальная проблема не очень сложна, но наше маленькое решение содержит множество идей, которые можно использовать при проектировании других компонентов.

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

ссылка на оригинал статьи https://habr.com/ru/company/tinkoff/blog/525042/


Комментарии

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

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