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

Squashed commit of the following:

commit f5029b04d2117df86eaf6692c88bdc692059d8d6
Merge: 3d776b1 45f2fb5
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Sat Sep 11 06:17:07 2021 +0200

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

commit 3d776b10824be6b54e186104a5bcd351e5b2fb42
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Fri Sep 10 23:26:47 2021 +0200

    TV-396 fixed some tests and so on

commit bd57fce383ba409ae8de1869c242b5a8f51071d2
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Fri Sep 10 23:20:26 2021 +0200

    TV-396 made some adjustments to the validation logic after feedback in PR

commit 942cddb263d0965e772f7f34305e85737da76df4
Merge: 174dfe9 ceee702
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Fri Sep 10 14:10:23 2021 +0200

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

commit 174dfe924f2eac979992275ddd55ed0758144efb
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Fri Sep 10 14:05:39 2021 +0200

    TV-396 fixed issue with general info after restructuring..

commit 7e0d4bdf9e76e0fb58fe30358c3e729cce1f9260
Merge: da02f6c 5b00453
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Fri Sep 10 12:00:29 2021 +0200

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

commit da02f6cc7f4a9405ad1d8167ef18729b18973d61
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Fri Sep 10 10:43:42 2021 +0200

    TV-396 added aria-expanded attribute

commit 48eb24ca6e354b44ae4d4b62ce2ffa496743d0b5
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Fri Sep 10 09:46:39 2021 +0200

    TV-396 moved some logic into seperate template

commit 0ef787d6c3700677ae793c486930b07748365412
Merge: 6dfd7b0 5f81d6f
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Thu Sep 9 17:34:40 2021 +0200

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

commit 6dfd7b00caa45d335f3fe8619b92c282038ac5cb
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Thu Sep 9 17:33:54 2021 +0200

    TV-396 used digi internal link instead of basic a-tag

commit 46c17011b7f6b1f628b14ccf020c06cdc95627c8
Merge: 4338e15 1938b94
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Thu Sep 9 15:07:16 2021 +0200

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

commit 4338e153f7e4ebdf8ab65a64a6194dbd4d9fa9c7
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Thu Sep 9 14:56:26 2021 +0200

    TV-396 added error summary an validation handling for the edit form etc.

commit ebb2e76993b99756d5a641ab8ca7d137be8a982f
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Wed Sep 8 22:58:44 2021 +0200

    TV-396 making sure utforande verksamheter and addresses are populated as they should be when editing an employee.

commit 01f4c9bf7ad8fc4ad44b0e8945182492b864d0cf
Merge: 1c2aa92 b06436a
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Wed Sep 8 22:07:25 2021 +0200

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

commit 1c2aa92f21aac57036ed05d5ebeab0f0e6a45c2c
Author: arbetsformedlingen_garcn <christian.gardebrink@arbetsformedlingen.se>
Date:   Wed Sep 8 22:07:00 2021 +0200

    Merge branch 'develop-remote' into bugs/TV-520

    # Conflicts:
    #	apps/mina-sidor-fa/src/app/pages/administration/pages/employee-card/employee-card.component.html
This commit is contained in:
Christian Gårdebrink
2021-09-11 06:19:16 +02:00
parent 45f2fb577a
commit 723ad02092
26 changed files with 724 additions and 723 deletions

View File

@@ -1,12 +1,17 @@
<digi-typography> <digi-typography #editEmployeeFormContainer>
<form <form
class="edit-employee-form" class="edit-employee-form"
*ngIf="editEmployeeFormGroup" *ngIf="editEmployeeFormGroup"
[formGroup]="editEmployeeFormGroup" [formGroup]="editEmployeeFormGroup"
(ngSubmit)="onFormSubmitted()" (ngSubmit)="onFormSubmitted()"
> >
<msfa-error-list
[hidden]="!displayErrorSummary"
[headingText]="'Åtgärda följande fel för att spara dina ändringar:'"
[validationErrorLinks]="getValidationErrorLinks()"
></msfa-error-list>
<digi-ng-form-input <digi-ng-form-input
afId="edit-employee-form-email" [afId]="emailElementId"
afLabel="E-post adress" afLabel="E-post adress"
afType="email" afType="email"
[formControlName]="emailFormControlName" [formControlName]="emailFormControlName"
@@ -20,7 +25,6 @@
<h2>Behörigheter</h2> <h2>Behörigheter</h2>
<p>Här kan du ändra personalkontots behörigheter.</p> <p>Här kan du ändra personalkontots behörigheter.</p>
</div> </div>
<div class="edit-employee-form__block"> <div class="edit-employee-form__block">
<h3>Tjänster</h3> <h3>Tjänster</h3>
<p>Välj de tjänster du vill ge personalen tillgång till.</p> <p>Välj de tjänster du vill ge personalen tillgång till.</p>
@@ -32,6 +36,7 @@
[afSelectItems]="selectableTjansterFormItems" [afSelectItems]="selectableTjansterFormItems"
[afDisableValidStyle]="true" [afDisableValidStyle]="true"
[afInvalid]="tjansterFormControl.invalid && tjansterFormControl.touched" [afInvalid]="tjansterFormControl.invalid && tjansterFormControl.touched"
[afId]="tjansterElementId"
(afOnChange)="toggleTjanst()" (afOnChange)="toggleTjanst()"
></digi-ng-form-select> ></digi-ng-form-select>
<digi-form-validation-message <digi-form-validation-message
@@ -48,22 +53,30 @@
<p *ngIf="!availableUtforandeVerksamheter || availableUtforandeVerksamheter.length === 0"> <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> <strong>Du måste välja en eller flera tjänster för att kunna välja utförande verksamheter.</strong>
</p> </p>
<ng-container *ngIf="!isLoadingUtforandeVerksamheter else loadingUtforandeVerksamheterTemplate">
<digi-ng-form-checkbox <digi-ng-form-checkbox
class="edit-employee-form__choose-all-utforande-verksamheter" class="edit-employee-form__choose-all-utforande-verksamheter"
[formControl]="toggleAllUtforandeVerksamhetFormControl" [formControl]="selectAllUtforandeVerksamheterFormControl"
[afLabel]="'Välj alla utförande verksamheter och alla utförande adresser'" [afLabel]="'Välj alla utförande verksamheter och alla utförande adresser'"
(afOnChange)="toggleAllUtforandeVerksamheter($event)"
></digi-ng-form-checkbox> ></digi-ng-form-checkbox>
<msfa-tree-nodes-selector <msfa-tree-nodes-selector
*ngIf="!toggleAllUtforandeVerksamhetFormControl.value" *ngIf="!selectAllUtforandeVerksamheterFormControl.value"
[buttonElementId]="utforandeVerksamhetElementId"
[headingText]="'Välj utförande verksamheter och adresser'" [headingText]="'Välj utförande verksamheter och adresser'"
[formControlName]="utforandeVerksamhetFormControlName" [formControlName]="utforandeVerksamheterFormControlName"
[isInvalid]="utforandeVerksamhetFormControl?.invalid" [isInvalid]="editEmployeeFormGroup.invalid && editEmployeeFormGroup.errors?.noUtforandeVerksamhetSelected"
[showValidation]="utforandeVerksamhetFormControl?.touched" [showValidation]="utforandeVerksamheterFormControl?.touched"
[validationMessages]="utforandeVerksamhetFormControl.errors?.required ? ['Välj minst en utförande verksamhet'] : []" [validationMessages]="editEmployeeFormGroup.errors?.noUtforandeVerksamhetSelected ? [utforandeVerksamhetRequiredMessage] : []"
(selectedTreeNodesChanged)="updateToggleAllUtforandeVerksamheter()" (selectedTreeNodesChanged)="updateToggleAllUtforandeVerksamheter()"
></msfa-tree-nodes-selector> ></msfa-tree-nodes-selector>
</ng-container>
<ng-template #loadingUtforandeVerksamheterTemplate>
<digi-ng-loader-spinner
*ngIf="isLoadingUtforandeVerksamheter"
[afLabel]="'Läser in utförande verksamheter för vald/a tjänster...'"
></digi-ng-loader-spinner>
</ng-template>
</div> </div>
<div class="edit-employee-form__block" *ngIf="rolesFormGroup && availableRoles" [formGroup]="rolesFormGroup"> <div class="edit-employee-form__block" *ngIf="rolesFormGroup && availableRoles" [formGroup]="rolesFormGroup">

View File

@@ -3,12 +3,14 @@ import { FormSelectItem } from '@af/digi-ng/_form/form-select';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
ElementRef,
EventEmitter, EventEmitter,
Input, Input,
OnChanges, OnChanges,
OnInit, OnInit,
Output, Output,
SimpleChanges, SimpleChanges,
ViewChild,
} from '@angular/core'; } from '@angular/core';
import { AbstractControl, FormControl, FormGroup } from '@angular/forms'; import { AbstractControl, FormControl, FormGroup } from '@angular/forms';
import { RoleEnum } from '@msfa-enums/role.enum'; import { RoleEnum } from '@msfa-enums/role.enum';
@@ -19,13 +21,12 @@ import { Role } from '@msfa-models/role.model';
import { Tjanst } from '@msfa-models/tjanst.model'; import { Tjanst } from '@msfa-models/tjanst.model';
import { UtforandeVerksamhet } from '@msfa-models/utforande-verksamhet.model'; import { UtforandeVerksamhet } from '@msfa-models/utforande-verksamhet.model';
import { UtforandeVerksamheterService } from '@msfa-services/utforande-verksamheter/utforande-verksamheter.service'; import { UtforandeVerksamheterService } from '@msfa-services/utforande-verksamheter/utforande-verksamheter.service';
import { import { ValidationErrorLink } from '@msfa-shared/components/error-list/error-list.component';
TreeNode, import { TreeNodesSelectorService } from '@msfa-shared/components/tree-nodes-selector/services/tree-nodes-selector.service';
TreeNodesSelectorService,
} from '@msfa-shared/components/tree-nodes-selector/services/tree-nodes-selector.service';
import { EmailValidator } from '@msfa-utils/validators/email.validator'; import { EmailValidator } from '@msfa-utils/validators/email.validator';
import { EmployeeValidator } from '@msfa-utils/validators/employee.validator';
import { RequiredValidator } from '@msfa-utils/validators/required.validator'; import { RequiredValidator } from '@msfa-utils/validators/required.validator';
import { TreeNodeValidator } from '@msfa-utils/validators/tree-node.validator'; import { UUID } from 'angular2-uuid';
import { EmployeeFormService } from '../services/employee-form.service'; import { EmployeeFormService } from '../services/employee-form.service';
@Component({ @Component({
@@ -35,9 +36,12 @@ import { EmployeeFormService } from '../services/employee-form.service';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class EditEmployeeFormComponent implements OnInit, OnChanges { export class EditEmployeeFormComponent implements OnInit, OnChanges {
@ViewChild('editEmployeeFormContainer') editEmployeeFormContainer: ElementRef;
@Input() employee: Employee; @Input() employee: Employee;
@Input() availableRoles: Role[]; @Input() availableRoles: Role[];
@Input() availableTjanster: Tjanst[]; @Input() availableTjanster: Tjanst[];
@Input() isLoadingUtforandeVerksamheter = false;
@Input() availableUtforandeVerksamheter: UtforandeVerksamhet[]; @Input() availableUtforandeVerksamheter: UtforandeVerksamhet[];
@Input() errorWhileUpdating: CustomError; @Input() errorWhileUpdating: CustomError;
@@ -51,14 +55,21 @@ export class EditEmployeeFormComponent implements OnInit, OnChanges {
readonly emailFormControlName = 'email'; readonly emailFormControlName = 'email';
readonly tjansterFormControlName = 'tjanster'; readonly tjansterFormControlName = 'tjanster';
readonly utforandeVerksamhetFormControlName = 'utforandeVerksamheter'; readonly utforandeVerksamheterFormControlName = 'utforandeVerksamheter';
readonly toggleAllUtforandeVerksamhetFormControlName = 'allaUtforandeVerksamheter'; readonly selectAllUtforandeVerksamheterFormControlName = 'allaUtforandeVerksamheter';
readonly utforandeVerksamhetRequiredMessage = 'Välj minst en utförande verksamhet';
readonly formUuid = UUID.UUID();
readonly emailElementId = `email-control-${this.formUuid}`;
readonly tjansterElementId = `tjanster-control-${this.formUuid}`;
readonly utforandeVerksamhetElementId = `utforande-verksamhet-control-${this.formUuid}`;
readonly firstValidationErrorLinkId = `validation-error-link-${this.formUuid}`;
editEmployeeFormGroup: FormGroup | null = null; editEmployeeFormGroup: FormGroup | null = null;
displayEditWithoutRolesDialog = false; displayEditWithoutRolesDialog = false;
displayPristineWarning = false; displayPristineWarning = false;
displayRolesDialog = false; displayRolesDialog = false;
displayErrorSummary = false;
selectableTjansterFormItems: Array<FormSelectItem> | null = null; selectableTjansterFormItems: Array<FormSelectItem> | null = null;
@@ -88,9 +99,11 @@ export class EditEmployeeFormComponent implements OnInit, OnChanges {
this.editEmployeeFormGroup.patchValue( this.editEmployeeFormGroup.patchValue(
Object.fromEntries([ Object.fromEntries([
[ [
this.utforandeVerksamhetFormControlName, this.utforandeVerksamheterFormControlName,
this.utforandeVerksamheterService.getTreeNodeDataFromUtforandeVerksamheter( this.utforandeVerksamheterService.getTreeNodeDataFromUtforandeVerksamheter(
this.availableUtforandeVerksamheter this.availableUtforandeVerksamheter,
this.employee?.utforandeVerksamheter,
this.employee?.allaUtforandeVerksamheter
), ),
], ],
]) ])
@@ -112,23 +125,23 @@ export class EditEmployeeFormComponent implements OnInit, OnChanges {
return this.editEmployeeFormGroup.get('roles'); return this.editEmployeeFormGroup.get('roles');
} }
get utforandeVerksamhetFormControl(): AbstractControl | undefined { get utforandeVerksamheterFormControl(): AbstractControl | undefined {
return this.editEmployeeFormGroup?.get(this.utforandeVerksamhetFormControlName); return this.editEmployeeFormGroup?.get(this.utforandeVerksamheterFormControlName);
} }
get toggleAllUtforandeVerksamhetFormControl(): AbstractControl | undefined { get selectAllUtforandeVerksamheterFormControl(): AbstractControl | undefined {
return this.editEmployeeFormGroup?.get(this.toggleAllUtforandeVerksamhetFormControlName); return this.editEmployeeFormGroup?.get(this.selectAllUtforandeVerksamheterFormControlName);
} }
private updateUtforandeVerksamhetStatus(): void { private updateUtforandeVerksamhetStatus(): void {
if (this.availableUtforandeVerksamheter && this.availableUtforandeVerksamheter.length > 0) { if (this.availableUtforandeVerksamheter && this.availableUtforandeVerksamheter.length > 0) {
this.utforandeVerksamhetFormControl.enable(); this.utforandeVerksamheterFormControl.enable();
this.toggleAllUtforandeVerksamhetFormControl.enable(); this.selectAllUtforandeVerksamheterFormControl.enable();
return; return;
} }
this.utforandeVerksamhetFormControl.disable(); this.utforandeVerksamheterFormControl.disable();
this.toggleAllUtforandeVerksamhetFormControl.disable(); this.selectAllUtforandeVerksamheterFormControl.disable();
} }
private updateSelectableTjansterFormItems(): void { private updateSelectableTjansterFormItems(): void {
@@ -143,22 +156,32 @@ export class EditEmployeeFormComponent implements OnInit, OnChanges {
? this.availableTjanster.find(tjanst => tjanst.code === currentTjanst.tjansteKod).tjanstId ? this.availableTjanster.find(tjanst => tjanst.code === currentTjanst.tjansteKod).tjanstId
: null; : null;
this.editEmployeeFormGroup = new FormGroup({ this.editEmployeeFormGroup = new FormGroup(
email: new FormControl(this.employee.email, [RequiredValidator('E-postadress'), EmailValidator()]), {
email: new FormControl(this.employee.email, [
RequiredValidator('E-postadress'),
EmailValidator('e-postadress'),
]),
tjanster: new FormControl(tjanstId, [RequiredValidator('Tjänst')]), tjanster: new FormControl(tjanstId, [RequiredValidator('Tjänst')]),
roles: this.employeeFormService.getRolesFormGroup(this.availableRoles, this.employee.roles), roles: this.employeeFormService.getRolesFormGroup(this.availableRoles, this.employee.roles),
utforandeVerksamheter: new FormControl( utforandeVerksamheter: new FormControl(
this.utforandeVerksamheterService.getTreeNodeDataFromUtforandeVerksamheter(this.availableUtforandeVerksamheter), this.utforandeVerksamheterService.getTreeNodeDataFromUtforandeVerksamheter(
[ this.availableUtforandeVerksamheter,
TreeNodeValidator.IsValidTreeNode( this.employee?.utforandeVerksamheter,
this.utforandeVerksamheterService.hasSelectedUtforandeVerksamhet, this.employee?.allaUtforandeVerksamheter
'required',
this.toggleAllUtforandeVerksamhetFormControl
), ),
] []
), ),
allaUtforandeVerksamheter: new FormControl(this.employee.allaUtforandeVerksamheter), allaUtforandeVerksamheter: new FormControl(this.employee.allaUtforandeVerksamheter),
}); },
{
validators: EmployeeValidator.HasSelectedAtLeastOneUtforandeVerksamhet(
this.utforandeVerksamheterFormControlName,
this.selectAllUtforandeVerksamheterFormControlName,
this.utforandeVerksamheterService.hasSelectedUtforandeVerksamhet
),
}
);
this.updateUtforandeVerksamhetStatus(); this.updateUtforandeVerksamhetStatus();
} }
@@ -172,22 +195,27 @@ export class EditEmployeeFormComponent implements OnInit, OnChanges {
} }
onFormSubmitted(saveWithoutRoles = false): void { onFormSubmitted(saveWithoutRoles = false): void {
if (!this.editEmployeeFormGroup) { if (!this.editEmployeeFormGroup || this.isLoadingUtforandeVerksamheter) {
return; return;
} }
this.displayPristineWarning = this.editEmployeeFormGroup.pristine;
this.editEmployeeFormGroup.markAllAsTouched(); this.editEmployeeFormGroup.markAllAsTouched();
if (this.editEmployeeFormGroup.invalid) { if (this.editEmployeeFormGroup.invalid || this.editEmployeeFormGroup.pristine) {
this.displayErrorSummary = true;
setTimeout(() => {
this.focusLinkElement('.error-list__validation-error-link a');
});
return; return;
} }
if (this.editEmployeeFormGroup.pristine) { this.displayErrorSummary = false;
this.displayPristineWarning = true;
return;
}
const roles = this.employeeFormService.getRolesFromFormGroup(this.rolesFormGroup, this.availableRoles); const roles = this.employeeFormService.getRolesFromFormGroup(this.rolesFormGroup, this.availableRoles);
if (!roles.length && !saveWithoutRoles) { if (!roles.length && !saveWithoutRoles) {
this.displayEditWithoutRolesDialog = true; this.displayEditWithoutRolesDialog = true;
return; return;
@@ -204,22 +232,68 @@ export class EditEmployeeFormComponent implements OnInit, OnChanges {
RoleEnum.MSFA_Standard, RoleEnum.MSFA_Standard,
]), ]),
], ],
adressIds: this.toggleAllUtforandeVerksamhetFormControl.value adressIds: this.selectAllUtforandeVerksamheterFormControl.value
? [] ? []
: this.utforandeVerksamheterService.getSelectedAdressIdsFromTreeNode( : this.utforandeVerksamheterService.getSelectedAdressIdsFromTreeNode(
this.utforandeVerksamhetFormControl?.value this.utforandeVerksamheterFormControl?.value
), ),
allaUtforandeVerksamheter: !!this.toggleAllUtforandeVerksamhetFormControl.value, allaUtforandeVerksamheter: !!this.selectAllUtforandeVerksamheterFormControl.value,
}); });
} }
getValidationErrorLinks(): ValidationErrorLink[] {
let validationErrorLinks: ValidationErrorLink[] = [];
if (!this.editEmployeeFormGroup) {
return;
}
if (this.emailFormControl?.errors) {
validationErrorLinks = validationErrorLinks.concat({
elementId: this.emailElementId,
text: this.emailFormControl?.errors?.message as string,
});
}
if (this.tjansterFormControl?.errors) {
validationErrorLinks = validationErrorLinks.concat({
elementId: this.tjansterElementId,
text: this.tjansterFormControl?.errors?.message as string,
});
}
if (this.editEmployeeFormGroup.errors?.noUtforandeVerksamhetSelected) {
validationErrorLinks = validationErrorLinks.concat({
elementId: this.utforandeVerksamhetElementId,
text: this.utforandeVerksamhetRequiredMessage,
});
}
return validationErrorLinks;
}
focusLinkElement(selector: string): void {
let errorListElement: HTMLElement = null;
let linkElement: HTMLLinkElement = null;
if (!this.editEmployeeFormContainer || !this.editEmployeeFormContainer.nativeElement) {
return;
}
errorListElement = this.editEmployeeFormContainer.nativeElement as HTMLElement;
linkElement = errorListElement?.querySelector(selector);
linkElement?.focus();
}
toggleTjanst(): void { toggleTjanst(): void {
if (this.tjansterFormControl.value) { if (!this.tjansterFormControl.value) {
return;
}
this.tjansterSelected.emit( this.tjansterSelected.emit(
this.employeeFormService.getSelectedTjanster(this.availableTjanster, +this.tjansterFormControl.value) this.employeeFormService.getSelectedTjanster(this.availableTjanster, +this.tjansterFormControl.value)
); );
} }
}
openRolesDialog(): void { openRolesDialog(): void {
this.displayRolesDialog = true; this.displayRolesDialog = true;
@@ -229,23 +303,13 @@ export class EditEmployeeFormComponent implements OnInit, OnChanges {
this.displayRolesDialog = false; this.displayRolesDialog = false;
} }
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 { updateToggleAllUtforandeVerksamheter(): void {
const hasSelectedAllLeafNodes = this.treeNodesSelectorService.hasSelectedAllLeafNodes( const hasSelectedAllLeafNodes = this.treeNodesSelectorService.hasSelectedAllLeafNodes(
this.utforandeVerksamhetFormControl.value this.utforandeVerksamheterFormControl.value
); );
this.editEmployeeFormGroup.patchValue( this.editEmployeeFormGroup.patchValue(
Object.fromEntries([[this.toggleAllUtforandeVerksamhetFormControlName, hasSelectedAllLeafNodes]]) Object.fromEntries([[this.selectAllUtforandeVerksamheterFormControlName, hasSelectedAllLeafNodes]])
); );
} }

View File

@@ -1,6 +1,6 @@
<msfa-layout> <msfa-layout>
<digi-typography> <digi-typography>
<section class="employee-form" *ngIf="employee$ | async as employee"> <section class="employee-form">
<header class="employee-form__header"> <header class="employee-form__header">
<h1>Redigera personalkonto</h1> <h1>Redigera personalkonto</h1>
<msfa-employee-delete [returnToEmployeeList]="true"></msfa-employee-delete> <msfa-employee-delete [returnToEmployeeList]="true"></msfa-employee-delete>
@@ -13,7 +13,7 @@
Ta bort konto Ta bort konto
</digi-button> --> </digi-button> -->
</header> </header>
<ng-container *ngIf="employee$ | async as employee; else isLoadingEmployeeTemplate">
<div class="employee-form__block"> <div class="employee-form__block">
<h2>Personuppgifter</h2> <h2>Personuppgifter</h2>
<dl> <dl>
@@ -41,6 +41,7 @@
[employee]="employee" [employee]="employee"
[availableRoles]="availableRoles" [availableRoles]="availableRoles"
[availableTjanster]="tjanster$ | async" [availableTjanster]="tjanster$ | async"
[isLoadingUtforandeVerksamheter]="isLoadingUtforandeVerksamheter$ | async"
[availableUtforandeVerksamheter]="availableUtforandeVerksamheter$ | async" [availableUtforandeVerksamheter]="availableUtforandeVerksamheter$ | async"
[errorWhileUpdating]="errorWhileUpdating$ | async" [errorWhileUpdating]="errorWhileUpdating$ | async"
(tjansterSelected)="setupAvailableUtforandeVerksamheter($event)" (tjansterSelected)="setupAvailableUtforandeVerksamheter($event)"
@@ -48,6 +49,10 @@
(closeError)="closeError()" (closeError)="closeError()"
></msfa-edit-employee-form> ></msfa-edit-employee-form>
</div> </div>
</ng-container>
<ng-template #isLoadingEmployeeTemplate>
<digi-ng-skeleton-base [afCount]="3" afText="Laddar personalkonto..."></digi-ng-skeleton-base>
</ng-template>
</section> </section>
</digi-typography> </digi-typography>
</msfa-layout> </msfa-layout>

View File

@@ -10,8 +10,8 @@ import { EmployeeService } from '@msfa-services/api/employee.service';
import { TjanstService } from '@msfa-services/api/tjanst.service'; import { TjanstService } from '@msfa-services/api/tjanst.service';
import { RoleService } from '@msfa-services/role.service'; import { RoleService } from '@msfa-services/role.service';
import { UtforandeVerksamheterService } from '@msfa-services/utforande-verksamheter/utforande-verksamheter.service'; import { UtforandeVerksamheterService } from '@msfa-services/utforande-verksamheter/utforande-verksamheter.service';
import { BehaviorSubject, Observable } from 'rxjs'; import { BehaviorSubject, Observable, of } from 'rxjs';
import { filter, switchMap } from 'rxjs/operators'; import { catchError, mapTo, startWith, switchMap } from 'rxjs/operators';
@Component({ @Component({
selector: 'msfa-employee-form', selector: 'msfa-employee-form',
@@ -23,16 +23,18 @@ export class EmployeeFormComponent implements OnInit {
private _employeeId$ = new BehaviorSubject<string>(this.activatedRoute.snapshot.params['employeeId']); private _employeeId$ = new BehaviorSubject<string>(this.activatedRoute.snapshot.params['employeeId']);
private _selectedTjanstIds$ = new BehaviorSubject<number[]>(null); private _selectedTjanstIds$ = new BehaviorSubject<number[]>(null);
private _errorWhileUpdating$ = new BehaviorSubject<CustomError>(null); private _errorWhileUpdating$ = new BehaviorSubject<CustomError>(null);
errorWhileUpdating$: Observable<CustomError> = this._errorWhileUpdating$.asObservable();
employee$ = this.employeeService.employee$;
tjanster$: Observable<Tjanst[]> = this.tjanstService.tjanster$;
availableUtforandeVerksamheter$: Observable<UtforandeVerksamhet[]> = this._selectedTjanstIds$.pipe( availableUtforandeVerksamheter$: Observable<UtforandeVerksamhet[]> = this._selectedTjanstIds$.pipe(
filter(selectedTjanstIds => !!selectedTjanstIds?.length),
switchMap(selectedTjanstIds => this.utforandeVerksamheterService.fetchUtforandeVerksamheter$(selectedTjanstIds)) switchMap(selectedTjanstIds => this.utforandeVerksamheterService.fetchUtforandeVerksamheter$(selectedTjanstIds))
); );
errorWhileUpdating$: Observable<CustomError> = this._errorWhileUpdating$.asObservable();
employee$ = this.employeeService.employee$;
tjanster$: Observable<Tjanst[]> = this.tjanstService.tjanster$;
availableRoles: Role[] = this.roleService.allRoles; availableRoles: Role[] = this.roleService.allRoles;
isLoadingUtforandeVerksamheter$: Observable<boolean>;
constructor( constructor(
private employeeService: EmployeeService, private employeeService: EmployeeService,
private roleService: RoleService, private roleService: RoleService,
@@ -68,6 +70,11 @@ export class EmployeeFormComponent implements OnInit {
setupAvailableUtforandeVerksamheter(selectedTjanster: Tjanst[]): void { setupAvailableUtforandeVerksamheter(selectedTjanster: Tjanst[]): void {
this._selectedTjanstIds$.next(selectedTjanster.map(tjanst => tjanst.tjanstId)); this._selectedTjanstIds$.next(selectedTjanster.map(tjanst => tjanst.tjanstId));
this.isLoadingUtforandeVerksamheter$ = this.availableUtforandeVerksamheter$.pipe(
mapTo(false),
catchError(() => of(false)),
startWith(true)
);
} }
setEmployeeToDelete(employee: Employee): void { setEmployeeToDelete(employee: Employee): void {
this.employeeService.setEmployeeToDelete(employee); this.employeeService.setEmployeeToDelete(employee);

View File

@@ -6,6 +6,7 @@ import { DigiNgFormInputModule } from '@af/digi-ng/_form/form-input';
import { DigiNgFormRadiobuttonGroupModule } from '@af/digi-ng/_form/form-radiobutton-group'; import { DigiNgFormRadiobuttonGroupModule } from '@af/digi-ng/_form/form-radiobutton-group';
import { DigiNgFormSelectModule } from '@af/digi-ng/_form/form-select'; import { DigiNgFormSelectModule } from '@af/digi-ng/_form/form-select';
import { DigiNgPopoverModule } from '@af/digi-ng/_popover/popover'; import { DigiNgPopoverModule } from '@af/digi-ng/_popover/popover';
import { DigiNgLoaderSpinnerModule } from '@af/digi-ng/_loader/loader-spinner';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';
@@ -18,6 +19,8 @@ import { LocalDatePipeModule } from '@msfa-shared/pipes/local-date/local-date.mo
import { EmployeeDeleteModule } from '../../components/employee-delete/employee-delete.module'; import { EmployeeDeleteModule } from '../../components/employee-delete/employee-delete.module';
import { EditEmployeeFormComponent } from './edit-employee-form/edit-employee-form.component'; import { EditEmployeeFormComponent } from './edit-employee-form/edit-employee-form.component';
import { EmployeeFormComponent } from './employee-form.component'; import { EmployeeFormComponent } from './employee-form.component';
import { DigiNgSkeletonBaseModule } from '@af/digi-ng/_skeleton/skeleton-base';
import { ErrorListModule } from '@msfa-shared/components/error-list/error-list.module';
@NgModule({ @NgModule({
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
@@ -34,11 +37,14 @@ import { EmployeeFormComponent } from './employee-form.component';
DigiNgPopoverModule, DigiNgPopoverModule,
DigiNgFormCheckboxModule, DigiNgFormCheckboxModule,
DigiNgButtonModule, DigiNgButtonModule,
DigiNgLoaderSpinnerModule,
DigiNgSkeletonBaseModule,
LayoutModule, LayoutModule,
EmployeeDeleteModule, EmployeeDeleteModule,
DigiNgDialogModule, DigiNgDialogModule,
HideTextModule, HideTextModule,
TreeNodesSelectorModule, TreeNodesSelectorModule,
ErrorListModule,
RolesDialogModule, RolesDialogModule,
], ],
}) })

View File

@@ -0,0 +1,34 @@
<div class="error-list-wrapper" [hidden]="!validationErrorLinks || validationErrorLinks.length === 0">
<digi-notification-alert
class="error-list"
af-variation="danger"
[attr.af-heading]="headingText"
[af-heading-level]="headingLevel"
[afCloseable]="false"
>
<ul class="error-list__validation-error-links">
<li *ngFor="let validationErrorLink of validationErrorLinks;">
<digi-ng-link-internal
msfaAnchorLink
class="error-list__validation-error-link"
[afHref]="'#' + validationErrorLink.elementId"
[afText]="validationErrorLink.text"
></digi-ng-link-internal>
</li>
</ul>
</digi-notification-alert>
</div>
<!-- <digi-form-error-list
class="edit-employee-form__validation-error-summary"
af-heading="Åtgärda följande fel för att spara dina ändringar:"
*ngIf="validationErrorLinks && validationErrorLinks.length !== 0"
>
Behöver hantera ankarlänkar kopplat till den här komponenten om det ska fungera att använda den...
<a
[attr.href]="'#' + validationErrorLink.elementId"
[attr.id]="first ? firstValidationErrorLinkId : 'validation-error-link-' + index"
*ngFor="let validationErrorLink of validationErrorLinks; let first = first; let index = index"
>
{{ validationErrorLink.text }}
</a>
</digi-form-error-list> -->

View File

@@ -0,0 +1,15 @@
@import 'mixins/list';
@import 'variables/gutters';
.error-list {
display: block;
margin: 1.5rem 0;
&__validation-error-links {
@include msfa__reset-list;
display: flex;
flex-direction: column;
gap: $digi--layout--gutter;
margin: 1.5rem 0;
}
}

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ErrorListComponent } from './error-list.component';
describe('ErrorListComponent', () => {
let component: ErrorListComponent;
let fixture: ComponentFixture<ErrorListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ErrorListComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ErrorListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,19 @@
import { TypographyDynamicHeadingLevel } from '@af/digi-ng/_typography/typography-dynamic-heading';
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
export interface ValidationErrorLink {
elementId: string;
text: string;
}
@Component({
selector: 'msfa-error-list',
templateUrl: './error-list.component.html',
styleUrls: ['./error-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ErrorListComponent {
@Input() validationErrorLinks: ValidationErrorLink[] = [];
@Input() headingText: string;
@Input() headingLevel: TypographyDynamicHeadingLevel = TypographyDynamicHeadingLevel.H3;
}

View File

@@ -0,0 +1,13 @@
import { DigiNgLinkInternalModule } from '@af/digi-ng/_link/link-internal';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AnchorLinkModule } from '@msfa-directives/anchor-link.module';
import { ErrorListComponent } from './error-list.component';
@NgModule({
schemas: [CUSTOM_ELEMENTS_SCHEMA],
declarations: [ErrorListComponent],
imports: [CommonModule, AnchorLinkModule, DigiNgLinkInternalModule],
exports: [ErrorListComponent],
})
export class ErrorListModule {}

View File

@@ -1,88 +1,115 @@
<div class="expanded-tree-node" *ngIf="treeNodeModel"> <div
class="expanded-tree-node"
[ngClass]="{
'expanded-tree-node--is-root-node' : treeNodeModel?.isRoot
}"
>
<div class="expanded-tree-node__content">
<digi-ng-typography-dynamic-heading <digi-ng-typography-dynamic-heading
class="expanded-tree-node__heading" class="expanded-tree-node__heading"
[afText]="getTreeNodeHeadingText(treeNodeModel)" [afText]="headingText"
[afLevel]="headingLevel" [afLevel]="headingLevel"
></digi-ng-typography-dynamic-heading> ></digi-ng-typography-dynamic-heading>
<p class="expanded-tree-node__info" *ngIf="treeNodeModel.showGeneralInfoAboutGrandChildren"> <ng-container *ngIf="showGeneralInfo else treeNodeChildrenTemplate">
{{treeNodeModel.grandChildrenInfo}} <p class="expanded-tree-node__info">{{generalInfo}}</p>
</p> </ng-container>
<div </div>
*ngIf="treeNodeModel.children && !treeNodeModel.showGeneralInfoAboutGrandChildren" <ng-template #treeNodeChildrenTemplate>
class="expanded-tree-node__filter" <ng-container *ngIf="treeNodeModel">
> <div *ngIf="treeNodeModel.children" class="expanded-tree-node__filter">
<digi-form-input-search <digi-form-input-search
[attr.af-variation]="FormInputSearchVariation.S" [attr.af-variation]="FormInputSearchVariation.S"
[attr.af-button-text]="getFilterButtonAriaLabelText(treeNodeModel)" [attr.af-button-text]="getFilterButtonAriaLabelText(treeNodeModel)"
[attr.af-button-type]="ButtonType.BUTTON" [attr.af-button-type]="ButtonType.BUTTON"
[attr.af-label]="' '" [attr.af-label]="' '"
[attr.af-aria-labelledby]="getFilterDescriptionId(treeNodeModel)" [attr.af-aria-labelledby]="'filter-description-'+treeNodeModel.uuid"
[attr.af-value]="treeNodeModel.filterText"
(afOnFocusOutside)="onFocusOutsideFilter($event)" (afOnFocusOutside)="onFocusOutsideFilter($event)"
(afOnChange)="onFilterTextChanged($event, treeNodeModel)" (afOnChange)="onFilterTextChanged($event, treeNodeModel)"
(afOnKeyup)="onFilterTextChanged($event, treeNodeModel)" (afOnKeyup)="onFilterTextChanged($event, treeNodeModel)"
></digi-form-input-search> ></digi-form-input-search>
<div class="msfa__a11y-sr-only" [attr.id]="getFilterDescriptionId(treeNodeModel)"> <div class="msfa__a11y-sr-only" [attr.id]="'filter-description-'+treeNodeModel.uuid">
Filtrera valbara alternativ där deras namn måste innehålla den angivna texten. Filtrera valbara alternativ där deras namn måste innehålla den angivna texten.
</div> </div>
</div> </div>
<div <digi-form-checkbox
*ngIf="hasChildLeafNodes(treeNodeModel)" *ngIf="hasChildLeafNodes(treeNodeModel)"
class="expanded-tree-node__node-checkbox-presentation expanded-tree-node__node-checkbox-presentation--toggle-all" class="expanded-tree-node__child-node-checkbox expanded-tree-node__child-node-checkbox--toggle-all"
[ngClass]="{ [attr.af-label]="treeNodeModel.toggleAllChildrenLabel"
'expanded-tree-node__node-checkbox-presentation--focus': treeNodeModel.toggleAllHasFocus, [attr.af-aria-labelledby]="'toggle-all-description-'+treeNodeModel.uuid"
'expanded-tree-node__node-checkbox-presentation--checked': allChildLeafNodesAreSelected(treeNodeModel) [attr.af-checked]="allChildLeafNodesAreSelected(treeNodeModel)"
}" (afOnChange)="onToggleAllChildLeafNodes(treeNodeModel)"
[attr.id]="getPresentationToggleAllId(treeNodeModel)" ></digi-form-checkbox>
(click)="nodePresentationToggleAllClicked(treeNodeModel)" <div class="msfa__a11y-sr-only" [attr.af-id]="'toggle-all-description-'+treeNodeModel.uuid">
> {{getAriaLabelForToggleAllButton(treeNodeModel)}}
<span class="expanded-tree-node__node-checkbox-presentation__box"></span>
<span class="expanded-tree-node__node-checkbox-presentation__text">{{treeNodeModel.toggleAllChildrenLabel}}</span>
</div> </div>
<ng-container *ngIf="visibleChildren"> <ul *ngIf="visibleChildren" class="expanded-tree-node__nodes">
<ul *ngIf="!treeNodeModel.showGeneralInfoAboutGrandChildren" class="expanded-tree-node__nodes"> <li *ngFor="let childNode of visibleChildren" class="expanded-tree-node__node">
<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 <ng-container
[ngTemplateOutlet]="childNode.children ? nodeExpansionPresentationTemplate : nodeCheckboxPresentationTemplate" [ngTemplateOutlet]="childNode.children ? nodeExpansionTemplate : nodeCheckboxTemplate"
[ngTemplateOutletContext]="{node: childNode, parentNode: treeNodeModel}" [ngTemplateOutletContext]="{node: childNode, parentNode: treeNodeModel, isExpanded: isExpandedNode(treeNodeModel, childNode)}"
></ng-container> >
</ng-container>
</li> </li>
</ul> </ul>
<ng-container
*ngIf="showGeneralInfoTemplate(treeNodeModel)"
[ngTemplateOutlet]="generalInfoPanelTemplate"
></ng-container>
</ng-container> </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>
<ng-template #nodeExpansionPresentationTemplate let-node="node" let-parentNode="parentNode"> <ng-template #nodeCheckboxTemplate let-node="node">
<div <digi-form-checkbox
class="expanded-tree-node__node-expansion-presentation" class="expanded-tree-node__child-node-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)"
></digi-form-checkbox>
</ng-template>
<ng-template #nodeExpansionTemplate let-node="node" let-parentNode="parentNode" let-isExpanded="isExpanded">
<button
type="button"
class="expanded-tree-node__child-node-expansion-btn"
[ngClass]="{ [ngClass]="{
'expanded-tree-node__node-expansion-presentation--focus': node.hasFocus, 'expanded-tree-node__child-node-expansion-btn--active' : isExpanded
'expanded-tree-node__node-expansion-presentation--active' : isExpandedNode(parentNode, node)
}" }"
(click)="nodePresentationItemClicked(node)" [attr.aria-expanded]="isExpanded"
[attr.aria-label]="getAriaLabelTextForExpansionButton(parentNode, node)"
[attr.aria-controls]="'expansion-panel-'+node.uuid"
(click)="onSetExpandedChild(parentNode, node)"
> >
<span class="expanded-tree-node__node-expansion-presentation__text">{{node.label}}</span> <span class="expanded-tree-node__child-node-expansion-btn__text">{{node.label}}</span>
<span <span
class="expanded-tree-node__node-expansion-presentation__has-selection-dot" class="expanded-tree-node__child-node-expansion-btn__has-selection-dot"
*ngIf="hasSelectedDescendant(node)" *ngIf="hasSelectedDescendant(node)"
aria-hidden="true" aria-hidden="true"
></span> ></span>
<digi-icon-arrow-right class="expanded-tree-node__node-expansion-presentation__icon"></digi-icon-arrow-right> <digi-icon-arrow-right class="expanded-tree-node__child-node-expansion-btn__icon"></digi-icon-arrow-right>
</button>
<div
[attr.id]="'expansion-panel-'+node.uuid"
class="expanded-tree-node__child-node-expansion-panel"
[ngClass]="{'expanded-tree-node__child-node-expansion-panel--active': isExpanded}"
>
<msfa-expanded-tree-node
*ngIf="isExpanded"
[treeNodeModel]="node"
[headingText]="node.label"
[visibleChildren]="childNodeVisibleChildren"
(filterVisibleChildrenRequested)="updateChildNodeVisibleChildren($event)"
(selectedNodesChanged)="updateExpandedChildPanel($event)"
></msfa-expanded-tree-node>
</div>
</ng-template>
<ng-template #generalInfoPanelTemplate>
<div class="expanded-tree-node__child-node-expansion-panel expanded-tree-node__child-node-expansion-panel--active">
<msfa-expanded-tree-node
[headingText]="treeNodeModel.grandChildrenItemType"
[showGeneralInfo]="true"
[generalInfo]="treeNodeModel.grandChildrenInfo"
></msfa-expanded-tree-node>
</div> </div>
</ng-template> </ng-template>
</div> </div>

View File

@@ -4,11 +4,24 @@
@import 'variables/gutters'; @import 'variables/gutters';
.expanded-tree-node { .expanded-tree-node {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
padding-top: $digi--layout--gutter--m;
&--is-root-node {
position: static;
}
&__content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100%; height: 100%;
max-height: 300px;
overflow: auto; overflow: auto;
}
&__filter { &__filter {
padding: 0 0.9375rem; padding: 0 0.9375rem;
@@ -60,53 +73,7 @@
} }
} }
&__node-checkbox-presentation { &__child-node-expansion-btn {
font-size: var(--digi--typography--font-size--s);
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 $digi--layout--gutter;
}
&--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; display: inline-flex;
padding: 0.3125rem 0.9375rem; padding: 0.3125rem 0.9375rem;
justify-content: space-between; justify-content: space-between;
@@ -122,7 +89,7 @@
position: relative; position: relative;
&:hover, &:hover,
&--focus { &:focus {
background-color: var(--digi--ui--color--background--secondary); background-color: var(--digi--ui--color--background--secondary);
} }
@@ -131,7 +98,7 @@
color: var(--digi--ui--color--background); color: var(--digi--ui--color--background);
&:hover, &:hover,
&--focus { &:focus {
background-color: lighten($digi--ui--color--primary, 10%); background-color: lighten($digi--ui--color--primary, 10%);
} }
} }
@@ -139,7 +106,6 @@
&__text { &__text {
text-align: left; text-align: left;
flex-grow: 1; flex-grow: 1;
max-width: rem(450);
margin-right: $digi--layout--gutter--s; margin-right: $digi--layout--gutter--s;
display: block; display: block;
overflow: hidden; overflow: hidden;
@@ -163,4 +129,33 @@
max-height: 1em; max-height: 1em;
} }
} }
&__child-node-checkbox {
display: block;
margin: 0.3125rem $digi--layout--gutter;
&--toggle-all {
margin: 1.25rem $digi--layout--gutter;
}
::ng-deep {
.digi-form-checkbox__label {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
&__child-node-expansion-panel {
&--active {
position: absolute;
top: 0;
left: 100%;
border-left: 1px solid var(--digi--typography--color--text--disabled);
height: 100%;
width: 100%;
}
}
} }

View File

@@ -1,13 +1,12 @@
import { TypographyDynamicHeadingLevel } from '@af/digi-ng/_typography/typography-dynamic-heading'; import { TypographyDynamicHeadingLevel } from '@af/digi-ng/_typography/typography-dynamic-heading';
import { import {
Component, Component,
OnInit,
ChangeDetectionStrategy, ChangeDetectionStrategy,
Input, Input,
OnChanges,
SimpleChanges,
Output, Output,
EventEmitter, EventEmitter,
OnChanges,
SimpleChanges,
} from '@angular/core'; } from '@angular/core';
import { UnsubscribeDirective } from '@msfa-directives/unsubscribe.directive'; import { UnsubscribeDirective } from '@msfa-directives/unsubscribe.directive';
import { ButtonType } from '../../../../../pages/avrop/enums/button-type.enum'; import { ButtonType } from '../../../../../pages/avrop/enums/button-type.enum';
@@ -26,42 +25,44 @@ import {
styleUrls: ['./expanded-tree-node.component.scss'], styleUrls: ['./expanded-tree-node.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ExpandedTreeNodeComponent extends UnsubscribeDirective implements OnInit, OnChanges { export class ExpandedTreeNodeComponent extends UnsubscribeDirective implements OnChanges {
@Input() treeNodeModel: TreeNodeModel | null = null; @Input() treeNodeModel: TreeNodeModel;
@Input() headingLevel: TypographyDynamicHeadingLevel = TypographyDynamicHeadingLevel.H3; @Input() headingLevel: TypographyDynamicHeadingLevel = TypographyDynamicHeadingLevel.H3;
@Input() latestParentActionKey: string; @Input() headingText: string;
@Output() filterTreeNodeRequested = new EventEmitter<FilterTreeNodeData>(); @Input() showGeneralInfo: boolean;
@Output() clickAndFocusElementRequested = new EventEmitter<string>(); @Input() generalInfo: string;
@Input() visibleChildren: Array<TreeNodeModel>;
@Output() filterVisibleChildrenRequested = new EventEmitter<FilterTreeNodeData>();
@Output() selectedNodesChanged = new EventEmitter<TreeNodeModel>();
readonly ButtonType = ButtonType; readonly ButtonType = ButtonType;
readonly FormInputSearchVariation = FormInputSearchVariation; readonly FormInputSearchVariation = FormInputSearchVariation;
visibleChildren: Array<TreeNodeModel> | null = null; childNodeVisibleChildren: Array<TreeNodeModel>;
private readonly filterTreeNodeDebouncer: Subject<FilterTreeNodeData> = new Subject<FilterTreeNodeData>(); private readonly filterTreeNodeDebouncer: Subject<string> = new Subject<string>();
constructor(private treeNodesSelectorService: TreeNodesSelectorService) { constructor(private treeNodesSelectorService: TreeNodesSelectorService) {
super(); super();
super.unsubscribeOnDestroy( super.unsubscribeOnDestroy(
this.filterTreeNodeDebouncer.pipe(debounceTime(200)).subscribe(filterTreeNodeData => { this.filterTreeNodeDebouncer.pipe(debounceTime(200)).subscribe(text => {
this.filterTreeNodeRequested.emit(filterTreeNodeData); this.filterVisibleChildrenRequested.emit({ text, treeNode: this.treeNodeModel });
}) })
); );
} }
ngOnInit(): void {
this.refreshComponentData();
}
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if (changes.latestParentActionKey || changes.treeNodeModel) { let expandedChildNode: TreeNodeModel | null = null;
this.refreshComponentData();
}
}
private refreshComponentData(): void { if (changes.visibleChildren) {
this.visibleChildren = this.treeNodesSelectorService.getVisibleChildren(this.treeNodeModel); expandedChildNode = this.visibleChildren?.find(childNode => this.isExpandedNode(this.treeNodeModel, childNode));
this.updateChildNodeVisibleChildren({
text: expandedChildNode?.filterText,
treeNode: expandedChildNode,
});
}
} }
onFilterTextChanged(event: CustomEvent<{ target: { value: string } }>, treeNode: TreeNodeModel): void { onFilterTextChanged(event: CustomEvent<{ target: { value: string } }>, treeNode: TreeNodeModel): void {
@@ -69,10 +70,7 @@ export class ExpandedTreeNodeComponent extends UnsubscribeDirective implements O
return; return;
} }
this.filterTreeNodeDebouncer.next({ this.filterTreeNodeDebouncer.next(event.detail.target.value);
text: event.detail.target.value,
treeNode,
});
} }
onFocusOutsideFilter(event: Event): void { onFocusOutsideFilter(event: Event): void {
@@ -80,42 +78,68 @@ export class ExpandedTreeNodeComponent extends UnsubscribeDirective implements O
event.stopPropagation(); event.stopPropagation();
} }
nodePresentationToggleAllClicked(treeNode: TreeNodeModel): void { onToggleSelected(treeNode: TreeNodeModel, event: CustomEvent<{ target: { checked: boolean } }>): void {
if (!treeNode) { if (!treeNode || !event?.detail?.target) {
return; return;
} }
this.clickAndFocusElementRequested.emit(`#${this.treeNodesSelectorService.getToggleAllButtonId(treeNode)}`); treeNode.isSelected = event?.detail?.target?.checked;
this.selectedNodesChanged.emit(this.treeNodeModel);
} }
nodePresentationItemClicked(treeNode: TreeNodeModel): void { onToggleAllChildLeafNodes(treeNode: TreeNodeModel): void {
if (!treeNode) { let allChildLeafNodesAreSelected = false;
if (!treeNode || !treeNode.children) {
return; return;
} }
this.clickAndFocusElementRequested.emit(`#${this.treeNodesSelectorService.getItemButtonId(treeNode)}`); allChildLeafNodesAreSelected = this.allChildLeafNodesAreSelected(treeNode);
treeNode.children = treeNode.children.map(child => {
return { ...child, isSelected: !allChildLeafNodesAreSelected };
});
this.selectedNodesChanged.emit(this.treeNodeModel);
} }
getPresentationItemId(treeNode: TreeNodeModel): string { onSetExpandedChild(parentTreeNode: TreeNodeModel, treeNode: TreeNodeModel): void {
return this.treeNodesSelectorService.getPresentationItemId(treeNode); if (!parentTreeNode || !treeNode) {
return;
} }
getPresentationToggleAllId(treeNode: TreeNodeModel): string { parentTreeNode.expandedChildUuid = parentTreeNode.expandedChildUuid === treeNode.uuid ? null : treeNode.uuid;
return this.treeNodesSelectorService.getPresentationToggleAllId(treeNode);
this.updateChildNodeVisibleChildren({ text: treeNode.filterText, treeNode });
} }
getFilterDescriptionId(treeNode: TreeNodeModel): string { updateExpandedChildPanel(treeNode: TreeNodeModel): void {
return this.treeNodesSelectorService.getFilterDescriptionId(treeNode); if (!this.childNodeVisibleChildren) {
return;
}
this.childNodeVisibleChildren = this.treeNodesSelectorService.getVisibleChildren(treeNode);
}
updateChildNodeVisibleChildren(filterTreeNodeData: FilterTreeNodeData): void {
if (!filterTreeNodeData || !filterTreeNodeData.treeNode) {
return;
}
filterTreeNodeData.treeNode.children = this.treeNodesSelectorService.getFilteredTreeNodeChildren(
filterTreeNodeData
);
filterTreeNodeData.treeNode.filterText = filterTreeNodeData.text;
this.childNodeVisibleChildren = this.treeNodesSelectorService.getVisibleChildren(filterTreeNodeData.treeNode);
} }
getFilterButtonAriaLabelText(treeNode: TreeNodeModel): string { getFilterButtonAriaLabelText(treeNode: TreeNodeModel): string {
return this.treeNodesSelectorService.getFilterButtonAriaLabelText(treeNode); return this.treeNodesSelectorService.getFilterButtonAriaLabelText(treeNode);
} }
getTreeNodeHeadingText(treeNode: TreeNodeModel): string {
return this.treeNodesSelectorService.getTreeNodeHeadingText(treeNode);
}
hasChildLeafNodes(node: TreeNodeModel): boolean { hasChildLeafNodes(node: TreeNodeModel): boolean {
return this.treeNodesSelectorService.hasChildLeafNodes(node); return this.treeNodesSelectorService.hasChildLeafNodes(node);
} }
@@ -135,4 +159,16 @@ export class ExpandedTreeNodeComponent extends UnsubscribeDirective implements O
hasSelectedDescendant(treeNode: TreeNodeModel): boolean { hasSelectedDescendant(treeNode: TreeNodeModel): boolean {
return this.treeNodesSelectorService.hasSelectedLeafNodeDescendant(treeNode); 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);
}
showGeneralInfoTemplate(treeNode: TreeNodeModel): boolean {
return this.treeNodesSelectorService.showGeneralInfoTemplate(treeNode);
}
} }

View File

@@ -1,6 +1,6 @@
<digi-util-detect-focus-outside (afOnFocusOutside)="closePanel()"> <digi-util-detect-focus-outside (afOnFocusOutside)="closePanel()">
<digi-util-keydown-handler (afOnEsc)="closePanel()"> <digi-util-keydown-handler (afOnEsc)="closePanel()">
<section class="tree-nodes-selector-panel" #treeNodesSelectorPanel> <section class="tree-nodes-selector-panel">
<header> <header>
<h2 class="tree-nodes-selector-panel__heading">{{headingText}}</h2> <h2 class="tree-nodes-selector-panel__heading">{{headingText}}</h2>
<p class="msfa__a11y-sr-only"> <p class="msfa__a11y-sr-only">
@@ -18,21 +18,13 @@
</button> </button>
</header> </header>
<div class="tree-nodes-selector-panel__content" *ngIf="pendingRootNode"> <div class="tree-nodes-selector-panel__content" *ngIf="pendingRootNode">
<ul class="tree-nodes-selector-panel__expanded-nodes" aria-hidden="true" *ngIf="expandedTreeNodes"> <div class="tree-nodes-selector-panel__tree">
<li class="tree-nodes-selector-panel__expanded-node" *ngFor="let node of expandedTreeNodes">
<msfa-expanded-tree-node <msfa-expanded-tree-node
[treeNodeModel]="node" [treeNodeModel]="pendingRootNode"
[latestParentActionKey]="latestParentActionKey" [visibleChildren]="visibleChildren"
(filterTreeNodeRequested)="filterTreeNode($event)" (selectedNodesChanged)="updateExpandedChildPanel($event)"
(clickAndFocusElementRequested)="clickAndFocusElementByQuerySelector($event)" (filterVisibleChildrenRequested)="updateVisibleChildren($event)"
></msfa-expanded-tree-node> ></msfa-expanded-tree-node>
</li>
</ul>
<div class="msfa__a11y-sr-only">
<ng-container
[ngTemplateOutlet]="nodeTemplate"
[ngTemplateOutletContext]="{node: pendingRootNode}"
></ng-container>
</div> </div>
</div> </div>
<footer> <footer>
@@ -45,64 +37,6 @@
{{confirmationButtonText}} {{confirmationButtonText}}
</digi-button> </digi-button>
</footer> </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> </section>
</digi-util-keydown-handler> </digi-util-keydown-handler>
</digi-util-detect-focus-outside> </digi-util-detect-focus-outside>

View File

@@ -41,28 +41,15 @@
font-size: var(--digi--typography--font-size--xs); font-size: var(--digi--typography--font-size--xs);
} }
&__expanded-nodes { &__content {
@include msfa__reset-list;
display: flex;
flex-direction: row;
overflow: auto; overflow: auto;
max-width: 100%; max-width: 100%;
} }
&__expanded-node { &__tree {
position: relative;
display: flex;
flex-direction: column;
flex: 0 0 50%;
max-width: 50%; max-width: 50%;
overflow: auto; height: 300px;
padding-top: $digi--layout--gutter--m; position: relative;
padding-bottom: $digi--layout--gutter--l;
border-left: 1px solid var(--digi--typography--color--text--disabled);
&:first-child {
border-left-width: 0;
}
} }
footer { footer {

View File

@@ -1,14 +1,5 @@
import { ButtonSize } from '@af/digi-ng/_button/button'; import { ButtonSize } from '@af/digi-ng/_button/button';
import { import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
} from '@angular/core';
import { import {
FilterTreeNodeData, FilterTreeNodeData,
TreeNodeModel, TreeNodeModel,
@@ -28,13 +19,10 @@ export class TreeNodesSelectorPanelComponent implements OnInit {
@Output() selectedChangesConfirmed = new EventEmitter<TreeNodeModel>(); @Output() selectedChangesConfirmed = new EventEmitter<TreeNodeModel>();
@Output() closePanelRequested = new EventEmitter<void>(); @Output() closePanelRequested = new EventEmitter<void>();
@ViewChild('treeNodesSelectorPanel') treeNodesSelector: ElementRef;
readonly ButtonSize = ButtonSize; readonly ButtonSize = ButtonSize;
pendingRootNode: TreeNodeModel | null = null; pendingRootNode: TreeNodeModel | null = null;
expandedTreeNodes: Array<TreeNodeModel> | null = null; visibleChildren: Array<TreeNodeModel> | null = null;
latestParentActionKey: string = null;
constructor(private treeNodesSelectorService: TreeNodesSelectorService) {} constructor(private treeNodesSelectorService: TreeNodesSelectorService) {}
@@ -44,123 +32,18 @@ export class TreeNodesSelectorPanelComponent implements OnInit {
private setupPendingRootNode(): void { private setupPendingRootNode(): void {
this.pendingRootNode = this.treeNodesSelectorService.getClonedTreeNode(this.rootNode); this.pendingRootNode = this.treeNodesSelectorService.getClonedTreeNode(this.rootNode);
this.expandedTreeNodes = this.treeNodesSelectorService.getExpandedTreeNodes(this.pendingRootNode);
}
onToggleSelected(treeNode: TreeNodeModel, event: CustomEvent<{ target: { checked: boolean } }>): void { if (!this.pendingRootNode) {
if (!treeNode || !event?.detail?.target) {
return; return;
} }
treeNode.isSelected = event?.detail?.target?.checked; this.pendingRootNode.isRoot = true;
this.updateExpandedChildPanel(this.pendingRootNode);
// 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 { clearSelections(): void {
this.pendingRootNode = this.treeNodesSelectorService.getTreeNodeWithNoNodesSelected(this.pendingRootNode); this.pendingRootNode = this.treeNodesSelectorService.getTreeNodeWithNoNodesSelected(this.pendingRootNode);
this.latestParentActionKey = `cleared-selections`; this.updateExpandedChildPanel(this.pendingRootNode);
this.expandedTreeNodes = this.treeNodesSelectorService.getExpandedTreeNodes(this.pendingRootNode);
} }
closePanel(): void { closePanel(): void {
@@ -168,65 +51,21 @@ export class TreeNodesSelectorPanelComponent implements OnInit {
this.closePanelRequested.emit(); 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 { hasSelectedDescendant(treeNode: TreeNodeModel): boolean {
return this.treeNodesSelectorService.hasSelectedLeafNodeDescendant(treeNode); return this.treeNodesSelectorService.hasSelectedLeafNodeDescendant(treeNode);
} }
getAriaLabelTextForExpansionButton(parentTreeNode: TreeNodeModel, treeNode: TreeNodeModel): string { updateExpandedChildPanel(treeNode: TreeNodeModel): void {
return this.treeNodesSelectorService.getAriaLabelTextForExpansionButton(parentTreeNode, treeNode); this.visibleChildren = this.treeNodesSelectorService.getVisibleChildren(treeNode);
} }
getAriaLabelForToggleAllButton(treeNode: TreeNodeModel): string { updateVisibleChildren(filterTreeNodeData: FilterTreeNodeData): void {
return this.treeNodesSelectorService.getAriaLabelForToggleAllButton(treeNode); filterTreeNodeData.treeNode.children = this.treeNodesSelectorService.getFilteredTreeNodeChildren(
} filterTreeNodeData
);
isExpandedNode(parentTreeNode: TreeNodeModel, treeNode: TreeNodeModel): boolean { filterTreeNodeData.treeNode.filterText = filterTreeNodeData.text;
return this.treeNodesSelectorService.isExpandedNode(parentTreeNode, treeNode);
}
getToggleAllButtonId(treeNode: TreeNodeModel): string { this.visibleChildren = this.treeNodesSelectorService.getVisibleChildren(filterTreeNodeData.treeNode);
return this.treeNodesSelectorService.getToggleAllButtonId(treeNode);
}
getItemButtonId(treeNode: TreeNodeModel): string {
return this.treeNodesSelectorService.getItemButtonId(treeNode);
} }
} }

View File

@@ -3,6 +3,7 @@
<button <button
#togglePanelBtn #togglePanelBtn
(click)="togglePanel()" (click)="togglePanel()"
[attr.id]="buttonElementId"
[attr.aria-controls]="panelId" [attr.aria-controls]="panelId"
[ngClass]="{'tree-nodes-selector__toggle-panel-btn--invalid': isInvalid && showValidation}" [ngClass]="{'tree-nodes-selector__toggle-panel-btn--invalid': isInvalid && showValidation}"
type="button" type="button"

View File

@@ -36,6 +36,7 @@ interface PropagateTouchedFn {
], ],
}) })
export class TreeNodesSelectorComponent implements ControlValueAccessor { export class TreeNodesSelectorComponent implements ControlValueAccessor {
@Input() buttonElementId: string = UUID.UUID();
@Input() headingText: string; @Input() headingText: string;
@Input() confirmationButtonText = 'Spara'; @Input() confirmationButtonText = 'Spara';
@Input() isInvalid = false; @Input() isInvalid = false;

View File

@@ -59,71 +59,6 @@ describe('TreeNodesSelectorService', () => {
}); });
}); });
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', () => { 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.', () => { 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 = { const treeNode: TreeNodeModel = {

View File

@@ -1,4 +1,4 @@
import { ElementRef, Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { UUID } from 'angular2-uuid'; import { UUID } from 'angular2-uuid';
export interface TreeNode { export interface TreeNode {
@@ -18,8 +18,9 @@ export interface TreeNodeModel extends TreeNode {
children?: Array<TreeNodeModel>; children?: Array<TreeNodeModel>;
hasFocus?: boolean; hasFocus?: boolean;
toggleAllHasFocus?: boolean; toggleAllHasFocus?: boolean;
showGeneralInfoAboutGrandChildren?: boolean;
hideTreeNode?: boolean; hideTreeNode?: boolean;
isRoot?: boolean;
filterText?: string;
} }
export interface FilterTreeNodeData { export interface FilterTreeNodeData {
@@ -82,30 +83,7 @@ export class TreeNodesSelectorService {
return `${isActiveNode ? 'Döljer' : 'Visar'} ${treeNode.grandChildrenItemType} för ${treeNode.label}`; return `${isActiveNode ? 'Döljer' : 'Visar'} ${treeNode.grandChildrenItemType} för ${treeNode.label}`;
} }
isFullyVisibleElement(element: HTMLElement, container: HTMLElement): boolean { hasChildLeafNodes(node: TreeNode): 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) { if (!node || !node.children || node.children.length === 0) {
return false; return false;
} }
@@ -125,42 +103,6 @@ export class TreeNodesSelectorService {
return !node.children.some(child => this.isLeafNode(child) && !child.isSelected); 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 { isExpandedNode(parentTreeNode: TreeNodeModel, treeNode: TreeNodeModel): boolean {
if (!parentTreeNode || !treeNode) { if (!parentTreeNode || !treeNode) {
return false; return false;
@@ -210,35 +152,7 @@ export class TreeNodesSelectorService {
return ''; return '';
} }
return `Filtrera ${treeNode.showGeneralInfoAboutGrandChildren ? treeNode.grandChildrenItemType : treeNode.label}`; return `Filtrera ${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 { getCleanedText(text: string): string {
@@ -360,4 +274,20 @@ export class TreeNodesSelectorService {
? treeNode.isSelected ? treeNode.isSelected
: !treeNode.children.some(childNode => !this.hasSelectedAllLeafNodes(childNode)); : !treeNode.children.some(childNode => !this.hasSelectedAllLeafNodes(childNode));
} }
hasExpandedChild(treeNode: TreeNode): boolean {
if (!treeNode || !treeNode.children) {
return false;
}
return treeNode.children.some(childNode => this.isExpandedNode(treeNode, childNode));
}
showGeneralInfoTemplate(treeNode: TreeNode): boolean {
if (!treeNode || !treeNode.grandChildrenInfo || this.hasExpandedChild(treeNode)) {
return false;
}
return this.getCleanedText(treeNode.grandChildrenInfo).length > 0;
}
} }

View File

@@ -0,0 +1,8 @@
import { AnchorLinkDirective } from './anchor-link.directive';
describe('AnchorLinkDirective', () => {
it('should create an instance', () => {
const directive = new AnchorLinkDirective();
expect(directive).toBeTruthy();
});
});

View File

@@ -0,0 +1,21 @@
import { Directive, HostListener } from '@angular/core';
@Directive({
selector: '[msfaAnchorLink]',
})
export class AnchorLinkDirective {
@HostListener('click', ['$event'])
onClick(event: MouseEvent): void {
const target = event.target as HTMLElement;
const link = target.tagName === 'a' ? target : target.closest('a');
const href = link?.getAttribute('href');
const element = document.getElementById(href?.trim().replace('#', ''));
if (element && element.focus) {
element.focus();
}
event.stopPropagation();
event.preventDefault();
}
}

View File

@@ -0,0 +1,8 @@
import { NgModule } from '@angular/core';
import { AnchorLinkDirective } from './anchor-link.directive';
@NgModule({
declarations: [AnchorLinkDirective],
exports: [AnchorLinkDirective],
})
export class AnchorLinkModule {}

View File

@@ -3,6 +3,7 @@ import { Injectable } from '@angular/core';
import { environment } from '@msfa-environment'; import { environment } from '@msfa-environment';
import { Params } from '@msfa-models/api/params.model'; import { Params } from '@msfa-models/api/params.model';
import { UtforandeVerksamhetResponse } from '@msfa-models/api/utforande-verksamhet.response.model'; import { UtforandeVerksamhetResponse } from '@msfa-models/api/utforande-verksamhet.response.model';
import { EmployeeUtforandeVerksamhet } from '@msfa-models/employee-utforande-verksamhet.model';
import { UtforandeAdress } from '@msfa-models/utforande-adress.model'; import { UtforandeAdress } from '@msfa-models/utforande-adress.model';
import { mapResponseToUtforandeVerksamhet, UtforandeVerksamhet } from '@msfa-models/utforande-verksamhet.model'; import { mapResponseToUtforandeVerksamhet, UtforandeVerksamhet } from '@msfa-models/utforande-verksamhet.model';
import { import {
@@ -21,7 +22,7 @@ export class UtforandeVerksamheterService {
constructor(private treeNodesSelectorService: TreeNodesSelectorService, private httpClient: HttpClient) {} constructor(private treeNodesSelectorService: TreeNodesSelectorService, private httpClient: HttpClient) {}
fetchUtforandeVerksamheter$(tjanstIds: number[]): Observable<UtforandeVerksamhet[]> { fetchUtforandeVerksamheter$(tjanstIds: number[]): Observable<UtforandeVerksamhet[]> {
if (!tjanstIds.length) { if (!tjanstIds?.length) {
return of<UtforandeVerksamhet[]>([]); return of<UtforandeVerksamhet[]>([]);
} }
@@ -38,10 +39,18 @@ export class UtforandeVerksamheterService {
return selectedUtforandeVerksamheter.map(uv => uv.adresser.map(adress => adress.id)).flat(); return selectedUtforandeVerksamheter.map(uv => uv.adresser.map(adress => adress.id)).flat();
} }
getTreeNodeDataFromUtforandeVerksamheter(utforandeVerksamhetList: UtforandeVerksamhet[]): TreeNode | null { getTreeNodeDataFromUtforandeVerksamheter(
availableUtforandeVerksamhetList: UtforandeVerksamhet[],
selectedUtforandeVerksamhetList: EmployeeUtforandeVerksamhet[],
selectAll = false
): TreeNode | null {
let treeNode: TreeNode | null = null; let treeNode: TreeNode | null = null;
if (!utforandeVerksamhetList || utforandeVerksamhetList.length === 0 || !Array.isArray(utforandeVerksamhetList)) { if (
!availableUtforandeVerksamhetList ||
availableUtforandeVerksamhetList.length === 0 ||
!Array.isArray(availableUtforandeVerksamhetList)
) {
return treeNode; return treeNode;
} }
@@ -52,17 +61,24 @@ export class UtforandeVerksamheterService {
isSelected: false, isSelected: false,
value: null, value: null,
childItemType: 'Utförande verksamheter', childItemType: 'Utförande verksamheter',
children: utforandeVerksamhetList.map( children: availableUtforandeVerksamhetList.map(
(utforandeVerksamhet: UtforandeVerksamhet): TreeNode => { (utforandeVerksamhet: UtforandeVerksamhet): TreeNode => {
const utforandeVerksahmetTreeNode: TreeNode = { const utforandeVerksahmetTreeNode: TreeNode = {
label: utforandeVerksamhet.name, label: utforandeVerksamhet.name,
toggleAllChildrenLabel: 'Välj alla adresser', toggleAllChildrenLabel: 'Välj alla adresser',
isSelected: false, isSelected:
selectAll || this.isSelectedUtforandeVerksamhet(utforandeVerksamhet, selectedUtforandeVerksamhetList),
value: utforandeVerksamhet, value: utforandeVerksamhet,
childItemType: 'Adresser', childItemType: 'Adresser',
children: utforandeVerksamhet.adresser children: utforandeVerksamhet.adresser
? utforandeVerksamhet.adresser.map(adress => { ? utforandeVerksamhet.adresser.map(adress => {
return { label: adress.name, isSelected: false, value: adress }; return {
label: adress.name,
isSelected:
selectAll ||
this.isSelectedUtforandeAdress(utforandeVerksamhet.id, adress, selectedUtforandeVerksamhetList),
value: adress,
};
}) })
: [], : [],
}; };
@@ -75,6 +91,52 @@ export class UtforandeVerksamheterService {
return treeNode; return treeNode;
} }
isSelectedUtforandeVerksamhet(
utforandeVerksamhet: UtforandeVerksamhet,
selectedUtforandeVerksamhetList: EmployeeUtforandeVerksamhet[]
): boolean {
if (
!utforandeVerksamhet ||
!selectedUtforandeVerksamhetList ||
selectedUtforandeVerksamhetList.length === 0 ||
!Array.isArray(selectedUtforandeVerksamhetList)
) {
return false;
}
return selectedUtforandeVerksamhetList.some(
selectedUtforandeVerksamhet => selectedUtforandeVerksamhet.id === utforandeVerksamhet.id
);
}
isSelectedUtforandeAdress(
utforandeVerksamhetId: number,
utforandeAdress: UtforandeAdress,
selectedUtforandeVerksamhetList: EmployeeUtforandeVerksamhet[]
): boolean {
let selectedUtforandeVerksamhet: EmployeeUtforandeVerksamhet | null = null;
if (
!utforandeAdress ||
!selectedUtforandeVerksamhetList ||
selectedUtforandeVerksamhetList.length === 0 ||
!Array.isArray(selectedUtforandeVerksamhetList)
) {
return false;
}
selectedUtforandeVerksamhet = selectedUtforandeVerksamhetList.find(
selectedUtforandeVerksamhet => selectedUtforandeVerksamhet.id === utforandeVerksamhetId
);
return selectedUtforandeVerksamhet
? selectedUtforandeVerksamhet.allaAdresser ||
selectedUtforandeVerksamhet.adresser.some(
selectedUtforandeAdress => selectedUtforandeAdress.id === utforandeAdress.id
)
: false;
}
getSelectedUtforandeVerksamheterFromTreeNode(treeNode: TreeNode): UtforandeVerksamhet[] { getSelectedUtforandeVerksamheterFromTreeNode(treeNode: TreeNode): UtforandeVerksamhet[] {
let utforandeVerksamhetList: UtforandeVerksamhet[] = []; let utforandeVerksamhetList: UtforandeVerksamhet[] = [];

View File

@@ -1,4 +1,5 @@
import { FormGroup, ValidatorFn } from '@angular/forms'; import { FormGroup, ValidatorFn } from '@angular/forms';
import { TreeNode } from '@msfa-shared/components/tree-nodes-selector/services/tree-nodes-selector.service';
export class EmployeeValidator { export class EmployeeValidator {
static HasSelectedAtLeastOneRole(roleFormControlNames: Array<string>): ValidatorFn { static HasSelectedAtLeastOneRole(roleFormControlNames: Array<string>): ValidatorFn {
@@ -12,4 +13,20 @@ export class EmployeeValidator {
: { noRoleSelected: true }; : { noRoleSelected: true };
}; };
} }
static HasSelectedAtLeastOneUtforandeVerksamhet(
utforandeVerksamheterFormControlName: string,
selectAllUtforandeVerksamheterFormControlName: string,
validationFn: (treeNode: TreeNode | null | undefined) => boolean
): ValidatorFn {
return (fg: FormGroup): { [key: string]: unknown } => {
if (fg?.get(selectAllUtforandeVerksamheterFormControlName)?.value) {
return null;
}
return validationFn(fg?.get(utforandeVerksamheterFormControlName)?.value)
? null
: { noUtforandeVerksamhetSelected: true };
};
}
} }

View File

@@ -4,15 +4,14 @@ import { TreeNode } from '@msfa-shared/components/tree-nodes-selector/services/t
export class TreeNodeValidator { export class TreeNodeValidator {
static IsValidTreeNode( static IsValidTreeNode(
validationFn: (treeNode: TreeNode | null | undefined) => boolean, validationFn: (treeNode: TreeNode | null | undefined) => boolean,
nameOfError: string, nameOfError: string
allUtforandeVerksamheterFormControl: AbstractControl
): ValidatorFn { ): ValidatorFn {
return (control: AbstractControl): { [key: string]: unknown } => { return (control: AbstractControl): { [key: string]: unknown } => {
const isValid = validationFn(control.value); const isValid = validationFn(control.value);
const validationObj = {}; const validationObj = {};
if (isValid || allUtforandeVerksamheterFormControl?.value) { if (isValid) {
return null; return null;
} }