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> <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> {{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> {{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> 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> 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> Propagation aux sous-élements__TO_TRANSLATE", + "hotkeyTitle": "Maintient de la touche shift__TO_TRANSLATE" } \ No newline at end of file -- GitLab