Merge pull request #1516 from ramvr1256/holidayview

feat: Holiday office multiselect replaced with TreeView
This commit is contained in:
Adam Saghy
2022-09-01 13:35:01 +02:00
committed by GitHub
7 changed files with 323 additions and 22 deletions
@@ -0,0 +1,46 @@
import { BehaviorSubject } from 'rxjs';
import { Injectable } from '@angular/core';
import { OfficeItemNode } from './office-item.class';
@Injectable({
providedIn: 'root'
})
export class ChecklistDatabase {
// rxjs functionality to update DOM via subscribe
dataChange = new BehaviorSubject<OfficeItemNode[]>([]);
get data(): OfficeItemNode[] {
return this.dataChange.value;
}
TREE_DATA = {};
constructor() {
}
// called every time the data in route changes
initialize(trie: any) {
this.TREE_DATA = trie;
const data = this.buildFileTree(this.TREE_DATA, 0);
this.dataChange.next(data);
}
// builds hierarchical tree of officeItemNodes
buildFileTree(obj: { [key: string]: any }, level: number): OfficeItemNode[] {
return Object.keys(obj).reduce<OfficeItemNode[]>((accumulator, key) => {
const value = obj[key];
const node = new OfficeItemNode();
node.item = key;
if (value != null) {
if (typeof value === 'object') {
node.children = this.buildFileTree(value, level + 1);
} else {
node.item = value;
}
}
return accumulator.concat(node);
}, []);
}
}
@@ -63,18 +63,27 @@
<input matInput formControlName="description">
</mat-form-field>
<!-- TODO: Replace multi-select by custom angular material tree -->
<mat-form-field>
<mat-label>Applicable Offices</mat-label>
<mat-select required formControlName="offices" multiple>
<mat-option *ngFor="let office of officesData" [value]="{ officeId: office.id }">
{{ office.name }}
</mat-option>
</mat-select>
<mat-error *ngIf="holidayForm.controls.offices.hasError('required')">
Offices is <strong>required</strong>
</mat-error>
</mat-form-field>
<label>Select applicable offices</label>
<mat-tree [dataSource]="dataSource" [treeControl]="treeControl">
<mat-tree-node *matTreeNodeDef="let node" matTreeNodeToggle matTreeNodePadding>
<button mat-icon-button disabled></button>
<mat-checkbox class="checklist-leaf-node"
[checked]="checklistSelection.isSelected(node)"
(change)="officeLeafItemSelectionToggle(node);">{{officesDict[node.item].name}}</mat-checkbox>
</mat-tree-node>
<mat-tree-node *matTreeNodeDef="let node; when: hasChild" matTreeNodePadding>
<button type="button" mat-icon-button matTreeNodeToggle
[attr.aria-label]="'toggle ' + node.filename">
<mat-icon class="mat-icon-rtl-mirror">
{{treeControl.isExpanded(node) ? 'expand_more' : 'chevron_right'}}
</mat-icon>
</button>
<mat-checkbox [checked]="descendantsAllSelected(node)"
(change)="officeItemSelectionToggle(node)">{{ officesDict[node.item].name }}</mat-checkbox>
</mat-tree-node>
</mat-tree>
</div>
@@ -1,16 +1,22 @@
/** Angular Imports. */
import { Component, OnInit, ViewChild } from '@angular/core';
import { SelectionModel } from '@angular/cdk/collections';
import { Component, OnInit, ViewChild, Injectable } from '@angular/core';
import { FormBuilder, FormGroup, Validators, FormControl } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
import { MatTreeFlatDataSource, MatTreeFlattener } from '@angular/material/tree';
import { FlatTreeControl } from '@angular/cdk/tree';
/** Custom Services. */
import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
import { OfficeItemNode } from './office-item.class';
import { OfficeItemFlatNode } from './office-flat-item.class';
import { ChecklistDatabase } from './checklist-db.class';
import { CreateHoliday } from './create-holiday.service';
/**
* Create Holiday component.
* TODO: Develop a custom angular checkbox tree and replace offices select.
*/
@Component({
selector: 'mifosx-create-holiday',
@@ -29,6 +35,35 @@ export class CreateHolidayComponent implements OnInit {
minDate = new Date(2000, 0, 1);
/** Maximum Date allowed. */
maxDate = new Date(2100, 0, 1);
// Stores office data in a trie-like structure
officesTrie: any;
// Stores office data for access from DOM via Office ID
officesDict = {};
// Angular Material Tree Configuration start ------------
/** Map from flat node to nested node. This helps us finding the nested node to be modified */
flatNodeMap = new Map<OfficeItemFlatNode, OfficeItemNode>();
/** Map from nested node to flattened node. This helps us to keep the same object for selection */
nestedNodeMap = new Map<OfficeItemNode, OfficeItemFlatNode>();
/** A selected parent node to be inserted */
selectedParent: OfficeItemFlatNode | null = null;
/** The new item's name */
newItemName = '';
treeControl: FlatTreeControl<OfficeItemFlatNode>;
treeFlattener: MatTreeFlattener<OfficeItemNode, OfficeItemFlatNode>;
dataSource: MatTreeFlatDataSource<OfficeItemNode, OfficeItemFlatNode>;
/** The selection for checklist */
checklistSelection = new SelectionModel<OfficeItemFlatNode>(true /* multiple */);
// Angular Material Tree Configuration end ------------
/**
* Get offices and holiday template from `Resolver`.
@@ -43,12 +78,173 @@ export class CreateHolidayComponent implements OnInit {
private dateUtils: Dates,
private organizationService: OrganizationService,
private settings: SettingsService,
private router: Router ) {
private router: Router,
private _database: ChecklistDatabase,
private createHoliday: CreateHoliday ) {
this.route.data.subscribe((data: { offices: any, holidayTemplate: any }) => {
this.officesData = data.offices;
this.repaymentSchedulingTypes = data.holidayTemplate;
// Constructs trie everytime data changes
this.constructOfficeHierarchy();
// Updates data in the CheckListDatabase
_database.initialize(this.officesTrie);
});
this.treeFlattener = new MatTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren);
this.treeControl = new FlatTreeControl<OfficeItemFlatNode>(this.getLevel, this.isExpandable);
this.dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener);
// Listens for changes in CheckListDatabase
this._database.dataChange.subscribe((data) => {
this.dataSource.data = data;
});
}
constructOfficeHierarchy() {
const trie = {};
// Iterates over all the offices
this.officesData.forEach((office: any) => {
this.officesDict[office.id] = office;
let hierarchy = office.hierarchy.split('.');
// Removes empty strings
hierarchy = hierarchy.filter((stage: any) => {
return stage.length > 0;
});
// Temporary variable to store current object
let root = trie;
if (hierarchy.length === 0) {
root[office.id] = {};
} else {
// Sets root as the first object
root = trie[Object.keys(trie)[0]];
// Construction of hierarchy via Depth First Search
hierarchy.forEach((stage: string) => {
if (!(stage in root)) {
root[stage] = {};
}
root = root[stage];
});
}
});
// Sets leaf offices to null as per the requirement of Angular Material
this.createHoliday.setEmptyObjectsToNull(trie);
this.officesTrie = trie;
}
// angular material tree config start ----------------------
getLevel = (node: OfficeItemFlatNode) => node.level;
isExpandable = (node: OfficeItemFlatNode) => node.expandable;
getChildren = (node: OfficeItemNode): OfficeItemNode[] => node.children;
hasChild = (_: number, _nodeData: OfficeItemFlatNode) => _nodeData.expandable;
hasNoContent = (_: number, _nodeData: OfficeItemFlatNode) => _nodeData.item === '';
/**
* Transformer to convert nested node to flat node. Record the nodes in maps for later use.
*/
transformer = (node: OfficeItemNode, level: number) => {
const existingNode = this.nestedNodeMap.get(node);
const flatNode = existingNode && existingNode.item === node.item ? existingNode : new OfficeItemFlatNode();
flatNode.item = node.item;
flatNode.level = level;
flatNode.expandable = !!node.children?.length;
this.flatNodeMap.set(flatNode, node);
this.nestedNodeMap.set(node, flatNode);
return flatNode;
}
/** Whether all the descendants of the node are selected. */
descendantsAllSelected(node: OfficeItemFlatNode): boolean {
return this.checklistSelection.isSelected(node);
}
/** Whether part of the descendants are selected */
descendantsPartiallySelected(node: OfficeItemFlatNode): boolean {
const descendants = this.treeControl.getDescendants(node);
const result = descendants.some((child) => this.checklistSelection.isSelected(child));
return result && !this.descendantsAllSelected(node);
}
setSelectedOffices() {
this.holidayForm.patchValue({
offices: this.checklistSelection.selected.map((item) => item.item),
});
}
/** Toggle the to-do item selection. Select/deselect all the descendants node */
officeItemSelectionToggle(node: OfficeItemFlatNode): void {
this.checklistSelection.toggle(node);
const descendants = this.treeControl.getDescendants(node);
this.checklistSelection.isSelected(node)
? this.checklistSelection.select(...descendants)
: this.checklistSelection.deselect(...descendants);
// Force update for the parent
descendants.forEach((child) => this.checklistSelection.isSelected(child));
this.setSelectedOffices();
}
/** Toggle a leaf to-do item selection. Check all the parents to see if they changed */
officeLeafItemSelectionToggle(node: OfficeItemFlatNode): void {
this.checklistSelection.toggle(node);
this.setSelectedOffices();
}
/* Checks all the parents when a leaf node is selected/unselected */
checkAllParentsSelection(node: OfficeItemFlatNode): void {
let parent: OfficeItemFlatNode | null = this.getParentNode(node);
while (parent) {
this.checkRootNodeSelection(parent);
parent = this.getParentNode(parent);
}
}
/** Check root node checked state and change it accordingly */
checkRootNodeSelection(node: OfficeItemFlatNode): void {
const nodeSelected = this.checklistSelection.isSelected(node);
const descendants = this.treeControl.getDescendants(node);
const descAllSelected =
descendants.length > 0 &&
descendants.every((child) => {
return this.checklistSelection.isSelected(child);
});
if (nodeSelected && !descAllSelected) {
this.checklistSelection.deselect(node);
} else if (!nodeSelected && descAllSelected) {
this.checklistSelection.select(node);
}
}
/* Get the parent node of a node */
getParentNode(node: OfficeItemFlatNode): OfficeItemFlatNode | null {
const currentLevel = this.getLevel(node);
if (currentLevel < 1) {
return null;
}
const startIndex = this.treeControl.dataNodes.indexOf(node) - 1;
for (let i = startIndex; i >= 0; i--) {
const currentNode = this.treeControl.dataNodes[i];
if (this.getLevel(currentNode) < currentLevel) {
return currentNode;
}
}
return null;
}
// angular material tree config end ----------------------
ngOnInit() {
this.setHolidayForm();
@@ -91,20 +287,20 @@ export class CreateHolidayComponent implements OnInit {
const locale = this.settings.language.code;
const prevFromDate: Date = this.holidayForm.value.fromDate;
const prevToDate: Date = this.holidayForm.value.toDate;
if (holidayFormData.closureDate instanceof Date) {
holidayFormData.fromDate = this.dateUtils.formatDate(prevFromDate, dateFormat);
}
if (holidayFormData.closureDate instanceof Date) {
holidayFormData.toDate = this.dateUtils.formatDate(prevToDate, dateFormat);
}
holidayFormData.fromDate = this.dateUtils.formatDate(prevFromDate, dateFormat);
holidayFormData.toDate = this.dateUtils.formatDate(prevToDate, dateFormat);
if (this.holidayForm.contains('repaymentsRescheduledTo')) {
const prevRepaymentsRescheduledTo: Date = this.holidayForm.value.repaymentsRescheduledTo;
holidayFormData.repaymentsRescheduledTo = this.dateUtils.formatDate(prevRepaymentsRescheduledTo, dateFormat);
}
const offices = this.holidayForm.value.offices.map((office: string) => {
return {'officeId': Number.parseInt(office, 10)};
});
const data = {
...holidayFormData,
dateFormat,
locale
locale,
offices
};
this.organizationService.createHoliday(data).subscribe((response: any) => {
this.router.navigate(['../', response.resourceId], { relativeTo: this.route });
@@ -0,0 +1,17 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class CreateHoliday {
setEmptyObjectsToNull(root: any) {
Object.keys(root).forEach((key) => {
if (Object.keys(root[key]).length === 0) {
root[key] = null;
} else {
this.setEmptyObjectsToNull(root[key]);
}
});
}
}
@@ -0,0 +1,10 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class OfficeItemFlatNode {
item: string;
level: number;
expandable: boolean;
}
@@ -0,0 +1,9 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class OfficeItemNode {
children: OfficeItemNode[];
item: string;
}
@@ -70,6 +70,14 @@ import { StandingInstructionsHistoryComponent } from './standing-instructions-hi
import { FundMappingComponent } from './fund-mapping/fund-mapping.component';
import { CreateHolidayComponent } from './holidays/create-holiday/create-holiday.component';
/** Custom Service */
import { OfficeItemNode } from './holidays/create-holiday/office-item.class';
import { OfficeItemFlatNode } from './holidays/create-holiday/office-flat-item.class';
import { ChecklistDatabase } from './holidays/create-holiday/checklist-db.class';
import { CreateHoliday } from './holidays/create-holiday/create-holiday.service';
/**
* Organization Module
*
@@ -144,6 +152,12 @@ import { CreateHolidayComponent } from './holidays/create-holiday/create-holiday
StandingInstructionsHistoryComponent,
FundMappingComponent,
CreateHolidayComponent,
],
providers: [
CreateHoliday,
ChecklistDatabase,
OfficeItemFlatNode,
OfficeItemNode
]
})
export class OrganizationModule { }