swimlane/ngx-ui

View on GitHub
projects/swimlane/ngx-ui/src/lib/components/tree/tree.component.ts

Summary

Maintainability
C
1 day
Test Coverage
import {
  Component,
  Input,
  EventEmitter,
  Output,
  ContentChild,
  ViewEncapsulation,
  ContentChildren,
  TemplateRef,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  OnDestroy,
  AfterContentInit,
  OnChanges,
  SimpleChanges
} from '@angular/core';
import type { QueryList } from '@angular/core';

import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

import { TreeNodeComponent } from './tree-node.component';
import { TreeNode } from './tree-node.model';

@Component({
  selector: 'ngx-tree',
  templateUrl: './tree.component.html',
  styleUrls: ['./tree.component.scss'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TreeComponent implements AfterContentInit, OnDestroy, OnChanges {
  @Input() nodes: TreeNode[];
  @Input() virtualScrolling = false;
  @Input() maxVirtualScrollHeight = 500;
  @Input() nodeHeight = 26;

  // eslint-disable-next-line @angular-eslint/no-input-rename
  @Input('template')
  _templateInput: TemplateRef<any>;

  @Input() icons = {
    collapse: 'icon-tree-collapse',
    expand: 'icon-tree-expand'
  };

  @ContentChild(TemplateRef, { static: true })
  _templateQuery: TemplateRef<any>;

  @ContentChildren(TreeNodeComponent) readonly nodeElms: QueryList<TreeNodeComponent>;
  @ContentChildren(TreeNodeComponent, { descendants: true }) readonly allNodeElms: QueryList<TreeNodeComponent>;

  @Output() expand = new EventEmitter();
  @Output() collapse = new EventEmitter();
  @Output() activate = new EventEmitter();
  @Output() deactivate = new EventEmitter();
  @Output() selectNode = new EventEmitter();

  get hasOneLeaf(): boolean {
    return this.nodes?.length === 1 || this.nodeElms?.length === 1;
  }

  get template(): TemplateRef<any> {
    return this._templateInput || this._templateQuery;
  }

  treeStructure: TreeNode[] = null;
  filteredTree: TreeNode[] = null;
  depthPadding = 28;

  private readonly _destroy$ = new Subject<void>();

  constructor(private readonly _cdr: ChangeDetectorRef) {}

  ngAfterContentInit(): void {
    this.nodeElms.changes.pipe(takeUntil(this._destroy$)).subscribe(() => this._cdr.markForCheck());
    if (this.allNodeElms) {
      this.allNodeElms.forEach(node => node.depth++);
    }
    if (this.virtualScrolling && !this.nodes?.length && this.allNodeElms) {
      const tmpTree = this.elementsToNodes(this.allNodeElms);
      this.treeStructure = tmpTree;
      this.filterTree(tmpTree);
    }
  }

  ngOnDestroy(): void {
    this._destroy$.next();
    this._destroy$.complete();
  }

  ngOnChanges(changes: SimpleChanges): void {
    if ((changes.nodes && this.virtualScrolling) || changes.virtualScrolling?.currentValue) {
      if (changes.nodes?.currentValue || this.nodes) {
        const tmpTree = this.generateTreeStructure(changes.nodes?.currentValue || this.nodes);
        this.treeStructure = tmpTree;
        this.filterTree(tmpTree);
      }
    }
  }

  onExpand(event: any): void {
    if (this.virtualScrolling) {
      const currentTreeStructure = [...this.treeStructure];
      currentTreeStructure.find(node => node.id === event.$implicit.id).expanded = true;
      this.applyTreeChanges(currentTreeStructure);
    }
    this._cdr.detectChanges();
    this.expand.emit(event);
  }

  onCollapse(event: any): void {
    if (this.virtualScrolling) {
      const currentTreeStructure = [...this.treeStructure];
      currentTreeStructure.find(node => node.id === event.$implicit.id).expanded = false;
      this.applyTreeChanges(currentTreeStructure);
    }
    this._cdr.detectChanges();
    this.collapse.emit(event);
  }

  applyTreeChanges(tree: TreeNode[]) {
    const tmpTree = this.applyExpandChange(tree);
    this.treeStructure = tmpTree;
    this.filterTree(tmpTree);
  }

  filterTree(tree: TreeNode[]) {
    const tmpTree = tree.filter(node => node.display);
    this.generateAditionalTreeInfo(tmpTree);
    this.filteredTree = tmpTree;
  }

  generateTreeStructure(nodes: TreeNode[]): TreeNode[] {
    const finalStructure: TreeNode[] = [];
    let id = 0;
    const processNodes = (currentNodes: TreeNode[], depth: number, display: boolean) => {
      currentNodes.forEach(node => {
        finalStructure.push({
          id: id++,
          label: node.label,
          model: node.model,
          disabled: node.disabled,
          expandable: node.expandable,
          expanded: node.expanded,
          selectable: node.selectable,
          depth: node.depth ?? depth,
          display
        });
        if (node.children) {
          processNodes(node.children, node.depth ?? depth + 1, display && node.expanded);
        }
      });
    };
    processNodes(nodes, 1, true);
    return finalStructure;
  }

  applyExpandChange(nodes: TreeNode[]): TreeNode[] {
    const depthReference = {
      0: {
        displayChild: true
      }
    };
    return nodes.map(node => {
      const result = {
        id: node.id,
        label: node.label,
        model: node.model,
        disabled: node.disabled,
        expandable: node.expandable,
        expanded: node.expanded,
        selectable: node.selectable,
        depth: node.depth,
        display: depthReference[node.depth - 1].displayChild
      };
      depthReference[node.depth] = {
        displayChild: node.expanded && depthReference[node.depth - 1].displayChild
      };
      return result;
    });
  }

  generateAditionalTreeInfo(nodes: TreeNode[]): void {
    const depthReference = {};
    nodes.forEach((node, index) => {
      node.childNodesCount = 0;
      // update the children count of the current node parents
      Object.keys(depthReference).forEach(key => {
        const depth = parseInt(key);
        const parent = nodes.find(n => n.id === depthReference[key].id);
        if (depth === node.depth) {
          delete depthReference[key];
        }
        if (depth < node.depth) {
          if (!parent.childNodesCount) parent.childNodesCount = 0;
          parent.childNodesCount++;
        }
      });
      depthReference[node.depth] = { id: node.id };
      if (node.depth - 1 > 0) {
        node.parentId = depthReference[node.depth - 1].id;
      }
      node.index = index;
    });
  }

  elementsToNodes(nodes: QueryList<TreeNodeComponent>): TreeNode[] {
    let id = 0;
    const tmpTree = nodes.map(
      node =>
        ({
          id: id++,
          label: node.label,
          children: node.children,
          model: node.model,
          disabled: node.disabled,
          expandable: node.expandable,
          expanded: node.expanded,
          selectable: node.selectable,
          depth: node.depth
        } as TreeNode)
    );
    return this.applyExpandChange(tmpTree);
  }

  trackBy(_index: number, node: any): number {
    return node.id;
  }

  filled(node: TreeNode, filteredTree: TreeNode[], isSingle = false) {
    let parent = filteredTree.find(n => n.id === node.parentId);
    // simulate a parent when dealing with depth 1 elements
    if (!parent) {
      parent = {
        index: -1,
        childNodesCount: filteredTree.length
      } as TreeNode;
    }
    const isFirst = parent.index + 1 < filteredTree.length && filteredTree[parent.index + 1].id === node.id;
    const isLast = filteredTree[parent.index + parent.childNodesCount].id === node.id;
    return isSingle ? isFirst && isLast : !isFirst && isLast;
  }

  empty(node: TreeNode, filteredTree: TreeNode[]) {
    let parent = filteredTree.find(n => n.id === node.parentId);
    // simulate a parent when dealing with depth 1 elements
    if (!parent) {
      parent = {
        index: -1,
        childNodesCount: filteredTree.length
      } as TreeNode;
    }
    const isFirst = parent.index + 1 < filteredTree.length && filteredTree[parent.index + 1].id === node.id;
    const isLast = filteredTree[parent.index + parent.childNodesCount].id === node.id;
    return isFirst && !isLast; // is only true when there is more than one element and this is the first one
  }

  dots(node: TreeNode, filteredTree: TreeNode[]) {
    if (node.index + 1 === filteredTree.length || node.index === 0) return false;
    return (
      !(this.filled(node, filteredTree) || this.filled(node, filteredTree, true)) && !this.empty(node, filteredTree)
    );
  }
}