File

projects/netgrif-components-core/src/lib/view/tree-case-view/tree-component/case-tree.service.ts

Description

An internal helper object, that is used to return two values from a function.

Index

Properties

Properties

childrenChanged
childrenChanged: boolean
Type : boolean

Whether the nodes children changed

visibleTreePropertiesChanged
visibleTreePropertiesChanged: boolean
Type : boolean

Whether the attributes that are visible on the tree changed

import {Inject, Injectable, OnDestroy, Optional} from '@angular/core';
import {Filter} from '../../../filter/models/filter';
import {HttpParams} from '@angular/common/http';
import {CaseResourceService} from '../../../resources/engine-endpoint/case-resource.service';
import {CaseTreeNode} from './model/case-tree-node';
import {TreeCaseViewService} from '../tree-case-view.service';
import {TaskResourceService} from '../../../resources/engine-endpoint/task-resource.service';
import {LoggerService} from '../../../logger/services/logger.service';
import {ImmediateData} from '../../../resources/interface/immediate-data';
import {Case} from '../../../resources/interface/case';
import {ProcessService} from '../../../process/process.service';
import {SideMenuService} from '../../../side-menu/services/side-menu.service';
import {SideMenuSize} from '../../../side-menu/models/side-menu-size';
import {forkJoin, Observable, of, ReplaySubject, Subject, Subscription, throwError} from 'rxjs';
import {Page} from '../../../resources/interface/page';
import {TreePetriflowIdentifiers} from '../model/tree-petriflow-identifiers';
import {tap} from 'rxjs/operators';
import {TranslateService} from '@ngx-translate/core';
import {hasContent} from '../../../utility/pagination/page-has-content';
import {NestedTreeControl} from '@angular/cdk/tree';
import {MatTreeNestedDataSource} from '@angular/material/tree';
import {ofVoid} from '../../../utility/of-void';
import {CaseGetRequestBody} from '../../../resources/interface/case-get-request-body';
import {getImmediateData} from '../../../utility/get-immediate-data';
import {NAE_OPTION_SELECTOR_COMPONENT} from '../../../side-menu/content-components/injection-tokens';
import {SimpleFilter} from '../../../filter/models/simple-filter';
import {CaseTreePath} from './model/case-tree-path';
import {ExpansionTree} from './model/expansion-tree';
import {ResultWithAfterActions} from '../../../utility/result-with-after-actions';
import {EventOutcomeMessageResource} from '../../../resources/interface/message-resource';
import {SetDataEventOutcome} from '../../../event/model/event-outcomes/data-outcomes/set-data-event-outcome';
import {CreateCaseEventOutcome} from '../../../event/model/event-outcomes/case-outcomes/create-case-event-outcome';
import {refreshTree} from '../../../utility/refresh-tree';
import {NAE_TREE_CASE_VIEW_CONFIGURATION} from './model/tree-configuration-injection-token';
import {TreeCaseViewConfiguration} from './model/tree-case-view-configuration';
import {PaginationParams} from '../../../utility/pagination/pagination-params';
import {createSortParam, PaginationSort} from '../../../utility/pagination/pagination-sort';

/**
 * An internal helper object, that is used to return two values from a function.
 */
interface CaseUpdateResult {
    /**
     * Whether the attributes that are visible on the tree changed
     */
    visibleTreePropertiesChanged: boolean;
    /**
     * Whether the nodes children changed
     */
    childrenChanged: boolean;
}

@Injectable()
export class CaseTreeService implements OnDestroy {

    public static readonly DEFAULT_PAGE_SIZE = 50;

    protected _currentNode: CaseTreeNode;
    private _rootNodesFilter: Filter;
    private readonly _treeDataSource: MatTreeNestedDataSource<CaseTreeNode>;
    private readonly _treeControl: NestedTreeControl<CaseTreeNode>;
    private _treeRootLoaded$: ReplaySubject<boolean>;
    private _rootNode: CaseTreeNode;
    private _showRoot: boolean;
    /**
     * Weather the tree is eager loaded or not.
     *
     * Defaults to `false`.
     *
     * It is not recommended to eager load large trees as each node sends a separate backend request to load its data.
     */
    private _isEagerLoaded = false;
    /**
     * string id of the case, that is currently being reloaded, `undefined` if no case is currently being reloaded
     */
    private _reloadedCaseId: string;

    constructor(protected _caseResourceService: CaseResourceService,
                protected _treeCaseViewService: TreeCaseViewService,
                protected _taskResourceService: TaskResourceService,
                protected _logger: LoggerService,
                protected _processService: ProcessService,
                protected _sideMenuService: SideMenuService,
                protected _translateService: TranslateService,
                @Optional() @Inject(NAE_OPTION_SELECTOR_COMPONENT) protected _optionSelectorComponent: any,
                @Optional() @Inject(NAE_TREE_CASE_VIEW_CONFIGURATION) protected _treeConfiguration: TreeCaseViewConfiguration) {
        if (!this._treeConfiguration) {
            this._treeConfiguration = {
                pageSize: CaseTreeService.DEFAULT_PAGE_SIZE
            };
        }

        this._treeDataSource = new MatTreeNestedDataSource<CaseTreeNode>();
        this._treeControl = new NestedTreeControl<CaseTreeNode>(node => node.children);
        _treeCaseViewService.reloadCase$.asObservable().subscribe(() => {
            this.reloadCurrentCase();
        });
        this._treeRootLoaded$ = new ReplaySubject<boolean>(1);
    }

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

    public set rootFilter(filter: Filter) {
        this._rootNodesFilter = filter;
        this.dataSource.data = [];
        this.loadTreeRoot();
    }

    public get dataSource(): MatTreeNestedDataSource<CaseTreeNode> {
        return this._treeDataSource;
    }

    public get treeControl(): NestedTreeControl<CaseTreeNode> {
        return this._treeControl;
    }

    public get currentNode(): CaseTreeNode {
        return this._currentNode;
    }

    /**
     * Emits a value whenever a new root node Filter is set.
     *
     * `true` is emitted if the root node was successfully loaded. `false` otherwise.
     *
     * On subscription emits the last emitted value (if any) to the subscriber.
     */
    public get treeRootLoaded$(): Observable<boolean> {
        return this._treeRootLoaded$.asObservable();
    }

    protected get _currentCase(): Case | undefined {
        return this._currentNode ? this._currentNode.case : undefined;
    }

    /**
     * @returns an `Observable` of the {@link LoadingEmitter} representing the loading state of the root node.
     * Returns `undefined` if the tree has not yet been initialized.
     *
     * Wait for an emission on the [treeRootLoaded$]{@link CaseTreeService#treeRootLoaded$} stream before getting this Observable.
     *
     * The first value emitted by the Observable is `false`, when the tree finishes initializing.
     */
    public get rootNodeLoading$(): Observable<boolean> | undefined {
        return !!this._rootNode ? this._rootNode.loadingChildren.asObservable() : undefined;
    }

    /**
     * @returns an `Observable` of the {@link LoadingEmitter} representing whether the root node is currently
     * in the process of adding a new child node or not.
     * Returns `undefined` if the tree has not yet been initialized.
     *
     * Wait for an emission on the [treeRootLoaded$]{@link CaseTreeService#treeRootLoaded$} stream before getting this Observable.
     *
     * The first value emitted by the Observable is `false`, when the tree finishes initializing.
     */
    public get rootNodeAddingChild$(): Observable<boolean> | undefined {
        return !!this._rootNode ? this._rootNode.addingNode.asObservable() : undefined;
    }

    /**
     * Weather the tree is eager loaded or not.
     *
     * Defaults to `false`.
     *
     * It is not recommended to eager load large trees as each node sends a separate backend request to load its data.
     */
    public get isEagerLoaded(): boolean {
        return this._isEagerLoaded;
    }

    /**
     * Weather the tree is eager loaded or not.
     *
     * Defaults to `false`.
     *
     * It is not recommended to eager load large trees as each node sends a separate backend request to load its data.
     *
     * @param eager the new setting for eager loading
     */
    public set isEagerLoaded(eager: boolean) {
        this._isEagerLoaded = eager;
    }

    /**
     * Loads and populates the topmost level of the tree.
     *
     * The displayed cases are determined by this object's [rootFilter]{@link CaseTreeService#rootFilter}.
     *
     * Cases are loaded one page at a time and the tree is refreshed after each page.
     * [finishedLoadingFirstLevel$]{@link CaseTreeService#treeRootLoaded$}
     * will emit `true` once the last page loads successfully.
     * `false` will be emitted if any of the requests fail.
     */
    protected loadTreeRoot() {
        if (this._rootNodesFilter) {
            this._caseResourceService.searchCases(this._rootNodesFilter).subscribe(page => {
                if (hasContent(page)) {
                    this._rootNode = new CaseTreeNode(page.content[0], undefined);
                    if (page.content.length !== 1) {
                        this._logger.warn('Filter for tree root returned more than one case. Using the first value as tree root...');
                    }
                    this._treeRootLoaded$.next(true);
                } else {
                    this._logger.error('Tree root cannot be generated from the provided filter', page);
                }
            }, error => {
                this._logger.error('Root node of the case tree could not be loaded', error);
                this._treeRootLoaded$.next(false);
            });
        }
    }

    /**
     * Adds the loaded tree root to the display based on the setting.
     * @param showRoot whether the root of the tree should be displayed in the tree or not.
     * If the root is not displayed it's children will be displayed on the first level.
     * @returns an Observable that emits when the tree finishes initialization.
     */
    public initializeTree(showRoot: boolean): Observable<void> {
        if (!this._rootNode) {
            this._logger.error('Set a Filter before initializing the case tree');
            return throwError(new Error('Set a Filter before initializing the case tree'));
        }

        this._showRoot = showRoot;

        if (showRoot) {
            this.dataSource.data = [this._rootNode];
        } else {
            this.dataSource.data = this._rootNode.children;
        }

        let ret: Observable<void>;
        if (!showRoot || this._isEagerLoaded) {
            const result = new ReplaySubject<void>(1);
            ret = result.asObservable();
            this.expandNode(this._rootNode).subscribe(() => {
                this.refreshTree();
                result.next();
                result.complete();
            });
        } else {
            this.refreshTree();
            ret = ofVoid();
        }
        return ret;
    }

    /**
     * Notifies the parent TreeCaseView that a case was clicked in the tree and it's Task should be displayed
     */
    public changeActiveNode(node: CaseTreeNode | undefined): void {
        this._currentNode = node;
        this._treeCaseViewService.loadTask$.next(node ? node.case : undefined);
    }

    /**
     * Toggles the expansion state of a node
     * @param node the {@link CaseTreeNode} who's content should be toggled
     */
    public toggleNode(node: CaseTreeNode): void {
        if (this._treeControl.isExpanded(node)) {
            this._treeControl.collapse(node);
        } else {
            this.expandNode(node);
        }
    }

    /**
     * Expands the target node in the tree and reloads it's children if they are marked as dirty
     * @param node the {@link CaseTreeNode} that should be expanded
     * @returns emits `true` if the node is expanded and `false` if not. If the expansion causes more node expansions
     * (e.g. eager loaded tree) then, the Observable emits after all the subtree expansions complete.
     */
    protected expandNode(node: CaseTreeNode): Observable<boolean> {
        this._logger.debug('Requesting expansion of tree node', node.toLoggableForm());

        if (node.loadingChildren.isActive) {
            this._logger.debug('Node requested for expansion is loading. Expansion canceled.');
            return of(false);
        }

        if (!node.dirtyChildren) {
            this._logger.debug('Node requested for expansion has clean children. Simple expansion.');
            this.treeControl.expand(node);
            return of(true);
        }

        const ret = new ReplaySubject<boolean>(1);
        this.updateNodeChildren(node).subscribe(() => {
            this._logger.debug('Node requested for expansion with dirty children had its children reloaded.');
            if (node.children.length > 0) {
                this._logger.debug('Node expanded.', node.toLoggableForm());
                this.treeControl.expand(node);
                if (this._isEagerLoaded) {
                    this._logger.debug(`Eager loading children of tree node with case id '${node.case.stringId}'`);
                    const innerObservables = node.children.map(childNode => this.expandNode(childNode));
                    // forkJoin doesn't emit with 0 input observables
                    innerObservables.push(of(true));
                    forkJoin(innerObservables).subscribe(() => {
                        ret.next(true);
                        ret.complete();
                    });
                } else {
                    ret.next(true);
                    ret.complete();
                }
            } else {
                ret.next(false);
                ret.complete();
            }
        });
        return ret.asObservable();
    }

    /**
     * Checks whether dirty children need to be reloaded and reloads them if needed.
     * @param node the {@link CaseTreeNode} who's children are updated
     * @returns emits when loading finishes
     */
    protected updateNodeChildren(node: CaseTreeNode): Observable<void> {
        node.loadingChildren.on();

        const childrenCaseRef = getImmediateData(node.case, TreePetriflowIdentifiers.CHILDREN_CASE_REF);
        if (!childrenCaseRef || childrenCaseRef.value.length === 0) {
            node.children = [];
            this.refreshTree();
            node.dirtyChildren = false;
            node.loadingChildren.off();
            return ofVoid();
        }

        if (node.children.length === childrenCaseRef.value.length) {
            const existingChildren = new Set<string>();
            node.children.forEach(childNode => existingChildren.add(childNode.case.stringId));
            if (childrenCaseRef.value.every(caseId => existingChildren.has(caseId))) {
                node.dirtyChildren = false;
                node.loadingChildren.off();
                return ofVoid();
            }
        }

        return this.updatePageOfChildren(node, 0).pipe(
            tap(() => {
                    this.refreshTree();
                    node.dirtyChildren = false;
                    node.loadingChildren.off();
                }
            )
        );
    }

    /**
     * Loads every page of children from the given number and updates the existing children.
     *
     * Missing nodes are removed. Existing nodes are marked as dirty. New nodes are added.
     *
     * Nodes are returned in their insertion order.
     * @param node the {@link CaseTreeNode} who's children are updated
     * @param pageNumber the number of the first page that should be loaded. All following pages are loaded as well
     * @returns next is emitted when loading of all pages completes (regardless of the outcome)
     */
    protected updatePageOfChildren(node: CaseTreeNode, pageNumber: number): Observable<void> {
        const requestBody = this.createChildRequestBody(node);
        if (!requestBody) {
            this._logger.error('Cannot create filter to find children of the given node', node.case);
            return throwError(new Error('Cannot create filter to find children of the given node'));
        }

        const done = new ReplaySubject<void>(1);

        let params: HttpParams = new HttpParams();
        params = params.set(PaginationParams.PAGE_SIZE, `${this._treeConfiguration.pageSize}`)
                       .set(PaginationParams.PAGE_NUMBER, `${pageNumber}`)
                       .set(PaginationParams.PAGE_SORT, createSortParam('creationDate', PaginationSort.ASCENDING));
        this._caseResourceService.getCases(requestBody, params).subscribe(page => {
            if (!hasContent(page)) {
                this._logger.error('Child cases invalid page content', page);
                done.next();
                done.complete();
                return;
            }

            this.updateCurrentChildrenWithNewPage(node, page);

            if (pageNumber + 1 < page.pagination.totalPages) {
                this.updatePageOfChildren(node, pageNumber + 1).subscribe(() => {
                    done.next();
                    done.complete();
                });
            } else {
                done.next();
                done.complete();
            }

        }, error => {
            this._logger.error('Child cases could not be loaded', error);
            done.next();
            done.complete();
        });

        return done.asObservable();
    }

    /**
     * Updates the children of the given {@link CaseTreeNode} with [Cases]{@link Case} from the provided {@link Page}.
     * @param node the {@link CaseTreeNode} who's children are updated
     * @param page the {@link Page} that contains the updated children
     */
    protected updateCurrentChildrenWithNewPage(node: CaseTreeNode, page: Page<Case>): void {
        page.content.forEach((newCase, index) => {
            const position = page.pagination.size * page.pagination.number + index;
            while (position < node.children.length && node.children[position].case.stringId !== newCase.stringId) {
                node.children.splice(position, 1);
            }
            if (node.children.length === position) {
                node.children.push(new CaseTreeNode(newCase, node));
            } else {
                node.children[position].case = newCase;
                node.dirtyChildren = true;
                this.treeControl.collapseDescendants(node.children[position]);
            }
        });
    }

    /**
     * @param node the {@link CaseTreeNode} who's children the {@link Filter} should return
     * @returns a request body that finds all child cases of the given `node`.
     * Returns `undefined` if the provided `node` doesn't contain enough information to create the request body.
     */
    protected createChildRequestBody(node: CaseTreeNode): CaseGetRequestBody {
        const childCaseRef = getImmediateData(node.case, TreePetriflowIdentifiers.CHILDREN_CASE_REF);
        if (!childCaseRef) {
            return undefined;
        }

        return {stringId: (childCaseRef.value as Array<string>)};
    }

    /**
     * Adds a child to the root node.
     *
     * Useful if you are using the layout where the root node is hidden.
     * @returns emits `true` if the child was successfully added, `false` if not
     */
    public addRootChildNode(): Observable<boolean> {
        const ret = new ReplaySubject<boolean>(1);
        this.addChildNode(this._rootNode).subscribe(added => {
            if (added) {
                if (!this._showRoot && this._treeDataSource.data.length === 0) {
                    this._treeDataSource.data = this._rootNode.children;
                    this.refreshTree();
                }
            }
            ret.next(added);
            ret.complete();
        });
        return ret;
    }

    /**
     * Adds a new child node to the given node based on the properties of the node's case
     * @returns emits `true` if the child was successfully added, `false` if not
     */
    public addChildNode(clickedNode: CaseTreeNode): Observable<boolean> {
        clickedNode.addingNode.on();
        const caseRefField = getImmediateData(clickedNode.case, TreePetriflowIdentifiers.CHILDREN_CASE_REF);

        if (caseRefField.allowedNets.length === 0) {
            this._logger.error(`Case ${clickedNode.case.stringId} can add new tree nodes but has no allowed nets`);
            clickedNode.addingNode.off();
            return of(false);
        }

        const ret = new ReplaySubject<boolean>(1);

        if (caseRefField.allowedNets.length === 1) {
            this.createAndAddChildCase(caseRefField.allowedNets[0], clickedNode, ret);
        } else {
            this._processService.getNets(caseRefField.allowedNets).subscribe(nets => {
                const sideMeuRef = this._sideMenuService.open(this._optionSelectorComponent, SideMenuSize.MEDIUM, {
                    title: clickedNode.case.title,
                    options: nets.map(net => ({text: net.title, value: net.identifier}))
                });
                let sideMenuSubscription: Subscription;
                sideMenuSubscription = sideMeuRef.onClose.subscribe(event => {
                    if (!!event.data) {
                        this.createAndAddChildCase(event.data.value, clickedNode, ret);
                        sideMenuSubscription.unsubscribe();
                    } else {
                        clickedNode.addingNode.off();
                        ret.next(false);
                        ret.complete();
                    }
                });
            });
        }
        return ret.asObservable();
    }

    /**
     * Creates a new case and adds it to the children of the specified node
     * @param processIdentifier identifier of the process that should be created
     * @param clickedNode the node that is the parent of the new case
     * @param operationResult the result of the operation will be emitted into this stream when the operation completes
     */
    protected createAndAddChildCase(processIdentifier: string,
                                    clickedNode: CaseTreeNode,
                                    operationResult: Subject<boolean>) {
        this._processService.getNet(processIdentifier).subscribe(net => {
            const childTitleImmediate = getImmediateData(clickedNode.case, TreePetriflowIdentifiers.CHILD_NODE_TITLE);
            let childTitle = this._translateService.instant('caseTree.newNodeDefaultName');
            if (!!childTitleImmediate) {
                childTitle = childTitleImmediate.value;
            } else if (!!net.defaultCaseName) {
                childTitle = net.defaultCaseName;
            }
            this._caseResourceService.createCase({
                title: childTitle,
                netId: net.stringId
            }).subscribe((outcomeResource: EventOutcomeMessageResource) => {
                const caseRefField = getImmediateData(clickedNode.case, TreePetriflowIdentifiers.CHILDREN_CASE_REF);
                const setCaseRefValue = [...caseRefField.value, (outcomeResource.outcome as CreateCaseEventOutcome).aCase.stringId];
                this.performCaseRefCall(clickedNode.case.stringId, setCaseRefValue).subscribe(
                    valueChange => this.updateTreeAfterChildAdd(clickedNode, valueChange ? valueChange : setCaseRefValue, operationResult)
                );
            });
        });
    }

    /**
     * Updates the tree after adding a new child
     * @param clickedNode the parent node
     * @param newCaseRefValue the new value of the parent node's case ref
     * @param operationResult the result of the operation will be emitted into this stream when the operation completes
     */
    protected updateTreeAfterChildAdd(clickedNode: CaseTreeNode, newCaseRefValue: Array<string>, operationResult: Subject<boolean>): void {
        this.updateNodeChildrenFromChangedFields(clickedNode, newCaseRefValue).subscribe(result => {
            clickedNode.addingNode.off();
            this.expandNode(clickedNode).subscribe(expandSuccess => {
                if (expandSuccess) {
                    this.changeActiveNode(clickedNode.children[clickedNode.children.length - 1]);
                    result.executeAfterActions();
                }
                operationResult.next(true);
                operationResult.complete();
            });
        });
    }

    /**
     * removes the provided non-root node if the underlying case allows it.
     *
     * The underlying case is removed from the case ref of it's parent element with the help from the `remove`
     * operation provided by case ref itself.
     * @param node the node that should be removed from the tree
     */
    public removeNode(node: CaseTreeNode): void {
        if (!node.parent) {
            this._logger.error('Case tree doesn\'t support removal of the root node, as it has no parent case ref.');
            return;
        }

        node.removingNode.on();
        const caseRefImmediate = getImmediateData(node.parent.case, TreePetriflowIdentifiers.CHILDREN_CASE_REF);
        const setCaseRefValue = (caseRefImmediate.value as Array<string>).filter(id => id !== node.case.stringId);
        this.performCaseRefCall(node.parent.case.stringId, setCaseRefValue).subscribe(caseRefChange => {
            const newCaseRefValue = caseRefChange ? caseRefChange : setCaseRefValue;
            this.deleteRemovedNodes(node.parent, newCaseRefValue);
            this.updateNodeChildrenFromChangedFields(node.parent, newCaseRefValue);

            node.removingNode.off();
        });

        this.deselectNodeIfDescendantOf(node);
    }

    /**
     * Expands all nodes in the tree dictated by the argument.
     *
     * @param path nodes that should be expanded along with their path from the root node
     */
    public expandPath(path: CaseTreePath): void {
        if (this.dataSource.data.length === 0) {
            return;
        }
        let rootNode = this.dataSource.data[0];
        while (rootNode.parent !== undefined) {
            rootNode = rootNode.parent;
        }
        this.expandLevel([rootNode], this.convertPathToExpansionTree(path));
    }

    /**
     * Transforms a {@Link CaseTreePath} object into an {@link ExpansionTree} object.
     * The result has all the common paths merged into single branches of the resulting tree structure.
     *
     * @param paths nodes that should be expanded along with their path from the root node
     * @returns an {@link ExpansionTree} equivalent to the provided {@link CaseTreePath}
     */
    protected convertPathToExpansionTree(paths: CaseTreePath): ExpansionTree {
        const result = {};
        Object.values(paths).forEach(path => {
            let currentNode = result;
            path.forEach(nodeId => {
                if (currentNode[nodeId] === undefined) {
                    currentNode[nodeId] = {};
                }
                currentNode = currentNode[nodeId];
            });
        });
        return result;
    }

    /**
     * Recursively expands all nodes from the provided array of nodes, that appear in the top level of the {@link ExpansionTree} object.
     *
     * @param levelNodes nodes from which the expansion should start
     * @param targets a tree structure representing the nodes that are to be expanded recursively.
     * The top level nodes are expanded first, from the provided `levelNodes`.
     */
    protected expandLevel(levelNodes: Array<CaseTreeNode>, targets: ExpansionTree): void {
        const desiredIds = new Set(Object.keys(targets));
        if (desiredIds.size === 0) {
            return;
        }

        levelNodes.forEach(n => {
            const node = n;
            if (!desiredIds.has(node.case.stringId)) {
                return; // continue
            }
            this.expandNode(node).subscribe(success => {
                if (!success) {
                    this._logger.debug('Could not expand tree node with ID: ' + node.case.stringId);
                    return;
                }
                this.expandLevel(node.children, targets[node.case.stringId]);
            });
        });
    }

    /**
     * Deletes the subtrees rooted at the nodes that are present in the parent node's child case ref values,
     * but are no longer present in the new value
     * @param parentNode an inner node of the tree that had some of it's children removed
     * @param newCaseRefValue the new value of the parent node's case ref
     */
    protected deleteRemovedNodes(parentNode: CaseTreeNode, newCaseRefValue: Array<string>): void {
        const removedChildren = new Set<string>();
        getImmediateData(parentNode.case, TreePetriflowIdentifiers.CHILDREN_CASE_REF).value.forEach(id => removedChildren.add(id));
        newCaseRefValue.forEach(id => removedChildren.delete(id));
        removedChildren.forEach(removedId => this._caseResourceService.deleteCase(removedId, true)
            .subscribe(responseMessage => {
                if (responseMessage.error) {
                    this._logger.error('Removal of child case unsuccessful', responseMessage.error);
                }
            }));
    }

    /**
     * Deselects the currently selected node if it is a descendant of the provided node
     * @param node the node who's descendants should be deselected
     */
    protected deselectNodeIfDescendantOf(node: CaseTreeNode): void {
        let bubblingNode = this.currentNode;
        while (bubblingNode && bubblingNode !== this._rootNode) {
            if (bubblingNode === node) {
                this.changeActiveNode(undefined);
                break;
            }
            bubblingNode = bubblingNode.parent;
        }
    }

    /**
     * Performs a backend call on the given case, and sets the value of the case ref field in the transition defined by
     * [CASE_REF_TRANSITION]{@link TreePetriflowIdentifiers#CASE_REF_TRANSITION}.
     * @param caseId string ID of the case that should have it's tree case ref set
     * @param newCaseRefValue the new value of the case ref field
     */
    private performCaseRefCall(caseId: string, newCaseRefValue: Array<string>): Observable<Array<string> | undefined> {
        const result$ = new ReplaySubject<Array<string>>(1);

        this._taskResourceService.getTasks(SimpleFilter.fromTaskQuery({
            case: {id: caseId},
            transitionId: TreePetriflowIdentifiers.CASE_REF_TRANSITION
        })).subscribe(page => {
            if (!hasContent(page)) {
                this._logger.error('Case ref accessor task doesn\'t exist!');
                result$.complete();
                return;
            }

            const task = page.content[0];

            this._taskResourceService.assignTask(task.stringId).subscribe(assignResponse => {
                if (!assignResponse.success) {
                    this._logger.error('Case ref accessor task could not be assigned', assignResponse.error);
                }

                const body = {};
                body[task.stringId] = {
                    [TreePetriflowIdentifiers.CHILDREN_CASE_REF]: {
                        type: 'caseRef',
                        value: newCaseRefValue
                    }
                };
                this._taskResourceService.setData(task.stringId, body).subscribe((outcomeResource: EventOutcomeMessageResource) => {
                    const changedFields = (outcomeResource.outcome as SetDataEventOutcome).changedFields.changedFields;
                    const caseRefChanges = changedFields[TreePetriflowIdentifiers.CHILDREN_CASE_REF];
                    result$.next(caseRefChanges ? caseRefChanges.value : undefined);
                    result$.complete();
                    this._taskResourceService.finishTask(task.stringId).subscribe(finishResponse => {
                        if (finishResponse.success) {
                            this._logger.debug('Case ref accessor task finished', finishResponse.success);
                        } else {
                            this._logger.error('Case ref accessor task finish failed', finishResponse.error);
                        }
                    });
                }, error => {
                    this._logger.error('Could not set data to tree case ref', error);
                    result$.complete();
                });
            }, error => {
                this._logger.error('Case ref accessor task could not be assigned', error);
                result$.complete();
            });
        }, error => {
            this._logger.error('Case ref accessor task could not be found', error);
            result$.complete();
        });
        return result$.asObservable();
    }

    /**
     * Performs an update after adding or removing a node from the tree.
     *
     * If only one node was added adds it into the tree
     *
     * If only one node was removed removes it from the tree
     *
     * Otherwise collapses the affected node and marks it's children as dirty
     *
     * @param affectedNode node that had it's children changed
     * @param newCaseRefValue new value of the caseRef field returned by backend
     * @returns an `Observable` that emits an object with the [result]{@link ResultWithAfterActions#result} attribute set to `true` if
     * the update completes successfully and `false` otherwise.
     */
    private updateNodeChildrenFromChangedFields(affectedNode: CaseTreeNode,
                                                newCaseRefValue: Array<string>): Observable<ResultWithAfterActions<boolean>> {
        const caseRefField = getImmediateData(affectedNode.case, TreePetriflowIdentifiers.CHILDREN_CASE_REF);
        const newChildren = new Set<string>();
        newCaseRefValue.forEach(id => newChildren.add(id));

        let numberOfMissingChildren = 0;
        for (let i = 0; i < caseRefField.value.length && numberOfMissingChildren < 2; i++) {
            if (!newChildren.has(caseRefField.value[i])) {
                numberOfMissingChildren++;
            }
        }

        const exactlyOneChildAdded = caseRefField.value.length + 1 === newCaseRefValue.length
            && caseRefField.value.every(it => newChildren.has(it));

        const exactlyOneChildRemoved = caseRefField.value.length - 1 === newCaseRefValue.length
            && numberOfMissingChildren === 1;

        if (!exactlyOneChildAdded && !exactlyOneChildRemoved) {
            caseRefField.value = newCaseRefValue;
            this._treeControl.collapseDescendants(affectedNode);
            affectedNode.dirtyChildren = true;
            return of(new ResultWithAfterActions(true));
        }

        if (exactlyOneChildAdded) {
            return this.processChildNodeAdd(affectedNode, caseRefField, newCaseRefValue);
        } else {
            return this.processChildNodeRemove(affectedNode, caseRefField, newChildren);
        }
    }

    /**
     * Adds a new child node to the `affectedNode` by adding the last Case from the `newCaseRefValue`
     * @param affectedNode the node in the tree that had a child added - the parent node
     * @param caseRefField the case ref field of the affected node
     * @param newCaseRefValue the new value of the case ref field in the node
     * @returns an `Observable` that emits `true` if a node was successfully added, `false` otherwise.
     */
    protected processChildNodeAdd(affectedNode: CaseTreeNode,
                                  caseRefField: ImmediateData,
                                  newCaseRefValue: Array<string>): Observable<ResultWithAfterActions<boolean>> {
        const result$ = new ReplaySubject<ResultWithAfterActions<boolean>>(1);

        caseRefField.value = newCaseRefValue;
        this._caseResourceService.getOneCase(newCaseRefValue[newCaseRefValue.length - 1]).subscribe(childCase => {
            if (childCase) {
                this._logger.debug('Pushing child node to tree', {childCase, affectedNode: affectedNode.toLoggableForm()});
                const childNode = this.pushChildToTree(affectedNode, childCase);
                result$.next(new ResultWithAfterActions(true, [() => {
                    if (this._isEagerLoaded) {
                        this._logger.debug('Eagerly expanding a newly added node.', childNode.toLoggableForm());
                        this.expandNode(childNode);
                    }
                }]));
            } else {
                this._logger.error('New child case was not found, illegal state', childCase);
                result$.next(new ResultWithAfterActions(false));
            }
            result$.complete();
        }, error => {
            this._logger.error('New child node case could not be found', error);
            result$.next(new ResultWithAfterActions(false));
            result$.complete();
        });

        return result$.asObservable();
    }

    /**
     * Adds a new child node to the target parent node.
     * @param parentNode the nodes whose child should be added
     * @param childCase the child case
     * @returns the newly added node
     */
    protected pushChildToTree(parentNode: CaseTreeNode, childCase: Case): CaseTreeNode {
        const childNode = new CaseTreeNode(childCase, parentNode);
        parentNode.children.push(childNode);
        this.refreshTree();
        return childNode;
    }

    /**
     * Removes the deleted node from the children of the `affectedNode`
     * @param affectedNode the node in the tree that had it's child removed
     * @param caseRefField the case ref field of the affected node
     * @param newCaseRefValues the new value of the case ref field in the node
     * @returns an `Observable` that emits `true` when the remove operation completes.
     */
    protected processChildNodeRemove(affectedNode: CaseTreeNode,
                                     caseRefField: ImmediateData,
                                     newCaseRefValues: Set<string>): Observable<ResultWithAfterActions<boolean>> {
        const index = caseRefField.value.findIndex(it => !newCaseRefValues.has(it));
        caseRefField.value.splice(index, 1);
        affectedNode.children.splice(index, 1);
        this.refreshTree();
        return of(new ResultWithAfterActions(true));
    }

    /**
     * @ignore
     * Forces a rerender of the tree content
     */
    private refreshTree(): void {
        refreshTree(this._treeDataSource);
    }

    /**
     * Reloads the currently selected case node. The {@link Case} object held in the {@link CaseTreeNode} instance is not replaced,
     * but the new Case is `Object.assign`-ed into it. This means that the reference to the Case instance is unchanged but references
     * to all it's non-primitive attributes are changed.
     *
     * If a reload of the current node is initiated before the previous one completed, the new one is ignored.
     *
     * If the currently selected case changed before a response from backend was received the response is ignored.
     *
     * Note that the parent node, nor the child nodes are reloaded.
     */
    protected reloadCurrentCase(): void {
        if (!this._currentNode) {
            this._logger.debug('No Tree Case Node selected, nothing to reload');
            return;
        }
        if (this._reloadedCaseId && this._currentNode.case.stringId !== this._reloadedCaseId) {
            this._logger.debug('Reload of the current case already in progress');
            return;
        }
        this._reloadedCaseId = this._currentNode.case.stringId;
        this._caseResourceService.getOneCase(this._currentCase.stringId).subscribe(reloadedCurrentCase => {
            if (!reloadedCurrentCase) {
                this._logger.error('Current Case Tree Node could not be reloaded. Invalid server response', reloadedCurrentCase);
                return;
            }
            if (this._currentNode && reloadedCurrentCase.stringId === this._currentNode.case.stringId) {
                this._reloadedCaseId = undefined;
                const change = this.determineCaseUpdate(this._currentCase, reloadedCurrentCase);
                Object.assign(this._currentCase, reloadedCurrentCase);
                this._treeCaseViewService.loadTask$.next(this._currentCase);
                if (change.visibleTreePropertiesChanged) {
                    this.refreshTree();
                }
                if (change.childrenChanged) {
                    this._currentNode.dirtyChildren = true;
                    this.expandNode(this._currentNode);
                }
                this._logger.debug('Current Case Tree Node reloaded');
            } else {
                this._logger.debug('Discarding case reload response, since the current node has changed before its case was received');
            }
        }, error => {
            this._logger.error('Current Case Tree Node reload request failed', error);
        });
    }

    /**
     * Determines if anny of the case attributes that are visible on the tree changed.
     * @param oldCase the previous version of the Case object, that is currently displayed on the tree
     * @param newCase the new version of the Case object, that should replace the old one
     */
    private determineCaseUpdate(oldCase: Case, newCase: Case): CaseUpdateResult {
        const visibleAttributes = [
            TreePetriflowIdentifiers.CAN_ADD_CHILDREN,
            TreePetriflowIdentifiers.CAN_REMOVE_NODE,
            TreePetriflowIdentifiers.BEFORE_TEXT_ICON,
            TreePetriflowIdentifiers.TREE_ADD_ICON
        ];

        const result: CaseUpdateResult = {
            visibleTreePropertiesChanged: true, // for short-circuiting the evaluation, if nodes children changed
            childrenChanged: false
        };

        const oldChildCaseRef = getImmediateData(oldCase, TreePetriflowIdentifiers.CHILDREN_CASE_REF);
        if (oldChildCaseRef !== undefined) {
            const oldChildren = new Set(oldChildCaseRef.value);
            const newChildren = new Set(getImmediateData(newCase, TreePetriflowIdentifiers.CHILDREN_CASE_REF).value);

            result.childrenChanged = oldChildren.size !== newChildren.size;
            if (!result.childrenChanged) {
                result.childrenChanged = Array.from(oldChildren).some(childId => !newChildren.has(childId));
            }

            // short-circuit
            if (result.childrenChanged) {
                return result;
            }
        }

        result.visibleTreePropertiesChanged = visibleAttributes.some(attribute => {
            return getImmediateData(oldCase, attribute)
                && getImmediateData(oldCase, attribute).value !== getImmediateData(newCase, attribute).value;
        });

        return result;
    }
}

result-matching ""

    No results matching ""