Merge pull request #74 in TEA/mina-sidor-fa-web from feature/TV-389 to develop

Squashed commit of the following:

commit a865f5452ae9cb5eab0b55080dd7e7ec43d9ed61
Merge: b4e5a9e d9938cc
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Fri Sep 3 10:25:38 2021 +0200

    Merge branch 'develop-remote' into feature/TV-389

    # Conflicts:
    #	apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/employee-form.component.ts
    #	apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/employee-form.module.ts

commit b4e5a9ef26f99d0e0e8b2f8104f5e432da4bc82e
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Fri Sep 3 02:43:51 2021 +0200

    TV-389 removed some references to inputs that are no longer existing.

commit 04c1527a994d9c5479ebcd523261dd331beb093e
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Thu Sep 2 15:17:24 2021 +0200

    TV-389 adjusted spelling error

commit 3ea3faf1b13fafc16d4a97a6fc748dc790d1bc41
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Thu Sep 2 15:13:57 2021 +0200

    TV-389 have adjusted a bunch of issues after feedback in PR

commit 9ced585dd830c19006ead3bfe5a52ae1467189ef
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Thu Sep 2 10:55:55 2021 +0200

    Merge branch 'develop-remote' into feature/TV-389

    # Conflicts:
    #	apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/edit-employee-form/edit-employee-form.component.html
    #	apps/mina-sidor-fa/src/app/pages/administration/pages/employee-form/employee-form.component.ts

commit eb873ecb2125574c624523818f0441acd0a1bb61
Merge: 8f896cb b80bf22
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Thu Sep 2 10:35:44 2021 +0200

    Merge branch 'develop-remote' into feature/TV-389

commit 8f896cbf156ea65fed95a19d17ef485d06046ed0
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Wed Sep 1 18:19:16 2021 +0200

    TV-389 making sure we're getting data of the right format

commit 801e0298781815c9b4ca78f900cda17fbf33ffb5
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Wed Sep 1 09:09:51 2021 +0200

    TV-389 fixed old function name

commit 145e312d68e9a067377b228a718386dcf419ef49
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Wed Sep 1 08:52:54 2021 +0200

    TV-389 restored file

commit b1cf3b44bae548979fd090fca4e2194ae9c586c1
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Wed Sep 1 08:47:30 2021 +0200

    TV-389 cleaned up some console logs for testing and renamed a function

commit e9d79205902771eafeaf0fbe3bdb63e7cceeb0d5
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Wed Sep 1 08:30:08 2021 +0200

    TV-389 have added a bunch of tests and refactored some stuff on the edit employee forms into a service.

commit 185b4597c303ff20ae079efdf9247a53615b627e
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Tue Aug 31 15:00:50 2021 +0200

    TV-389 made a first working version of the tree node selector

commit ddff1ed3a05434a42a81d4dabfd8b2f2ff3c468e
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Tue Aug 31 13:01:49 2021 +0200

    TV-389 adjustments after checking out integration against API.

commit 92117d54b248f00a8b0619c3200d20a06510d9ba
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Tue Aug 31 12:21:05 2021 +0200

    TV-389 made various changes in prepertion for integration against the api..

commit 2f15741eb47335cfe4e8c47dc779642a8ab9893b
Merge: 062f42b 02cf0f6
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Tue Aug 31 08:13:21 2021 +0200

    Merge branch 'develop-remote' into feature/TV-389

commit 062f42b4d89976685fce463eec4f8deff399fd75
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Tue Aug 31 08:12:37 2021 +0200

    TV-389 preparing for integration with api..

commit 674b636e4b32aa391e1e14763c5781fa25bc31fb
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Tue Aug 31 07:51:12 2021 +0200

    TV-389 fixed some custom validators for utforandeverksamheter..

commit 07256654273e499b41cbb6b06e26b9ca4f7627c5
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Tue Aug 31 05:39:27 2021 +0200

    TV-389 removed useless z-index

commit 36b6ac2f6f846f5e88b393650c6d66821b600933
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Tue Aug 31 05:32:16 2021 +0200

    TV-389 added styling to button for opening the panel.

commit 75ea6b7196e6ab69b0ec4ce103214dc742ea5252
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Tue Aug 31 03:23:10 2021 +0200

    TV-389 minor adjustments of panel.

... and 13 more commits
This commit is contained in:
Christian Gårdebrink
2021-09-03 10:36:34 +02:00
parent d9938cc770
commit 773d8f33ab
36 changed files with 2701 additions and 201 deletions

View File

@@ -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);

View File

@@ -16,54 +16,50 @@
[afInvalid]="emailFormControl.invalid && emailFormControl.touched"
></digi-ng-form-input>
<fieldset *ngIf="rolesFormGroup && availableRoles" [formGroup]="rolesFormGroup">
<fieldset>
<legend>Tjänster</legend>
<p>Välj de tjänster du vill ge personalen tillgång till.</p>
<digi-ng-form-select
[formControl]="tjansterFormControl"
afLabel="Välj tjänster"
[afPlaceholder]="'Välj tjänst'"
[afSelectItems]="availableTjanster"
[afDescription]="description"
[afSelectItems]="selectableTjansterFormItems"
[afDisableValidStyle]="true"
[afDisableValidation]="disableValidation"
[afValidMessage]="validMessage"
[afDisabled]="disabled"
[afInvalidMessage]="invalidMessage"
[afInvalid]="invalid"
[afInvalid]="tjansterFormControl.invalid && tjansterFormControl.touched"
(afOnChange)="toggleTjanst()"
></digi-ng-form-select>
<div class="edit-employee-form__service-tag">
<ng-container *ngFor="let employeeTjanst of selectedTjanster">
<digi-tag
class="edit-employee-form__service-tag--item"
[attr.af-text]="employeeTjanst?.name"
af-no-icon="false"
af-size="s"
(click)="unselectTjanstTag(employeeTjanst)"
<digi-form-validation-message
*ngIf="tjansterFormControl.invalid && tjansterFormControl.touched"
af-variation="error"
>
</digi-tag>
</ng-container>
</div>
Du måste välja minst en tjänst
</digi-form-validation-message>
<!-- Vi får se till att bygga en kontrol för att kunna välja flera tjänster här istället, en digi-ng-select får vara en temporär lösning.. -->
</fieldset>
<fieldset *ngIf="rolesFormGroup && availableRoles" [formGroup]="rolesFormGroup">
<fieldset>
<legend>Utförande verksamheter och adresser</legend>
<p>Välj de utförandeverksamheter och utförande adresser du vill ge personalen behörighet till.</p>
<p *ngIf="!availableUtforandeVerksamheter || availableUtforandeVerksamheter.length === 0">
<strong>Du måste välja en eller flera tjänster för att kunna välja utförande verksamheter.</strong>
</p>
<div class="edit-employee-form__choose_all-utforande-verksamh">
<digi-form-checkbox
af-variation="primary"
<digi-ng-form-checkbox
[formControlName]="toggleAllUtforandeVerksamhetFormControlName"
[afLabel]="'Välj alla utförade verksamheter och alla utförande adresser'"
[afValue]=""
(afOnChange)="selectAllUtforandeVerksamheter($event.detail.target.checked)"
></digi-form-checkbox>
</div>
<div
style="display: flex; border: 1px solid; background-color: #eee; padding: 5px; justify-content: space-between"
(afOnChange)="toggleAllUtforandeVerksamheter($event)"
>
Plats för digi-select-form-item för utförande verksamheter
<digi-icon-arrow-down></digi-icon-arrow-down>
</digi-ng-form-checkbox>
</div>
<msfa-tree-nodes-selector
[headingText]="'Välj utförande verksamheter och adresser'"
[formControlName]="utforandeVerksamhetFormControlName"
[isInvalid]="utforandeVerksamhetFormControl?.invalid"
[showValidation]="utforandeVerksamhetFormControl?.touched"
[validationMessages]="utforandeVerksamhetFormControl.errors?.required ? ['Välj minst en utförande verksamhet'] : []"
(selectedTreeNodesChanged)="updateToggleAllUtforandeVerksamheter()"
>
</msfa-tree-nodes-selector>
</fieldset>
<fieldset *ngIf="rolesFormGroup && availableRoles" [formGroup]="rolesFormGroup">

View File

@@ -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();

View File

@@ -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<Role>;
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<Role> | null = null;
@Input() currentEmployeeRoles: Array<string> | null = null;
@Input() availableTjanster: Array<FormSelectItem> | null = null;
@Input() currentEmployeeTjanster: Array<Tjanst> | null = null;
selectedTjanster: Array<FormTagData> | null = null;
@Input() currentEmail: string;
@Input() availableRoles: Role[];
@Input() currentEmployeeRoles: string[];
@Input() availableTjanster: Tjanst[];
@Input() currentEmployeeTjanster: Tjanst[];
@Input() availableUtforandeVerksamheter: UtforandeVerksamhet[];
@Output() tjansterSelected = new EventEmitter<Tjanst[]>();
@Output() formSubmitted = new EventEmitter<EditEmployeeFormData>();
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<FormSelectItem> | 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();
}
@@ -80,14 +116,83 @@ export class EditEmployeeFormComponent implements OnInit, OnChanges {
}
}
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<Role> | null
): Array<Role> {
if (!formGroup || !roles) {
return;
}
return roles.filter(
role => formGroup.get(this.getFormControlName(role))?.value === true
);
}
private getRolesFormGroup(
roles: Array<Role> | null,
selectedRoles: Array<string> | 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<string> | 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);
}
}

View File

@@ -41,14 +41,18 @@
</ng-container>
</div>
<!-- Component för att hantera formuläret -->
<ng-container>
<msfa-edit-employee-form
[currentEmail]="employee?.email"
[availableRoles]="selectableRoles"
[currentEmployeeRoles]="employee?.roles"
[availableTjanster]="availableTjanster"
[currentEmployeeRoles]="currentEmployeeRoles$ | async"
[availableTjanster]="tjanster$ | async"
[currentEmployeeTjanster]="employee.tjanster"
[selectedTjanster]="selectedServices$ | async"
[availableUtforandeVerksamheter]="availableUtforandeVerksamheter$ | async"
(tjansterSelected)="setupAvailableUtforandeVerksamheter($event)"
(formSubmitted)="updateEmployee($event)"
></msfa-edit-employee-form>
</ng-container>
</section>
</msfa-layout>

View File

@@ -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<Tjanst[]> = this.tjanstService.tjanster$;
availableTjanster: FormSelectItem[] | null = null;
currentEmployeeRoles$: Observable<Role[] | undefined | null> = null;
availableUtforandeVerksamheter$: Observable<Array<UtforandeVerksamhet>> | 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<Tjanst>): void {
if (!selectedTjanster) {
return;
}
this.availableUtforandeVerksamheter$ = this.utforandeVerksamheterService.getUtforandeVerksamheter(
selectedTjanster.map(tjanst => tjanst.tjanstId)
);
}
setEmployeeToDelete(employee: Employee): void {
this.employeeService.setEmployeeToDelete(employee);
}

View File

@@ -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 {}

View File

@@ -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();
});
});

View File

@@ -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<string> | null): boolean {
if (!selectedRoles || !role) {
return false;
}
return selectedRoles.some(selectedRole => selectedRole === role.type);
}
getSelectedTjanster(tjanster: Array<Tjanst>, tjanstId: number): Array<Tjanst> {
let selectedTjanst: Tjanst | null = null;
if (!tjanster) {
return [];
}
selectedTjanst = tjanster.find(tjanst => tjanst.tjanstId === tjanstId);
return selectedTjanst ? [selectedTjanst] : [];
}
getRolesFromFormGroup(formGroup: FormGroup | null, roles: Array<Role> | null): Array<Role> {
if (!formGroup || !roles) {
return;
}
return roles.filter(role => formGroup.get(this.getFormControlName(role))?.value === true);
}
getRolesFormGroup(roles: Array<Role> | null, selectedRoles: Array<string> | 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];
}
}

View File

@@ -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);

View File

@@ -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,
},
];

View File

@@ -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;

View File

@@ -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();
});

View File

@@ -12,42 +12,45 @@
</div>
<dl class="avrop-row__name">
<dt class="avrop-table__label">Namn:</dt>
<dd *ngIf="avrop.fullName; else emptyText">{{avrop.fullName}}</dd>
<dd *ngIf="avrop?.fullName; else emptyText">{{avrop?.fullName}}</dd>
</dl>
<dl class="avrop-row__tjanst">
<dt class="avrop-table__label">Tjänst:</dt>
<dd *ngIf="avrop.tjanst; else emptyText">{{avrop.tjanst}}</dd>
<dd *ngIf="avrop?.tjanst; else emptyText">{{avrop?.tjanst}}</dd>
</dl>
<dl class="avrop-row__start">
<dt class="avrop-table__label">Startdatum:</dt>
<dd>
<digi-typography-time
*ngIf="avrop.startDate; else emptyText"
[afDateTime]="avrop.startDate"
*ngIf="avrop?.startDate; else emptyText"
[afDateTime]="avrop?.startDate"
></digi-typography-time>
</dd>
</dl>
<dl class="avrop-row__end">
<dt class="avrop-table__label">Slutdatum:</dt>
<dd>
<digi-typography-time *ngIf="avrop.endDate; else emptyText" [afDateTime]="avrop.endDate"></digi-typography-time>
<digi-typography-time
*ngIf="avrop?.endDate; else emptyText"
[afDateTime]="avrop?.endDate"
></digi-typography-time>
</dd>
</dl>
<dl class="avrop-row__translator">
<dt class="avrop-table__label">Språkstöd/Tolk:</dt>
<dd>{{avrop.sprakstod || '- '}}/{{avrop.tolkbehov || ' -'}}</dd>
<dd>{{avrop?.sprakstod || '- '}}/{{avrop?.tolkbehov || ' -'}}</dd>
</dl>
<dl class="avrop-row__address">
<dt class="avrop-table__label">Utförande adress:</dt>
<dd *ngIf="avrop.utforandeAdress; else emptyText">{{avrop.utforandeAdress}}</dd>
<dd *ngIf="avrop?.utforandeAdress; else emptyText">{{avrop?.utforandeAdress}}</dd>
</dl>
<dl class="avrop-row__level">
<dt class="avrop-table__label">Spår/nivå:</dt>
<dd *ngIf="avrop.trackCode; else emptyText">{{avrop.trackCode}}</dd>
<dd *ngIf="avrop?.trackCode; else emptyText">{{avrop?.trackCode}}</dd>
</dl>
<dl class="avrop-row__handledare" *ngIf="isLocked">
<dt class="avrop-table__label">Vald handledare:</dt>
<dd *ngIf="handledare?.fullName; else emptyText">{{handledare.fullName}}</dd>
<dd *ngIf="handledare?.fullName; else emptyText">{{handledare?.fullName}}</dd>
</dl>
<div *ngIf="isLocked" class="avrop-row__delete">
<digi-button

View File

@@ -0,0 +1,5 @@
export enum ButtonType {
BUTTON = 'button',
SUBMIT = 'submit',
RESET = 'reset',
}

View File

@@ -0,0 +1,5 @@
export enum FormInputSearchVariation {
S = 's',
M = 'm',
L = 'l',
}

View File

@@ -27,16 +27,16 @@ export class OrganizationPickerFormComponent implements OnInit, OnChanges {
organizationPickerFormGroup: FormGroup | null = null;
selectableOrganizations: Array<FormSelectItem> = [];
constructor() {}
ngOnInit(): void {
this.setupOrganizationPickerFormGroup();
this.setupSelectableOrganizations(this.organizations);
}
ngOnChanges(changes: SimpleChanges): void {
if (changes.organizations) {
this.setupSelectableOrganizations(this.organizations);
}
}
get organizationFormControl(): AbstractControl | null {
return this.organizationPickerFormGroup?.get(this.organizationFormControlName);
@@ -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]),
});
}

View File

@@ -0,0 +1,88 @@
<div class="expanded-tree-node" *ngIf="treeNodeModel">
<digi-ng-typography-dynamic-heading
class="expanded-tree-node__heading"
[afText]="getTreeNodeHeadingText(treeNodeModel)"
[afLevel]="headingLevel"
></digi-ng-typography-dynamic-heading>
<p class="expanded-tree-node__info" *ngIf="treeNodeModel.showGeneralInfoAboutGrandChildren">
{{treeNodeModel.grandChildrenInfo}}
</p>
<div
*ngIf="treeNodeModel.children && !treeNodeModel.showGeneralInfoAboutGrandChildren"
class="expanded-tree-node__filter"
>
<digi-form-input-search
[attr.af-variation]="FormInputSearchVariation.S"
[attr.af-button-text]="getFilterButtonAriaLabelText()"
[attr.af-button-type]="ButtonType.BUTTON"
[attr.af-label]="' '"
[attr.af-aria-labelledby]="filterDescriptionId"
(afOnFocusOutside)="onFocusOutsideFilter($event)"
(afOnChange)="onFilterTextChanged($event, treeNodeModel)"
(afOnKeyup)="onFilterTextChanged($event, treeNodeModel)"
></digi-form-input-search>
<div class="msfa__a11y-sr-only" [attr.id]="getFilterDescriptionId(treeNodeModel)">
Filtrera valbara alternativ där deras namn måste innehålla den angivna texten.
</div>
</div>
<div
*ngIf="hasChildLeafNodes(treeNodeModel)"
class="expanded-tree-node__node-checkbox-presentation expanded-tree-node__node-checkbox-presentation--toggle-all"
[ngClass]="{
'expanded-tree-node__node-checkbox-presentation--focus': treeNodeModel.toggleAllHasFocus,
'expanded-tree-node__node-checkbox-presentation--checked': allChildLeafNodesAreSelected(treeNodeModel)
}"
[attr.id]="getPresentationToggleAllId(treeNodeModel)"
(click)="nodePresentationToggleAllClicked(treeNodeModel)"
>
<span class="expanded-tree-node__node-checkbox-presentation__box"></span>
<span class="expanded-tree-node__node-checkbox-presentation__text">{{treeNodeModel.toggleAllChildrenLabel}}</span>
</div>
<ng-container *ngIf="visibleChildren">
<ul *ngIf="!treeNodeModel.showGeneralInfoAboutGrandChildren" class="expanded-tree-node__nodes">
<li
*ngFor="let childNode of visibleChildren"
[attr.id]="getPresentationItemId(childNode)"
[ngClass]="{'expanded-tree-node__node--leaf' : isLeafNode(childNode)}"
class="expanded-tree-node__node"
>
<ng-container
[ngTemplateOutlet]="childNode.children ? nodeExpansionPresentationTemplate : nodeCheckboxPresentationTemplate"
[ngTemplateOutletContext]="{node: childNode, parentNode: treeNodeModel}"
></ng-container>
</li>
</ul>
</ng-container>
<ng-template #nodeCheckboxPresentationTemplate let-node="node">
<div
class="expanded-tree-node__node-checkbox-presentation"
[ngClass]="{
'expanded-tree-node__node-checkbox-presentation--focus': node.hasFocus,
'expanded-tree-node__node-checkbox-presentation--checked': node.isSelected
}"
(click)="nodePresentationItemClicked(node)"
>
<span class="expanded-tree-node__node-checkbox-presentation__box"></span>
<span class="expanded-tree-node__node-checkbox-presentation__text">{{node.label}}</span>
</div>
</ng-template>
<ng-template #nodeExpansionPresentationTemplate let-node="node" let-parentNode="parentNode">
<div
class="expanded-tree-node__node-expansion-presentation"
[ngClass]="{
'expanded-tree-node__node-expansion-presentation--focus': node.hasFocus,
'expanded-tree-node__node-expansion-presentation--active' : isExpandedNode(parentNode, node)
}"
(click)="nodePresentationItemClicked(node)"
>
<span class="expanded-tree-node__node-expansion-presentation__text">{{node.label}}</span>
<span
class="expanded-tree-node__node-expansion-presentation__has-selection-dot"
*ngIf="hasSelectedDescendant(node)"
aria-hidden="true"
></span>
<digi-icon-arrow-right class="expanded-tree-node__node-expansion-presentation__icon"></digi-icon-arrow-right>
</div>
</ng-template>
</div>

View File

@@ -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;
}
}
}

View File

@@ -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<ExpandedTreeNodeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ExpandedTreeNodeComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ExpandedTreeNodeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -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<FilterTreeNodeData>();
@Output() clickAndFocusElementRequested = new EventEmitter<string>();
readonly ButtonType = ButtonType;
readonly FormInputSearchVariation = FormInputSearchVariation;
visibleChildren: Array<TreeNodeModel> | null = null;
private readonly filterTreeNodeDebouncer: Subject<FilterTreeNodeData> = new Subject<FilterTreeNodeData>();
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);
}
}

View File

@@ -0,0 +1,108 @@
<digi-util-detect-focus-outside (afOnFocusOutside)="closePanel()">
<digi-util-keydown-handler (afOnEsc)="closePanel()">
<section class="tree-nodes-selector-panel" #treeNodesSelectorPanel>
<header>
<h2 class="tree-nodes-selector-panel__heading">{{headingText}}</h2>
<p class="msfa__a11y-sr-only">
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.
</p>
<button
*ngIf="hasSelectedDescendant(pendingRootNode)"
class="tree-nodes-selector-panel__clear-btn"
type="button"
aria-label="ta bort samtliga val bland alternativen nedan"
(click)="clearSelections()"
>
Rensa
</button>
</header>
<div class="tree-nodes-selector-panel__content" *ngIf="pendingRootNode">
<ul class="tree-nodes-selector-panel__expanded-nodes" aria-hidden="true" *ngIf="expandedTreeNodes">
<li class="tree-nodes-selector-panel__expanded-node" *ngFor="let node of expandedTreeNodes">
<msfa-expanded-tree-node
[treeNodeModel]="node"
[latestParentActionKey]="latestParentActionKey"
(filterTreeNodeRequested)="filterTreeNode($event)"
(clickAndFocusElementRequested)="clickAndFocusElementByQuerySelector($event)"
></msfa-expanded-tree-node>
</li>
</ul>
<div class="msfa__a11y-sr-only">
<ng-container
[ngTemplateOutlet]="nodeTemplate"
[ngTemplateOutletContext]="{node: pendingRootNode}"
></ng-container>
</div>
</div>
<footer>
<digi-button
[attr.af-aria-label]="'Bekräftar dina val och stänger kontrollen'"
[attr.af-type]="'button'"
[attr.af-size]="ButtonSize.S"
(afOnClick)="closePanel()"
>
{{confirmationButtonText}}
</digi-button>
</footer>
<ng-template #nodeTemplate let-node="node">
<div class="tree-nodes-selector-panel__level">
<h3 class="tree-nodes-selector-panel__heading">{{node.label}}</h3>
<digi-form-checkbox
*ngIf="hasChildLeafNodes(node)"
[attr.af-id]="getToggleAllButtonId(node)"
[attr.af-label]="node.selectAllChildrenLabel"
[attr.af-aria-labelledby]="'toggle-all-description-'+node.uuid"
[attr.af-checked]="allChildLeafNodesAreSelected(node)"
(afOnChange)="toggleAllChildLeafNodes(node)"
(afOnFocus)="setFocusOnToggleAll(node, true)"
(afOnBlur)="setFocusOnToggleAll(node, false)"
></digi-form-checkbox>
<div [attr.af-id]="'toggle-all-description-'+node.uuid">{{getAriaLabelForToggleAllButton(node)}}</div>
<ul *ngIf="getVisibleChildren(node) as visibleChildren" class="tree-nodes-selector-panel__nodes">
<li
*ngFor="let childNode of visibleChildren"
class="tree-nodes-selector-panel__node"
[ngClass]="{'tree-nodes-selector-panel__node--expanded': isExpandedNode(node, childNode)}"
>
<ng-container
[ngTemplateOutlet]="childNode.children ? nodeExpansionTemplate : nodeCheckboxTemplate"
[ngTemplateOutletContext]="{node: childNode, parentNode: node}"
>
</ng-container>
</li>
</ul>
</div>
</ng-template>
<ng-template #nodeCheckboxTemplate let-node="node">
<digi-form-checkbox
[attr.af-id]="'item-'+node.uuid"
[attr.af-label]="node.label"
[attr.af-checked]="node.isSelected"
[attr.af-value]="node.value"
(afOnChange)="onToggleSelected(node, $event)"
(afOnFocus)="setFocusOnNodeItem(node, true)"
(afOnBlur)="setFocusOnNodeItem(node, false)"
></digi-form-checkbox>
</ng-template>
<ng-template #nodeExpansionTemplate let-node="node" let-parentNode="parentNode">
<button
type="button"
[attr.id]="getItemButtonId(node)"
[attr.aria-label]="getAriaLabelTextForExpansionButton(parentNode, node)"
(click)="setExpandedChild(parentNode, node)"
(focus)="setFocusOnNodeItem(node, true)"
(blur)="setFocusOnNodeItem(node, false)"
>
{{node.label}}
</button>
<ng-container
*ngIf="isExpandedNode(parentNode, node)"
[ngTemplateOutlet]="nodeTemplate"
[ngTemplateOutletContext]="{node: node}"
>
</ng-container>
</ng-template>
</section>
</digi-util-keydown-handler>
</digi-util-detect-focus-outside>

View File

@@ -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;
}
}

View File

@@ -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<TreeNodesSelectorPanelComponent>;
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();
});
});

View File

@@ -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<TreeNodeModel>();
@Output() closePanelRequested = new EventEmitter<void>();
@ViewChild('treeNodesSelectorPanel') treeNodesSelector: ElementRef;
readonly ButtonSize = ButtonSize;
pendingRootNode: TreeNodeModel | null = null;
expandedTreeNodes: Array<TreeNodeModel> | 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<TreeNodeModel> | 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);
}
}

View File

@@ -0,0 +1,38 @@
<div class="tree-nodes-selector">
<div class="tree-nodes-selector__panel-wrapper">
<button
#togglePanelBtn
(click)="togglePanel()"
[attr.aria-controls]="panelId"
[ngClass]="{'tree-nodes-selector__toggle-panel-btn--invalid': isInvalid && showValidation}"
type="button"
class="tree-nodes-selector__toggle-panel-btn"
>
<span class="tree-nodes-selector__toggle-panel-btn__text">{{selectionLabel}}</span>
<digi-icon-arrow-down
aria-hidden="true"
class="tree-nodes-selector__toggle-panel-btn__arrow-down"
></digi-icon-arrow-down>
<span class="msfa__a11y-sr-only">
Klicka här för att öppna en kontroll där du kan välja mellan ett flertal alternativ i en hierarkisk struktur.
</span>
</button>
<div [attr.id]="panelId">
<msfa-tree-nodes-selector-panel
class="tree-nodes-selector__panel"
*ngIf="displayPanel && rootNode"
[rootNode]="rootNode"
[headingText]="headingText"
[confirmationButtonText]="confirmationButtonText"
(selectedChangesConfirmed)="updateTreeNodeSelectorWithSelectedChanges($event)"
(closePanelRequested)="closePanel()"
>
</msfa-tree-nodes-selector-panel>
</div>
</div>
<ul class="tree-nodes-selector__validation-messages" *ngIf="showValidation && validationMessages">
<li *ngFor="let validationMessage of validationMessages">
<digi-form-validation-message af-variation="error"> {{validationMessage}} </digi-form-validation-message>
</li>
</ul>
</div>

View File

@@ -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;
}
}

View File

@@ -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<TreeNodesSelectorComponent>;
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();
});
});

View File

@@ -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<string>;
@Output() selectedTreeNodesChanged = new EventEmitter<void>();
@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;
}
}

View File

@@ -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);
});
});
});

View File

@@ -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<TreeNode>;
}
export interface TreeNodeModel extends TreeNode {
uuid?: string;
expandedChildUuid?: string;
children?: Array<TreeNodeModel>;
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<TreeNode> = [];
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<TreeNodeModel> {
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<TreeNodeModel> | 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<TreeNodeModel> {
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<TreeNode> = [];
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));
}
}

View File

@@ -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 {}

View File

@@ -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();
});
});

View File

@@ -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<UtforandeVerksamhetAdress>;
}
@Injectable({
providedIn: 'root',
})
export class UtforandeVerksamheterService {
private readonly apiBaseUrl = `${environment.api.url}/utforandeverksamheter`;
constructor(private treeNodesSelectorService: TreeNodesSelectorService, private httpClient: HttpClient) {}
getUtforandeVerksamheter(tjanstIds: Array<number>): Observable<Array<UtforandeVerksamhet>> {
let params = new HttpParams();
let i: number;
if (!tjanstIds) {
return of<Array<UtforandeVerksamhet>>([]);
}
for (i = 0; i < tjanstIds.length; i++) {
params = params.append('tjansteIds', tjanstIds[i].toString());
}
return this.httpClient.get<Array<UtforandeVerksamhet>>(`${this.apiBaseUrl}`, { params });
}
getTreeNodeDataFromUtforandeVerksamheter(utforandeVerksamhetList: Array<UtforandeVerksamhet>): 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<UtforandeVerksamhet> {
let utforandeVerksamhetList: Array<UtforandeVerksamhet> = [];
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));
};
}

View File

@@ -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;
};
}
}

View File

@@ -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",