import {Component, EventEmitter, Input, OnInit, Output, ViewChildren} from '@angular/core'; import {debug} from '../../utility'; /** * A recursive tree listing component. */ @Component({ selector: 'app-tree-root', templateUrl: './tree-root.component.html', styleUrls: ['./tree-root.component.scss'], }) export class TreeRootComponent implements OnInit { /** The immediate children of this component. */ @ViewChildren('childComponents') protected childComponents; /** How deeply nested this component is in the tree. */ @Input() public nestingLevel = 0; /** Field on the listing record with the display string. */ @Input() public displayField = 'name'; /** Field on the listing record that contains the children of an item. */ @Input() public childrenField = 'children'; /** Optionally, field on the listing record that contains the icon class. */ @Input() public iconClassField?: string; /** The nested listing items. */ @Input() public items: any[] = []; /** @private Set by the parent component when recursively nested. */ @Input() public parent?: TreeRootComponent; /** @private Item object that created this listing when recursively nested. */ @Input() public parentItem?: any; /** Emits the selected item, when top-level tree. */ @Output() public itemSelected: EventEmitter<{ event: MouseEvent, item: any }> = new EventEmitter<{event: MouseEvent; item: any}>(); /** Emits the activated item, when top-level tree. */ @Output() public itemActivated: EventEmitter<{ event: MouseEvent, item: any }> = new EventEmitter<{event: MouseEvent; item: any}>(); /** Emits the right-clicked item, when top-level tree. */ @Output() public itemRightClicked: EventEmitter<{ event: MouseEvent, item: any }> = new EventEmitter<{event: MouseEvent; item: any}>(); /** The selected item record at this level, if one exists. */ public selectedItem?: any; /** The current tree filter value, if one exists. */ protected filterValue?: string; /** Called on initialization. */ ngOnInit() { if ( !this.parent ) { debug('Top-level tree root component:', this); } } /** True if we're in dark mode. */ public isDark() { return document.body.classList.contains('dark'); } /** Recursively filter the tree using the given quick-filter string. */ public filterTree(filterValue?: string) { this.setFilterRecursively(filterValue); } /** Emit an item selected event, or bubble up to the parent. */ public emitItemSelected(event: MouseEvent, item: any) { if ( this.parent ) { return this.parent.emitItemSelected(event, item); } this.itemSelected.emit({ event, item, }); } /** Emit an item activated event, or bubble up to the parent. */ public emitItemActivated(event: MouseEvent, item: any) { if ( this.parent ) { return this.parent.emitItemActivated(event, item); } this.itemActivated.emit({ event, item, }); } /** Emit an item right-clicked event, or bubble up to the parent. */ public emitItemRightClicked(event: MouseEvent, item: any) { if ( this.parent ) { return this.parent.emitItemRightClicked(event, item); } this.itemRightClicked.emit({ event, item, }); } /** Wrapper for checking arrays for use in NG templates. */ public isArray(something: unknown): boolean { return Array.isArray(something); } /** * Returns true if an item has nested children. * @param item * @param includeHidden - if true, will include children hidden by the quick-filter */ public hasNesting(item: any, includeHidden = false): boolean { const hasUnfilteredNesting = item && this.isArray(item[this.childrenField]) && item[this.childrenField].length; if ( !this.filterValue || includeHidden ) { return hasUnfilteredNesting; } return hasUnfilteredNesting && item[this.childrenField].some(child => !child.__app_tree_comp_is_hidden); } /** Called when an item expand/collapse arrow is clicked. */ public onItemHandleClick(event: MouseEvent, item: any) { if ( this.hasNesting(item) ) { this.setExpansionRecursively(item, !item.__app_tree_comp_is_collapsed); } } /** Called when an item is clicked. */ public onItemClick(event: MouseEvent, item: any) { this.clearSelection(); this.selectedItem = item; this.emitItemSelected(event, item); } /** Called when an item is double-clicked. */ public onItemDoubleClick(event: MouseEvent, item: any) { this.emitItemActivated(event, item); } /** Called when an item is right-clicked. */ public onItemRightClick(event: MouseEvent, item: any) { this.clearSelection(); this.selectedItem = item; this.emitItemRightClicked(event, item); } /** Recursively clear the selection from the entire tree, calling children and bubbling up to parent. */ public clearSelection(from?: TreeRootComponent) { this.selectedItem = undefined; if ( this.childComponents ) { for ( const child of this.childComponents ) { if ( child === from ) { continue; } child.clearSelection(this); } } if ( this.parent ) { if ( this.parent !== from ) { this.parent.clearSelection(this); } } } /** * Recursively set the filter on this subtree. * Returns true if any of the children of this level match the filter. * @param filterValue * @param from * @protected */ protected setFilterRecursively(filterValue?: string, from?: TreeRootComponent): boolean { this.filterValue = filterValue; let anyChildMatchesFilter = false; for ( const item of this.items ) { item.__app_tree_comp_is_hidden = this.getFilterHiddenStatusForItem(item); anyChildMatchesFilter = anyChildMatchesFilter || !item.__app_tree_comp_is_hidden; } return anyChildMatchesFilter; } /** * Returns true if the given item should be hidden with the current quick-filter. * @param item * @protected */ protected getFilterHiddenStatusForItem(item: any): boolean { const itemMatchesFilter = ( !this.filterValue || ( item[this.displayField]?.trim()?.toLowerCase()?.includes(this.filterValue?.trim()?.toLowerCase()) ) ); if ( !this.hasNesting(item, true) ) { return !itemMatchesFilter; } let anyChildMatchesFilter = false; const childTree = this.getChildComponentForItem(item); if ( childTree ) { anyChildMatchesFilter = childTree.setFilterRecursively(this.filterValue, this); } else { debug('Unable to find childTree for item:', item[this.displayField]); } return !(!this.filterValue || itemMatchesFilter || anyChildMatchesFilter); } /** * Get the TreeRootComponent instance for some item's children, if it exists. * @param item * @protected */ protected getChildComponentForItem(item: any): TreeRootComponent | undefined { return [...(this.childComponents || [])]?.find(child => { return child.parentItem === item; }); } /** * Expand or collapse an item recursively. * @param item * @param isCollapsed */ public setExpansionRecursively(item: any, isCollapsed: boolean) { item.__app_tree_comp_is_collapsed = isCollapsed; if ( this.hasNesting(item, true) && isCollapsed ) { for ( const child of item.children ) { this.setExpansionRecursively(child, isCollapsed); } } } }