From b83083b78e160473f17c89b342837c858ea29ba4 Mon Sep 17 00:00:00 2001
From: Alex ORLUC <alex.orluc@maarch.org>
Date: Mon, 10 Aug 2020 09:31:35 +0200
Subject: [PATCH] FEAT #14456 TIME 10 add flat tree material plugin

---
 src/frontend/app/app-common.module.ts         |   3 +
 .../tree/maarch-flat-tree.component.html      |  46 +++
 .../tree/maarch-flat-tree.component.scss      | 144 ++++++++
 .../tree/maarch-flat-tree.component.ts        | 333 ++++++++++++++++++
 src/lang/lang-en.json                         |   6 +-
 src/lang/lang-fr.json                         |   5 +-
 src/lang/lang-nl.json                         |   6 +-
 7 files changed, 540 insertions(+), 3 deletions(-)
 create mode 100644 src/frontend/plugins/tree/maarch-flat-tree.component.html
 create mode 100644 src/frontend/plugins/tree/maarch-flat-tree.component.scss
 create mode 100644 src/frontend/plugins/tree/maarch-flat-tree.component.ts

diff --git a/src/frontend/app/app-common.module.ts b/src/frontend/app/app-common.module.ts
index 90d73a15ee3..37f63c176b8 100755
--- a/src/frontend/app/app-common.module.ts
+++ b/src/frontend/app/app-common.module.ts
@@ -13,6 +13,7 @@ import { AppServiceModule } from './app-service.module';
 import { NotificationModule } from '../service/notification/notification.module';
 
 import { MaarchTreeComponent } from '../plugins/tree/maarch-tree.component';
+import { MaarchFlatTreeComponent } from '../plugins/tree/maarch-flat-tree.component';
 import { AutocompleteListComponent } from '../plugins/autocomplete-list/autocomplete-list.component';
 
 /*FRONT IMPORTS*/
@@ -108,6 +109,7 @@ import { TranslateService } from '@ngx-translate/core';
         VisaWorkflowComponent,
         AvisWorkflowComponent,
         MaarchTreeComponent,
+        MaarchFlatTreeComponent,
         ContactResourceComponent,
         ContactDetailComponent,
         AutocompleteListComponent,
@@ -150,6 +152,7 @@ import { TranslateService } from '@ngx-translate/core';
         VisaWorkflowComponent,
         AvisWorkflowComponent,
         MaarchTreeComponent,
+        MaarchFlatTreeComponent,
         ContactResourceComponent,
         ContactDetailComponent,
         AutocompleteListComponent,
diff --git a/src/frontend/plugins/tree/maarch-flat-tree.component.html b/src/frontend/plugins/tree/maarch-flat-tree.component.html
new file mode 100644
index 00000000000..5cd3c465fe5
--- /dev/null
+++ b/src/frontend/plugins/tree/maarch-flat-tree.component.html
@@ -0,0 +1,46 @@
+<mat-form-field>
+    <input matInput type="text" [formControl]="searchTerm" placeholder="{{'lang.searchEntities' | translate}}">
+    <mat-hint align="end" [innerHTML]="'lang.hotkeyInfo' | translate"></mat-hint>
+</mat-form-field>
+<div style="position: relative;padding-top: 20px;">
+    <div class="msgHotkey" *ngIf="holdShift" [title]="'lang.hotkeyTitle' | translate">
+        <i class="fas fa-keyboard" [iinerHTML]=""></i>&nbsp;<span [innerHTML]="'lang.hotkeyMsg' | translate"></span>
+    </div>
+    <mat-tree [dataSource]="dataSource" [treeControl]="treeControl">
+        <mat-nested-tree-node *matTreeNodeDef="let node">
+            <li>
+                <div class="mat-tree-node">
+                    <button style="position: absolute;left: -35px;" mat-icon-button matTreeNodeToggle disabled>
+                    </button>
+                    <div class="node-content" [class.node-selected]="node.state.selected"
+                        [class.node-disabled]="node.state.disabled" [class.node-hide]="searchMode && !node.state.search" [class.node-highlight]="searchMode && node.state.search"
+                        (click)="selectNode(node)">
+                        <i [class]="node.icon" style="width: 24px;text-align: center;"></i>&nbsp;{{node.text}}
+                    </div>
+                </div>
+            </li>
+        </mat-nested-tree-node>
+        <mat-nested-tree-node *matTreeNodeDef="let node; when: hasChild">
+            <li class="example-tree-container" [class.fixHeight]="!node.state.opened">
+                <div class="mat-tree-node">
+                    <button id="button-{{node.id}}" style="position: absolute;left: -35px;" mat-icon-button
+                        [attr.aria-label]="'toggle ' + node.filename"
+                        (click)="toggleNode(dataSource.data, {opened : !node.state.opened}, [node.id])">
+                        <mat-icon class="tree-exp far {{node.state.opened ? 'fa-minus-square' : 'fa-plus-square'}}">
+                        </mat-icon>
+                    </button>
+                    <div class="node-content" [class.node-selected]="node.state.selected"
+                        [class.node-disabled]="node.state.disabled" [class.node-hide]="searchMode && !node.state.search" [class.node-highlight]="searchMode && node.state.search"
+                        (click)="selectNode(node)">
+                        <i [class]="node.icon" style="width: 24px;text-align: center;"></i>&nbsp;{{node.text}}
+                    </div>
+                </div>
+                <ul class="example-tree-nested-node" [class.lastNode]="node.last">
+                    <div *ngIf="node.state.opened">
+                        <ng-container matTreeNodeOutlet></ng-container>
+                    </div>
+                </ul>
+            </li>
+        </mat-nested-tree-node>
+    </mat-tree>
+</div>
\ No newline at end of file
diff --git a/src/frontend/plugins/tree/maarch-flat-tree.component.scss b/src/frontend/plugins/tree/maarch-flat-tree.component.scss
new file mode 100644
index 00000000000..a653f93901e
--- /dev/null
+++ b/src/frontend/plugins/tree/maarch-flat-tree.component.scss
@@ -0,0 +1,144 @@
+@import '../../css/vars.scss';
+
+.example-tree-progress-bar {
+    margin-left: 30px;
+}
+
+.example-tree-nested-node {
+    padding-left: 30px;
+}
+
+mat-tree {
+    margin-left: 10px;
+}
+
+.mat-tree-node {
+    font-family: 'Titillium Web', sans-serif, Arial, sans-serif;
+    min-height: 32px;
+    line-height: 32px;
+    min-width: 32px;
+    height: 32px;
+    padding: 0;
+    background-color: white;
+    white-space: pre;
+}
+
+.mat-nested-tree-node {
+    top: -24px;
+}
+
+ul,
+li {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+}
+
+li.example-tree-container {
+    border-bottom: 0;
+}
+
+ul {
+    padding-left: 40px;
+}
+
+li {
+    padding-left: 15px;
+    border: 1px dotted grey;
+    border-width: 0 0 1px 1px;
+    position: relative;
+    top: -24px;
+}
+
+li.mat-tree-node,
+li div {
+    margin: 0;
+    position: relative;
+    top: 17px;
+}
+
+li ul {
+    padding-top: 15px;
+    border-top: 1px dotted grey;
+    margin-left: -15px;
+    padding-left: 27px;
+}
+
+.lastNode {
+    border-left: 1px solid white;
+    margin-left: -16px !important;
+}
+
+.mat-icon-button {
+    z-index: 100;
+}
+
+.fixHeight {
+    height: 33px;
+}
+
+.node-content {
+    height: 24px;
+    -webkit-touch-callout: none;
+    -webkit-user-select: none;
+    -khtml-user-select: none;
+    -moz-user-select: none;
+    -ms-user-select: none;
+    user-select: none;
+    cursor: pointer;
+    border-radius: 5px;
+    display: flex;
+    align-items: center;
+    margin-top: -35px !important;
+    z-index: 2;
+
+    i {
+        color: #666;
+        color: $primary
+    }
+}
+
+.node-content:not(.node-selected):not(.node-disabled):hover {
+    background-color: #6666661c !important;
+    transition: all 0.3s;
+}
+
+.node-selected {
+    background-color: #1a80ab;
+    color: white;
+    transition: all 0.3s;
+
+    i {
+        color: white;
+    }
+}
+
+.node-disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+}
+
+.node-hide {
+    opacity: 0.5;
+}
+
+.node-highlight {
+    font-weight: bold;
+    color: $primary;
+}
+
+.tree-exp::before {
+    background: white;
+}
+
+.msgHotkey {
+    position: absolute;
+    background: #00000096;
+    color: white;
+    z-index: 3;
+    padding: 5px;
+    border-radius: 5px;
+    font-size: 10px;
+    top: 0px;
+    right: 0px;
+}
\ No newline at end of file
diff --git a/src/frontend/plugins/tree/maarch-flat-tree.component.ts b/src/frontend/plugins/tree/maarch-flat-tree.component.ts
new file mode 100644
index 00000000000..9ad27ef8544
--- /dev/null
+++ b/src/frontend/plugins/tree/maarch-flat-tree.component.ts
@@ -0,0 +1,333 @@
+import { NestedTreeControl } from '@angular/cdk/tree';
+import { Component, Input, OnInit, HostListener, Output, EventEmitter } from '@angular/core';
+import { MatTreeNestedDataSource } from '@angular/material/tree';
+import { SortPipe } from '../../plugins/sorting.pipe';
+import { FormControl } from '@angular/forms';
+import { tap, debounceTime } from 'rxjs/operators';
+import { LatinisePipe } from 'ngx-pipes';
+
+
+/** Flat node with expandable and level information */
+interface ExampleFlatNode {
+    expandable: boolean;
+    item: string;
+    parent_id: string;
+    level: number;
+}
+
+/**
+ * @title Tree with flat nodes
+ */
+@Component({
+    selector: 'app-maarch-flat-tree',
+    templateUrl: 'maarch-flat-tree.component.html',
+    styleUrls: ['maarch-flat-tree.component.scss'],
+    providers: [SortPipe],
+})
+export class MaarchFlatTreeComponent implements OnInit {
+
+    @Input() rawData: any = [];
+
+    @Output() afterSelectNode = new EventEmitter<any>();
+    @Output() afterDeselectNode = new EventEmitter<any>();
+
+    holdShift: boolean = false;
+
+    treeControl = new NestedTreeControl<any>(node => node.children);
+    dataSource = new MatTreeNestedDataSource<any>();
+
+    searchMode: boolean = false;
+    searchTerm: FormControl = new FormControl('');
+
+    lastSelectedNodeIds: any[] = [];
+
+    @HostListener('document:keydown.Shift', ['$event']) onKeydownHandler(event: KeyboardEvent) {
+        this.holdShift = true;
+    }
+    @HostListener('document:keyup.Shift', ['$event']) onKeyupHandler(event: KeyboardEvent) {
+        this.holdShift = false;
+    }
+
+    constructor(
+        private sortPipe: SortPipe,
+        private latinisePipe: LatinisePipe,
+    ) { }
+
+    ngOnInit(): void {
+        // SAMPLE
+        /* this.rawData = [
+            {
+                id: '46',
+                text: 'bonjour',
+                parent_id: null,
+                icon: 'fa fa-building',
+                state: {
+                    selected: true,
+                }
+            },
+            {
+                id: '42',
+                text: 'coucou',
+                parent_id: '46',
+                icon: 'fa fa-building',
+                state: {
+                    selected: true,
+                }
+            },
+            {
+                id: '41',
+                text: 'coucou',
+                parent_id: '42',
+                icon: 'fa fa-building',
+                state: {
+                    selected: true,
+                }
+            },
+            {
+                id: '1',
+                text: 'Compétences fonctionnelles',
+                parent_id: null,
+                icon: 'fa fa-building',
+                state: {
+                    selected: true,
+                }
+            },
+            {
+                id: '232',
+                text: 'Compétences technique',
+                parent_id: null,
+                icon: 'fa fa-building',
+                state: {
+                    selected: true,
+                }
+            }
+        ]; */
+        if (this.rawData.length > 0) {
+            this.initData();
+        }
+    }
+
+    initData(data: any = this.rawData) {
+        this.rawData = data.map((item: any) => {
+            return {
+                ...item,
+                parent_id : item.parent_id === '#' ? null : item.parent_id,
+                state: {
+                    selected: item.state.selected,
+                    opened: item.state.opened,
+                    disabled: item.state.disabled,
+                }
+            };
+        });
+
+        this.rawData = this.sortPipe.transform(this.rawData, 'parent_id');
+
+        const nestedData = this.flatToNestedObject(this.rawData);
+
+        this.dataSource.data = nestedData;
+        this.treeControl.dataNodes = nestedData;
+
+        this.searchTerm.valueChanges
+            .pipe(
+                debounceTime(300),
+                // filter(value => value.length > 2),
+                tap((filterValue: any) => {
+                    filterValue = filterValue.trim(); // Remove whitespace
+                    filterValue = filterValue.toLowerCase(); // MatTableDataSource defaults to lowercase matches
+                    this.searchNode(this.dataSource.data, filterValue);
+                }),
+            ).subscribe();
+    }
+
+    getData(id: any) {
+        return this.rawData.filter((elem: any) => elem.id === id)[0];
+    }
+
+    getIteration(it: number) {
+        return Array(it).fill(0).map((x, i) => i);
+    }
+
+    flatToNestedObject(data: any) {
+        const nested = data.reduce((initial: any, value: any, index: any, original: any) => {
+            if (value.parent_id === '') {
+                if (initial.left.length) {
+                    this.checkLeftOvers(initial.left, value);
+                }
+                delete value.parent_id;
+                value.root = true;
+                initial.nested.push(value);
+                initial.nested = this.sortPipe.transform(initial.nested, 'text');
+                initial.nested = initial.nested.map((info: any, indexPar: number) => {
+                    return {
+                        ...info,
+                        last: initial.nested.length - 1 === indexPar,
+                    };
+                });
+            } else {
+                const parentFound = this.findParent(initial.nested, value);
+                if (parentFound) {
+                    this.checkLeftOvers(initial.left, value);
+                } else {
+                    initial.left.push(value);
+                }
+            }
+            return index < original.length - 1 ? initial : initial.nested;
+        }, { nested: [], left: [] });
+        return nested;
+    }
+
+    checkLeftOvers(leftOvers: any, possibleParent: any) {
+        for (let i = 0; i < leftOvers.length; i++) {
+            if (leftOvers[i].parent_id === possibleParent.id) {
+                // delete leftOvers[i].parent_id;
+                possibleParent.children ? possibleParent.children.push(leftOvers[i]) : possibleParent.children = [leftOvers[i]];
+                possibleParent.count = possibleParent.children.length;
+                const addedObj = leftOvers.splice(i, 1);
+                this.checkLeftOvers(leftOvers, addedObj[0]);
+            }
+        }
+    }
+
+    findParent(possibleParents: any, possibleChild: any): any {
+        let found = false;
+        for (let i = 0; i < possibleParents.length; i++) {
+            if (possibleParents[i].id === possibleChild.parent_id) {
+                found = true;
+                // delete possibleChild.parent_id;
+                if (possibleParents[i].children) {
+                    possibleParents[i].children.push(possibleChild);
+                } else {
+                    possibleParents[i].children = [possibleChild];
+                }
+                possibleParents[i].count = possibleParents[i].children.length;
+                possibleParents[i].children = this.sortPipe.transform(possibleParents[i].children, 'text');
+                possibleParents[i].children = possibleParents[i].children.map((info: any, index: number) => {
+                    return {
+                        ...info,
+                        last: possibleParents[i].children.length - 1 === index,
+                    };
+                });
+                return true;
+            } else if (possibleParents[i].children) {
+                found = this.findParent(possibleParents[i].children, possibleChild);
+            }
+        }
+        return found;
+    }
+
+    hasChild = (_: number, node: any) => !!node.children && node.children.length > 0;
+
+    selectNode(node: any) {
+        if (!node.state.disabled) {
+            if (this.searchMode) {
+                this.searchMode = false;
+                this.searchTerm.setValue('');
+            }
+
+            this.lastSelectedNodeIds = [];
+
+            if (this.holdShift) {
+                this.toggleNode(
+                    this.dataSource.data,
+                    {
+                        selected: !node.state.selected,
+                        opened: true
+                    },
+                    [node.id]
+                );
+            } else {
+                node.state.selected = !node.state.selected;
+                this.lastSelectedNodeIds = [node];
+            }
+
+            if (node.state.selected) {
+                this.afterSelectNode.emit(this.lastSelectedNodeIds);
+            } else {
+                this.afterDeselectNode.emit(this.lastSelectedNodeIds);
+            }
+        }
+    }
+
+    toggleNode(data, state, nodeIds) {
+        // traverse throuh each node
+        if (Array.isArray(data)) { // if data is an array
+            data.forEach((d) => {
+
+                if (nodeIds.indexOf(d.id) > -1 || (this.holdShift && nodeIds.indexOf(d.parent_id) > -1)) {
+                    Object.keys(state).forEach(key => {
+                        if (d.state.disabled && key === 'opened') {
+                            d.state[key] = state[key];
+                        } else if (!d.state.disabled) {
+                            d.state[key] = state[key];
+                            if (key === 'selected') {
+                                this.lastSelectedNodeIds.push(d);
+                            }
+                        }
+                    });
+                }
+                if (this.holdShift && nodeIds.indexOf(d.parent_id) > -1) {
+                    nodeIds.push(d.id);
+                }
+
+                this.toggleNode(d, state, nodeIds);
+
+            }); // call the function on each item
+        } else if (data instanceof Object) { // otherwise, if data is an object
+            (data.children || []).forEach((f) => {
+                if (nodeIds.indexOf(f.id) > -1 || (this.holdShift && nodeIds.indexOf(f.parent_id) > -1)) {
+                    Object.keys(state).forEach(key => {
+                        if (f.state.disabled && key === 'opened') {
+                            f.state[key] = state[key];
+                        } else if (!f.state.disabled) {
+                            f.state[key] = state[key];
+                            if (key === 'selected') {
+                                this.lastSelectedNodeIds.push(f);
+                            }
+                        }
+                    });
+                }
+                if (this.holdShift && nodeIds.indexOf(f.parent_id) > -1) {
+                    nodeIds.push(f.id);
+                }
+                this.toggleNode(f, state, nodeIds);
+
+            }); // and call function on each child
+        }
+    }
+
+    searchNode(data, term) {
+        this.searchMode = term !== '';
+        // traverse throuh each node
+        if (Array.isArray(data)) { // if data is an array
+            data.forEach((d) => {
+                d.state.opened = true;
+                if (this.latinisePipe.transform(d.text.toLowerCase()).indexOf(this.latinisePipe.transform(term)) > -1) {
+                    d.state.search = true;
+                } else if (term === '') {
+                    delete d.state.search;
+                } else {
+                    d.state.search = false;
+                }
+                this.searchNode(d, term);
+
+            }); // call the function on each item
+        } else if (data instanceof Object) { // otherwise, if data is an object
+            (data.children || []).forEach((f) => {
+                f.state.opened = true;
+                if (this.latinisePipe.transform(f.text.toLowerCase()).indexOf(this.latinisePipe.transform(term)) > -1) {
+                    f.state.search = true;
+                } else if (term === '') {
+                    delete f.state.search;
+                } else {
+                    f.state.search = false;
+                }
+                this.searchNode(f, term);
+
+            }); // and call function on each child
+        }
+    }
+
+    getSelectedNodes() {
+        return this.rawData.filter((data: any) => data.state.selected);
+    }
+}
diff --git a/src/lang/lang-en.json b/src/lang/lang-en.json
index 2a572c7b19a..976e618c999 100644
--- a/src/lang/lang-en.json
+++ b/src/lang/lang-en.json
@@ -1891,5 +1891,9 @@
     "emailSubject": "E-mail's subject",
     "authorizedRoutes": "Authorized routes",
     "infoImportusers": "The <b>id</b> column is used to identify a user",
-    "infoImportusers2": "The <b>id</b> and <b>user_id</b> columns are ignored when updating users"
+    "infoImportusers2": "The <b>id</b> and <b>user_id</b> columns are ignored when updating users",
+    "usersExport": "Export users",
+    "hotkeyInfo": "Hold <b>SHIFT</b> to propagate your selections to sub-elements",
+    "hotkeyMsg": "<b>SHIFT</b>&nbsp;Propagation to sub-elements",
+    "hotkeyTitle": "Hold the shift key"
 }
\ No newline at end of file
diff --git a/src/lang/lang-fr.json b/src/lang/lang-fr.json
index b1fec1ffb72..0cd6bdfb470 100644
--- a/src/lang/lang-fr.json
+++ b/src/lang/lang-fr.json
@@ -1886,5 +1886,8 @@
     "disableField": "Désactiver le champ",
     "emailSubject": "Objet du courriel",
     "infoImportusers": "La colonne <b>id</b> est utilisée pour identifier un utilisateur",
-    "infoImportusers2": "Les colonnes <b>id</b> et <b>user_id</b> ne sont pas prises en compte lors de mise à jour d'utilisateurs"
+    "infoImportusers2": "Les colonnes <b>id</b> et <b>user_id</b> ne sont pas prises en compte lors de mise à jour d'utilisateurs",
+    "hotkeyInfo": "Maintenez <b>SHIFT</b> pour propager vos selections aux sous-éléments",
+    "hotkeyMsg": "<b>SHIFT</b>&nbsp;Propagation aux sous-élements",
+    "hotkeyTitle": "Maintient de la touche shift"
 }
diff --git a/src/lang/lang-nl.json b/src/lang/lang-nl.json
index 118d1ed7569..da04095b780 100644
--- a/src/lang/lang-nl.json
+++ b/src/lang/lang-nl.json
@@ -1902,5 +1902,9 @@
     "enableField": "Activer le champ__TO_TRANSLATE",
     "disableField": "Désactiver le champ__TO_TRANSLATE",
     "infoImportusers": "La colonne <b>id</b> est utilisée pour identifier un utilisateur__TO_TRANSLATE",
-    "infoImportusers2": "Les colonnes <b>id</b> et <b>user_id</b> ne sont pas prises en compte lors de mise à jour d'utilisateurs__TO_TRANSLATE"
+    "infoImportusers2": "Les colonnes <b>id</b> et <b>user_id</b> ne sont pas prises en compte lors de mise à jour d'utilisateurs__TO_TRANSLATE",
+    "usersExport": "Exporter des utilisateurs__TO_TRANSLATE",
+    "hotkeyInfo": "Maintenez <b>SHIFT</b> pour propager vos selections aux sous-éléments__TO_TRANSLATE",
+    "hotkeyMsg": "<b>SHIFT</b>&nbsp;Propagation aux sous-élements__TO_TRANSLATE",
+    "hotkeyTitle": "Maintient de la touche shift__TO_TRANSLATE"
 }
\ No newline at end of file
-- 
GitLab