You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
frontend/src/app/components/tree-root/tree-root.component.ts

254 lines
8.0 KiB

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 <i> 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);
}
}
}
}