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

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