File

src/app/shared/widgets/select-tree/select-tree.component.ts

Index

Properties

Properties

category
category: string
Type : string
description
description: string
Type : string
path
path: string[]
Type : string[]
root
root: string
Type : string
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;
  }
}

results matching ""

    No results matching ""