diff --git a/apps/mina-sidor-fa/src/app/pages/administration/components/employee-delete/employee-delete.component.spec.ts b/apps/mina-sidor-fa/src/app/pages/administration/components/employee-delete/employee-delete.component.spec.ts index 4bd9592..3ebf718 100644 --- a/apps/mina-sidor-fa/src/app/pages/administration/components/employee-delete/employee-delete.component.spec.ts +++ b/apps/mina-sidor-fa/src/app/pages/administration/components/employee-delete/employee-delete.component.spec.ts @@ -1,3 +1,4 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; @@ -11,7 +12,7 @@ describe('EmployeeDeleteComponent', () => { await TestBed.configureTestingModule({ schemas: [CUSTOM_ELEMENTS_SCHEMA], declarations: [EmployeeDeleteComponent], - imports: [RouterTestingModule], + imports: [RouterTestingModule, HttpClientTestingModule], }).compileComponents(); fixture = TestBed.createComponent(EmployeeDeleteComponent); diff --git a/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/edit-employee-form/edit-employee-form.component.html b/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/edit-employee-form/edit-employee-form.component.html index 3d644e2..d7cde11 100644 --- a/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/edit-employee-form/edit-employee-form.component.html +++ b/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/edit-employee-form/edit-employee-form.component.html @@ -16,54 +16,50 @@ [afInvalid]="emailFormControl.invalid && emailFormControl.touched" > -
+
Tjänster

Välj de tjänster du vill ge personalen tillgång till.

-
- - - - -
+ + Du måste välja minst en tjänst + +
-
+
Utförande verksamheter och adresser

Välj de utförandeverksamheter och utförande adresser du vill ge personalen behörighet till.

+

+ Du måste välja en eller flera tjänster för att kunna välja utförande verksamheter. +

- + (afOnChange)="toggleAllUtforandeVerksamheter($event)" + > +
-
- Plats för digi-select-form-item för utförande verksamheter - -
+
diff --git a/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/edit-employee-form/edit-employee-form.component.spec.ts b/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/edit-employee-form/edit-employee-form.component.spec.ts index 41329c0..97d1ebe 100644 --- a/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/edit-employee-form/edit-employee-form.component.spec.ts +++ b/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/edit-employee-form/edit-employee-form.component.spec.ts @@ -4,9 +4,11 @@ import { DigiNgFormInputModule } from '@af/digi-ng/_form/form-input'; import { DigiNgFormRadiobuttonGroupModule } from '@af/digi-ng/_form/form-radiobutton-group'; import { DigiNgFormSelectModule } from '@af/digi-ng/_form/form-select'; import { DigiNgPopoverModule } from '@af/digi-ng/_popover/popover'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; +import { TreeNodesSelectorModule } from '@msfa-shared/components/tree-nodes-selector/tree-nodes-selector.module'; import { EditEmployeeFormComponent } from './edit-employee-form.component'; @@ -25,6 +27,8 @@ describe('EditEmployeeFormComponent', () => { DigiNgFormSelectModule, DigiNgPopoverModule, DigiNgFormCheckboxModule, + HttpClientTestingModule, + TreeNodesSelectorModule, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents(); diff --git a/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/edit-employee-form/edit-employee-form.component.ts b/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/edit-employee-form/edit-employee-form.component.ts index 2352fe9..b10e637 100644 --- a/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/edit-employee-form/edit-employee-form.component.ts +++ b/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/edit-employee-form/edit-employee-form.component.ts @@ -1,5 +1,4 @@ import { ButtonSize, ButtonType, ButtonVariation } from '@af/digi-ng/_button/button'; -import { FormSelectItem } from '@af/digi-ng/_form/form-select'; import { Component, OnInit, @@ -12,14 +11,24 @@ import { } from '@angular/core'; import { AbstractControl, FormControl, FormGroup, Validators } from '@angular/forms'; import { Role } from '@msfa-models/role.model'; -import { RoleEnum } from '@msfa-enums/role.enum'; import { Tjanst } from '@msfa-models/tjanst.model'; -import { FormTagData } from '@msfa-models/form-tag.model'; +import { FormSelectItem } from '@af/digi-ng/_form/form-select'; +import { TreeNodeValidator } from '@msfa-utils/validators/tree-node.validator'; +import { + UtforandeVerksamhet, + UtforandeVerksamheterService, +} from '@msfa-services/utforande-verksamheter/utforande-verksamheter.service'; +import { + TreeNode, + TreeNodesSelectorService, +} from '@msfa-shared/components/tree-nodes-selector/services/tree-nodes-selector.service'; +import { EmployeeFormService } from '../services/employee-form.service'; export interface EditEmployeeFormData { email: string; - tjanster: FormTagData[], - roles: Array; + tjanster: Tjanst[]; + roles: Role[]; + utforandeVerksamheter: UtforandeVerksamhet[]; } @Component({ @@ -29,14 +38,14 @@ export interface EditEmployeeFormData { changeDetection: ChangeDetectionStrategy.OnPush, }) export class EditEmployeeFormComponent implements OnInit, OnChanges { - @Input() currentEmail: string | null = null; - @Input() availableRoles: Array | null = null; - @Input() currentEmployeeRoles: Array | null = null; - - @Input() availableTjanster: Array | null = null; - @Input() currentEmployeeTjanster: Array | null = null; - selectedTjanster: Array | null = null; + @Input() currentEmail: string; + @Input() availableRoles: Role[]; + @Input() currentEmployeeRoles: string[]; + @Input() availableTjanster: Tjanst[]; + @Input() currentEmployeeTjanster: Tjanst[]; + @Input() availableUtforandeVerksamheter: UtforandeVerksamhet[]; + @Output() tjansterSelected = new EventEmitter(); @Output() formSubmitted = new EventEmitter(); readonly ButtonVariation = ButtonVariation; @@ -45,28 +54,55 @@ export class EditEmployeeFormComponent implements OnInit, OnChanges { readonly emailFormControlName = 'email'; readonly tjansterFormControlName = 'tjanster'; + readonly utforandeVerksamhetFormControlName = 'utforandeVerksamheter'; + readonly toggleAllUtforandeVerksamhetFormControlName = 'toggleAllUtforandeVerksamhet'; editEmployeeFormGroup: FormGroup | null = null; rolesFormGroup: FormGroup | null = null; displayRolesDialog = false; - constructor() {} + selectableTjansterFormItems: Array | null = null; + + constructor( + private employeeFormService: EmployeeFormService, + private utforandeVerksamheterService: UtforandeVerksamheterService, + private treeNodesSelectorService: TreeNodesSelectorService + ) {} ngOnInit(): void { this.initializeEditEmployeeFormGroup(); - - if(this.currentEmployeeTjanster) { - this.selectedTjanster = this.currentEmployeeTjanster - .map(tjanst => ({tjanstekod: tjanst.code, name: tjanst.name} as FormTagData)); - } + this.updateSelectableTjansterFormItems(); } ngOnChanges(changes: SimpleChanges): void { - if (changes.availableRoles || changes.availableTjanster) { + if (changes.availableRoles) { this.initializeEditEmployeeFormGroup(); } + if (changes.availableTjanster) { + this.updateSelectableTjansterFormItems(); + } + + if (changes.availableUtforandeVerksamheter) { + this.editEmployeeFormGroup.patchValue( + Object.fromEntries([ + [ + this.utforandeVerksamhetFormControlName, + this.utforandeVerksamheterService.getTreeNodeDataFromUtforandeVerksamheter( + this.availableUtforandeVerksamheter + ), + ], + ]) + ); + + this.editEmployeeFormGroup.patchValue( + Object.fromEntries([[this.toggleAllUtforandeVerksamhetFormControlName, false]]) + ); + + this.updateUtforandeVerksamhetStatus(); + } + if (changes.currentEmployeeRoles) { this.updateRolesFormGroup(); } @@ -75,19 +111,88 @@ export class EditEmployeeFormComponent implements OnInit, OnChanges { this.editEmployeeFormGroup.patchValue(Object.fromEntries([[this.emailFormControlName, this.currentEmail]])); } - if(changes.currentEmployeeTjanster) { + if (changes.currentEmployeeTjanster) { this.editEmployeeFormGroup.patchValue(Object.fromEntries([[this.tjansterFormControlName, '']])); } } - get emailFormControl(): AbstractControl | null { + get emailFormControl(): AbstractControl | undefined { return this.editEmployeeFormGroup?.get(this.emailFormControlName); } - get tjansterFormControl(): AbstractControl | null { + get tjansterFormControl(): AbstractControl | undefined { return this.editEmployeeFormGroup?.get(this.tjansterFormControlName); } + get utforandeVerksamhetFormControl(): AbstractControl | undefined { + return this.editEmployeeFormGroup?.get(this.utforandeVerksamhetFormControlName); + } + + get toggleAllUtforandeVerksamhetFormControl(): AbstractControl | undefined { + return this.editEmployeeFormGroup?.get(this.toggleAllUtforandeVerksamhetFormControlName); + } + + private updateUtforandeVerksamhetStatus(): void { + if (this.availableUtforandeVerksamheter && this.availableUtforandeVerksamheter.length > 0) { + this.utforandeVerksamhetFormControl.enable(); + this.toggleAllUtforandeVerksamhetFormControl.enable(); + return; + } + + this.utforandeVerksamhetFormControl.disable(); + this.toggleAllUtforandeVerksamhetFormControl.disable(); + } + + private updateSelectableTjansterFormItems(): void { + this.selectableTjansterFormItems = this.availableTjanster?.map(tjanst => { + return { name: tjanst.name, value: `${tjanst.tjanstId}` }; + }); + } + + private initializeEditEmployeeFormGroup(): void { + this.rolesFormGroup = this.employeeFormService.getRolesFormGroup(this.availableRoles, this.currentEmployeeRoles); + + this.editEmployeeFormGroup = new FormGroup({ + // eslint-disable-next-line + email: new FormControl(this.currentEmail, [Validators.required, Validators.email]), + // eslint-disable-next-line + tjanster: new FormControl('', [Validators.required]), + roles: this.rolesFormGroup, + utforandeVerksamheter: new FormControl( + this.utforandeVerksamheterService.getTreeNodeDataFromUtforandeVerksamheter(this.availableUtforandeVerksamheter), + [ + // eslint-disable-next-line + TreeNodeValidator.IsValidTreeNode( + this.utforandeVerksamheterService.hasSelectedUtforandeVerksamhet, + 'required' + ), + ] + ), + toggleAllUtforandeVerksamhet: new FormControl(false, []), + }); + + this.updateUtforandeVerksamhetStatus(); + } + + private updateRolesFormGroup(): void { + if (!this.rolesFormGroup || !this.availableRoles) { + return; + } + + this.rolesFormGroup.patchValue( + Object.fromEntries( + this.availableRoles.map(role => [ + this.employeeFormService.getFormControlName(role), + this.employeeFormService.isSelectedRole(role, this.currentEmployeeRoles), + ]) + ) + ); + } + + getFormControlName(role: Role): string { + return this.employeeFormService.getFormControlName(role); + } + onFormSubmitted(): void { if (!this.editEmployeeFormGroup) { return; @@ -98,14 +203,22 @@ export class EditEmployeeFormComponent implements OnInit, OnChanges { } this.formSubmitted.emit({ - email: this.emailFormControl?.value, - tjanster: this.selectedTjanster, - roles: this.getRolesFromFormGroup(this.rolesFormGroup, this.availableRoles), + email: this.emailFormControl?.value as string, + tjanster: this.employeeFormService.getSelectedTjanster( + this.availableTjanster, + parseInt(this.tjansterFormControl?.value, 10) + ), + roles: this.employeeFormService.getRolesFromFormGroup(this.rolesFormGroup, this.availableRoles), + utforandeVerksamheter: this.utforandeVerksamheterService.getSelectedUtforandeVerksamheterFromTreeNode( + this.utforandeVerksamhetFormControl?.value + ), }); } - getFormControlName(role: Role): string { - return RoleEnum[role?.type]; + toggleTjanst(): void { + this.tjansterSelected.emit( + this.employeeFormService.getSelectedTjanster(this.availableTjanster, parseInt(this.tjansterFormControl.value, 10)) + ); } openRolesDialog(): void { @@ -116,99 +229,23 @@ export class EditEmployeeFormComponent implements OnInit, OnChanges { this.displayRolesDialog = false; } - private initializeEditEmployeeFormGroup(): void { - this.rolesFormGroup = this.getRolesFormGroup( - this.availableRoles, - this.currentEmployeeRoles + toggleAllUtforandeVerksamheter(selectAll: boolean): void { + let treeNode: TreeNode = this.utforandeVerksamhetFormControl.value as TreeNode; + + treeNode = selectAll + ? this.treeNodesSelectorService.getTreeNodeWithAllNodesSelected(treeNode) + : this.treeNodesSelectorService.getTreeNodeWithNoNodesSelected(treeNode); + + this.editEmployeeFormGroup.patchValue(Object.fromEntries([[this.utforandeVerksamhetFormControlName, treeNode]])); + } + + updateToggleAllUtforandeVerksamheter(): void { + const hasSelectedAllLeafNodes = this.treeNodesSelectorService.hasSelectedAllLeafNodes( + this.utforandeVerksamhetFormControl.value ); - this.editEmployeeFormGroup = new FormGroup({ - email: new FormControl(this.currentEmail, [Validators.required, Validators.email]), - tjanster: new FormControl('', []), - roles: this.rolesFormGroup, - }); - } - - private updateRolesFormGroup(): void { - if (!this.rolesFormGroup || !this.availableRoles) { - return; - } - - this.rolesFormGroup.patchValue( - Object.fromEntries( - this.availableRoles.map(role => [ - this.getFormControlName(role), - this.isSelectedRole(role, this.currentEmployeeRoles), - ]) - ) + this.editEmployeeFormGroup.patchValue( + Object.fromEntries([[this.toggleAllUtforandeVerksamhetFormControlName, hasSelectedAllLeafNodes]]) ); } - - private getRolesFromFormGroup( - formGroup: FormGroup | null, - roles: Array | null - ): Array { - if (!formGroup || !roles) { - return; - } - - return roles.filter( - role => formGroup.get(this.getFormControlName(role))?.value === true - ); - } - - private getRolesFormGroup( - roles: Array | null, - selectedRoles: Array | null - ): FormGroup { - if (!roles) { - return new FormGroup({}); - } - - return new FormGroup( - Object.fromEntries( - roles.map(role => [ - this.getFormControlName(role), - new FormControl(this.isSelectedRole(role, selectedRoles), []), - ]) - ) - ); - } - - private isSelectedRole( - role: Role, - selectedRoles: Array | null - ): boolean { - if (!selectedRoles || !role) { - return false; - } - - return selectedRoles.some(selectedRole => selectedRole === role.type); - } - - // Tjanster helper methods - toggleTjanst(): void { - const tjanstExistsInList: boolean = this.selectedTjanster - .some(tag => tag.tjanstekod === this.tjansterFormControl.value); - - const selectedTjanst: FormTagData[] = this.availableTjanster - .filter(tjanst => tjanst.value === this.tjansterFormControl.value) - .map(tjanst => ({tjanstekod: tjanst.value, name: tjanst.name})); - if(this.tjansterFormControl.value && !tjanstExistsInList) { - this.selectedTjanster.push(...selectedTjanst); - } - } - - - unselectTjanstTag(tjanst: FormTagData): void { - const tagExistsInList = this.selectedTjanster.some(tag => tag.tjanstekod === tjanst.tjanstekod); - if(tjanst.tjanstekod && tagExistsInList) { - this.selectedTjanster.splice(this.selectedTjanster.indexOf(tjanst), 1); - } - this.tjansterFormControl.setValue(''); -} - - selectAllUtforandeVerksamheter(checked: boolean):void { - console.log('selectAllUtforandeVerksamheter', checked); - } } diff --git a/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/employee-form.component.html b/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/employee-form.component.html index e9d2596..a1c3602 100644 --- a/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/employee-form.component.html +++ b/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/employee-form.component.html @@ -41,14 +41,18 @@ - + + + diff --git a/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/employee-form.component.ts b/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/employee-form.component.ts index 48202ec..ea14ec8 100644 --- a/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/employee-form.component.ts +++ b/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/employee-form.component.ts @@ -1,13 +1,16 @@ -import { FormSelectItem } from '@af/digi-ng/_form/form-select'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { UnsubscribeDirective } from '@msfa-directives/unsubscribe.directive'; import { Employee } from '@msfa-models/employee.model'; import { mapRoleResponseToRoleObject, Role } from '@msfa-models/role.model'; import { Tjanst } from '@msfa-models/tjanst.model'; import { EmployeeService } from '@msfa-services/api/employee.service'; import { TjanstService } from '@msfa-services/api/tjanst.service'; +import { + UtforandeVerksamhet, + UtforandeVerksamheterService, +} from '@msfa-services/utforande-verksamheter/utforande-verksamheter.service'; import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; import { EditEmployeeFormData } from './edit-employee-form/edit-employee-form.component'; @Component({ @@ -16,48 +19,42 @@ import { EditEmployeeFormData } from './edit-employee-form/edit-employee-form.co styleUrls: ['./employee-form.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class EmployeeFormComponent extends UnsubscribeDirective implements OnInit { - subscriptionsList = []; +export class EmployeeFormComponent implements OnInit { employee$ = this.employeeService.employee$; - employee: Employee; - tjanster$: Observable = this.tjanstService.tjanster$; - availableTjanster: FormSelectItem[] | null = null; + currentEmployeeRoles$: Observable = null; + availableUtforandeVerksamheter$: Observable> | null = null; selectableRoles: Role[] = this.employeeService.allRoles; - currentEmployeeRoles: Role[] | undefined | null = null; constructor( private employeeService: EmployeeService, private tjanstService: TjanstService, + private utforandeVerksamheterService: UtforandeVerksamheterService, private activatedRoute: ActivatedRoute - ) { - super(); - super.unsubscribeOnDestroy(...this.subscriptionsList); - } + ) {} ngOnInit(): void { this.employeeService.setCurrentEmployeeId(this.activatedRoute.snapshot.params['employeeId']); - const employeeDataSub = this.employee$.subscribe(employee => { - this.employee = employee; - this.currentEmployeeRoles = employee?.roles.map(role => mapRoleResponseToRoleObject(role)); - }); - const tjanstRelatedDataSub = this.tjanster$.subscribe(tjanster => { - const tjanstOptions: FormSelectItem[] = []; - tjanster?.forEach(tjanst => { - tjanstOptions.push({ name: tjanst?.name, value: tjanst?.code }); - }); - this.availableTjanster = tjanstOptions; - }); - - this.subscriptionsList.push(employeeDataSub, tjanstRelatedDataSub); + this.currentEmployeeRoles$ = this.employee$.pipe( + map(employee => employee?.roles?.map(role => mapRoleResponseToRoleObject(role))) + ); } updateEmployee(editEmployeeFormData: EditEmployeeFormData): void { console.log(editEmployeeFormData); } + setupAvailableUtforandeVerksamheter(selectedTjanster: Array): void { + if (!selectedTjanster) { + return; + } + + this.availableUtforandeVerksamheter$ = this.utforandeVerksamheterService.getUtforandeVerksamheter( + selectedTjanster.map(tjanst => tjanst.tjanstId) + ); + } setEmployeeToDelete(employee: Employee): void { this.employeeService.setEmployeeToDelete(employee); } diff --git a/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/employee-form.module.ts b/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/employee-form.module.ts index 9c5fb48..33d0000 100644 --- a/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/employee-form.module.ts +++ b/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/employee-form.module.ts @@ -9,12 +9,13 @@ import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; -import { HideTextModule } from '@msfa-shared/components/hide-text/hide-text.module'; import { LayoutModule } from '@msfa-shared/components/layout/layout.module'; import { LocalDatePipeModule } from '@msfa-shared/pipes/local-date/local-date.module'; import { EmployeeDeleteModule } from '../../components/employee-delete/employee-delete.module'; import { EditEmployeeFormComponent } from './edit-employee-form/edit-employee-form.component'; +import { HideTextModule } from '@msfa-shared/components/hide-text/hide-text.module'; import { EmployeeFormComponent } from './employee-form.component'; +import { TreeNodesSelectorModule } from '@msfa-shared/components/tree-nodes-selector/tree-nodes-selector.module'; @NgModule({ schemas: [CUSTOM_ELEMENTS_SCHEMA], @@ -34,6 +35,7 @@ import { EmployeeFormComponent } from './employee-form.component'; LayoutModule, EmployeeDeleteModule, HideTextModule, + TreeNodesSelectorModule, ], }) export class EmployeeFormModule {} diff --git a/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/services/employee-form.service.spec.ts b/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/services/employee-form.service.spec.ts new file mode 100644 index 0000000..3a01023 --- /dev/null +++ b/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/services/employee-form.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { EmployeeFormService } from './employee-form.service'; + +describe('EmployeeFormService', () => { + let service: EmployeeFormService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EmployeeFormService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/services/employee-form.service.ts b/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/services/employee-form.service.ts new file mode 100644 index 0000000..92e3533 --- /dev/null +++ b/apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/services/employee-form.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; +import { RoleEnum } from '@msfa-enums/role.enum'; +import { Role } from '@msfa-models/role.model'; +import { Tjanst } from '@msfa-models/tjanst.model'; + +@Injectable({ + providedIn: 'root', +}) +export class EmployeeFormService { + isSelectedRole(role: Role, selectedRoles: Array | null): boolean { + if (!selectedRoles || !role) { + return false; + } + + return selectedRoles.some(selectedRole => selectedRole === role.type); + } + + getSelectedTjanster(tjanster: Array, tjanstId: number): Array { + let selectedTjanst: Tjanst | null = null; + + if (!tjanster) { + return []; + } + + selectedTjanst = tjanster.find(tjanst => tjanst.tjanstId === tjanstId); + + return selectedTjanst ? [selectedTjanst] : []; + } + + getRolesFromFormGroup(formGroup: FormGroup | null, roles: Array | null): Array { + if (!formGroup || !roles) { + return; + } + + return roles.filter(role => formGroup.get(this.getFormControlName(role))?.value === true); + } + + getRolesFormGroup(roles: Array | null, selectedRoles: Array | null): FormGroup { + if (!roles) { + return new FormGroup({}); + } + + return new FormGroup( + Object.fromEntries( + roles.map(role => [ + this.getFormControlName(role), + new FormControl(this.isSelectedRole(role, selectedRoles), []), + ]) + ) + ); + } + + getFormControlName(role: Role): string { + return RoleEnum[role?.type]; + } +} diff --git a/apps/mina-sidor-fa/src/app/pages/administration/pages/employees/components/employees-list/employees-list.component.spec.ts b/apps/mina-sidor-fa/src/app/pages/administration/pages/employees/components/employees-list/employees-list.component.spec.ts index 132c2f2..20e2e48 100644 --- a/apps/mina-sidor-fa/src/app/pages/administration/pages/employees/components/employees-list/employees-list.component.spec.ts +++ b/apps/mina-sidor-fa/src/app/pages/administration/pages/employees/components/employees-list/employees-list.component.spec.ts @@ -1,4 +1,5 @@ import { DigiNgSkeletonBaseModule } from '@af/digi-ng/_skeleton/skeleton-base'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; @@ -18,7 +19,7 @@ describe('EmployeesListComponent', () => { await TestBed.configureTestingModule({ schemas: [CUSTOM_ELEMENTS_SCHEMA], declarations: [EmployeesListComponent, LayoutComponent], - imports: [RouterTestingModule, DigiNgSkeletonBaseModule], + imports: [RouterTestingModule, DigiNgSkeletonBaseModule, HttpClientTestingModule], }).compileComponents(); fixture = TestBed.createComponent(EmployeesListComponent); diff --git a/apps/mina-sidor-fa/src/app/pages/administration/pages/employees/components/employees-list/employees-list.mock.ts b/apps/mina-sidor-fa/src/app/pages/administration/pages/employees/components/employees-list/employees-list.mock.ts index 4eb2b6d..a1ec183 100644 --- a/apps/mina-sidor-fa/src/app/pages/administration/pages/employees/components/employees-list/employees-list.mock.ts +++ b/apps/mina-sidor-fa/src/app/pages/administration/pages/employees/components/employees-list/employees-list.mock.ts @@ -1,4 +1,3 @@ -import { Service } from '@msfa-enums/service.enum'; import { EmployeeCompact } from '@msfa-models/employee.model'; export const employeesMock: EmployeeCompact[] = [ @@ -7,5 +6,6 @@ export const employeesMock: EmployeeCompact[] = [ fullName: 'Jayson Karlsson', tjanster: ['KROM'], utforandeVerksamheter: [], + allaUtforandeVerksamheter: true, }, ]; diff --git a/apps/mina-sidor-fa/src/app/pages/avrop/components/avrop-filters/avrop-filters.component.spec.ts b/apps/mina-sidor-fa/src/app/pages/avrop/components/avrop-filters/avrop-filters.component.spec.ts index 1786ac6..929f5e3 100644 --- a/apps/mina-sidor-fa/src/app/pages/avrop/components/avrop-filters/avrop-filters.component.spec.ts +++ b/apps/mina-sidor-fa/src/app/pages/avrop/components/avrop-filters/avrop-filters.component.spec.ts @@ -2,10 +2,10 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { ComponentFixture, discardPeriodicTasks, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; +import { AvropService } from '@msfa-services/avrop.service'; import { of } from 'rxjs'; -import { AvropService } from '../../avrop.service'; +import { TemporaryFilterComponent } from '../temporary-filter/temporary-filter.component'; import { AvropFiltersComponent } from './avrop-filters.component'; -import { TemporaryFilterComponent } from './temporary-filter/temporary-filter.component'; describe('AvropFiltersComponent', () => { let component: AvropFiltersComponent; diff --git a/apps/mina-sidor-fa/src/app/pages/avrop/components/avrop-list/avrop-list.component.spec.ts b/apps/mina-sidor-fa/src/app/pages/avrop/components/avrop-list/avrop-list.component.spec.ts index 906184b..aa9f084 100644 --- a/apps/mina-sidor-fa/src/app/pages/avrop/components/avrop-list/avrop-list.component.spec.ts +++ b/apps/mina-sidor-fa/src/app/pages/avrop/components/avrop-list/avrop-list.component.spec.ts @@ -1,3 +1,4 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AvropListComponent } from './avrop-list.component'; @@ -8,6 +9,7 @@ describe('AvropListComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [AvropListComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents(); }); diff --git a/apps/mina-sidor-fa/src/app/pages/avrop/components/avrop-row/avrop-row.component.html b/apps/mina-sidor-fa/src/app/pages/avrop/components/avrop-row/avrop-row.component.html index 67b58b8..12ddc83 100644 --- a/apps/mina-sidor-fa/src/app/pages/avrop/components/avrop-row/avrop-row.component.html +++ b/apps/mina-sidor-fa/src/app/pages/avrop/components/avrop-row/avrop-row.component.html @@ -12,42 +12,45 @@
Namn:
-
{{avrop.fullName}}
+
{{avrop?.fullName}}
Tjänst:
-
{{avrop.tjanst}}
+
{{avrop?.tjanst}}
Startdatum:
Slutdatum:
- +
Språkstöd/Tolk:
-
{{avrop.sprakstod || '- '}}/{{avrop.tolkbehov || ' -'}}
+
{{avrop?.sprakstod || '- '}}/{{avrop?.tolkbehov || ' -'}}
Utförande adress:
-
{{avrop.utforandeAdress}}
+
{{avrop?.utforandeAdress}}
Spår/nivå:
-
{{avrop.trackCode}}
+
{{avrop?.trackCode}}
Vald handledare:
-
{{handledare.fullName}}
+
{{handledare?.fullName}}
= []; - constructor() {} - ngOnInit(): void { this.setupOrganizationPickerFormGroup(); this.setupSelectableOrganizations(this.organizations); } ngOnChanges(changes: SimpleChanges): void { - this.setupSelectableOrganizations(this.organizations); + if (changes.organizations) { + this.setupSelectableOrganizations(this.organizations); + } } get organizationFormControl(): AbstractControl | null { @@ -44,6 +44,7 @@ export class OrganizationPickerFormComponent implements OnInit, OnChanges { private setupOrganizationPickerFormGroup(): void { this.organizationPickerFormGroup = new FormGroup({ + // eslint-disable-next-line organization: new FormControl(null, [Validators.required]), }); } diff --git a/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/expanded-tree-node/expanded-tree-node.component.html b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/expanded-tree-node/expanded-tree-node.component.html new file mode 100644 index 0000000..5ae9188 --- /dev/null +++ b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/expanded-tree-node/expanded-tree-node.component.html @@ -0,0 +1,88 @@ +
+ +

+ {{treeNodeModel.grandChildrenInfo}} +

+
+ +
+ Filtrera valbara alternativ där deras namn måste innehålla den angivna texten. +
+
+
+ + {{treeNodeModel.toggleAllChildrenLabel}} +
+ +
    +
  • + +
  • +
+
+ + +
+ + {{node.label}} +
+
+ +
+ {{node.label}} + + +
+
+
diff --git a/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/expanded-tree-node/expanded-tree-node.component.scss b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/expanded-tree-node/expanded-tree-node.component.scss new file mode 100644 index 0000000..710849d --- /dev/null +++ b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/expanded-tree-node/expanded-tree-node.component.scss @@ -0,0 +1,169 @@ +@import 'variables/colors'; +@import 'mixins/list'; +@import 'variables/gutters'; + +.expanded-tree-node { + display: flex; + flex-direction: column; + min-height: 100%; + max-height: 300px; + overflow: auto; + + &__filter { + padding: 0 0.9375rem; + margin-bottom: 1.25rem; + } + + &__heading { + font-size: 1rem; + padding: 0 0.9375rem; + display: block; + font-weight: 600; + + ::ng-deep { + h1, + h2, + h3, + h4, + h5, + h6 { + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + } + + &__nodes { + overflow: auto; + display: flex; + flex-direction: column; + flex-grow: 1; + @include msfa__reset-list; + padding: 0.3125rem 0; + } + + &__info { + padding: 0.9375rem; + } + + &__node { + &--leaf { + margin-top: 0.625rem; + padding-left: 0.625rem; + + &:first-child { + margin-top: 0; + } + } + } + + &__node-checkbox-presentation { + font-size: 1rem; + font-weight: 400; + display: block; + position: relative; + padding-left: 1.875rem; + cursor: pointer; + line-height: normal; + + &__box { + position: absolute; + top: 0; + left: 0; + height: 1.25rem; + width: 1.25rem; + border-radius: 0.1875rem; + background-color: var(--digi--ui--color--background); + border: 1px solid var(--digi--ui--color--background--dark); + } + + &--toggle-all { + margin: 1.25rem 0.625rem; + } + + &--checked &__box { + background-color: var(--digi--ui--color--success); + border-color: var(--digi--ui--color--success); + + &::after { + position: absolute; + left: 0.375rem; + top: 0.0625rem; + width: 0.375rem; + height: 0.6875rem; + border: solid #fff; + border-width: 0 0.125rem 0.125rem 0; + transform: rotate(35deg); + content: ''; + } + } + + &--focus &__box { + box-shadow: var(--digi--ui--outline--focus--m); + } + } + + &__node-expansion-presentation { + display: inline-flex; + padding: 0.3125rem 0.9375rem; + justify-content: space-between; + width: 100%; + cursor: pointer; + position: relative; + border-width: 0; + background-color: transparent; + align-items: center; + white-space: nowrap; + font-size: 0.875rem; + text-align: center; + position: relative; + + &:hover, + &--focus { + background-color: var(--digi--ui--color--background--secondary); + } + + &--focus { + border-top: 1px solid var(--digi--typography--color--text--disabled); + border-bottom: 1px solid var(--digi--typography--color--text--disabled); + } + + &--active { + background-color: var(--digi--ui--color--primary); + color: var(--digi--ui--color--background); + + &:hover, + &--focus { + background-color: lighten($digi--ui--color--primary, 10%); + } + } + + &__text { + text-align: left; + flex-grow: 1; + max-width: 250px; + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + &__has-selection-dot { + width: 1rem; + height: 1rem; + border-radius: 50%; + background-color: var(--digi--ui--color--success); + margin-right: 10px; + } + + &__icon { + display: inline-flex; + justify-content: center; + align-items: center; + width: 1em; + max-height: 1em; + } + } +} diff --git a/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/expanded-tree-node/expanded-tree-node.component.spec.ts b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/expanded-tree-node/expanded-tree-node.component.spec.ts new file mode 100644 index 0000000..0940ffb --- /dev/null +++ b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/expanded-tree-node/expanded-tree-node.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ExpandedTreeNodeComponent } from './expanded-tree-node.component'; + +describe('ExpandedTreeNodeComponent', () => { + let component: ExpandedTreeNodeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ExpandedTreeNodeComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ExpandedTreeNodeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/expanded-tree-node/expanded-tree-node.component.ts b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/expanded-tree-node/expanded-tree-node.component.ts new file mode 100644 index 0000000..6858ed5 --- /dev/null +++ b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/expanded-tree-node/expanded-tree-node.component.ts @@ -0,0 +1,138 @@ +import { TypographyDynamicHeadingLevel } from '@af/digi-ng/_typography/typography-dynamic-heading'; +import { + Component, + OnInit, + ChangeDetectionStrategy, + Input, + OnChanges, + SimpleChanges, + Output, + EventEmitter, +} from '@angular/core'; +import { UnsubscribeDirective } from '@msfa-directives/unsubscribe.directive'; +import { ButtonType } from '../../../../../pages/avrop/enums/button-type.enum'; +import { FormInputSearchVariation } from '../../../../../pages/avrop/enums/form-input-search-variation'; +import { Subject } from 'rxjs'; +import { debounceTime } from 'rxjs/operators'; +import { + FilterTreeNodeData, + TreeNodeModel, + TreeNodesSelectorService, +} from '../../services/tree-nodes-selector.service'; + +@Component({ + selector: 'msfa-expanded-tree-node', + templateUrl: './expanded-tree-node.component.html', + styleUrls: ['./expanded-tree-node.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ExpandedTreeNodeComponent extends UnsubscribeDirective implements OnInit, OnChanges { + @Input() treeNodeModel: TreeNodeModel | null = null; + @Input() headingLevel: TypographyDynamicHeadingLevel = TypographyDynamicHeadingLevel.H3; + @Input() latestParentActionKey: string; + @Output() filterTreeNodeRequested = new EventEmitter(); + @Output() clickAndFocusElementRequested = new EventEmitter(); + + readonly ButtonType = ButtonType; + readonly FormInputSearchVariation = FormInputSearchVariation; + + visibleChildren: Array | null = null; + + private readonly filterTreeNodeDebouncer: Subject = new Subject(); + + constructor(private treeNodesSelectorService: TreeNodesSelectorService) { + super(); + + super.unsubscribeOnDestroy( + this.filterTreeNodeDebouncer.pipe(debounceTime(200)).subscribe(filterTreeNodeData => { + this.filterTreeNodeRequested.emit(filterTreeNodeData); + }) + ); + } + + ngOnInit(): void { + this.refreshComponentData(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.latestParentActionKey || changes.treeNodeModel) { + this.refreshComponentData(); + } + } + + private refreshComponentData(): void { + this.visibleChildren = this.treeNodesSelectorService.getVisibleChildren(this.treeNodeModel); + } + + onFilterTextChanged(event: CustomEvent<{ target: { value: string } }>, treeNode: TreeNodeModel): void { + if (!treeNode || !event?.detail?.target) { + return; + } + + this.filterTreeNodeDebouncer.next({ + text: event.detail.target.value, + treeNode, + }); + } + + onFocusOutsideFilter(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + } + + nodePresentationToggleAllClicked(treeNode: TreeNodeModel): void { + if (!treeNode) { + return; + } + + this.clickAndFocusElementRequested.emit(`#${this.treeNodesSelectorService.getToggleAllButtonId(treeNode)}`); + } + + nodePresentationItemClicked(treeNode: TreeNodeModel): void { + if (!treeNode) { + return; + } + + this.clickAndFocusElementRequested.emit(`#${this.treeNodesSelectorService.getItemButtonId(treeNode)}`); + } + + getPresentationItemId(treeNode: TreeNodeModel): string { + return this.treeNodesSelectorService.getPresentationItemId(treeNode); + } + + getPresentationToggleAllId(treeNode: TreeNodeModel): string { + return this.treeNodesSelectorService.getPresentationToggleAllId(treeNode); + } + + getFilterDescriptionId(treeNode: TreeNodeModel): string { + return this.treeNodesSelectorService.getFilterDescriptionId(treeNode); + } + + getFilterButtonAriaLabelText(treeNode: TreeNodeModel): string { + return this.treeNodesSelectorService.getFilterButtonAriaLabelText(treeNode); + } + + getTreeNodeHeadingText(treeNode: TreeNodeModel): string { + return this.treeNodesSelectorService.getTreeNodeHeadingText(treeNode); + } + + hasChildLeafNodes(node: TreeNodeModel): boolean { + return this.treeNodesSelectorService.hasChildLeafNodes(node); + } + + allChildLeafNodesAreSelected(node: TreeNodeModel): boolean { + return this.treeNodesSelectorService.allChildLeafNodesAreSelected(node); + } + + isLeafNode(node: TreeNodeModel): boolean { + return this.treeNodesSelectorService.isLeafNode(node); + } + + isExpandedNode(parentTreeNode: TreeNodeModel, treeNode: TreeNodeModel): boolean { + return this.treeNodesSelectorService.isExpandedNode(parentTreeNode, treeNode); + } + + hasSelectedDescendant(treeNode: TreeNodeModel): boolean { + return this.treeNodesSelectorService.hasSelectedLeafNodeDescendant(treeNode); + } +} diff --git a/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector-panel/tree-nodes-selector-panel.component.html b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector-panel/tree-nodes-selector-panel.component.html new file mode 100644 index 0000000..de0b2bd --- /dev/null +++ b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector-panel/tree-nodes-selector-panel.component.html @@ -0,0 +1,108 @@ + + +
+
+

{{headingText}}

+

+ I den här kontrollen kan du välja flera olika alternativ genom att fylla i kryssrutor som listas hierarkiskt + nedan. Du kan stänga kontrollen genom att trycka Escape. +

+ +
+
+ +
+ +
+
+
+ + {{confirmationButtonText}} + +
+ +
+

{{node.label}}

+ +
{{getAriaLabelForToggleAllButton(node)}}
+
    +
  • + + +
  • +
+
+
+ + + + + + + + +
+
+
diff --git a/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector-panel/tree-nodes-selector-panel.component.scss b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector-panel/tree-nodes-selector-panel.component.scss new file mode 100644 index 0000000..5d7798f --- /dev/null +++ b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector-panel/tree-nodes-selector-panel.component.scss @@ -0,0 +1,75 @@ +@import 'variables/colors'; +@import 'mixins/list'; +@import 'variables/gutters'; + +.tree-nodes-selector-panel { + background-color: var(--digi--ui--color--background); + min-width: 710px; + min-height: 144px; + box-shadow: 0 0.2rem 0.6rem 0 var(--digi--ui--color--shadow); + border-radius: 4px; + + header { + margin: 0; + padding: 0; + border-width: 0; + position: relative; + } + + &__heading { + width: 100%; + height: 45px; + display: flex; + align-items: center; + margin: 0; + font-weight: 700; + font-size: 1rem; + padding: 0.625rem 0.9375rem; + border-bottom: 1px solid var(--digi--typography--color--text--disabled); + } + + &__clear-btn { + position: absolute; + z-index: 20; + right: 0; + top: 0; + display: block; + background-image: none; + background-color: transparent; + border: 0 none transparent; + color: var(--digi--ui--color--danger); + padding: 0.625rem 0.9375rem; + font-size: 0.875rem; + } + + &__expanded-nodes { + @include msfa__reset-list; + display: flex; + flex-direction: row; + overflow: auto; + max-width: 100%; + } + + &__expanded-node { + position: relative; + display: flex; + flex-direction: column; + flex: 0 0 50%; + max-width: 50%; + overflow: auto; + padding-top: 15px; + border-left: 1px solid var(--digi--typography--color--text--disabled); + + &:first-child { + border-left: 0 none transparent; + } + } + + footer { + display: flex; + justify-content: center; + align-items: center; + background-color: var(--digi--ui--color--background--tertiary); + height: 60px; + } +} diff --git a/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector-panel/tree-nodes-selector-panel.component.spec.ts b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector-panel/tree-nodes-selector-panel.component.spec.ts new file mode 100644 index 0000000..6413a6e --- /dev/null +++ b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector-panel/tree-nodes-selector-panel.component.spec.ts @@ -0,0 +1,26 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TreeNodesSelectorPanelComponent } from './tree-nodes-selector-panel.component'; + +describe('TreeNodesSelectorPanelComponent', () => { + let component: TreeNodesSelectorPanelComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TreeNodesSelectorPanelComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TreeNodesSelectorPanelComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector-panel/tree-nodes-selector-panel.component.ts b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector-panel/tree-nodes-selector-panel.component.ts new file mode 100644 index 0000000..4a091b7 --- /dev/null +++ b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector-panel/tree-nodes-selector-panel.component.ts @@ -0,0 +1,232 @@ +import { ButtonSize } from '@af/digi-ng/_button/button'; +import { + Component, + OnInit, + ChangeDetectionStrategy, + Input, + ElementRef, + ViewChild, + Output, + EventEmitter, +} from '@angular/core'; +import { + FilterTreeNodeData, + TreeNodeModel, + TreeNodesSelectorService, +} from '../../services/tree-nodes-selector.service'; + +@Component({ + selector: 'msfa-tree-nodes-selector-panel', + templateUrl: './tree-nodes-selector-panel.component.html', + styleUrls: ['./tree-nodes-selector-panel.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TreeNodesSelectorPanelComponent implements OnInit { + @Input() rootNode: TreeNodeModel | null = null; + @Input() headingText: string; + @Input() confirmationButtonText = 'Stäng'; + @Output() selectedChangesConfirmed = new EventEmitter(); + @Output() closePanelRequested = new EventEmitter(); + + @ViewChild('treeNodesSelectorPanel') treeNodesSelector: ElementRef; + + readonly ButtonSize = ButtonSize; + + pendingRootNode: TreeNodeModel | null = null; + expandedTreeNodes: Array | null = null; + latestParentActionKey: string = null; + + constructor(private treeNodesSelectorService: TreeNodesSelectorService) {} + + ngOnInit(): void { + this.setupPendingRootNode(); + } + + private setupPendingRootNode(): void { + this.pendingRootNode = this.treeNodesSelectorService.getClonedTreeNode(this.rootNode); + this.expandedTreeNodes = this.treeNodesSelectorService.getExpandedTreeNodes(this.pendingRootNode); + } + + onToggleSelected(treeNode: TreeNodeModel, event: CustomEvent<{ target: { checked: boolean } }>): void { + if (!treeNode || !event?.detail?.target) { + return; + } + + treeNode.isSelected = event?.detail?.target?.checked; + + // eslint-disable-next-line + this.latestParentActionKey = `${treeNode.uuid}-selected-${treeNode.isSelected}`; + } + + setExpandedChild(parentTreeNode: TreeNodeModel, treeNode: TreeNodeModel): void { + if (!parentTreeNode || !treeNode) { + return; + } + + parentTreeNode.expandedChildUuid = parentTreeNode.expandedChildUuid === treeNode.uuid ? null : treeNode.uuid; + + this.expandedTreeNodes = this.treeNodesSelectorService.getExpandedTreeNodes(this.pendingRootNode); + + this.latestParentActionKey = `${parentTreeNode.uuid}-expanded-child-${parentTreeNode.expandedChildUuid}`; + } + + setFocusOnToggleAll(treeNode: TreeNodeModel, hasFocus: boolean): void { + let presentationToggleAllElement: HTMLElement = null; + let expansionColumnElement: HTMLElement = null; + + if (!treeNode) { + return; + } + + treeNode.toggleAllHasFocus = hasFocus; + + if (!hasFocus) { + return; + } + + presentationToggleAllElement = this.treeNodesSelectorService.getPresentationToggleAllElement( + this.treeNodesSelector, + treeNode + ); + + expansionColumnElement = this.treeNodesSelectorService.getExpansionColumnElement(presentationToggleAllElement); + + this.scrollElementInContainer(expansionColumnElement); + } + + private scrollElementInContainer(element: HTMLElement): void { + let elementRect: DOMRect = null; + let parentElementRect: DOMRect = null; + + if (!element || !element.parentElement) { + return; + } + + if (this.treeNodesSelectorService.isFullyVisibleElement(element, element.parentElement)) { + return; + } + + elementRect = element.getBoundingClientRect(); + + parentElementRect = element.parentElement.getBoundingClientRect(); + + element.parentElement.scrollTop = elementRect.top + element.parentElement.scrollTop - parentElementRect.top; + element.parentElement.scrollLeft = elementRect.left + element.parentElement.scrollLeft - parentElementRect.left; + } + + setFocusOnNodeItem(treeNode: TreeNodeModel, hasFocus: boolean): void { + let presentationItemElement: HTMLElement = null; + let expansionColumnElement: HTMLElement = null; + + if (!treeNode) { + return; + } + + treeNode.hasFocus = hasFocus; + + if (!hasFocus) { + return; + } + + this.latestParentActionKey = `${treeNode.uuid}-focused-item`; + + presentationItemElement = this.treeNodesSelectorService.getPresentationItemElement( + this.treeNodesSelector, + treeNode + ); + + expansionColumnElement = this.treeNodesSelectorService.getExpansionColumnElement(presentationItemElement); + + this.scrollElementInContainer(presentationItemElement); + this.scrollElementInContainer(expansionColumnElement); + } + + toggleAllChildLeafNodes(treeNode: TreeNodeModel): void { + let allChildLeafNodesAreSelected = false; + + if (!treeNode || !treeNode.children) { + return; + } + + allChildLeafNodesAreSelected = this.allChildLeafNodesAreSelected(treeNode); + + treeNode.children = treeNode.children.map(child => { + return { ...child, isSelected: !allChildLeafNodesAreSelected }; + }); + + this.latestParentActionKey = `${treeNode.uuid}-selected-${allChildLeafNodesAreSelected.toString()}`; + } + + clearSelections(): void { + this.pendingRootNode = this.treeNodesSelectorService.getTreeNodeWithNoNodesSelected(this.pendingRootNode); + this.latestParentActionKey = `cleared-selections`; + this.expandedTreeNodes = this.treeNodesSelectorService.getExpandedTreeNodes(this.pendingRootNode); + } + + closePanel(): void { + this.selectedChangesConfirmed.emit(this.pendingRootNode); + this.closePanelRequested.emit(); + } + + filterTreeNode(filterTreeNodeData: FilterTreeNodeData): void { + if (!filterTreeNodeData || !filterTreeNodeData.treeNode) { + return; + } + + filterTreeNodeData.treeNode.children = this.treeNodesSelectorService.getFilteredTreeNodeChildren( + filterTreeNodeData + ); + + this.latestParentActionKey = `${filterTreeNodeData.treeNode.uuid}-filtered-${filterTreeNodeData.text}`; + this.expandedTreeNodes = this.treeNodesSelectorService.getExpandedTreeNodes(this.pendingRootNode); + } + + clickAndFocusElementByQuerySelector(selector: string): void { + const selectedItem = this.treeNodesSelector + ? this.treeNodesSelectorService.getElementByQuerySelector(this.treeNodesSelector, selector) + : null; + + if (!selectedItem) { + return; + } + + selectedItem.focus(); + selectedItem.click(); + } + + getVisibleChildren(treeNode: TreeNodeModel): Array | null { + return this.treeNodesSelectorService.getVisibleChildren(treeNode); + } + + hasChildLeafNodes(node: TreeNodeModel): boolean { + return this.treeNodesSelectorService.hasChildLeafNodes(node); + } + + allChildLeafNodesAreSelected(node: TreeNodeModel): boolean { + return this.treeNodesSelectorService.allChildLeafNodesAreSelected(node); + } + + hasSelectedDescendant(treeNode: TreeNodeModel): boolean { + return this.treeNodesSelectorService.hasSelectedLeafNodeDescendant(treeNode); + } + + getAriaLabelTextForExpansionButton(parentTreeNode: TreeNodeModel, treeNode: TreeNodeModel): string { + return this.treeNodesSelectorService.getAriaLabelTextForExpansionButton(parentTreeNode, treeNode); + } + + getAriaLabelForToggleAllButton(treeNode: TreeNodeModel): string { + return this.treeNodesSelectorService.getAriaLabelForToggleAllButton(treeNode); + } + + isExpandedNode(parentTreeNode: TreeNodeModel, treeNode: TreeNodeModel): boolean { + return this.treeNodesSelectorService.isExpandedNode(parentTreeNode, treeNode); + } + + getToggleAllButtonId(treeNode: TreeNodeModel): string { + return this.treeNodesSelectorService.getToggleAllButtonId(treeNode); + } + + getItemButtonId(treeNode: TreeNodeModel): string { + return this.treeNodesSelectorService.getItemButtonId(treeNode); + } +} diff --git a/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector/tree-nodes-selector.component.html b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector/tree-nodes-selector.component.html new file mode 100644 index 0000000..2d2cce3 --- /dev/null +++ b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector/tree-nodes-selector.component.html @@ -0,0 +1,38 @@ +
+
+ +
+ + +
+
+
    +
  • + {{validationMessage}} +
  • +
+
diff --git a/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector/tree-nodes-selector.component.scss b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector/tree-nodes-selector.component.scss new file mode 100644 index 0000000..bf8c2a2 --- /dev/null +++ b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector/tree-nodes-selector.component.scss @@ -0,0 +1,64 @@ +@import 'variables/colors'; +@import 'mixins/list'; +@import 'variables/gutters'; + +.tree-nodes-selector { + &__panel-wrapper { + position: relative; + } + + &__toggle-panel-btn { + position: relative; + display: flex; + border-radius: 0.375rem; + border: 1px solid var(--digi--ui--input--border--color); + background-color: var(--digi--ui--color--background); + color: var(--digi--ui--color--primary); + padding: 6px 12px; + width: 100%; + justify-content: space-between; + align-items: center; + font-size: var(--digi--typography--font-size--xs); + + &:focus { + border-color: var(--digi--ui--color--focus--light); + box-shadow: 0 0 0.1rem 0.05rem var(--digi--ui--color--focus); + outline: none; + } + + &:disabled { + background-color: var(--digi--ui--color--background--disabled); + border-color: var(--digi--ui--color--border--disabled); + color: var(--digi--typography--color--text--disabled); + } + + &--invalid { + background-color: var(--digi--ui--color--background--error); + border-color: var(--digi--ui--color--border--error); + box-shadow: 0 0 0 1px var(--digi--ui--color--border--error); + } + + &__text { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + &__validation-messages { + @include msfa__reset-list; + display: flex; + justify-content: space-between; + padding: var(--digi--layout--gutter--s) 0; + flex-direction: column; + gap: var(--digi--layout--gutter--s); + } + + &__panel { + position: absolute; + z-index: 10; + top: 100%; + left: 0; + } +} diff --git a/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector/tree-nodes-selector.component.spec.ts b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector/tree-nodes-selector.component.spec.ts new file mode 100644 index 0000000..2add096 --- /dev/null +++ b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector/tree-nodes-selector.component.spec.ts @@ -0,0 +1,26 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TreeNodesSelectorComponent } from './tree-nodes-selector.component'; + +describe('TreeNodesSelectorComponent', () => { + let component: TreeNodesSelectorComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TreeNodesSelectorComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TreeNodesSelectorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector/tree-nodes-selector.component.ts b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector/tree-nodes-selector.component.ts new file mode 100644 index 0000000..a70b765 --- /dev/null +++ b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/components/tree-nodes-selector/tree-nodes-selector.component.ts @@ -0,0 +1,96 @@ +import { + Component, + ChangeDetectionStrategy, + forwardRef, + Input, + Renderer2, + ViewChild, + ElementRef, + Output, + EventEmitter, + ChangeDetectorRef, +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { UUID } from 'angular2-uuid'; +import { TreeNode, TreeNodeModel, TreeNodesSelectorService } from '../../services/tree-nodes-selector.service'; + +interface PropagateChangeFn { + (_: unknown): void; +} + +interface PropagateTouchedFn { + (): void; +} + +@Component({ + selector: 'msfa-tree-nodes-selector', + templateUrl: './tree-nodes-selector.component.html', + styleUrls: ['./tree-nodes-selector.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TreeNodesSelectorComponent), + multi: true, + }, + ], +}) +export class TreeNodesSelectorComponent implements ControlValueAccessor { + @Input() headingText: string; + @Input() confirmationButtonText = 'Stäng'; + @Input() isInvalid = false; + @Input() showValidation = false; + @Input() validationMessages: Array; + @Output() selectedTreeNodesChanged = new EventEmitter(); + + @ViewChild('togglePanelBtn') togglePanelBtn: ElementRef; + + rootNode: TreeNodeModel | null = null; + displayPanel = false; + selectionLabel: string | null = null; + isDisabled = false; + + panelId = `panel-${UUID.UUID()}`; + + private propagateChange: PropagateChangeFn; + private propagateTouched: PropagateTouchedFn; + + constructor( + private treeNodesSelectorService: TreeNodesSelectorService, + private renderer: Renderer2, + private changeDetectorRef: ChangeDetectorRef + ) {} + + writeValue(rootNode: TreeNode | null): void { + this.rootNode = this.treeNodesSelectorService.getTreeNodeModelFromTreeNode(rootNode); + this.selectionLabel = this.treeNodesSelectorService.getTreeNodeSelectorLabel(this.rootNode); + this.changeDetectorRef.detectChanges(); + } + registerOnChange(fn: PropagateChangeFn): void { + this.propagateChange = fn; + } + registerOnTouched(fn: PropagateTouchedFn): void { + this.propagateTouched = fn; + } + setDisabledState?(isDisabled: boolean): void { + this.renderer.setProperty(this.togglePanelBtn.nativeElement, 'disabled', isDisabled); + + this.closePanel(); + } + + updateTreeNodeSelectorWithSelectedChanges(treeNode: TreeNodeModel): void { + this.rootNode = treeNode; + this.selectionLabel = this.treeNodesSelectorService.getTreeNodeSelectorLabel(this.rootNode); + this.propagateTouched(); + this.propagateChange(this.treeNodesSelectorService.getBasicTreeNodeFromModel(this.rootNode)); + this.selectedTreeNodesChanged.emit(); + } + + closePanel(): void { + this.displayPanel = false; + } + + togglePanel(): void { + this.displayPanel = !this.displayPanel; + } +} diff --git a/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/services/tree-nodes-selector.service.spec.ts b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/services/tree-nodes-selector.service.spec.ts new file mode 100644 index 0000000..5b35cf1 --- /dev/null +++ b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/services/tree-nodes-selector.service.spec.ts @@ -0,0 +1,724 @@ +import { TestBed } from '@angular/core/testing'; + +import { TreeNode, TreeNodeModel, TreeNodesSelectorService } from './tree-nodes-selector.service'; + +describe('TreeNodesSelectorService', () => { + let service: TreeNodesSelectorService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(TreeNodesSelectorService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('allChildLeafNodesAreSelected', () => { + it('should return false if not all leaf nodes among the given tree nodes direct children are selectd', () => { + const treeNode: TreeNode = { + label: 'root', + isSelected: false, + value: null, + children: [ + { + label: 'level 1', + isSelected: false, + value: null, + }, + { + label: 'level 1', + isSelected: true, + value: null, + }, + ], + }; + expect(service.allChildLeafNodesAreSelected(treeNode)).toBe(false); + }); + + it('should return true if all leaf nodes among the given tree nodes direct children are selectd', () => { + const treeNode = { + label: 'root', + isSelected: false, + value: null, + children: [ + { + label: 'level 1', + isSelected: true, + value: null, + }, + { + label: 'level 1', + isSelected: true, + value: null, + }, + ], + }; + + expect(service.allChildLeafNodesAreSelected(treeNode)).toBe(true); + }); + }); + + describe('getExpandedTreeNodes', () => { + it('should return a list of all tree nodes that are in an expanded chain starting with the provided tree node.', () => { + const treeNode: TreeNodeModel = { + label: 'root', + isSelected: false, + value: null, + uuid: '1', + expandedChildUuid: '1_1', + children: [ + { + label: 'level 1', + isSelected: false, + value: null, + uuid: '1_1', + expandedChildUuid: '1_1_2', + children: [ + { + label: 'level 2', + isSelected: false, + value: null, + uuid: '1_1_1', + }, + { + label: 'level 2', + isSelected: true, + value: null, + uuid: '1_1_2', + }, + ], + }, + { + label: 'level 1', + isSelected: true, + value: null, + uuid: '1_2', + expandedChildUuid: '1_2_2', + children: [ + { + label: 'level 2', + isSelected: false, + value: null, + uuid: '1_2_1', + }, + { + label: 'level 2', + isSelected: true, + value: null, + uuid: '1_2_2', + }, + ], + }, + ], + }; + + const result = service.getExpandedTreeNodes(treeNode); + + expect(result).not.toBeUndefined(); + expect(result).not.toBeNull(); + expect(result.length).toEqual(3); + expect(result[0].uuid).toEqual('1'); + expect(result[1].uuid).toEqual('1_1'); + expect(result[2].uuid).toEqual('1_1_2'); + }); + }); + + describe('getFilteredTreeNodeChildren', () => { + it('should set the propert hideTreeNode to true for all children with a label that contains the provided text when compared case insensitive, trimming white space and return them.', () => { + const treeNode: TreeNodeModel = { + label: 'root', + isSelected: false, + value: null, + children: [ + { + label: 'a B', + isSelected: true, + value: null, + uuid: '1', + }, + { + label: 'a b c', + isSelected: false, + value: null, + uuid: '2', + }, + { + label: 'h b C f', + isSelected: true, + value: null, + uuid: '3', + }, + { + label: 'd f', + isSelected: true, + value: null, + uuid: '4', + }, + ], + }; + + const result = service.getFilteredTreeNodeChildren({ text: 'B C', treeNode }); + + expect(result).not.toBeUndefined(); + expect(result).not.toBeNull(); + expect(result.length).toEqual(4); + expect(result[0].hideTreeNode).toBe(true); + expect(result[0].uuid).toEqual('1'); + expect(result[1].hideTreeNode).toBe(false); + expect(result[1].uuid).toEqual('2'); + expect(result[2].hideTreeNode).toBe(false); + expect(result[2].uuid).toEqual('3'); + expect(result[3].hideTreeNode).toBe(true); + expect(result[3].uuid).toEqual('4'); + }); + }); + + describe('getTreeNodeFilteredBySelectedLeafOrDecsendants', () => { + it('should return a tree node that either is a selected leaf node or has at least one descendant tree node that is a selected leaf node', () => { + const treeNode: TreeNodeModel = { + label: 'root', + isSelected: false, + value: null, + uuid: '0', + children: [ + { + label: '1evel 1', + isSelected: false, + value: null, + uuid: '1', + children: [ + { + label: '1evel 2', + isSelected: true, + value: null, + uuid: '1_1', + children: [ + { + label: '1evel 3', + isSelected: false, + value: null, + uuid: '1_1_1', + }, + { + label: 'level 3', + isSelected: false, + value: null, + uuid: '1_1_2', + }, + ], + }, + { + label: 'level 2', + isSelected: true, + value: null, + uuid: '1_2', + }, + ], + }, + { + label: 'level 1', + isSelected: false, + value: null, + uuid: '2', + children: [ + { + label: 'level 2', + isSelected: false, + value: null, + uuid: '2_1', + children: [ + { + label: '1evel 3', + isSelected: false, + value: null, + uuid: '2_1_1', + }, + { + label: 'level 3', + isSelected: true, + value: null, + uuid: '2_1_2', + }, + ], + }, + { + label: 'level 2', + isSelected: true, + value: null, + uuid: '2_2', + }, + ], + }, + ], + }; + + const result: TreeNodeModel = service.getTreeNodeFilteredBySelectedLeafOrDecsendants(treeNode); + + expect(result).not.toBeUndefined(); + expect(result).not.toBeNull(); + expect(result).not.toBeNull(); + expect(result.uuid).toEqual('0'); + expect(result.children).not.toBeUndefined(); + expect(result.children).not.toBeNull(); + expect(result.children.length).toEqual(2); + expect(result.children[0].uuid).toEqual('1'); + expect(result.children[0].children).not.toBeUndefined(); + expect(result.children[0].children).not.toBeNull(); + expect(result.children[0].children.length).toEqual(1); + expect(result.children[0].children[0].uuid).toEqual('1_2'); + expect(result.children[1].uuid).toEqual('2'); + expect(result.children[1].children).not.toBeUndefined(); + expect(result.children[1].children).not.toBeNull(); + expect(result.children[1].children.length).toEqual(2); + expect(result.children[1].children[0].uuid).toEqual('2_1'); + expect(result.children[1].children[0].children).not.toBeUndefined(); + expect(result.children[1].children[0].children).not.toBeNull(); + expect(result.children[1].children[0].children.length).toEqual(1); + expect(result.children[1].children[0].children[0].uuid).toEqual('2_1_2'); + expect(result.children[1].children[1].uuid).toEqual('2_2'); + }); + }); + + describe('getTreeNodeWithAllNodesSelected', () => { + it('should return a tree node with all sub nodes having the property isSelected set to true', () => { + const treeNode: TreeNodeModel = { + label: 'root', + isSelected: false, + value: null, + uuid: '0', + children: [ + { + label: '1evel 1', + isSelected: false, + value: null, + uuid: '1', + children: [ + { + label: '1evel 2', + isSelected: true, + value: null, + uuid: '1_1', + }, + { + label: 'level 2', + isSelected: true, + value: null, + uuid: '1_2', + }, + ], + }, + { + label: 'level 1', + isSelected: false, + value: null, + uuid: '2', + children: [ + { + label: 'level 2', + isSelected: false, + value: null, + uuid: '2_1', + }, + { + label: 'level 2', + isSelected: true, + value: null, + uuid: '2_2', + }, + ], + }, + ], + }; + + const result: TreeNodeModel = service.getTreeNodeWithAllNodesSelected(treeNode); + + expect(result).not.toBeUndefined(); + expect(result).not.toBeNull(); + expect(result.isSelected).toBe(true); + expect(result.children[0].isSelected).toBe(true); + expect(result.children[0].children[0].isSelected).toBe(true); + expect(result.children[0].children[1].isSelected).toBe(true); + expect(result.children[1].isSelected).toBe(true); + expect(result.children[1].children[0].isSelected).toBe(true); + expect(result.children[1].children[1].isSelected).toBe(true); + }); + }); + + describe('getTreeNodeWithNoNodesSelected', () => { + it('should return a tree node with all sub nodes having the property isSelected set to false', () => { + const treeNode: TreeNodeModel = { + label: 'root', + isSelected: false, + value: null, + uuid: '0', + children: [ + { + label: '1evel 1', + isSelected: false, + value: null, + uuid: '1', + children: [ + { + label: '1evel 2', + isSelected: true, + value: null, + uuid: '1_1', + }, + { + label: 'level 2', + isSelected: true, + value: null, + uuid: '1_2', + }, + ], + }, + { + label: 'level 1', + isSelected: false, + value: null, + uuid: '2', + children: [ + { + label: 'level 2', + isSelected: false, + value: null, + uuid: '2_1', + }, + { + label: 'level 2', + isSelected: true, + value: null, + uuid: '2_2', + }, + ], + }, + ], + }; + + const result: TreeNodeModel = service.getTreeNodeWithNoNodesSelected(treeNode); + + expect(result).not.toBeUndefined(); + expect(result).not.toBeNull(); + expect(result.isSelected).toBe(false); + expect(result.children[0].isSelected).toBe(false); + expect(result.children[0].children[0].isSelected).toBe(false); + expect(result.children[0].children[1].isSelected).toBe(false); + expect(result.children[1].isSelected).toBe(false); + expect(result.children[1].children[0].isSelected).toBe(false); + expect(result.children[1].children[1].isSelected).toBe(false); + }); + }); + + describe('getVisibleChildren', () => { + it('should return a list with all the child nodes having property hideTreeNode set to false', () => { + const treeNode: TreeNodeModel = { + label: 'root', + isSelected: false, + value: null, + uuid: '0', + children: [ + { + label: '1evel 1', + isSelected: false, + value: null, + uuid: '1', + hideTreeNode: true, + }, + { + label: 'level 1', + isSelected: false, + value: null, + uuid: '2', + hideTreeNode: false, + }, + { + label: '1evel 1', + isSelected: false, + value: null, + uuid: '3', + hideTreeNode: true, + }, + { + label: 'level 1', + isSelected: false, + value: null, + uuid: '4', + hideTreeNode: false, + }, + ], + }; + + const result = service.getVisibleChildren(treeNode); + + expect(result).not.toBeUndefined(); + expect(result).not.toBeNull(); + expect(result.length).toEqual(2); + expect(result[0].uuid).toEqual('2'); + expect(result[1].uuid).toEqual('4'); + }); + }); + + describe('hasChildLeafNodes', () => { + it('should return true when the tree node has a leaf node amongst its children', () => { + const treeNode: TreeNodeModel = { + label: 'root', + isSelected: false, + value: null, + uuid: '0', + children: [ + { + label: '1evel 1', + isSelected: false, + value: null, + }, + { + label: 'level 1', + isSelected: false, + value: null, + children: [ + { + label: '1evel 2', + isSelected: false, + value: null, + }, + ], + }, + ], + }; + + const result = service.hasChildLeafNodes(treeNode); + + expect(result).toBe(true); + }); + + it('should return false when the tree node has no leaf node amongst its children', () => { + const treeNode: TreeNodeModel = { + label: 'root', + isSelected: false, + value: null, + uuid: '0', + children: [ + { + label: '1evel 1', + isSelected: false, + value: null, + children: [ + { + label: '1evel 2', + isSelected: false, + value: null, + }, + ], + }, + { + label: 'level 1', + isSelected: false, + value: null, + children: [ + { + label: '1evel 2', + isSelected: false, + value: null, + }, + ], + }, + ], + }; + + const result = service.hasChildLeafNodes(treeNode); + + expect(result).toBe(false); + }); + }); + + describe('hasSelectedAllLeafNodes', () => { + it('should return true when all leaf nodes are selected', () => { + const treeNode: TreeNodeModel = { + label: 'root', + isSelected: false, + value: null, + uuid: '0', + children: [ + { + label: '1evel 1', + isSelected: true, + value: null, + }, + { + label: 'level 1', + isSelected: false, + value: null, + children: [ + { + label: '1evel 2', + isSelected: true, + value: null, + }, + { + label: '1evel 2', + isSelected: false, + value: null, + children: [ + { + label: '1evel 3', + isSelected: true, + value: null, + }, + { + label: '1evel 3', + isSelected: true, + value: null, + }, + ], + }, + ], + }, + ], + }; + + const result = service.hasChildLeafNodes(treeNode); + + expect(result).toBe(true); + }); + + it('should return false when at least one leaf node is not selected', () => { + const treeNode: TreeNodeModel = { + label: 'root', + isSelected: false, + value: null, + uuid: '0', + children: [ + { + label: '1evel 1', + isSelected: false, + value: null, + children: [ + { + label: '1evel 2', + isSelected: true, + value: null, + }, + ], + }, + { + label: 'level 1', + isSelected: true, + value: null, + children: [ + { + label: '1evel 2', + isSelected: false, + value: null, + }, + ], + }, + ], + }; + + const result = service.hasSelectedAllLeafNodes(treeNode); + + expect(result).toBe(false); + }); + }); + + describe('hasSelectedLeafNodeDescendant', () => { + it('should return true when there is at least one selected leaf node descendant.', () => { + const treeNode: TreeNodeModel = { + label: 'root', + isSelected: false, + value: null, + uuid: '0', + children: [ + { + label: '1evel 1', + isSelected: false, + value: null, + }, + { + label: 'level 1', + isSelected: false, + value: null, + children: [ + { + label: '1evel 2', + isSelected: false, + value: null, + }, + { + label: '1evel 2', + isSelected: false, + value: null, + children: [ + { + label: '1evel 3', + isSelected: false, + value: null, + }, + { + label: '1evel 3', + isSelected: true, + value: null, + }, + ], + }, + ], + }, + ], + }; + + const result = service.hasSelectedLeafNodeDescendant(treeNode); + + expect(result).toBe(true); + }); + + it('should return false when no leaf node is selected', () => { + const treeNode: TreeNodeModel = { + label: 'root', + isSelected: false, + value: null, + uuid: '0', + children: [ + { + label: '1evel 1', + isSelected: false, + value: null, + }, + { + label: 'level 1', + isSelected: false, + value: null, + children: [ + { + label: '1evel 2', + isSelected: false, + value: null, + }, + { + label: '1evel 2', + isSelected: true, + value: null, + children: [ + { + label: '1evel 3', + isSelected: false, + value: null, + }, + { + label: '1evel 3', + isSelected: false, + value: null, + }, + ], + }, + ], + }, + ], + }; + + const result = service.hasSelectedLeafNodeDescendant(treeNode); + + expect(result).toBe(false); + }); + }); +}); diff --git a/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/services/tree-nodes-selector.service.ts b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/services/tree-nodes-selector.service.ts new file mode 100644 index 0000000..c2c46c1 --- /dev/null +++ b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/services/tree-nodes-selector.service.ts @@ -0,0 +1,363 @@ +import { ElementRef, Injectable } from '@angular/core'; +import { UUID } from 'angular2-uuid'; + +export interface TreeNode { + label: string; + isSelected: boolean; + value: unknown; + childItemType?: string; + grandChildrenItemType?: string; + grandChildrenInfo?: string; + toggleAllChildrenLabel?: string; + children?: Array; +} + +export interface TreeNodeModel extends TreeNode { + uuid?: string; + expandedChildUuid?: string; + children?: Array; + hasFocus?: boolean; + toggleAllHasFocus?: boolean; + showGeneralInfoAboutGrandChildren?: boolean; + hideTreeNode?: boolean; +} + +export interface FilterTreeNodeData { + text: string; + treeNode: TreeNodeModel; +} + +@Injectable({ + providedIn: 'root', +}) +export class TreeNodesSelectorService { + getTreeNodeModelFromTreeNode(treeNode: TreeNode): TreeNodeModel | null { + let children: Array = []; + + if (!treeNode) { + return null; + } + + children = treeNode.children?.map(childNode => this.getTreeNodeModelFromTreeNode(childNode)); + + return { + ...treeNode, + uuid: UUID.UUID(), + hasFocus: false, + children, + hideTreeNode: false, + }; + } + + getBasicTreeNodeFromModel(treeNodeModel: TreeNodeModel): TreeNode { + const children = treeNodeModel?.children?.map(childNode => this.getBasicTreeNodeFromModel(childNode)); + + const treeNode: TreeNode = { + isSelected: treeNodeModel.isSelected, + value: treeNodeModel.value, + label: treeNodeModel.label, + grandChildrenItemType: treeNodeModel.grandChildrenItemType, + grandChildrenInfo: treeNodeModel.grandChildrenInfo, + childItemType: treeNodeModel.childItemType, + toggleAllChildrenLabel: treeNodeModel.toggleAllChildrenLabel, + children, + }; + + return treeNode; + } + + hasSelectedLeafNodeDescendant(treeNode: TreeNodeModel): boolean { + if (!treeNode || !treeNode.children) { + return false; + } + + return treeNode.children.some( + childNode => (this.isLeafNode(childNode) && childNode.isSelected) || this.hasSelectedLeafNodeDescendant(childNode) + ); + } + + getAriaLabelTextForExpansionButton(parentTreeNode: TreeNodeModel, treeNode: TreeNodeModel): string { + const isActiveNode = parentTreeNode && treeNode ? parentTreeNode.expandedChildUuid === treeNode.uuid : false; + + return `${isActiveNode ? 'Döljer' : 'Visar'} ${treeNode.grandChildrenItemType} för ${treeNode.label}`; + } + + isFullyVisibleElement(element: HTMLElement, container: HTMLElement): boolean { + const { bottom, top, left, right } = element.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + if (top <= containerRect.top && containerRect.top - top > 0) { + return false; + } + + if (bottom - containerRect.bottom > 0) { + return false; + } + + if (left <= containerRect.left && containerRect.left - left > 0) { + return false; + } + + if (right - containerRect.right > 0) { + return false; + } + + return true; + } + + hasChildLeafNodes(node: TreeNodeModel): boolean { + if (!node || !node.children || node.children.length === 0) { + return false; + } + + return node.children.some(child => this.isLeafNode(child)); + } + + isLeafNode(node: TreeNodeModel): boolean { + return node && (!node.children || node.children.length === 0); + } + + allChildLeafNodesAreSelected(node: TreeNodeModel): boolean { + if (!node || !node.children || node.children.length === 0) { + return true; + } + + return !node.children.some(child => this.isLeafNode(child) && !child.isSelected); + } + + getElementByQuerySelector(elementRef: ElementRef, selector: string): HTMLElement { + const element = elementRef?.nativeElement as HTMLElement; + + return element?.querySelector(selector); + } + + getPresentationItemElement(elementRef: ElementRef, treeNode: TreeNodeModel): HTMLElement { + return this.getElementByQuerySelector(elementRef, `#${this.getPresentationItemId(treeNode)}`); + } + + getPresentationToggleAllElement(elementRef: ElementRef, treeNode: TreeNodeModel): HTMLElement { + return this.getElementByQuerySelector(elementRef, `#${this.getPresentationToggleAllId(treeNode)}`); + } + + getExpansionColumnElement(element: HTMLElement): HTMLElement { + return element?.closest('.tree-nodes-selector-panel__expanded-node'); + } + + getExpandedTreeNodes(treeNode: TreeNodeModel): Array { + let expandedChildNode: TreeNodeModel | null = null; + + if (!treeNode) { + return []; + } + + expandedChildNode = treeNode.children?.find(childNode => this.isExpandedNode(treeNode, childNode)); + + if (expandedChildNode) { + return [treeNode].concat(this.getExpandedTreeNodes(expandedChildNode)); + } + + return treeNode.children && treeNode.children.some(child => child.children && child.children.length > 0) + ? [treeNode, { ...treeNode, children: undefined, showGeneralInfoAboutGrandChildren: true }] + : [treeNode]; + } + + isExpandedNode(parentTreeNode: TreeNodeModel, treeNode: TreeNodeModel): boolean { + if (!parentTreeNode || !treeNode) { + return false; + } + + return !parentTreeNode.hideTreeNode && !treeNode.hideTreeNode && parentTreeNode.expandedChildUuid === treeNode.uuid; + } + + getVisibleChildren(treeNode: TreeNodeModel): Array | null { + if (!treeNode || !treeNode.children) { + return null; + } + + return treeNode.children.filter(childNode => !childNode.hideTreeNode); + } + + getTreeNodeWithNoNodesSelected(treeNode: TreeNodeModel): TreeNodeModel { + return this.getTreeNodeWithAllOrNoNodesSelected(treeNode, false); + } + + getTreeNodeWithAllNodesSelected(treeNode: TreeNodeModel): TreeNodeModel { + return this.getTreeNodeWithAllOrNoNodesSelected(treeNode, true); + } + + private getTreeNodeWithAllOrNoNodesSelected(treeNode: TreeNodeModel, selectingAll: boolean): TreeNodeModel { + if (!treeNode) { + return null; + } + + return { + ...treeNode, + isSelected: selectingAll, + children: treeNode.children + ? treeNode.children.map(child => this.getTreeNodeWithAllOrNoNodesSelected(child, selectingAll)) + : undefined, + }; + } + + getAriaLabelForToggleAllButton(treeNode: TreeNodeModel): string { + const allChildNodesAreSelected = this.allChildLeafNodesAreSelected(treeNode); + + return `${allChildNodesAreSelected ? 'Tar bort' : 'Väljer'} samtliga alternativ under: ${treeNode.label}`; + } + + getFilterButtonAriaLabelText(treeNode: TreeNodeModel): string { + if (!treeNode) { + return ''; + } + + return `Filtrera ${treeNode.showGeneralInfoAboutGrandChildren ? treeNode.grandChildrenItemType : treeNode.label}`; + } + + getTreeNodeHeadingText(treeNode: TreeNodeModel): string { + if (!treeNode) { + return ''; + } + + return treeNode.showGeneralInfoAboutGrandChildren ? treeNode.grandChildrenItemType : treeNode.label; + } + + getToggleAllButtonId(treeNode: TreeNodeModel): string { + return `toggle-all-${treeNode.uuid}`; + } + + getItemButtonId(treeNode: TreeNodeModel): string { + return `item-${treeNode.uuid}`; + } + + getPresentationItemId(treeNode: TreeNodeModel): string { + return `presentation-item-${treeNode?.uuid}`; + } + + getPresentationToggleAllId(treeNode: TreeNodeModel): string { + return `presentation-toggle-all-${treeNode?.uuid}`; + } + + getFilterDescriptionId(treeNode: TreeNodeModel): string { + return `filter-description-${treeNode?.uuid}`; + } + + getCleanedText(text: string): string { + return text?.trim().toLowerCase(); + } + + getFilteredTreeNodeChildren(filterTreeNodeData: FilterTreeNodeData): Array { + let cleanedFilterText: string = null; + + if (!filterTreeNodeData || !filterTreeNodeData.treeNode || !filterTreeNodeData.treeNode.children) { + return []; + } + + cleanedFilterText = this.getCleanedText(filterTreeNodeData.text); + + if (!cleanedFilterText || cleanedFilterText.length === 0) { + return filterTreeNodeData.treeNode.children.map(childNode => { + return { ...childNode, hideTreeNode: false }; + }); + } + + return filterTreeNodeData.treeNode.children.map(childNode => { + return { ...childNode, hideTreeNode: this.getCleanedText(childNode?.label).indexOf(cleanedFilterText) === -1 }; + }); + } + + getTreeNodeFilteredBySelectedLeafOrDecsendants(treeNode: TreeNode): TreeNode { + const filteredTreeNode: TreeNode = { + ...treeNode, + children: treeNode.children + ? treeNode.children + .filter( + childNode => + this.hasSelectedLeafNodeDescendant(childNode) || (this.isLeafNode(childNode) && childNode.isSelected) + ) + .map(childNode => this.getTreeNodeFilteredBySelectedLeafOrDecsendants(childNode)) + : undefined, + }; + + return filteredTreeNode; + } + + getTreeNodeSelectorLabel(treeNode: TreeNode): string { + const levelInfoList: Array<{ levelItemType: string; sumOfItems: number }> = []; + let selectorLabel = ''; + + if (!treeNode) { + return selectorLabel; + } + + selectorLabel = `Välj ${this.getCleanedText(treeNode.label)}`; + + if (!treeNode.children || treeNode.children.length === 0) { + return selectorLabel; + } + + this.updateLevelInfoList(treeNode, 0, levelInfoList); + + if (!levelInfoList || levelInfoList.length === 0 || levelInfoList[0].sumOfItems === 0) { + return selectorLabel; + } + + selectorLabel = levelInfoList.map(levelInfo => `${levelInfo.sumOfItems} ${levelInfo.levelItemType}`).join(', '); + + return selectorLabel; + } + + private updateLevelInfoList( + treeNode: TreeNode, + level: number, + levelInfoList: Array<{ levelItemType: string; sumOfItems: number }> + ): void { + let selectedDescendants: Array = []; + let nodeSum = 0; + let i: number; + + if (!treeNode || !treeNode.children || treeNode.children.length === 0) { + return; + } + + selectedDescendants = treeNode.children.filter( + childNode => this.hasSelectedLeafNodeDescendant(childNode) || (this.isLeafNode(childNode) && childNode.isSelected) + ); + nodeSum = selectedDescendants.length; + + if (levelInfoList.length > level && levelInfoList[level]) { + levelInfoList[level].sumOfItems += nodeSum; + } else { + levelInfoList[level] = { levelItemType: treeNode.childItemType, sumOfItems: nodeSum }; + } + + if (this.isLeafNode(treeNode)) { + return; + } + + for (i = 0; i < selectedDescendants.length; i++) { + this.updateLevelInfoList(selectedDescendants[i], level + 1, levelInfoList); + } + } + + getClonedTreeNode(treeNode: TreeNodeModel): TreeNodeModel | null { + let clonedTreeNode: TreeNodeModel | null; + + try { + clonedTreeNode = JSON.parse(JSON.stringify(treeNode)) as TreeNode; + } catch (error) { + console.error(error, 'Failed to clone tree node'); + } + + return clonedTreeNode; + } + + hasSelectedAllLeafNodes(treeNode: TreeNode): boolean { + if (!treeNode) { + return false; + } + + return this.isLeafNode(treeNode) + ? treeNode.isSelected + : !treeNode.children.some(childNode => !this.hasSelectedAllLeafNodes(childNode)); + } +} diff --git a/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/tree-nodes-selector.module.ts b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/tree-nodes-selector.module.ts new file mode 100644 index 0000000..54920ff --- /dev/null +++ b/apps/mina-sidor-fa/src/app/shared/components/tree-nodes-selector/tree-nodes-selector.module.ts @@ -0,0 +1,14 @@ +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TreeNodesSelectorPanelComponent } from './components/tree-nodes-selector-panel/tree-nodes-selector-panel.component'; +import { TreeNodesSelectorComponent } from './components/tree-nodes-selector/tree-nodes-selector.component'; +import { ExpandedTreeNodeComponent } from './components/expanded-tree-node/expanded-tree-node.component'; +import { DigiNgTypographyDynamicHeadingModule } from '@af/digi-ng/_typography/typography-dynamic-heading'; + +@NgModule({ + declarations: [TreeNodesSelectorComponent, TreeNodesSelectorPanelComponent, ExpandedTreeNodeComponent], + imports: [CommonModule, DigiNgTypographyDynamicHeadingModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + exports: [TreeNodesSelectorComponent], +}) +export class TreeNodesSelectorModule {} diff --git a/apps/mina-sidor-fa/src/app/shared/services/utforande-verksamheter/utforande-verksamheter.service.spec.ts b/apps/mina-sidor-fa/src/app/shared/services/utforande-verksamheter/utforande-verksamheter.service.spec.ts new file mode 100644 index 0000000..bac7d1c --- /dev/null +++ b/apps/mina-sidor-fa/src/app/shared/services/utforande-verksamheter/utforande-verksamheter.service.spec.ts @@ -0,0 +1,20 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { UtforandeVerksamheterService } from './utforande-verksamheter.service'; + +describe('UtforandeVerksamheterService', () => { + let service: UtforandeVerksamheterService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [UtforandeVerksamheterService], + }); + service = TestBed.inject(UtforandeVerksamheterService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/apps/mina-sidor-fa/src/app/shared/services/utforande-verksamheter/utforande-verksamheter.service.ts b/apps/mina-sidor-fa/src/app/shared/services/utforande-verksamheter/utforande-verksamheter.service.ts new file mode 100644 index 0000000..afa8733 --- /dev/null +++ b/apps/mina-sidor-fa/src/app/shared/services/utforande-verksamheter/utforande-verksamheter.service.ts @@ -0,0 +1,112 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { environment } from '@msfa-environment'; +import { + TreeNode, + TreeNodesSelectorService, +} from '@msfa-shared/components/tree-nodes-selector/services/tree-nodes-selector.service'; +import { Observable, of } from 'rxjs'; + +export interface UtforandeVerksamhetAdress { + id: number; + namn: string; +} + +export interface UtforandeVerksamhet { + id: number; + name: string; + adresser: Array; +} + +@Injectable({ + providedIn: 'root', +}) +export class UtforandeVerksamheterService { + private readonly apiBaseUrl = `${environment.api.url}/utforandeverksamheter`; + + constructor(private treeNodesSelectorService: TreeNodesSelectorService, private httpClient: HttpClient) {} + + getUtforandeVerksamheter(tjanstIds: Array): Observable> { + let params = new HttpParams(); + let i: number; + + if (!tjanstIds) { + return of>([]); + } + + for (i = 0; i < tjanstIds.length; i++) { + params = params.append('tjansteIds', tjanstIds[i].toString()); + } + + return this.httpClient.get>(`${this.apiBaseUrl}`, { params }); + } + + getTreeNodeDataFromUtforandeVerksamheter(utforandeVerksamhetList: Array): TreeNode | null { + let treeNode: TreeNode | null = null; + + if (!utforandeVerksamhetList || utforandeVerksamhetList.length === 0 || !Array.isArray(utforandeVerksamhetList)) { + return treeNode; + } + + treeNode = { + label: 'Utförande Verksamheter', + grandChildrenItemType: 'Adresser', + grandChildrenInfo: 'Här kan du välja adresser när du valt en verksamhet till vänster.', + isSelected: false, + value: null, + childItemType: 'Utförande verksamheter', + children: utforandeVerksamhetList.map( + (utforandeVerksamhet: UtforandeVerksamhet): TreeNode => { + const utforandeVerksahmetTreeNode: TreeNode = { + label: utforandeVerksamhet.name, + toggleAllChildrenLabel: 'Välj alla adresser', + isSelected: false, + value: utforandeVerksamhet, + childItemType: 'Adresser', + children: utforandeVerksamhet.adresser + ? utforandeVerksamhet.adresser.map(adress => { + return { label: adress.namn, isSelected: false, value: adress }; + }) + : [], + }; + + return utforandeVerksahmetTreeNode; + } + ), + }; + + return treeNode; + } + + getSelectedUtforandeVerksamheterFromTreeNode(treeNode: TreeNode): Array { + let utforandeVerksamhetList: Array = []; + + if (!treeNode || !treeNode.children) { + return utforandeVerksamhetList; + } + + utforandeVerksamhetList = this.treeNodesSelectorService + .getTreeNodeFilteredBySelectedLeafOrDecsendants(treeNode) + ?.children?.map(utforandeVerksamhetNode => { + const originalUtforandeVerksamhet = utforandeVerksamhetNode.value as UtforandeVerksamhet; + const utforandeVerksamhet: UtforandeVerksamhet = { + name: originalUtforandeVerksamhet?.name, + id: originalUtforandeVerksamhet?.id, + adresser: utforandeVerksamhetNode.children.map(adressNode => { + return adressNode.value as UtforandeVerksamhetAdress; + }), + }; + return utforandeVerksamhet; + }); + + return utforandeVerksamhetList ? utforandeVerksamhetList : []; + } + + hasSelectedUtforandeVerksamhet = (treeNode: TreeNode): boolean => { + if (!treeNode || !treeNode.children || treeNode.children.length === 0) { + return false; + } + + return treeNode.children.some(childNode => this.treeNodesSelectorService.hasSelectedLeafNodeDescendant(childNode)); + }; +} diff --git a/apps/mina-sidor-fa/src/app/shared/utils/validators/tree-node.validator.ts b/apps/mina-sidor-fa/src/app/shared/utils/validators/tree-node.validator.ts new file mode 100644 index 0000000..cf9c859 --- /dev/null +++ b/apps/mina-sidor-fa/src/app/shared/utils/validators/tree-node.validator.ts @@ -0,0 +1,50 @@ +import { AbstractControl, ValidatorFn } from '@angular/forms'; +import { TreeNode } from '@msfa-shared/components/tree-nodes-selector/services/tree-nodes-selector.service'; + +export class TreeNodeValidator { + static IsValidTreeNode( + validationFn: (treeNode: TreeNode | null | undefined) => boolean, + nameOfError: string + ): ValidatorFn { + return (control: AbstractControl): { [key: string]: unknown } => { + const isValid = validationFn(control.value); + + const validationObj = {}; + + if (isValid) { + return null; + } + + validationObj[nameOfError] = { value: control.value as TreeNode }; + + return validationObj; + }; + } + + static Required(): ValidatorFn { + return (control: AbstractControl): { [key: string]: unknown } => { + const hasSelectedDescendantsOrIsSelectedLeaf = (treeNode: TreeNode): boolean => { + if (!treeNode) { + return false; + } + + if (!treeNode.children) { + return treeNode.isSelected; + } + + return treeNode.children.some(childNode => hasSelectedDescendantsOrIsSelectedLeaf(childNode)); + }; + const isValid = hasSelectedDescendantsOrIsSelectedLeaf(control.value); + + const validationObj = {}; + + if (isValid) { + return null; + } + + validationObj['required'] = { value: control.value as TreeNode }; + + return validationObj; + }; + } +} diff --git a/package.json b/package.json index ff3902d..d95ec71 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@digi/core": "^9.4.0", "@digi/styles": "^6.0.2", "@nrwl/angular": "11.5.1", + "angular2-uuid": "^1.1.1", "date-fns": "^2.22.1", "ngx-markdown": "^11.1.3", "rxjs": "~6.6.3",