import {
concatAll,
concatMap,
flatMap,
last,
mergeMap,
scan,
switchMap,
takeUntil,
} from "rxjs/operators";
import {
AfterViewInit,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
HostListener,
Input,
Output,
TemplateRef,
ViewChild,
ViewContainerRef,
} from "@angular/core";
import { from, Observable, of, ReplaySubject, Subject } from "rxjs";
import { WidgetframeService } from "../widgetframe/widgetframe.service";
import { getOrDefault, WidgetConfig } from "../widget.configuration";
import {
WidgetComponent,
WidgetConfiguration,
WidgetConfigure,
WidgetId,
WidgetInput,
WidgetOutput,
} from "../widget.metadata";
import { NgUnsubscribe } from "../../ng-unsubscribe";
import { MatDialog } from "@angular/material/dialog";
import { TranslateService } from "@ngx-translate/core";
import { ShopCategory } from "../apps/my-shop-md/shop-category/shop-category";
import { ShopCategoryService } from "../apps/my-shop-md/shop-category/shop-category.service";
import { AppdataStore } from "../../components/appdata/appdata.store";
import {
LocalStorageEntry,
LocalStorageService,
} from "../../components/local-storage/local-storage.service";
import {
DeletionMode,
Scope,
} from "../../components/local-storage/local-storage-constants";
import { TreeSelectorComponent } from "../../components/tree-selector/tree-selector.component";
import {
ConnectionPositionPair,
FlexibleConnectedPositionStrategy,
Overlay,
OverlayRef,
} from "@angular/cdk/overlay";
import { SelectionEvent } from "../../components/tree-selector/tree-selector.api";
import * as uriTemplates_ from "uri-templates";
import { TemplatePortal } from "@angular/cdk/portal";
const uriTemplates = uriTemplates_;
const POSITIONS: { [key: string]: ConnectionPositionPair } = {
"combo-bottom": {
originX: "center",
originY: "top",
overlayX: "center",
overlayY: "top",
offsetX: 40,
},
"combo-top": {
originX: "center",
originY: "bottom",
overlayX: "center",
overlayY: "bottom",
offsetX: 40,
},
bottom: {
originX: "center",
originY: "bottom",
overlayX: "center",
overlayY: "top",
},
top: {
originX: "center",
originY: "top",
overlayX: "center",
overlayY: "bottom",
},
left: {
originX: "start",
originY: "center",
overlayX: "end",
overlayY: "center",
},
right: {
originX: "end",
originY: "center",
overlayX: "start",
overlayY: "center",
},
"left-below": {
originX: "start",
originY: "bottom",
overlayX: "end",
overlayY: "top",
},
"right-below": {
originX: "end",
originY: "top",
overlayX: "start",
overlayY: "top",
},
"right-above": {
originX: "end",
originY: "bottom",
overlayX: "start",
overlayY: "bottom",
},
"left-above": {
originX: "start",
originY: "bottom",
overlayX: "end",
overlayY: "bottom",
},
};
const DEFAULT_POSITIONS = ["right", "left-below", "right-below", "right-above"];
interface Selection {
root: string;
category: string;
description: string;
path: string[];
}
interface Node {
identifier: string;
children: Node[];
}
@WidgetComponent("nm-select-tree")
@Component({
selector: "nm-edit-tree",
templateUrl: "./select-tree.component.html",
styleUrls: ["./select-tree.component.scss"],
})
export class SelectTreeWidgetComponent implements AfterViewInit {
@WidgetConfiguration()
public configuration: WidgetConfig;
@WidgetOutput()
public selectedValue = new ReplaySubject<any>(1);
@WidgetOutput()
public selectedValueDescription = new ReplaySubject<any>(1);
@WidgetOutput()
public selectedValueRootNode = new Subject<any>();
@WidgetOutput()
public selectionEmitter = new Subject<Selection[]>();
@WidgetInput()
public payload = new Subject<any>();
@WidgetInput()
public selectionIdentifier = new ReplaySubject<string[]>(1);
/**
* Input to select given node, based on its path
*/
@WidgetInput()
public selectionPath = new ReplaySubject<string[] | string[][]>(1);
@WidgetInput("disabled")
private disabledInput = new Subject<boolean>();
@Input()
public disabled: boolean = false;
@Input()
public filterable: boolean = true;
@Output()
public emitCategory = new EventEmitter<ShopCategory>();
@Input()
public preferredPosition = DEFAULT_POSITIONS;
@WidgetOutput("profile")
private profile = new Subject<any>();
@WidgetInput("reload")
public reloadChannel = new Subject<any>();
@WidgetInput("reset")
public resetChannel: Subject<any> = new Subject<any>();
@WidgetInput("clearcategorysearch")
private clearChannel: Subject<any> = new Subject<any>();
@WidgetInput("rootUri")
private rootUriChannel = new ReplaySubject<any>(1);
@WidgetOutput("cleartextsearch")
private clearTextSearch: Subject<any>;
@WidgetOutput("triggerSearch")
private triggerSearch: Subject<any> = new Subject<any>();
@WidgetOutput("uri")
private outputUri = new Subject<any>();
@WidgetId()
public _id: string;
@ViewChild("menuTrigger")
triggerRef: ElementRef;
@ViewChild("treeinput")
treeinput;
@ViewChild("treeSelector")
treeSelector: TreeSelectorComponent;
public header: string;
public withHeader: boolean;
// The current amount of requests that are running to fetch children
private pendingRequests = 0;
public selection: any[];
public selectionString;
public placeholder: string;
public shouldLabelFloat: boolean = false;
public identifierField;
public descriptionField;
public options: any[] = [];
private programaticExpansion: boolean = false;
public inputLink: string;
public rootCategoryLink: string;
public rootSearchCategoryLink: string;
public dataType: string;
public localStorageDescriptionEntry: LocalStorageEntry;
public localstorageSelectedCategoryEntry: LocalStorageEntry;
public nodes: any[];
public appdata: any;
public outputType: string;
public triggerAsChipList: boolean;
public wikiLink: string;
public infotext: string;
public title: string;
public static DEFAULT_OUTPUT_TYPE: string = "uri";
private unsubscribe = NgUnsubscribe.create();
public isOpen = false;
@ViewChild("treeOverlay", { static: false })
private template: TemplateRef<any>;
private position: FlexibleConnectedPositionStrategy;
private templatePortal: TemplatePortal<any>;
private overlayRef: OverlayRef;
public selectionRootNodes: any[];
private lastFocused;
constructor(
private _widgetframeService: WidgetframeService,
private categoryService: ShopCategoryService,
public dialog: MatDialog,
public translateService: TranslateService,
private localStorageService: LocalStorageService,
private appdataStore: AppdataStore,
private cdr: ChangeDetectorRef,
private overlay: Overlay,
private viewContainerRef: ViewContainerRef
) {}
keyDownFunction(event) {
if (event.keyCode == 13 && !!this.selection) {
this.triggerSearch.next(new Date());
}
}
@HostListener("keydown", ["$event"])
onKeyDown(event: KeyboardEvent) {
const key = event.key.toLowerCase();
if (!this.isOpen) {
switch (key) {
case "space":
case "spacebar":
case " ":
case "enter":
//case "down":
//case "arrowdown":
this.toggleTree();
}
}
event.stopPropagation();
}
onChange() {
// this.localStorageDescriptionEntry.value = this.selection
// this.selectedValue.next(this.selection);
}
ngAfterViewInit() {
let preferredPosition = this.preferredPosition;
if (this.preferredPosition == null) {
preferredPosition = DEFAULT_POSITIONS;
} else if (typeof this.preferredPosition === "string") {
preferredPosition = [this.preferredPosition];
}
let positions = preferredPosition
.map((key) => POSITIONS[key])
.filter((pos) => !!pos);
this.position = this.overlay
.position()
.flexibleConnectedTo(this.triggerRef)
.withPositions(positions)
.withPush(false)
.withFlexibleDimensions(true);
this.templatePortal = new TemplatePortal(
this.template,
this.viewContainerRef
);
this.overlayRef = this.overlay.create({
width: "400px",
height: "550px",
panelClass: "tree-popup",
positionStrategy: this.position,
disposeOnNavigation: true,
hasBackdrop: true,
backdropClass: "cdk-overlay-transparent-backdrop",
});
}
toggleTree() {
this.lastFocused = document.activeElement;
this.isOpen = !this.isOpen && this.nodes && this.nodes.length > 0;
if (this.isOpen) {
this.overlayRef.attach(this.templatePortal);
this.overlayRef
.backdropClick()
.pipe(takeUntil(this.unsubscribe))
.subscribe(() => {
this.closeTreeOverlay();
});
} else {
this.overlayRef.detach();
}
this.cdr.markForCheck();
}
get initialSelection(): any[] {
return this.selection
? this.selection.map((selection) => selection.id)
: [];
}
// using property syntax, to bind `this` correctly
loadChildNodes = (parentNode: any, done: (children: any[]) => void) => {
if (
parentNode._links &&
parentNode._links.children &&
parentNode._links.children.href
) {
this.categoryService
.getCategories(parentNode._links.children.href)
.pipe(takeUntil(this.unsubscribe))
.subscribe(
(data) => {
let parentIdentifier = parentNode[this.identifierField];
let children = this.transformData(
this.extractData(data),
parentIdentifier
);
done(children);
this.cdr.detectChanges();
},
(err) => {
console.error(err);
done([]);
}
);
} else {
done([]);
}
};
forceLoad(root: any, tree: Node[] = ([] = [])): Observable<any[]> {
let rootNodes = this.transformData(this.extractData(root));
// iterate all loaded nodes
// and collapse the resulting arrays
return from(rootNodes).pipe(
flatMap((rootNode) => this.forceLoadChildren(rootNode, tree)),
scan((l, r) => [...l, ...r]),
last()
);
}
private forceLoadChildren(node: any, tree: Node[]): Observable<any[]> {
const treeNode = tree.find(
(tn) => node[this.identifierField] === tn.identifier
);
const nodeObs = of([node]);
// we use the tree representation of one or more given category paths
// to lazily load the children for each level of the tree
if (treeNode?.children?.length) {
// load children of the current node
const children = this.loadChildren(node._links.children.href).pipe(
// map the loaded children to node objects and push each node through the resulting stream
flatMap((data) =>
from(
this.transformData(
this.extractData(data),
node[this.identifierField]
)
)
),
// then, for each loaded & mapped child node, recursively load their child nodes
// and concatenate the resulting streams
concatMap((childNode) =>
this.forceLoadChildren(childNode, treeNode.children)
)
);
return from([nodeObs, children]).pipe(concatAll());
}
return nodeObs;
}
loadChildren(link): Observable<any> {
return this.categoryService.getCategories(link);
}
clearSelection() {
this.selection = [];
this.emitSelection();
if (this.localStorageDescriptionEntry) {
this.localStorageDescriptionEntry.clear();
}
if (this.localstorageSelectedCategoryEntry) {
this.localstorageSelectedCategoryEntry.clear();
}
}
onClickOnOverlayBackdrop(event) {
this.closeTreeOverlay();
}
initCategoryFromInput(categoryValues: { id: string; description: string }[]) {
if (categoryValues && categoryValues.length > 0) {
this.selection = categoryValues;
this.selectionString = categoryValues
.map((value) => value.description)
.join(" | ");
if (this.localStorageDescriptionEntry) {
this.localStorageDescriptionEntry.value = this.selectionString;
}
if (this.localstorageSelectedCategoryEntry) {
this.localstorageSelectedCategoryEntry.value = JSON.stringify(
this.selection
);
}
this.selectedValueDescription.next(this.selectionString);
} else if (this.localstorageSelectedCategoryEntry) {
this.localstorageSelectedCategoryEntry.clear();
}
this.cdr.markForCheck();
}
onSelectionEvent(event: SelectionEvent) {
// to reload the last selection when clicking on the input field again
// we need to set "initial-selection" which immediately triggers a
if (event.source === "initial") {
return;
}
let nodes = event.newSelection;
if (nodes.length === 0 && this.localstorageSelectedCategoryEntry) {
this.localstorageSelectedCategoryEntry.clear();
}
let newSelection = nodes.map((node) => ({
id: node[this.identifierField],
description: node[this.descriptionField],
path: this.treeSelector.pathForNode(node[this.identifierField]),
}));
this.selection = newSelection;
this.onSelectionChange();
if (!this.configuration.configuration.multi) {
this.closeTreeOverlay();
}
if (this.localstorageSelectedCategoryEntry) {
this.localstorageSelectedCategoryEntry.value = JSON.stringify(
this.selection
);
}
this.updateCategoryOutput(event);
}
public loadChildrenOnFilter = (query: string) => {
if (this.rootSearchCategoryLink) {
const uri = uriTemplates(this.rootSearchCategoryLink).fill({ query });
return this._widgetframeService.getData(uri).pipe(
switchMap((data) => {
if (data.paths) {
const obs = new Subject<any>();
const tree = this.buildTree(data.paths);
this.loadRootCategories((nodes) => {
obs.next();
obs.complete();
this.cdr.markForCheck();
}, tree);
return obs;
}
return of();
})
);
}
return of();
};
private onSelectionChange() {
this.emitSelection();
this.cdr.detectChanges();
}
private updateCategoryOutput(event: SelectionEvent) {
if (event.added.length <= 0) {
return;
}
let category = event.added[0];
this.emitCategory.emit(category);
if (category._links && category._links[this.dataType] !== undefined) {
if (this.outputType === SelectTreeWidgetComponent.DEFAULT_OUTPUT_TYPE) {
this.outputUri.next(category._links[this.dataType].href);
} else {
var categories = [];
for (var item of this.selection) {
categories.push(item.id);
}
this.outputUri.next(categories);
}
this.profile.next("categorySearchResult");
} else {
this.outputUri.next(null);
}
}
public closeTreeOverlay() {
this.isOpen = false;
if (this.overlayRef && this.overlayRef.hasAttached()) {
this.overlayRef.detach();
}
this.cdr.markForCheck();
this.lastFocused.focus();
}
private emitSelection() {
this.selectionString = this.selection
.map((entry) => entry.description)
.join(" | ");
this.selectedValueDescription.next(this.selectionString);
if (this.localStorageDescriptionEntry) {
this.localStorageDescriptionEntry.value = this.selectionString;
}
this.cdr.markForCheck();
if (this.selection.length == 0) {
this.selectionEmitter.next([]);
this.selectedValueRootNode.next(null);
return;
} else {
const selection: Selection[] = this.selection.map((sel) => {
return {
category: sel.id,
path: sel.path,
root: sel.path[0],
description: sel.description,
};
});
this.selectionEmitter.next(selection);
}
if (!this.configuration.configuration.multi) {
if (this.selection.length === 0) {
this.selectedValue.next(null);
return;
}
if (this.selection.length > 2) {
console.warn(
"Select-tree is not in multi mode but multiple selected nodes where detected"
);
}
let selection = this.selection[0];
this.selectedValue.next(selection.id);
this.selectedValueRootNode.next(selection.path[0]);
return;
}
this.selectedValueRootNode.next(
this.selection.map((entry) => entry.path[0])
);
this.selectedValue.next(this.selection.map((entry) => entry.id));
}
@WidgetConfigure()
public configureWidget(configuration: WidgetConfig) {
this.header = getOrDefault(
this.configuration.configuration.header,
"primary"
);
this.withHeader = getOrDefault(
this.configuration.configuration.withHeader,
false
);
this.identifierField = getOrDefault(
this.configuration.configuration.identifierField,
"identifier"
);
this.disabled = getOrDefault(
this.configuration.configuration.disabled,
false
);
this.descriptionField = getOrDefault(
this.configuration.configuration.descriptionField,
"description"
);
this.triggerAsChipList =
configuration.configuration["triggerAsChipList"] !== undefined
? configuration.configuration["triggerAsChipList"]
: false;
let title = configuration.configuration["title"];
this.placeholder = configuration.configuration["placeholder"];
this.shouldLabelFloat = configuration.configuration["shouldLabelFloat"];
this.preferredPosition = configuration.configuration["preferredPosition"];
const descriptionEntryKey =
configuration.configuration["local-storage-key"];
if (descriptionEntryKey) {
this.localStorageDescriptionEntry = this.localStorageService.getLocalStorageEntry(
descriptionEntryKey,
Scope.GLOBAL,
DeletionMode.LOGIN
);
if (this.localStorageDescriptionEntry.exists()) {
this.selectionString = this.localStorageDescriptionEntry.value;
}
}
if (this.configuration.configuration["localstorage-category"]) {
this.localstorageSelectedCategoryEntry = this.localStorageService.getLocalStorageEntry(
this.configuration.configuration["localstorage-category"],
Scope.GLOBAL,
DeletionMode.LOGIN
);
}
if (
this.localstorageSelectedCategoryEntry &&
this.localstorageSelectedCategoryEntry.exists()
) {
const value = this.localstorageSelectedCategoryEntry.value;
this.selection = JSON.parse(value);
if (value && value[0] === "[") {
} else {
// Support legacy localstorage keys that have saved this value in non array format, they will have the entries saved as path
this.selection = [value];
}
this.programaticExpansion = true;
}
this.selectedValue.next(this.selection);
this.infotext = this.configuration.configuration["infoText"];
this.wikiLink = this.configuration.configuration["wikiLink"];
this.title = this.configuration.configuration["title"];
if (this.configuration._links) {
this.rootCategoryLink = this.configuration["_links"]["categories"][
"href"
];
}
this.dataType = this.configuration.configuration["dataType"];
this.disabledInput.pipe(takeUntil(this.unsubscribe)).subscribe((data) => {
this.disabled = data;
});
this.selectionIdentifier
.asObservable()
.pipe(takeUntil(this.unsubscribe))
.subscribe((selectionIdentifier) => {
if (this.treeSelector) {
this.treeSelector.selectNodes(selectionIdentifier);
} else {
this.setSelection(selectionIdentifier);
}
});
this.selectionPath.pipe(takeUntil(this.unsubscribe)).subscribe((input) => {
if (input?.length) {
if (this.treeSelector) {
const paths: string[][] = <string[][]>(
(Array.isArray(input[0]) ? input : [input])
);
for (let path of paths) {
this.treeSelector.expand(path);
this.treeSelector.selectNodes([path.length - 1]);
}
} else {
this.setSelection(input);
}
} else {
this.clearSelection();
}
});
this.reloadChannel
.asObservable()
.pipe(takeUntil(this.unsubscribe))
.subscribe((reload) => {
this.doReload(false);
});
this.rootUriChannel.pipe(takeUntil(this.unsubscribe)).subscribe((uri) => {
this.rootCategoryLink = uri;
this.loadRootCategories();
});
this.clearChannel
.asObservable()
.pipe(takeUntil(this.unsubscribe))
.subscribe((reset) => {
this.doReload(true);
});
this.resetChannel
.asObservable()
.pipe(takeUntil(this.unsubscribe))
.subscribe((reset) => {
this.clearSelection();
this.doReload(true);
});
// rootcategorylink might be set later if this component is used embedded in category-select-control
if (this.rootCategoryLink) {
this.loadRootCategories();
}
}
private setSelection(input: string[] | string[][]) {
const paths = <string[][]>(Array.isArray(input[0]) ? input : [input]);
const tree = this.buildTree(paths);
const path = <string[]>input;
//If we send a selection before the tree is loaded just set the selection string so the user gets a visual representation of his selection
if (this.rootCategoryLink) {
this.loadRootCategories((nodes) => {
this.selection = paths
.map((path) => {
const category = nodes.find(
(node) => node[this.identifierField] === path[path.length - 1]
);
if (category == null) {
return null;
}
return {
id: category[this.identifierField],
description: category[this.descriptionField],
path,
};
})
.filter((selection) => !!selection);
this.emitSelection();
}, tree);
} else {
this.selectionString = path.join(" | ");
}
}
private forceSelect(categoryValue: string) {
this.selection = [categoryValue];
}
private loadRootCategories(
callback?: (nodes: any[]) => void,
tree: Node[] = []
) {
if (this.rootCategoryLink == null) {
this.nodes = [];
callback(this.nodes);
return;
}
this.categoryService
.getRootCategories(this.rootCategoryLink)
.pipe(mergeMap((root) => this.forceLoad(root, tree)))
.subscribe(
(data) => {
this.nodes = data;
if (callback) {
callback(data);
}
},
(err) => console.error(err)
);
}
private doReload(withReset) {
this.loadRootCategories(
() => {
if (withReset) {
if (this.treeSelector) {
this.treeSelector.collapseAll();
this.treeSelector.clearSelection();
}
}
},
withReset ? [] : this.selection
);
}
removeFromSelection(event) {
this.selection = this.selection.filter(
(value) => value.id !== event.owner.id
);
this.onSelectionChange();
return false;
}
onChipClick(event) {
event.stopPropagation();
event.preventDefault();
return false;
}
private extractData(response): any[] {
if (response._embedded.categories) {
return response._embedded.categories;
}
if (response.type) {
return response._embedded[response.type];
}
return [];
}
transformData(data: any[], parentIdentifier: any = null): any[] {
for (var item of data) {
item.label = item[this.descriptionField];
item.data = item[this.identifierField];
item["parent-node"] = parentIdentifier;
item.name = item.description;
item.expandedIcon = "fa-folder-open";
item.collapsedIcon = "fa-folder";
item.allowDrag = false;
if (item._links && item._links.children != undefined) {
item.expandedIcon = "fa-folder-open";
item.collapsedIcon = "fa-folder";
item.leaf = false;
item.hasChildren = true;
} else {
item.expandedIcon = "fa-folder-open";
item.collapsedIcon = "fa-folder-o";
item.leaf = true;
item.hasChildren = false;
}
}
return data;
}
// build a tree model of all given category paths
// this allows us to lazy load the required paths,
// but only load duplicate steps once
// CATEGORY -> Cars -> VW -> X and CATEGORY -> Cars -> Ford -> Y
// will load CATEGORY and Cars only once
private buildTree(paths: string[][]): Node[] {
const tree: Node[] = [];
function findNode(identifier: string, tree: Node[]): Node | null {
let found = tree.find((node) => node.identifier === identifier);
if (found != null) {
return found;
}
for (let node of tree) {
if (!node.children?.length) {
continue;
}
found = findNode(identifier, node.children);
if (found != null) {
return found;
}
}
return null;
}
for (let path of paths) {
let parentChildren: Node[] = tree;
for (let identifier of path) {
let node = findNode(identifier, tree);
if (node == null) {
node = {
identifier,
children: [],
};
parentChildren.push(node);
}
parentChildren = node.children;
}
}
return tree;
}
}