FR-710: Mobile sidebar menu refactoring (#549)

This commit is contained in:
Ildar Galeev 2021-08-17 16:50:17 +03:00 committed by GitHub
parent 73d060cda9
commit 6ace562c9b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
96 changed files with 279 additions and 2652 deletions

View File

@ -9,7 +9,7 @@
"build": "ng build --extra-webpack-config webpack.extra.js",
"test": "ng test",
"coverage": "npx http-server -c-1 -o -p 9875 ./coverage",
"lint-cmd": "eslint \"src/**/*.{ts,js,html}\" --max-warnings 1765",
"lint-cmd": "eslint \"src/**/*.{ts,js,html}\" --max-warnings 1729",
"lint-cache-cmd": "npm run lint-cmd -- --cache",
"lint": "npm run lint-cache-cmd",
"lint-fix": "npm run lint-cache-cmd -- --fix",

View File

@ -6,7 +6,6 @@ import { Observable } from 'rxjs';
import { filter, map, pluck, take } from 'rxjs/operators';
import { ThemeManager } from '../theme-manager';
import { ROOT_ROUTE_PATH } from './navigation/consts';
@UntilDestroy()
@Component({
@ -26,7 +25,7 @@ export class HomeComponent implements OnInit {
) {}
get hasBackground(): boolean {
return this.router.url === ROOT_ROUTE_PATH && this.themeManager.isMainBackgroundImages;
return this.router.url === '/' && this.themeManager.isMainBackgroundImages;
}
get logoName(): string {

View File

@ -8,7 +8,6 @@ import { ConfigModule } from '../config';
import { HomeComponent } from './home.component';
import { LaptopGridModule } from './laptop-grid/laptop-grid.module';
import { MobileGridModule } from './mobile-grid/mobile-grid.module';
import { NavigationModule } from './navigation';
import { ToolbarModule } from './toolbar';
import { WelcomeImageModule } from './welcome-image';
@ -22,7 +21,6 @@ import { WelcomeImageModule } from './welcome-image';
WelcomeImageModule,
MobileGridModule,
LaptopGridModule,
NavigationModule,
ConfigModule,
],
declarations: [HomeComponent],

View File

@ -0,0 +1,17 @@
@import '@angular/material/theming';
@import './mobile-menu/mobile-menu-theme';
@mixin dsh-mobile-grid-theme($theme) {
@include dsh-mobile-menu-theme($theme);
$background: map-get($theme, background);
.dsh-mobile-grid-drawer {
background-color: map-get($background, card);
}
}
@mixin dsh-mobile-grid-typography($config) {
@include dsh-mobile-menu-typography($config);
}

View File

@ -1,12 +0,0 @@
@import 'node_modules/@angular/material/theming';
@import './menu/navigation/mobile_menu_navigation_theme';
@mixin dsh-mobile-menu-theme($theme) {
@include dsh-mobile-menu-navigation-theme($theme);
$background: map-get($theme, background);
.dsh-mobile-grid-drawer {
background-color: map-get($background, card);
}
}

View File

@ -1,67 +0,0 @@
import { InjectionToken } from '@angular/core';
import { NavigationSections } from '../navigation';
import { PartialNavigationNode } from './types/partial-navigation-node';
export const ROOT_NODE_LEVEL = 0;
export const MOBILE_MENU_TOKEN = new InjectionToken<PartialNavigationNode[]>('mobile-menu-token');
export const MOBILE_MENU: PartialNavigationNode[] = [
{
id: NavigationSections.Main,
},
{
id: 'payments_folder',
children: [
{
id: NavigationSections.PaymentsAnalytics,
icon: 'pie_chart',
},
{
id: NavigationSections.PaymentsOperations,
icon: 'table_chart',
},
{
id: NavigationSections.PaymentsPayouts,
icon: 'output',
},
{
id: NavigationSections.PaymentsReports,
icon: 'description',
},
{
id: NavigationSections.PaymentsIntegrations,
icon: 'build',
},
],
},
{
id: 'wallets_folder',
children: [
{
id: NavigationSections.WalletsWallet,
icon: 'wallet_menu',
},
{
id: NavigationSections.Wallets,
icon: 'wallet_menu',
},
{
id: NavigationSections.WalletsDeposits,
icon: 'input',
},
{
id: NavigationSections.WalletsWithdrawals,
icon: 'output',
},
{
id: NavigationSections.WalletsIntegrations,
icon: 'build',
},
],
},
{
id: NavigationSections.Claims,
},
];

View File

@ -0,0 +1,2 @@
export * from './mobile-grid.module';
export * from './mobile-grid.component';

View File

@ -1,7 +0,0 @@
<div
class="dsh-title dsh-menu-feedback"
*transloco="let t; scope: 'toolbar'; read: 'toolbar.nav'"
(click)="openFeedbackDialog()"
>
{{ t('feedback') }}
</div>

View File

@ -1,3 +0,0 @@
.dsh-menu-feedback {
cursor: pointer;
}

View File

@ -1,16 +0,0 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexModule } from '@angular/flex-layout';
import { MatIconModule } from '@angular/material/icon';
import { TranslocoModule } from '@ngneat/transloco';
import { FeedbackDialogModule } from '@dsh/app/shared/components/dialog';
import { FeedbackComponent } from './feedback.component';
@NgModule({
imports: [CommonModule, MatIconModule, FlexModule, TranslocoModule, FeedbackDialogModule],
declarations: [FeedbackComponent],
exports: [FeedbackComponent],
})
export class FeedbackModule {}

View File

@ -1,12 +0,0 @@
<div class="mobile-menu" fxLayout="column">
<div fxFlex fxLayout="column">
<dsh-mobile-navigation
[menu]="menu"
[activeId]="activeId"
(navigationChanged)="navigated()"
></dsh-mobile-navigation>
<dsh-feedback></dsh-feedback>
</div>
<mat-divider></mat-divider>
<dsh-mobile-user-bar></dsh-mobile-user-bar>
</div>

View File

@ -1,13 +0,0 @@
.mobile-menu {
height: 100%;
dsh-mobile-navigation,
dsh-mobile-user-bar {
padding: 24px 16px;
}
dsh-feedback {
margin-top: -16px;
padding: 0 16px 16px;
}
}

View File

@ -1,53 +0,0 @@
import { Component, Input } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDividerModule } from '@angular/material/divider';
import { NavigationFlatNode } from '../types/navigation-flat-node';
import { MobileMenuComponent } from './mobile-menu.component';
@Component({
selector: 'dsh-mobile-navigation',
template: '',
})
class MockNavComponent {
@Input() menu: NavigationFlatNode[];
@Input() activeId: string;
}
@Component({
selector: 'dsh-mobile-user-bar',
template: '',
})
class MockUserBarComponent {}
describe('MobileMenuComponent', () => {
let component: MobileMenuComponent;
let fixture: ComponentFixture<MobileMenuComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MatDividerModule],
declarations: [MobileMenuComponent, MockNavComponent, MockUserBarComponent],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(MobileMenuComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('navigated', () => {
it('should emit navigationChanged event', () => {
const spyOnNavigationChanged = spyOn(component.navigationChanged, 'emit');
component.navigated();
expect(spyOnNavigationChanged).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -1,19 +0,0 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { NavigationFlatNode } from '../types/navigation-flat-node';
@Component({
selector: 'dsh-mobile-menu',
templateUrl: './mobile-menu.component.html',
styleUrls: ['./mobile-menu.component.scss'],
})
export class MobileMenuComponent {
@Input() menu: NavigationFlatNode[];
@Input() activeId: string;
@Output() navigationChanged = new EventEmitter<void>();
navigated(): void {
this.navigationChanged.emit();
}
}

View File

@ -1,16 +0,0 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexModule } from '@angular/flex-layout';
import { MatDividerModule } from '@angular/material/divider';
import { FeedbackModule } from './feedback/feedback.module';
import { MobileMenuComponent } from './mobile-menu.component';
import { MobileNavigationModule } from './navigation/mobile-navigation.module';
import { MobileUserBarModule } from './user-bar/mobile-user-bar.module';
@NgModule({
imports: [CommonModule, MobileNavigationModule, MatDividerModule, MobileUserBarModule, FlexModule, FeedbackModule],
declarations: [MobileMenuComponent],
exports: [MobileMenuComponent],
})
export class MobileMenuModule {}

View File

@ -1,15 +0,0 @@
@use '~@angular/material' as mat;
@import 'node_modules/@angular/material/theming';
@mixin dsh-mobile-menu-navigation-theme($theme) {
$primary: map-get($theme, primary);
$foreground: map-get($theme, foreground);
.mobile-navigation-node-title {
color: map-get($foreground, text);
}
.mobile-navigation-node-wrap .mobile-navigation-node-title-active {
color: mat.get-color-from-palette($primary, 500);
}
}

View File

@ -1,2 +0,0 @@
export const CSS_UNIT_PATTERN = /([A-Za-z%]+)$/;
export const DEFAULT_INDENT_UNITS = 'px';

View File

@ -1,289 +0,0 @@
import { CdkTreeModule, FlatTreeControl } from '@angular/cdk/tree';
import { Component, DebugElement, Input, Type, ViewChild } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { isParentFlatNode } from '../../../../types/is-parent-flat-node';
import { NavigationFlatNode } from '../../../../types/navigation-flat-node';
import { TreeNavChildPaddingDirective } from './tree-nav-child-padding.directive';
abstract class BaseMockComponent {
directive: TreeNavChildPaddingDirective;
treeData: NavigationFlatNode[];
treeControl = new FlatTreeControl<NavigationFlatNode>(
(node: NavigationFlatNode) => node.level,
(node: NavigationFlatNode) => isParentFlatNode(node)
);
}
function configureModule(mockComponent: Type<BaseMockComponent>) {
TestBed.configureTestingModule({
imports: [CdkTreeModule],
declarations: [mockComponent, TreeNavChildPaddingDirective],
});
}
describe('ChildNavPaddingDirective', () => {
let fixture: ComponentFixture<BaseMockComponent>;
let component: BaseMockComponent;
describe('creation', () => {
@Component({
template: `
<cdk-tree [dataSource]="treeData" [treeControl]="treeControl">
<cdk-tree-node
*cdkTreeNodeDef="let node"
dshTreeNavChildPadding="10"
dshTreeNavChildPaddingLevelSwitch="20"
></cdk-tree-node>
</cdk-tree>
`,
})
class MockComponent extends BaseMockComponent {
@ViewChild(TreeNavChildPaddingDirective) directive: TreeNavChildPaddingDirective;
@Input() treeData: NavigationFlatNode[];
}
beforeEach(() => {
configureModule(MockComponent);
fixture = TestBed.createComponent(MockComponent);
component = fixture.componentInstance;
component.treeData = [
{
id: 'any',
level: 0,
},
];
fixture.detectChanges();
});
it('should create an instance', () => {
expect(component.directive).toBeTruthy();
});
});
describe('ngOnInit', () => {
@Component({
template: `
<cdk-tree [dataSource]="treeData" [treeControl]="treeControl">
<cdk-tree-node
*cdkTreeNodeDef="let node"
dshTreeNavChildPadding="10"
dshTreeNavChildPaddingLevelSwitch="20"
></cdk-tree-node>
</cdk-tree>
`,
})
class MockComponent extends BaseMockComponent {
@Input() treeData: NavigationFlatNode[];
}
let nodes: DebugElement[];
beforeEach(() => {
configureModule(MockComponent);
fixture = TestBed.createComponent(MockComponent);
component = fixture.componentInstance;
component.treeData = [
{ id: 'one', level: 0 },
{ id: 'two', level: 0 },
];
fixture.detectChanges();
});
it('should update padding values on treeData changes', () => {
nodes = fixture.debugElement.queryAll(By.css('cdk-tree-node'));
expect(nodes.length).toBe(2);
component.treeData = [
{ id: 'one', level: 0 },
{ id: 'two', level: 0, isExpanded: true },
{ id: 'two one', level: 1 },
{ id: 'two two', level: 1 },
{ id: 'two three', level: 1 },
{ id: 'two four', level: 1 },
{ id: 'three', level: 0 },
];
fixture.detectChanges();
nodes = fixture.debugElement.queryAll(By.css('cdk-tree-node'));
expect(nodes.length).toBe(7);
});
});
describe('inputs', () => {
let nodes: DebugElement[];
function createComponent(mockComponent: Type<BaseMockComponent>, treeData: NavigationFlatNode[]) {
configureModule(mockComponent);
fixture = TestBed.createComponent(mockComponent);
component = fixture.componentInstance;
component.treeData = treeData;
fixture.detectChanges();
nodes = fixture.debugElement.queryAll(By.css('cdk-tree-node'));
}
describe('dshTreeNavChildPadding', () => {
it('should use px for default padding metric', () => {
@Component({
template: `
<cdk-tree [dataSource]="treeData" [treeControl]="treeControl">
<cdk-tree-node *cdkTreeNodeDef="let node" dshTreeNavChildPadding="10"></cdk-tree-node>
</cdk-tree>
`,
})
class MockComponent extends BaseMockComponent {
@Input() treeData: NavigationFlatNode[];
}
createComponent(MockComponent, [
{ id: 'one', level: 0, isExpanded: true },
{ id: 'one one', level: 1 },
{ id: 'one four', level: 1 },
]);
expect(nodes[2].nativeElement.style.paddingTop).toBe('10px');
expect(nodes[2].nativeElement.style.paddingBottom).toBe('10px');
});
it('should work with custom units for padding metric', () => {
@Component({
template: `
<cdk-tree [dataSource]="treeData" [treeControl]="treeControl">
<cdk-tree-node *cdkTreeNodeDef="let node" dshTreeNavChildPadding="1.2em"></cdk-tree-node>
</cdk-tree>
`,
})
class MockComponent extends BaseMockComponent {
@Input() treeData: NavigationFlatNode[];
}
createComponent(MockComponent, [
{ id: 'one', level: 0, isExpanded: true },
{ id: 'one one', level: 1 },
{ id: 'one four', level: 1 },
]);
expect(nodes[2].nativeElement.style.paddingTop).toBe('1.2em');
expect(nodes[2].nativeElement.style.paddingBottom).toBe('1.2em');
});
});
describe('dshTreeNavChildPaddingLevelSwitch', () => {
it('should change paddingTop for first child item using provided value', () => {
@Component({
template: `
<cdk-tree [dataSource]="treeData" [treeControl]="treeControl">
<cdk-tree-node
*cdkTreeNodeDef="let node"
dshTreeNavChildPadding="10"
dshTreeNavChildPaddingLevelSwitch="20"
></cdk-tree-node>
</cdk-tree>
`,
})
class MockComponent extends BaseMockComponent {
@Input() treeData: NavigationFlatNode[];
}
createComponent(MockComponent, [
{ id: 'one', level: 0, isExpanded: true },
{ id: 'one one', level: 1 },
{ id: 'one four', level: 1 },
]);
expect(nodes[1].nativeElement.style.paddingTop).toBe('20px');
expect(nodes[1].nativeElement.style.paddingBottom).toBe('10px');
});
it('should change paddingBottom for last child in the row item using provided value', () => {
@Component({
template: `
<cdk-tree [dataSource]="treeData" [treeControl]="treeControl">
<cdk-tree-node
*cdkTreeNodeDef="let node"
dshTreeNavChildPadding="10"
dshTreeNavChildPaddingLevelSwitch="20"
></cdk-tree-node>
</cdk-tree>
`,
})
class MockComponent extends BaseMockComponent {
@Input() treeData: NavigationFlatNode[];
}
createComponent(MockComponent, [
{ id: 'one', level: 0, isExpanded: true },
{ id: 'one one', level: 1 },
{ id: 'one four', level: 1 },
{ id: 'two', level: 0 },
]);
expect(nodes[2].nativeElement.style.paddingTop).toBe('10px');
expect(nodes[2].nativeElement.style.paddingBottom).toBe('20px');
});
it('should not change paddingBottom for last child in the row if there is no parent after it', () => {
@Component({
template: `
<cdk-tree [dataSource]="treeData" [treeControl]="treeControl">
<cdk-tree-node
*cdkTreeNodeDef="let node"
dshTreeNavChildPadding="10"
dshTreeNavChildPaddingLevelSwitch="20"
></cdk-tree-node>
</cdk-tree>
`,
})
class MockComponent extends BaseMockComponent {
@Input() treeData: NavigationFlatNode[];
}
createComponent(MockComponent, [
{ id: 'one', level: 0, isExpanded: true },
{ id: 'one one', level: 1 },
{ id: 'one four', level: 1 },
]);
expect(nodes[2].nativeElement.style.paddingTop).toBe('10px');
expect(nodes[2].nativeElement.style.paddingBottom).toBe('10px');
});
it('should support non px units', () => {
@Component({
template: `
<cdk-tree [dataSource]="treeData" [treeControl]="treeControl">
<cdk-tree-node
*cdkTreeNodeDef="let node"
dshTreeNavChildPadding="10"
dshTreeNavChildPaddingLevelSwitch="2em"
></cdk-tree-node>
</cdk-tree>
`,
})
class MockComponent extends BaseMockComponent {
@Input() treeData: NavigationFlatNode[];
}
createComponent(MockComponent, [
{ id: 'one', level: 0, isExpanded: true },
{ id: 'one one', level: 1 },
{ id: 'one four', level: 1 },
]);
expect(nodes[1].nativeElement.style.paddingTop).toBe('2em');
expect(nodes[1].nativeElement.style.paddingBottom).toBe('10px');
});
});
});
});

View File

@ -1,94 +0,0 @@
import { CdkTree, CdkTreeNode } from '@angular/cdk/tree';
import { Directive, ElementRef, Input, OnInit } from '@angular/core';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import isObject from 'lodash-es/isObject';
import { ROOT_NODE_LEVEL } from '../../../../consts';
import { NavigationFlatNode } from '../../../../types/navigation-flat-node';
import { parseIndentValue } from '../../utils/parse-indent-value';
@UntilDestroy()
@Directive({
selector: '[dshTreeNavChildPadding]',
})
export class TreeNavChildPaddingDirective<T extends NavigationFlatNode = NavigationFlatNode, K = T> implements OnInit {
@Input('dshTreeNavChildPadding')
get indent(): number | string {
return this.elPadding;
}
set indent(value: number | string) {
this.setPadding(value);
}
@Input('dshTreeNavChildPaddingLevelSwitch')
get levelIndent(): number | string {
return this.elLevelPadding;
}
set levelIndent(value: number | string) {
this.setLevelPadding(value);
}
private elPadding: number;
private elPaddingUnit: string;
private elLevelPadding: number;
private elLevelPaddingUnit: string;
constructor(
private treeNode: CdkTreeNode<T, K>,
private tree: CdkTree<T, K>,
private element: ElementRef<HTMLElement>
) {}
ngOnInit(): void {
this.tree.viewChange.pipe(untilDestroyed(this)).subscribe(() => {
this.updateElementIndents();
});
}
private updateElementIndents(): void {
const nodeData = this.treeNode.data;
const hasParents = nodeData.level > ROOT_NODE_LEVEL;
if (!hasParents) {
return;
}
const dataSource = this.tree.dataSource as T[];
const positionIndex = dataSource.map(({ id }: T) => id).indexOf(nodeData.id);
const elBefore = dataSource[positionIndex - 1];
const elAfter = dataSource[positionIndex + 1];
const hasParentBefore = isObject(elBefore) && elBefore.level === nodeData.level - 1;
const hasParentAfter = isObject(elAfter) && elAfter.level === nodeData.level - 1;
const elementDomRef = this.element.nativeElement;
const childPadding = `${this.elPadding}${this.elPaddingUnit}`;
const levelPadding = `${this.elLevelPadding * nodeData.level}${this.elLevelPaddingUnit}`;
elementDomRef.style.paddingTop = childPadding;
elementDomRef.style.paddingBottom = childPadding;
if (hasParentBefore) {
elementDomRef.style.paddingTop = levelPadding;
}
if (hasParentAfter) {
elementDomRef.style.paddingBottom = levelPadding;
}
}
private setPadding(value: number | string): void {
const [indent, units] = parseIndentValue(value);
this.elPadding = indent;
this.elPaddingUnit = units;
}
private setLevelPadding(value: number | string): void {
const [indent, units] = parseIndentValue(value);
this.elLevelPadding = indent;
this.elLevelPaddingUnit = units;
}
}

View File

@ -1,79 +0,0 @@
<cdk-tree [dataSource]="menu" [treeControl]="treeControl">
<!-- leaf nodes -->
<cdk-tree-node
*cdkTreeNodeDef="let node"
class="mobile-navigation-node"
cdkTreeNodePadding
cdkTreeNodePaddingIndent="4px"
dshTreeNavChildPadding="4px"
dshTreeNavChildPaddingLevelSwitch="12px"
[class.mobile-navigation-node--hidden]="!shouldDisplayNode(node)"
>
<div class="mobile-navigation-node-wrap">
<ng-container
*ngTemplateOutlet="nodeName; context: { $implicit: node, isLink: hasNodeLink(node) }"
></ng-container>
</div>
</cdk-tree-node>
<!-- expandable nodes -->
<cdk-tree-node
*cdkTreeNodeDef="let node; when: isExpandableNode"
class="mobile-navigation-node mobile-navigation-node-parent"
cdkTreeNodePadding
cdkTreeNodePaddingIndent="4px"
[class.mobile-navigation-node--hidden]="!shouldDisplayNode(node)"
>
<div
class="mobile-navigation-node-wrap"
(click)="toggleNode(node)"
fxLayout="row"
fxLayoutAlign="space-between center"
>
<ng-container *ngTemplateOutlet="nodeName; context: { $implicit: node }"></ng-container>
<mat-icon>
{{ node.isExpanded ? 'expand_more' : 'chevron_right' }}
</mat-icon>
</div>
</cdk-tree-node>
</cdk-tree>
<ng-template #nodeName let-node let-isLink="isLink">
<div fxLayout="row" fxLayoutGap="16px" fxLayoutAlign="center">
<dsh-colored-icon
fxLayout
fxLayoutAlign="center center"
*ngIf="hasNodeIcon(node)"
[color]="isNodeActive(node) ? 'primary' : 'default'"
[icon]="node.meta.icon"
>
</dsh-colored-icon>
<ng-container *ngTemplateOutlet="isLink ? nodeLink : nodeTitle; context: { $implicit: node }"></ng-container>
</div>
</ng-template>
<ng-template #nodeTitle let-node>
<ng-container *transloco="let t; scope: 'toolbar'; read: 'toolbar.nav'">
<div
fxFlex
class="mobile-navigation-node-title"
[class.mobile-navigation-node-title-active]="isNodeActive(node)"
[class.mobile-navigation-node-title-bold]="node.level === 0"
>
{{ t(node.id) }}
</div>
</ng-container>
</ng-template>
<ng-template #nodeLink let-node>
<ng-container *transloco="let t; scope: 'toolbar'; read: 'toolbar.nav'">
<a
fxFlex
class="mobile-navigation-node-title"
[class.mobile-navigation-node-title-active]="isNodeActive(node)"
[class.mobile-navigation-node-title-bold]="node.level === 0"
[routerLink]="node.meta.path"
(click)="navigated()"
>{{ t(node.id) }}</a
>
</ng-container>
</ng-template>

View File

@ -1,41 +0,0 @@
.mobile-navigation {
&-node {
display: block;
a {
font-family: 'Roboto', 'Helvetica Neue', sans-serif;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 28px;
text-decoration: none;
}
&-parent {
cursor: pointer;
& > .mobile-navigation-node-wrap > .mat-icon {
user-select: none;
}
}
&-wrap {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
.mobile-navigation-node-title-bold {
font-family: 'Roboto', 'Helvetica Neue', sans-serif;
font-size: 20px;
font-style: normal;
font-weight: 500;
line-height: 32px;
}
}
&--hidden {
display: none;
}
}
}

View File

@ -1,221 +0,0 @@
import { CdkTreeModule } from '@angular/cdk/tree';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FlexModule } from '@angular/flex-layout';
import { MatIcon } from '@angular/material/icon';
import { MatIconTestingModule } from '@angular/material/icon/testing';
import { RouterModule } from '@angular/router';
import { getTranslocoModule } from '@dsh/app/shared/tests/get-transloco-module';
import { ColoredIconModule } from '@dsh/components/indicators';
import { NavigationFlatNodeParent } from '../../types/navigation-flat-node-parent';
import { TreeNavChildPaddingDirective } from './directives/tree-nav-child-padding/tree-nav-child-padding.directive';
import { MobileNavigationComponent } from './mobile-navigation.component';
describe('MobileNavigationComponent', () => {
let component: MobileNavigationComponent;
let fixture: ComponentFixture<MobileNavigationComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
CdkTreeModule,
MatIconTestingModule,
RouterModule,
getTranslocoModule(),
FlexModule,
ColoredIconModule,
],
declarations: [MobileNavigationComponent, TreeNavChildPaddingDirective, MatIcon],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(MobileNavigationComponent);
component = fixture.componentInstance;
component.menu = [];
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('ngOnChanges', () => {
it('should open level nodes that contains active node id', () => {
component.menu = [
{ id: 'one', level: 0, isExpanded: false },
{ id: 'one one', level: 1, isExpanded: false },
{ id: 'one one one', level: 2, isExpanded: false },
{ id: 'one one one one', level: 3 },
{ id: 'two', level: 0, isExpanded: false },
{ id: 'two one', level: 1, isExpanded: false },
{ id: 'two one one', level: 2 },
];
component.activeId = 'one one one one';
fixture.detectChanges();
component.ngOnChanges({
menu: {
previousValue: [],
currentValue: component.menu,
isFirstChange(): boolean {
return false;
},
firstChange: false,
},
activeId: {
previousValue: undefined,
currentValue: component.activeId,
isFirstChange(): boolean {
return true;
},
firstChange: true,
},
});
expect(component.menu).toEqual([
{ id: 'one', level: 0, isExpanded: true },
{ id: 'one one', level: 1, isExpanded: true },
{ id: 'one one one', level: 2, isExpanded: true },
{ id: 'one one one one', level: 3 },
{ id: 'two', level: 0, isExpanded: false },
{ id: 'two one', level: 1, isExpanded: false },
{ id: 'two one one', level: 2 },
]);
});
});
describe('isExpandableNode', () => {
it('should return true if node its own isExpanded property', () => {
expect(component.isExpandableNode(0, { id: 'one one one', level: 2, isExpanded: true })).toBe(true);
});
it('should return false if node has no isExpanded property', () => {
expect(component.isExpandableNode(0, { id: 'one one one', level: 2 })).toBe(false);
});
});
describe('hasNodeIcon', () => {
it('should return true if node has icon meta info', () => {
expect(component.hasNodeIcon({ id: 'one one one', level: 2, meta: { path: '/', icon: 'pie_chart' } })).toBe(
true
);
});
it('should return false if node is expandable', () => {
expect(component.hasNodeIcon({ id: 'one one one', level: 2, isExpanded: true })).toBe(false);
});
it('should return false if node has no meta information', () => {
expect(component.hasNodeIcon({ id: 'one one one', level: 2 })).toBe(false);
});
it('should return false if node has not provided icon info', () => {
expect(component.hasNodeIcon({ id: 'one one one', level: 2, meta: { path: '/' } })).toBe(false);
});
});
describe('hasNodeLink', () => {
it('should return true if node has path', () => {
expect(component.hasNodeLink({ id: 'one one one', level: 2, meta: { path: '/' } })).toBe(true);
});
it('should return false if node is expandable', () => {
expect(component.hasNodeLink({ id: 'one one one', level: 2, isExpanded: true })).toBe(false);
});
it('should return false if node has no meta information', () => {
expect(component.hasNodeLink({ id: 'one one one', level: 2 })).toBe(false);
});
});
describe('shouldDisplayNode', () => {
beforeEach(() => {
component.menu = [
{ id: 'one', level: 0, isExpanded: true },
{ id: 'one one', level: 1, isExpanded: true },
{ id: 'one one one', level: 2, isExpanded: true },
{ id: 'one one one one', level: 3 },
{ id: 'two', level: 0, isExpanded: false },
{ id: 'two one', level: 1, isExpanded: false },
{ id: 'two one one', level: 2 },
];
});
it('should return true if parent node isExpanded', () => {
expect(component.shouldDisplayNode(component.menu[3])).toBe(true);
});
it('should return true if there is no parent of node', () => {
expect(component.shouldDisplayNode(component.menu[4])).toBe(true);
});
it('should return false if parent node is not expanded', () => {
expect(component.shouldDisplayNode(component.menu[5])).toBe(false);
});
});
describe('toggleNode', () => {
beforeEach(() => {
component.menu = [
{ id: 'one', level: 0, isExpanded: true },
{ id: 'one one', level: 1, isExpanded: true },
{ id: 'one one one', level: 2, isExpanded: true },
{ id: 'one one one one', level: 3 },
{ id: 'two', level: 0, isExpanded: false },
{ id: 'two one', level: 1, isExpanded: false },
{ id: 'two one one', level: 2 },
];
});
it('should switch true to false', () => {
component.toggleNode(component.menu[2]);
expect((component.menu[2] as NavigationFlatNodeParent).isExpanded).toBe(false);
});
it('should switch false to true', () => {
component.toggleNode(component.menu[4]);
expect((component.menu[4] as NavigationFlatNodeParent).isExpanded).toBe(true);
});
it('should not toggle node that is not a parent', () => {
component.toggleNode(component.menu[3]);
expect(component.menu[3]).toEqual({ id: 'one one one one', level: 3 });
});
});
describe('isNodeActive', () => {
beforeEach(() => {
component.menu = [
{ id: 'one', level: 0, isExpanded: true },
{ id: 'one one', level: 1, isExpanded: true },
{ id: 'one one one', level: 2, isExpanded: true },
{ id: 'one one one one', level: 3 },
{ id: 'two', level: 0, isExpanded: false },
{ id: 'two one', level: 1, isExpanded: false },
{ id: 'two one one', level: 2 },
];
component.activeId = 'two one one';
});
it('should return true if active id equals to node id', () => {
expect(component.isNodeActive(component.menu[6])).toBe(true);
});
it('should return false if active id does not match node id', () => {
expect(component.isNodeActive(component.menu[3])).toBe(false);
});
});
describe('navigated', () => {
it('should emit navigationChanged event', () => {
const spyOnNavigationChanged = spyOn(component.navigationChanged, 'emit');
component.navigated();
expect(spyOnNavigationChanged).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -1,112 +0,0 @@
import { FlatTreeControl } from '@angular/cdk/tree';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import isNil from 'lodash-es/isNil';
import isObject from 'lodash-es/isObject';
import { ComponentChanges } from '@dsh/type-utils';
import { isParentFlatNode } from '../../types/is-parent-flat-node';
import { NavigationFlatNode } from '../../types/navigation-flat-node';
import { NavigationFlatNodeParent } from '../../types/navigation-flat-node-parent';
@Component({
selector: 'dsh-mobile-navigation',
templateUrl: './mobile-navigation.component.html',
styleUrls: ['./mobile-navigation.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MobileNavigationComponent implements OnChanges {
@Input() menu: NavigationFlatNode[];
@Input() activeId: string;
@Output() navigationChanged = new EventEmitter<void>();
treeControl = new FlatTreeControl<NavigationFlatNode>(
(node: NavigationFlatNode) => node.level,
(node: NavigationFlatNode) => isParentFlatNode(node)
);
ngOnChanges(changes: ComponentChanges<MobileNavigationComponent>): void {
if (isObject(changes.activeId) || isObject(changes.menu)) {
this.openActiveNodeParents();
}
}
isExpandableNode(_: number, node: NavigationFlatNode): boolean {
return isParentFlatNode(node);
}
hasNodeIcon(node: NavigationFlatNode): boolean {
if (isParentFlatNode(node)) {
return false;
}
return Boolean(node?.meta?.icon);
}
hasNodeLink(node: NavigationFlatNode): boolean {
return isParentFlatNode(node) ? false : Boolean(node?.meta?.path);
}
shouldDisplayNode(node: NavigationFlatNode): boolean {
let parent = this.getParentNode(node);
while (parent) {
if (!parent.isExpanded) {
return false;
}
parent = this.getParentNode(parent);
}
return true;
}
toggleNode(node: NavigationFlatNode): void {
if (isParentFlatNode(node)) {
node.isExpanded = !node.isExpanded;
}
}
navigated(): void {
this.navigationChanged.emit();
}
isNodeActive(node: NavigationFlatNode): boolean {
return node.id === this.activeId;
}
private getParentNode(node: NavigationFlatNode): NavigationFlatNodeParent | null {
const nodeIndex = this.menu.indexOf(node);
for (let i = nodeIndex - 1; i >= 0; i -= 1) {
if (this.menu[i].level === node.level - 1) {
return this.menu[i] as NavigationFlatNodeParent;
}
}
return null;
}
private openActiveNodeParents(): void {
if (isNil(this.menu) || isNil(this.activeId)) {
return;
}
const activeNode = this.menu.find(({ id }: NavigationFlatNode) => id === this.activeId);
const parents = [];
let parentNode = this.getParentNode(activeNode);
while (!isNil(parentNode)) {
parents.push(parentNode);
parentNode = this.getParentNode(parentNode);
}
this.menu.forEach((menuNode: NavigationFlatNode) => {
if (isParentFlatNode(menuNode)) {
menuNode.isExpanded = false;
}
});
parents.forEach((node: NavigationFlatNodeParent) => {
node.isExpanded = true;
});
}
}

View File

@ -1,19 +0,0 @@
import { CdkTreeModule } from '@angular/cdk/tree';
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexModule } from '@angular/flex-layout';
import { MatIconModule } from '@angular/material/icon';
import { RouterModule } from '@angular/router';
import { TranslocoModule } from '@ngneat/transloco';
import { ColoredIconModule } from '@dsh/components/indicators';
import { TreeNavChildPaddingDirective } from './directives/tree-nav-child-padding/tree-nav-child-padding.directive';
import { MobileNavigationComponent } from './mobile-navigation.component';
@NgModule({
imports: [CommonModule, CdkTreeModule, MatIconModule, RouterModule, TranslocoModule, FlexModule, ColoredIconModule],
declarations: [MobileNavigationComponent, TreeNavChildPaddingDirective],
exports: [MobileNavigationComponent],
})
export class MobileNavigationModule {}

View File

@ -1,29 +0,0 @@
import { parseIndentValue } from './parse-indent-value';
const ERROR_MESSAGE = `Сan't parse non numeric indent`;
describe('parseIndentValue', () => {
it('should return value as is and px units if value is number', () => {
expect(parseIndentValue(1)).toEqual([1, 'px']);
});
it('should return value as a number and px units if value is numeric string', () => {
expect(parseIndentValue('1')).toEqual([1, 'px']);
});
it('should parse custom units from string value', () => {
expect(parseIndentValue('1em')).toEqual([1, 'em']);
});
it('should throw an error if number part of string is not parsable', () => {
expect(() => {
parseIndentValue('-)1em');
}).toThrowError(ERROR_MESSAGE);
});
it('should throw an error string is not parsable in valid number', () => {
expect(() => {
parseIndentValue('-)1');
}).toThrowError(ERROR_MESSAGE);
});
});

View File

@ -1,18 +0,0 @@
import { CSS_UNIT_PATTERN, DEFAULT_INDENT_UNITS } from '../consts';
export const parseIndentValue = (value: number | string): [number, string] => {
let indent: number = Number(value);
let units = DEFAULT_INDENT_UNITS;
if (typeof value === 'string') {
const parts = value.split(CSS_UNIT_PATTERN);
indent = Number(parts[0]);
units = parts[1] || units;
}
if (isNaN(indent)) {
throw new Error(`Сan't parse non numeric indent`);
}
return [indent, units];
};

View File

@ -1,4 +0,0 @@
<div class="mobile-user-bar" fxLayout="row" fxLayoutAlign="space-between center">
<div class="mobile-user-bar-username">{{ userName }}</div>
<mat-icon class="mobile-user-bar-exit" svgIcon="output" (click)="logout()"></mat-icon>
</div>

View File

@ -1,17 +0,0 @@
.mobile-user-bar {
&-username {
font-family: 'Roboto', 'Helvetica Neue', sans-serif;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
&-exit {
cursor: pointer;
&:hover {
opacity: 0.5;
}
}
}

View File

@ -1,61 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatIcon } from '@angular/material/icon';
import { MatIconTestingModule } from '@angular/material/icon/testing';
import { KeycloakService } from 'keycloak-angular';
import { instance, mock, verify, when } from 'ts-mockito';
import { MobileUserBarComponent } from './mobile-user-bar.component';
const MOCK_USERNAME = 'TestUserName@user.user';
describe('MobileUserBarComponent', () => {
let component: MobileUserBarComponent;
let fixture: ComponentFixture<MobileUserBarComponent>;
let mockKeycloakService: KeycloakService;
beforeEach(() => {
mockKeycloakService = mock(KeycloakService);
});
beforeEach(() => {
when(mockKeycloakService.getUsername()).thenReturn(MOCK_USERNAME);
});
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MatIconTestingModule],
declarations: [MobileUserBarComponent, MatIcon],
providers: [
{
provide: KeycloakService,
useFactory: () => instance(mockKeycloakService),
},
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(MobileUserBarComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
describe('creation', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('should update set userName property using keycloak data', () => {
expect(component.userName).toBe(MOCK_USERNAME);
});
});
describe('logout', () => {
it('should call keycloak logout method', () => {
component.logout();
verify(mockKeycloakService.logout()).once();
expect().nothing();
});
});
});

View File

@ -1,13 +0,0 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexModule } from '@angular/flex-layout';
import { MatIconModule } from '@angular/material/icon';
import { MobileUserBarComponent } from './mobile-user-bar.component';
@NgModule({
imports: [CommonModule, MatIconModule, FlexModule],
declarations: [MobileUserBarComponent],
exports: [MobileUserBarComponent],
})
export class MobileUserBarModule {}

View File

@ -4,12 +4,7 @@
<div class="dsh-mobile-grid-drawer-actions" fxLayout="row" fxLayoutAlign="end center">
<mat-icon class="dsh-mobile-grid-toggle-button" (click)="closeSideNav()" svgIcon="cross"></mat-icon>
</div>
<dsh-mobile-menu
fxFlex
[menu]="menu$ | async"
[activeId]="activeMenuItemId$ | async"
(navigationChanged)="closeSideNav()"
></dsh-mobile-menu>
<dsh-mobile-menu fxFlex (menuItemSelected)="closeSideNav()"></dsh-mobile-menu>
</div>
</mat-drawer>
<mat-drawer-content>

View File

@ -1,299 +0,0 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FlexLayoutModule } from '@angular/flex-layout';
import { MatIcon } from '@angular/material/icon';
import { MatIconTestingModule } from '@angular/material/icon/testing';
import { MatDrawer, MatSidenavModule } from '@angular/material/sidenav';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { cold } from 'jasmine-marbles';
import { of } from 'rxjs';
import { instance, mock, verify, when } from 'ts-mockito';
import { NavigationService } from '../navigation';
import { MOBILE_MENU_TOKEN } from './consts';
import { MobileGridComponent } from './mobile-grid.component';
import { NavigationFlatNode } from './types/navigation-flat-node';
import { PartialNavigationNode } from './types/partial-navigation-node';
@Component({
selector: 'dsh-mobile-menu',
template: '',
})
class MockMobileMenuComponent {
@Input() menu: NavigationFlatNode[];
@Input() activeId: string;
@Output() navigationChanged = new EventEmitter<void>();
}
@Component({
selector: 'dsh-brand',
template: '',
})
class MockBrandComponent {
@Input() inverted: boolean;
@Input() navigationLink = '/';
}
describe('MobileGridComponent', () => {
let component: MobileGridComponent;
let fixture: ComponentFixture<MobileGridComponent>;
let mockNavigationService: NavigationService;
let mockMatDrawer: MatDrawer;
beforeEach(() => {
mockNavigationService = mock(NavigationService);
mockMatDrawer = mock(MatDrawer);
});
beforeEach(() => {
when(mockNavigationService.availableLinks$).thenReturn(of([]));
when(mockNavigationService.activeLink$).thenReturn(of());
});
async function createComponent(menu: PartialNavigationNode[] = []) {
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, MatSidenavModule, FlexLayoutModule, MatIconTestingModule],
declarations: [MobileGridComponent, MockMobileMenuComponent, MatIcon, MockBrandComponent],
providers: [
{
provide: NavigationService,
useFactory: () => instance(mockNavigationService),
},
{
provide: MOBILE_MENU_TOKEN,
useValue: menu,
},
],
}).compileComponents();
fixture = TestBed.createComponent(MobileGridComponent);
component = fixture.componentInstance;
}
it('should create', async () => {
await createComponent();
fixture.detectChanges();
expect(component).toBeTruthy();
});
describe('menuIcon', () => {
beforeEach(async () => {
await createComponent();
fixture.detectChanges();
});
it('should return "menu_inverted" if logo was inverted', () => {
component.inverted = true;
expect(component.menuIcon).toBe('menu_inverted');
});
it('should return "menu" if logo was not inverted', () => {
component.inverted = false;
expect(component.menuIcon).toBe('menu');
});
});
describe('ngOnInit', () => {
it('should flat provided mobile menu and map it on availableLinks', async () => {
when(mockNavigationService.availableLinks$).thenReturn(
of([
{
id: 'one',
path: '/one',
navPlace: {
page: 'one',
},
},
{
id: 'two one',
path: '/two_one',
navPlace: {
page: 'two_one',
},
},
{
id: 'two two',
path: '/two_two',
navPlace: {
page: 'two_two',
},
},
{
id: 'three',
path: '/three',
navPlace: {
page: 'three',
},
},
{
id: 'four',
path: '/four',
navPlace: {
page: 'four',
},
},
])
);
await createComponent([
{ id: 'one' },
{
id: 'two',
children: [{ id: 'two one' }, { id: 'two two' }],
},
{ id: 'three', icon: 'description' },
{ id: 'four' },
]);
fixture.detectChanges();
expect(component.menu$).toBeObservable(
cold('(a|)', {
a: [
{
id: 'one',
level: 0,
meta: {
path: '/one',
},
},
{
id: 'two',
level: 0,
isExpanded: false,
},
{
id: 'two one',
level: 1,
meta: {
path: '/two_one',
},
},
{
id: 'two two',
level: 1,
meta: {
path: '/two_two',
},
},
{
id: 'three',
level: 0,
meta: {
path: '/three',
icon: 'description',
},
},
{
id: 'four',
level: 0,
meta: {
path: '/four',
},
},
],
})
);
});
it('should remove parent element from list if children is not in available links list', async () => {
when(mockNavigationService.availableLinks$).thenReturn(
of([
{
id: 'one',
path: '/one',
navPlace: {
page: 'one',
},
},
{
id: 'three',
path: '/three',
navPlace: {
page: 'three',
},
},
{
id: 'four',
path: '/four',
navPlace: {
page: 'four',
},
},
])
);
await createComponent([
{ id: 'one' },
{
id: 'two',
children: [{ id: 'two one' }, { id: 'two two' }],
},
{ id: 'three', icon: 'description' },
{ id: 'four' },
]);
fixture.detectChanges();
expect(component.menu$).toBeObservable(
cold('(a|)', {
a: [
{
id: 'one',
level: 0,
meta: {
path: '/one',
},
},
{
id: 'three',
level: 0,
meta: {
path: '/three',
icon: 'description',
},
},
{
id: 'four',
level: 0,
meta: {
path: '/four',
},
},
],
})
);
});
});
describe('openSideNav', () => {
beforeEach(async () => {
await createComponent();
fixture.detectChanges();
component.drawer = instance(mockMatDrawer);
});
it('should call drawer open method', () => {
component.openSideNav();
verify(mockMatDrawer.open('program')).once();
expect().nothing();
});
});
describe('closeSideNav', () => {
beforeEach(async () => {
await createComponent();
fixture.detectChanges();
component.drawer = instance(mockMatDrawer);
});
it('should call drawer close method', () => {
component.closeSideNav();
verify(mockMatDrawer.close()).once();
expect().nothing();
});
});
});

View File

@ -1,25 +1,14 @@
import { Component, Inject, Input, OnInit, ViewChild } from '@angular/core';
import { MatDrawer } from '@angular/material/sidenav';
import isNil from 'lodash-es/isNil';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Component, Input, ViewChild } from '@angular/core';
import { MatDrawer, MatDrawerToggleResult } from '@angular/material/sidenav';
import { coerceBoolean } from '@dsh/utils';
import { NavigationLink, NavigationService } from '../navigation';
import { MOBILE_MENU_TOKEN } from './consts';
import { isParentFlatNode } from './types/is-parent-flat-node';
import { NavigationFlatNode } from './types/navigation-flat-node';
import { PartialNavigationFlatNode } from './types/partial-navigation-flat-node';
import { PartialNavigationNode } from './types/partial-navigation-node';
import { getFlattenMobileMenu } from './utils/get-flatten-mobile-menu';
@Component({
selector: 'dsh-mobile-grid',
templateUrl: './mobile-grid.component.html',
styleUrls: ['./mobile-grid.component.scss'],
})
export class MobileGridComponent implements OnInit {
export class MobileGridComponent {
@Input()
@coerceBoolean
inverted: boolean;
@ -28,67 +17,15 @@ export class MobileGridComponent implements OnInit {
@ViewChild(MatDrawer) drawer: MatDrawer;
menu$: Observable<NavigationFlatNode[]>;
activeMenuItemId$: Observable<NavigationFlatNode['id']>;
private mobileMenuFormat: PartialNavigationFlatNode[];
constructor(
private navigationService: NavigationService,
@Inject(MOBILE_MENU_TOKEN)
private mobileMenu: PartialNavigationNode[]
) {}
get menuIcon(): string {
return this.inverted ? 'menu_inverted' : 'menu';
}
ngOnInit(): void {
this.mobileMenuFormat = getFlattenMobileMenu(this.mobileMenu);
this.activeMenuItemId$ = this.navigationService.activeLink$.pipe(map((link: NavigationLink) => link.id));
this.menu$ = this.navigationService.availableLinks$.pipe(
map((links: NavigationLink[]) => {
return links.reduce((linksMap: Map<string, NavigationLink>, link: NavigationLink) => {
linksMap.set(link.id, link);
return linksMap;
}, new Map());
}),
map((linksMap: Map<string, NavigationLink>) => {
const flatMenuNodes = this.mobileMenuFormat
.filter(
(menuNode: PartialNavigationFlatNode) => linksMap.has(menuNode.id) || isParentFlatNode(menuNode)
)
.map((menuNode: PartialNavigationFlatNode) => {
if (isParentFlatNode(menuNode)) {
return menuNode;
}
const { path } = linksMap.get(menuNode.id);
const { meta = {} } = menuNode;
return {
...menuNode,
meta: {
...meta,
path,
},
};
});
return flatMenuNodes.filter((menuNode: NavigationFlatNode, index: number) => {
if (isParentFlatNode(menuNode)) {
const nextNode = flatMenuNodes[index + 1];
return !(isNil(nextNode) || nextNode.level <= menuNode.level);
}
return true;
});
})
);
openSideNav(): Promise<MatDrawerToggleResult> {
return this.drawer.open('program');
}
openSideNav(): void {
this.drawer.open('program');
}
closeSideNav(): void {
this.drawer.close();
closeSideNav(): Promise<MatDrawerToggleResult> {
return this.drawer.close();
}
}

View File

@ -3,22 +3,14 @@ import { NgModule } from '@angular/core';
import { FlexLayoutModule } from '@angular/flex-layout';
import { MatIconModule } from '@angular/material/icon';
import { MatSidenavModule } from '@angular/material/sidenav';
import cloneDeep from 'lodash-es/cloneDeep';
import { BrandModule } from '../brand';
import { MOBILE_MENU, MOBILE_MENU_TOKEN } from './consts';
import { MobileMenuModule } from './menu/mobile-menu.module';
import { MobileGridComponent } from './mobile-grid.component';
import { MobileMenuModule } from './mobile-menu/mobile-menu.module';
@NgModule({
imports: [CommonModule, MatSidenavModule, MatIconModule, BrandModule, FlexLayoutModule, MobileMenuModule],
declarations: [MobileGridComponent],
exports: [MobileGridComponent],
providers: [
{
provide: MOBILE_MENU_TOKEN,
useValue: cloneDeep(MOBILE_MENU),
},
],
})
export class MobileGridModule {}

View File

@ -0,0 +1,13 @@
@import '@angular/material/theming';
@import './components/mobile-menu-nav-item/mobile-menu-nav-item-theme';
@import './components/mobile-user-bar/mobile-user-bar-theme';
@mixin dsh-mobile-menu-theme($theme) {
@include dsh-mobile-nav-item-theme($theme);
}
@mixin dsh-mobile-menu-typography($config) {
@include dsh-mobile-menu-nav-item-typography($config);
@include dsh-mobile-user-bar-typography($config);
}

View File

@ -0,0 +1,3 @@
export * from './mobile-menu-nav-item';
export * from './mobile-menu-feedback-item';
export * from './mobile-user-bar';

View File

@ -0,0 +1 @@
export * from './mobile-menu-feedback-item.component';

View File

@ -0,0 +1,3 @@
<dsh-mobile-menu-nav-item *transloco="let t; scope: 'toolbar'; read: 'toolbar.nav'" (click)="openFeedbackDialog()">{{
t('feedback')
}}</dsh-mobile-menu-nav-item>

View File

@ -1,14 +1,14 @@
import { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { FeedbackDialogComponent } from '@dsh/app/shared/components/dialog';
@Component({
selector: 'dsh-feedback',
templateUrl: './feedback.component.html',
styleUrls: ['./feedback.component.scss'],
selector: 'dsh-mobile-menu-feedback-item',
templateUrl: 'mobile-menu-feedback-item.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FeedbackComponent {
export class MobileMenuFeedbackItemComponent {
constructor(private dialog: MatDialog) {}
openFeedbackDialog(): MatDialogRef<FeedbackDialogComponent> {

View File

@ -0,0 +1,15 @@
@use '~@angular/material' as mat;
@mixin dsh-mobile-nav-item-theme($theme) {
$primary: map-get($theme, primary);
.dsh-mobile-nav-item-active {
color: mat.get-color-from-palette($primary, default);
}
}
@mixin dsh-mobile-menu-nav-item-typography($config) {
.dsh-mobile-menu-nav-item {
@include mat.typography-level($config, title);
}
}

View File

@ -0,0 +1 @@
export * from './mobile-menu-nav-item.component';

View File

@ -0,0 +1,3 @@
<div class="dsh-mobile-menu-nav-item" [ngClass]="{ 'dsh-mobile-nav-item-active': active }">
<ng-content></ng-content>
</div>

View File

@ -0,0 +1,7 @@
:host {
outline: none;
}
.dsh-mobile-menu-nav-item {
cursor: pointer;
}

View File

@ -0,0 +1,11 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
@Component({
selector: 'dsh-mobile-menu-nav-item',
templateUrl: 'mobile-menu-nav-item.component.html',
styleUrls: ['mobile-menu-nav-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NavItemComponent {
@Input() active = false;
}

View File

@ -0,0 +1,7 @@
@use '~@angular/material' as mat;
@mixin dsh-mobile-user-bar-typography($config) {
.dsh-mobile-user-bar-username {
@include mat.typography-level($config, body-2);
}
}

View File

@ -0,0 +1 @@
export * from './mobile-user-bar.component';

View File

@ -0,0 +1,4 @@
<div class="dsh-mobile-user-bar" fxLayout="row" fxLayoutAlign="space-between center">
<div class="dsh-mobile-user-bar-username">{{ userName }}</div>
<mat-icon class="dsh-mobile-user-bar-exit" svgIcon="output" (click)="logout()"></mat-icon>
</div>

View File

@ -0,0 +1,13 @@
$dsh-mobile-user-bar-padding: 24px 16px;
.dsh-mobile-user-bar {
padding: $dsh-mobile-user-bar-padding;
&-exit {
cursor: pointer;
&:hover {
opacity: 0.5;
}
}
}

View File

@ -11,7 +11,7 @@ export class MobileUserBarComponent {
constructor(private keycloakService: KeycloakService) {}
logout(): void {
this.keycloakService.logout();
logout(): Promise<void> {
return this.keycloakService.logout();
}
}

View File

@ -0,0 +1,2 @@
export * from './mobile-menu.module';
export * from './mobile-menu.component';

View File

@ -0,0 +1,17 @@
<div class="dsh-mobile-menu" fxLayout="column">
<div class="dsh-enu-nav-items" fxFlex fxLayout="column" fxLayoutGap="16px">
<dsh-mobile-menu-nav-item
*ngFor="let link of sectionLinks$ | async"
[routerLink]="link.path"
routerLinkActive
[routerLinkActiveOptions]="{ exact: link?.exact }"
#rla="routerLinkActive"
[active]="rla.isActive"
(click)="menuItemSelected.emit()"
>{{ link.label }}</dsh-mobile-menu-nav-item
>
<dsh-mobile-menu-feedback-item></dsh-mobile-menu-feedback-item>
</div>
<mat-divider></mat-divider>
<dsh-mobile-user-bar></dsh-mobile-user-bar>
</div>

View File

@ -0,0 +1,9 @@
$dsh-enu-nav-items-padding: 0 16px;
.dsh-mobile-menu {
height: 100%;
}
.dsh-enu-nav-items {
padding: $dsh-enu-nav-items-padding;
}

View File

@ -0,0 +1,17 @@
import { Component, EventEmitter, Output } from '@angular/core';
import { Observable } from 'rxjs';
import { SectionsLinksService, SectionLink } from '@dsh/app/shared/services/sections-links';
@Component({
selector: 'dsh-mobile-menu',
templateUrl: './mobile-menu.component.html',
styleUrls: ['./mobile-menu.component.scss'],
})
export class MobileMenuComponent {
@Output() menuItemSelected: EventEmitter<void> = new EventEmitter<void>();
sectionLinks$: Observable<SectionLink[]> = this.sectionsLinksService.sectionLinks$;
constructor(private sectionsLinksService: SectionsLinksService) {}
}

View File

@ -0,0 +1,27 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexModule } from '@angular/flex-layout';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { RouterModule } from '@angular/router';
import { TranslocoModule } from '@ngneat/transloco';
import { SectionsLinksModule } from '@dsh/app/shared/services/sections-links';
import { MobileMenuFeedbackItemComponent, MobileUserBarComponent, NavItemComponent } from './components';
import { MobileMenuComponent } from './mobile-menu.component';
@NgModule({
imports: [
CommonModule,
MatDividerModule,
FlexModule,
SectionsLinksModule,
RouterModule,
TranslocoModule,
MatIconModule,
],
declarations: [MobileMenuComponent, NavItemComponent, MobileMenuFeedbackItemComponent, MobileUserBarComponent],
exports: [MobileMenuComponent],
})
export class MobileMenuModule {}

View File

@ -1,4 +0,0 @@
export interface BaseNavigationFlatNode {
id: string;
level: number;
}

View File

@ -1,10 +0,0 @@
import { NavigationFlatNode } from './navigation-flat-node';
import { NavigationFlatNodeParent } from './navigation-flat-node-parent';
import { PartialNavigationFlatNode } from './partial-navigation-flat-node';
export function isParentFlatNode(
node: NavigationFlatNode | PartialNavigationFlatNode
): node is NavigationFlatNodeParent {
// eslint-disable-next-line no-prototype-builtins
return node.hasOwnProperty('isExpanded');
}

View File

@ -1,6 +0,0 @@
import { BaseNavigationFlatNode } from './base-navigation-flat-node';
import { NavigationLinkNodeMeta } from './navigation-link-node-meta';
export interface NavigationFlatNodeLeaf extends BaseNavigationFlatNode {
meta?: NavigationLinkNodeMeta;
}

View File

@ -1,5 +0,0 @@
import { BaseNavigationFlatNode } from './base-navigation-flat-node';
export interface NavigationFlatNodeParent extends BaseNavigationFlatNode {
isExpanded: boolean;
}

View File

@ -1,4 +0,0 @@
import { NavigationFlatNodeLeaf } from './navigation-flat-node-leaf';
import { NavigationFlatNodeParent } from './navigation-flat-node-parent';
export type NavigationFlatNode = NavigationFlatNodeLeaf | NavigationFlatNodeParent;

View File

@ -1,8 +0,0 @@
import { IconName } from '@dsh/components/indicators/colored-icon/icon-name';
import { NavigationLink } from '../../navigation';
// export interface NavigationLinkNodeMeta extends Pick<NavigationLink, 'path' | 'navPlace'> {
export interface NavigationLinkNodeMeta extends Pick<NavigationLink, 'path'> {
icon?: IconName;
}

View File

@ -1,6 +0,0 @@
import { NavigationFlatNodeLeaf } from './navigation-flat-node-leaf';
import { NavigationLinkNodeMeta } from './navigation-link-node-meta';
export type PartialNavigationFlatNodeLeaf = Omit<NavigationFlatNodeLeaf, 'meta'> & {
meta?: { icon?: NavigationLinkNodeMeta['icon'] };
};

View File

@ -1,4 +0,0 @@
import { NavigationFlatNodeParent } from './navigation-flat-node-parent';
import { PartialNavigationFlatNodeLeaf } from './partial-navigation-flat-node-leaf';
export type PartialNavigationFlatNode = NavigationFlatNodeParent | PartialNavigationFlatNodeLeaf;

View File

@ -1,6 +0,0 @@
import { PartialNavigationNode } from './partial-navigation-node';
export interface PartialNavigationGroup {
id: string;
children: PartialNavigationNode[];
}

View File

@ -1,6 +0,0 @@
import { IconName } from '@dsh/components/indicators/colored-icon/icon-name';
export interface PartialNavigationLeaf {
id: string;
icon?: string | IconName;
}

View File

@ -1,4 +0,0 @@
import { PartialNavigationGroup } from './partial-navigation-group';
import { PartialNavigationLeaf } from './partial-navigation-leaf';
export type PartialNavigationNode = PartialNavigationLeaf | PartialNavigationGroup;

View File

@ -1,81 +0,0 @@
import { getFlattenMobileMenu } from './get-flatten-mobile-menu';
describe('getFlattenMobileMenu', () => {
it('should flat menu with no children', () => {
expect(getFlattenMobileMenu([{ id: 'one' }, { id: 'two' }])).toEqual([
{ id: 'one', level: 0, meta: {} },
{ id: 'two', level: 0, meta: {} },
]);
});
it('should flat menu with one child level', () => {
expect(
getFlattenMobileMenu([
{ id: 'one' },
{
id: 'two',
children: [{ id: 'two one' }, { id: 'two two' }],
},
{ id: 'three' },
{ id: 'four' },
])
).toEqual([
{ id: 'one', level: 0, meta: {} },
{ id: 'two', level: 0, isExpanded: false },
{ id: 'two one', level: 1, meta: {} },
{ id: 'two two', level: 1, meta: {} },
{ id: 'three', level: 0, meta: {} },
{ id: 'four', level: 0, meta: {} },
]);
});
it('should flat menu with two child levels', () => {
expect(
getFlattenMobileMenu([
{ id: 'one' },
{
id: 'two',
children: [
{ id: 'two one' },
{
id: 'two two',
children: [{ id: 'two two one' }, { id: 'two two two' }, { id: 'two two three' }],
},
{ id: 'two three' },
],
},
{
id: 'three',
children: [{ id: 'three one' }, { id: 'three two' }, { id: 'three three' }],
},
{ id: 'four' },
])
).toEqual([
{ id: 'one', level: 0, meta: {} },
{ id: 'two', level: 0, isExpanded: false },
{ id: 'two one', level: 1, meta: {} },
{ id: 'two two', level: 1, isExpanded: false },
{ id: 'two two one', level: 2, meta: {} },
{ id: 'two two two', level: 2, meta: {} },
{ id: 'two two three', level: 2, meta: {} },
{ id: 'two three', level: 1, meta: {} },
{ id: 'three', level: 0, isExpanded: false },
{ id: 'three one', level: 1, meta: {} },
{ id: 'three two', level: 1, meta: {} },
{ id: 'three three', level: 1, meta: {} },
{ id: 'four', level: 0, meta: {} },
]);
});
it('should add icon data in meta object', () => {
expect(
getFlattenMobileMenu([
{ id: 'one', icon: 'build' },
{ id: 'two', icon: 'description' },
])
).toEqual([
{ id: 'one', level: 0, meta: { icon: 'build' } },
{ id: 'two', level: 0, meta: { icon: 'description' } },
]);
});
});

View File

@ -1,62 +0,0 @@
import isNil from 'lodash-es/isNil';
import { ROOT_NODE_LEVEL } from '../consts';
import { NavigationFlatNodeParent } from '../types/navigation-flat-node-parent';
import { NavigationLinkNodeMeta } from '../types/navigation-link-node-meta';
import { PartialNavigationFlatNode } from '../types/partial-navigation-flat-node';
import { PartialNavigationGroup } from '../types/partial-navigation-group';
import { PartialNavigationLeaf } from '../types/partial-navigation-leaf';
import { PartialNavigationNode } from '../types/partial-navigation-node';
export function getFlattenMobileMenu(menu: PartialNavigationNode[]): PartialNavigationFlatNode[] {
const flatNodes = menu.map((el: PartialNavigationNode) => {
return {
...el,
level: ROOT_NODE_LEVEL,
};
});
let hasUnhandledChildren = true;
while (hasUnhandledChildren) {
const processingNodes = flatNodes.slice();
hasUnhandledChildren = false;
for (let index = 0; index < processingNodes.length; index += 1) {
const node: PartialNavigationNode & { level: number; isExpanded?: boolean } = processingNodes[index];
const children = (node as PartialNavigationGroup).children;
if (Array.isArray(children)) {
hasUnhandledChildren = true;
const childNodes = children.map((child: PartialNavigationNode) => {
return {
...child,
level: node.level + 1,
};
});
flatNodes.splice(index + 1, 0, ...childNodes);
(node as NavigationFlatNodeParent).isExpanded = false;
delete (node as PartialNavigationGroup).children;
break;
}
}
}
return flatNodes.map(
({ id, level, isExpanded, icon }: PartialNavigationLeaf & { level: number; isExpanded?: boolean }) => {
const meta = isNil(icon) ? {} : ({ icon } as Partial<NavigationLinkNodeMeta>);
return isNil(isExpanded)
? {
id,
level,
meta,
}
: {
id,
level,
isExpanded,
};
}
);
}

View File

@ -1,124 +0,0 @@
import { InjectionToken } from '@angular/core';
import { NavigationLink } from './types/navigation-link';
import { NavigationSections } from './types/navigation-sections';
export const ROOT_ROUTE_PATH = '/';
export const REALM_SEGMENT = 'realm';
export const REALM_TYPE = 'REALM_TYPE';
export const PAGE_POSITION_INDEX = 0;
export const SECTION_POSITION_INDEX = 1;
export const SUBSECTION_POSITION_INDEX = 2;
export const REALM_POSITION_INDEX = 1;
export const REALM_TYPE_POSITION_INDEX = 2;
export const REALM_SECTION_POSITION_INDEX = 3;
export const REALM_SUBSECTION_POSITION_INDEX = 4;
export const MENU_LINKS_TOKEN = new InjectionToken<NavigationLink[]>('menu-links-token');
export const WALLETS_LINKS: string[] = [
NavigationSections.Wallets,
NavigationSections.WalletsWallet,
NavigationSections.WalletsDeposits,
NavigationSections.WalletsWithdrawals,
NavigationSections.WalletsReports,
NavigationSections.WalletsIntegrations,
];
export const MENU_LINKS: NavigationLink[] = [
{
id: NavigationSections.Main,
path: ROOT_ROUTE_PATH,
navPlace: {
page: ROOT_ROUTE_PATH,
},
},
{
id: NavigationSections.Payments,
path: `/payment-section/realm/${REALM_TYPE}/operations/payments`,
navPlace: {
page: 'payment-section',
},
},
{
id: NavigationSections.Wallets,
path: '/wallet-section/wallets',
navPlace: {
page: ['wallet-section', 'wallet'],
section: 'wallets',
},
},
{
id: NavigationSections.WalletsDeposits,
path: '/wallet-section/deposits',
navPlace: {
page: 'wallet-section',
section: 'deposits',
},
},
{
id: NavigationSections.WalletsWithdrawals,
path: '/wallet-section/withdrawals',
navPlace: {
page: 'wallet-section',
section: 'withdrawals',
},
},
{
id: NavigationSections.WalletsIntegrations,
path: '/wallet-section/integrations',
navPlace: {
page: 'wallet-section',
section: 'integrations',
},
},
{
id: NavigationSections.Claims,
path: '/claims',
navPlace: {
page: ['claims', 'claim', 'onboarding'],
},
},
{
id: NavigationSections.PaymentsAnalytics,
path: `/payment-section/realm/${REALM_TYPE}/analytics`,
navPlace: {
page: 'payment-section',
section: 'analytics',
},
},
{
id: NavigationSections.PaymentsOperations,
path: `/payment-section/realm/${REALM_TYPE}/operations`,
navPlace: {
page: 'payment-section',
section: 'operations',
},
},
{
id: NavigationSections.PaymentsPayouts,
path: `/payment-section/realm/${REALM_TYPE}/payouts`,
navPlace: {
page: 'payment-section',
section: 'payouts',
},
},
{
id: NavigationSections.PaymentsReports,
path: `/payment-section/realm/${REALM_TYPE}/reports`,
navPlace: {
page: 'payment-section',
section: 'reports',
},
},
{
id: NavigationSections.PaymentsIntegrations,
path: `/payment-section/realm/${REALM_TYPE}/integrations`,
navPlace: {
page: 'payment-section',
section: 'integrations',
},
},
];

View File

@ -1,5 +0,0 @@
export * from './navigation.module';
export * from './navigation.service';
export * from './types/navigation-link';
export * from './types/navigation-sections';

View File

@ -1,18 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import cloneDeep from 'lodash-es/cloneDeep';
import { MENU_LINKS, MENU_LINKS_TOKEN } from './consts';
import { NavigationService } from './navigation.service';
@NgModule({
imports: [RouterModule],
providers: [
NavigationService,
{
provide: MENU_LINKS_TOKEN,
useValue: cloneDeep(MENU_LINKS),
},
],
})
export class NavigationModule {}

View File

@ -1,339 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { cold } from 'jasmine-marbles';
import { of } from 'rxjs';
import { anything, instance, mock, when } from 'ts-mockito';
import { PaymentInstitutionRealm, WalletService } from '@dsh/api';
import { MENU_LINKS_TOKEN, REALM_TYPE, ROOT_ROUTE_PATH } from './consts';
import { NavigationService } from './navigation.service';
import { NavigationLink } from './types/navigation-link';
import { NavigationSections } from './types/navigation-sections';
describe('NavigationService', () => {
let service: NavigationService;
let mockWalletService: WalletService;
let mockRouter: Router;
function createService(links: NavigationLink[]) {
TestBed.configureTestingModule({
providers: [
NavigationService,
{
provide: WalletService,
useFactory: () => instance(mockWalletService),
},
{
provide: Router,
useFactory: () => instance(mockRouter),
},
{
provide: MENU_LINKS_TOKEN,
useValue: links,
},
],
});
service = TestBed.inject(NavigationService);
}
beforeEach(() => {
mockWalletService = mock(WalletService);
mockRouter = mock(Router);
});
beforeEach(() => {
when(mockRouter.url).thenReturn('test_url');
when(mockRouter.events).thenReturn(of());
when(mockRouter.parseUrl(anything())).thenReturn({
root: {
children: {
primary: null,
},
},
} as any);
when(mockWalletService.hasWallets$).thenReturn(of());
});
it('should be created', () => {
createService([]);
expect(service).toBeTruthy();
});
describe('availableLinks$', () => {
beforeEach(() => {
when(mockWalletService.hasWallets$).thenReturn(of(true));
});
it('should filter wallets links if wallets are not available', () => {
when(mockWalletService.hasWallets$).thenReturn(of(false));
createService([
{
id: NavigationSections.Wallets,
path: 'wallets',
navPlace: {
page: 'wallets',
},
},
{
id: NavigationSections.WalletsWithdrawals,
path: 'walletsWithdrawals',
navPlace: {
page: 'walletsWithdrawals',
},
},
{
id: 'mine',
path: 'mine',
navPlace: {
page: 'mine',
},
},
]);
expect(service.availableLinks$).toBeObservable(
cold('(a|)', {
a: [
{
id: 'mine',
path: 'mine',
navPlace: {
page: 'mine',
},
},
],
})
);
});
it('should return links as is if wallets is available', () => {
when(mockWalletService.hasWallets$).thenReturn(of(true));
createService([
{
id: NavigationSections.Wallets,
path: 'wallets',
navPlace: {
page: 'wallets',
},
},
{
id: 'mine',
path: 'mine',
navPlace: {
page: 'mine',
},
},
]);
expect(service.availableLinks$).toBeObservable(
cold('(a|)', {
a: [
{
id: NavigationSections.Wallets,
path: 'wallets',
navPlace: {
page: 'wallets',
},
},
{
id: 'mine',
path: 'mine',
navPlace: {
page: 'mine',
},
},
],
})
);
});
it('should replace realm type using existing realm from url', () => {
when(mockRouter.url).thenReturn('/home/realm/live/section/subsection');
createService([
{
id: 'mine_realm',
path: `/mine/realm/${REALM_TYPE}/section`,
navPlace: {
page: 'mine',
section: 'section',
},
},
{
id: 'mine',
path: 'mine',
navPlace: {
page: 'mine',
},
},
]);
expect(service.availableLinks$).toBeObservable(
cold('(a|)', {
a: [
{
id: 'mine_realm',
path: `/mine/realm/live/section`,
navPlace: {
page: 'mine',
section: 'section',
},
},
{
id: 'mine',
path: 'mine',
navPlace: {
page: 'mine',
},
},
],
})
);
});
it('should replace realm type using live if realm does not set in url', () => {
when(mockRouter.url).thenReturn('/');
createService([
{
id: 'mine_realm',
path: `/mine/realm/${REALM_TYPE}/section`,
navPlace: {
page: 'mine',
section: 'section',
},
},
{
id: 'mine',
path: 'mine',
navPlace: {
page: 'mine',
},
},
]);
expect(service.availableLinks$).toBeObservable(
cold('(a|)', {
a: [
{
id: 'mine_realm',
path: `/mine/realm/${PaymentInstitutionRealm.Live}/section`,
navPlace: {
page: 'mine',
section: 'section',
},
},
{
id: 'mine',
path: 'mine',
navPlace: {
page: 'mine',
},
},
],
})
);
});
});
describe('activeLink$', () => {
beforeEach(() => {
when(mockWalletService.hasWallets$).thenReturn(of(true));
});
it('should return link that match current url', () => {
when(mockRouter.url).thenReturn('/mine');
when(mockRouter.parseUrl('/mine')).thenReturn({
root: {
children: {
primary: {
segments: [{ path: 'mine' }],
},
},
},
} as any);
createService([
{
id: 'root',
path: ROOT_ROUTE_PATH,
navPlace: { page: ROOT_ROUTE_PATH },
},
{
id: 'mine_realm',
path: `/mine/realm/${REALM_TYPE}/section`,
navPlace: {
page: 'mine',
section: 'section',
},
},
{
id: 'mine',
path: 'mine',
navPlace: {
page: 'mine',
},
},
]);
expect(service.activeLink$).toBeObservable(
cold('(a|)', {
a: {
id: 'mine',
path: 'mine',
navPlace: {
page: 'mine',
},
},
})
);
});
it('should return link with root path if there is no valid link for current url', () => {
when(mockRouter.url).thenReturn('/idk');
when(mockRouter.parseUrl('/idk')).thenReturn({
root: {
children: {
primary: {
segments: [{ path: 'idk' }],
},
},
},
} as any);
createService([
{
id: 'root',
path: ROOT_ROUTE_PATH,
navPlace: { page: ROOT_ROUTE_PATH },
},
{
id: 'mine_realm',
path: `/mine/realm/${REALM_TYPE}/section`,
navPlace: {
page: 'mine',
section: 'section',
},
},
{
id: 'mine',
path: 'mine',
navPlace: {
page: 'mine',
},
},
]);
expect(service.activeLink$).toBeObservable(
cold('(a|)', {
a: {
id: 'root',
path: ROOT_ROUTE_PATH,
navPlace: { page: ROOT_ROUTE_PATH },
},
})
);
});
});
});

View File

@ -1,85 +0,0 @@
import { Inject, Injectable } from '@angular/core';
import { Event, Router, UrlSegment } from '@angular/router';
import { combineLatest, Observable } from 'rxjs';
import { distinctUntilChanged, map, shareReplay, startWith } from 'rxjs/operators';
import { PaymentInstitutionRealm, WalletService } from '@dsh/api';
import { MENU_LINKS_TOKEN, REALM_TYPE, REALM_TYPE_POSITION_INDEX, ROOT_ROUTE_PATH, WALLETS_LINKS } from './consts';
import { NavigationLink } from './types/navigation-link';
import { findActiveNavLink } from './utils/find-active-nav-link';
@Injectable()
export class NavigationService {
availableLinks$: Observable<NavigationLink[]>;
activeLink$: Observable<NavigationLink>;
private currentUrl$: Observable<string> = this.router.events.pipe(
startWith<Event, null>(null),
map(() => this.router.url),
distinctUntilChanged(),
shareReplay(1)
);
private currentUrlSegments$: Observable<string[]> = this.currentUrl$.pipe(
map((url: string) => {
const urlRootChildren = this.router.parseUrl(url).root.children;
return urlRootChildren.primary?.segments ?? [];
}),
map((segments: UrlSegment[]) => segments.map((segment: UrlSegment) => segment.path))
);
private realm$: Observable<PaymentInstitutionRealm> = this.currentUrlSegments$.pipe(
map(
(segments: string[]) =>
(segments[REALM_TYPE_POSITION_INDEX] as PaymentInstitutionRealm) ?? PaymentInstitutionRealm.Live
)
);
constructor(
private walletsService: WalletService,
private router: Router,
@Inject(MENU_LINKS_TOKEN)
private menuLinks: NavigationLink[]
) {
this.initLinks();
this.initActiveLink();
this.activeLink$.subscribe();
}
private initLinks(): void {
this.availableLinks$ = combineLatest([this.walletsService.hasWallets$, this.realm$]).pipe(
map(([hasWallets, realm]: [boolean, PaymentInstitutionRealm]) => {
return [this.filterLinks(hasWallets), realm];
}),
map(([links, realm]: [NavigationLink[], PaymentInstitutionRealm]) => {
return links.map((link: NavigationLink) => {
return {
...link,
path: link.path.replace(REALM_TYPE, realm),
};
});
}),
shareReplay(1)
);
}
private initActiveLink(): void {
this.activeLink$ = combineLatest([this.currentUrlSegments$, this.availableLinks$]).pipe(
map(([segments, links]: [string[], NavigationLink[]]) => {
// route link is active by default
return (
findActiveNavLink(segments, links) ??
links.find((link: NavigationLink) => link.path === ROOT_ROUTE_PATH)
);
}),
shareReplay(1)
);
}
private filterLinks(hasWallets: boolean): NavigationLink[] {
return this.menuLinks.filter(({ id }: NavigationLink) => {
return WALLETS_LINKS.indexOf(id) >= 0 ? hasWallets : true;
});
}
}

View File

@ -1,5 +0,0 @@
export interface NavigationLinkSections {
page: string | string[];
section?: string;
subsection?: string;
}

View File

@ -1,7 +0,0 @@
import { NavigationLinkSections } from './navigation-link-sections';
export interface NavigationLink {
id: string;
path: string;
navPlace: NavigationLinkSections;
}

View File

@ -1,16 +0,0 @@
export enum NavigationSections {
Main = 'main',
Payments = 'payments',
PaymentsAnalytics = 'payments_analytics',
PaymentsOperations = 'payments_operations',
PaymentsPayouts = 'payments_payouts',
PaymentsReports = 'payments_reports',
PaymentsIntegrations = 'payments_integrations',
Wallets = 'wallets',
WalletsWallet = 'wallets_wallet',
WalletsDeposits = 'wallets_deposits',
WalletsWithdrawals = 'wallets_withdrawals',
WalletsReports = 'wallets_reports',
WalletsIntegrations = 'wallets_integrations',
Claims = 'claims',
}

View File

@ -1,83 +0,0 @@
import { findActiveNavLink } from './find-active-nav-link';
describe('findActiveNavLink', () => {
it('should return the deepest matched link', () => {
expect(
findActiveNavLink(
['page', 'realm', 'live', 'section', 'subsection'],
[
{
id: 'home',
path: '/',
navPlace: { page: '/' },
},
{
id: 'not_find_one',
path: '/page/realm/any/section/another_section',
navPlace: {
page: 'page',
section: 'section',
subsection: 'another_section',
},
},
{
id: 'find',
path: '/page/realm/any/section/subsection',
navPlace: {
page: 'page',
section: 'section',
subsection: 'subsection',
},
},
{
id: 'not_find_two',
path: '/page/realm/any/section/another_section_two',
navPlace: {
page: 'page',
section: 'section',
subsection: 'another_section',
},
},
]
)
).toEqual({
id: 'find',
path: '/page/realm/any/section/subsection',
navPlace: {
page: 'page',
section: 'section',
subsection: 'subsection',
},
});
});
it('should return null if there is no url segments was provided', () => {
expect(
findActiveNavLink(
[],
[
{
id: 'home',
path: '/',
navPlace: { page: '/' },
},
]
)
).toBeNull();
});
it('should return null if there is no valid active url was found', () => {
expect(
findActiveNavLink(
['home'],
[
{
id: 'root',
path: '/',
navPlace: { page: '/' },
},
]
)
).toBeNull();
});
});

View File

@ -1,84 +0,0 @@
import isEmpty from 'lodash-es/isEmpty';
import isNil from 'lodash-es/isNil';
import {
PAGE_POSITION_INDEX,
REALM_POSITION_INDEX,
REALM_SECTION_POSITION_INDEX,
REALM_SEGMENT,
REALM_SUBSECTION_POSITION_INDEX,
SECTION_POSITION_INDEX,
SUBSECTION_POSITION_INDEX,
} from '../consts';
import { NavigationLink } from '../types/navigation-link';
const NO_MATCH_LEVEL = 0;
const PAGE_MATCH_LEVEL = 1;
const SECTION_MATCH_LEVEL = 2;
const SUBSECTION_MATCH_LEVEL = 3;
const findSectionsSegments = (urlSegments: string[]): [string | undefined, string | undefined] => {
const realmSegment = urlSegments[REALM_POSITION_INDEX];
let sectionSegment: string | undefined;
let subsectionSegment: string | undefined;
if (realmSegment === REALM_SEGMENT) {
sectionSegment = urlSegments[REALM_SECTION_POSITION_INDEX];
subsectionSegment = urlSegments[REALM_SUBSECTION_POSITION_INDEX];
} else {
sectionSegment = urlSegments[SECTION_POSITION_INDEX];
subsectionSegment = urlSegments[SUBSECTION_POSITION_INDEX];
}
return [sectionSegment, subsectionSegment];
};
export const findActiveNavLink = (urlSegments: string[], links: NavigationLink[]): NavigationLink | null => {
if (isEmpty(urlSegments)) {
return null;
}
const pageSegment = urlSegments[PAGE_POSITION_INDEX];
const [sectionSegment, subsectionSegment] = findSectionsSegments(urlSegments);
const activeLink = links
.map((link: NavigationLink) => {
const { page, section, subsection } = link.navPlace;
const isSamePage = Array.isArray(page)
? page.some((path: string) => path === pageSegment)
: page === pageSegment;
const isSameSection = section === sectionSegment;
const isSameSubsection = subsection === subsectionSegment;
if (isNil(section) || isNil(sectionSegment)) {
return {
matchLevel: isSamePage ? PAGE_MATCH_LEVEL : NO_MATCH_LEVEL,
link,
};
}
if (isNil(subsection) || isNil(subsectionSegment)) {
return {
matchLevel: isSamePage && isSameSection ? SECTION_MATCH_LEVEL : NO_MATCH_LEVEL,
link,
};
}
return {
matchLevel: isSamePage && isSameSection && isSameSubsection ? SUBSECTION_MATCH_LEVEL : NO_MATCH_LEVEL,
link,
};
})
.filter(({ matchLevel }: { matchLevel: number; link: NavigationLink }) => matchLevel > NO_MATCH_LEVEL)
.sort((a: { matchLevel: number; link: NavigationLink }, b: { matchLevel: number; link: NavigationLink }) =>
a.matchLevel >= b.matchLevel ? 1 : -1
)
.pop();
if (isNil(activeLink)) {
return null;
}
return activeLink.link;
};

View File

@ -1,2 +0,0 @@
export * from './link-id';
export * from './toolbar-link';

View File

@ -1,6 +0,0 @@
export enum LinkId {
Main = 'main',
Payments = 'payments',
Wallets = 'wallets',
Claims = 'claims',
}

View File

@ -1,7 +0,0 @@
import { LinkId } from './link-id';
export interface ToolbarLink {
id: LinkId;
path: string;
exact?: boolean;
}

View File

@ -1,12 +1,8 @@
<div class="toolbar" fxLayout fxLayoutGap="24px" fxLayoutAlign="start center">
<dsh-brand fxFlex="168px" [inverted]="inverted" [name]="logoName" class="dsh-side-section"></dsh-brand>
<nav
[class.dsh-inverted-nav-bar]="inverted"
*transloco="let t; scope: 'toolbar'; read: 'toolbar.nav'"
mat-tab-nav-bar
>
<nav [class.dsh-inverted-nav-bar]="inverted" mat-tab-nav-bar>
<a
*ngFor="let link of links$ | async"
*ngFor="let link of sectionLinks$ | async"
mat-tab-link
[routerLink]="link.path"
routerLinkActive
@ -14,7 +10,7 @@
#rla="routerLinkActive"
[active]="rla.isActive"
>
{{ t(link.id) }}
{{ link.label }}
</a>
</nav>
<dsh-actionbar fxFlex="grow" fxLayoutAlign="end"></dsh-actionbar>

View File

@ -1,11 +1,8 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { first, map } from 'rxjs/operators';
import { WalletService } from '@dsh/api/wallet';
import { SectionsLinksService } from '@dsh/app/shared/services/sections-links';
import { coerceBoolean } from '@dsh/utils';
import { createLinks } from './utils';
@Component({
selector: 'dsh-toolbar',
templateUrl: 'toolbar.component.html',
@ -16,7 +13,7 @@ export class ToolbarComponent {
@Input() @coerceBoolean inverted: boolean;
@Input() logoName: string;
links$ = this.walletsService.hasWallets$.pipe(map(createLinks), first());
sectionLinks$ = this.sectionsLinksService.sectionLinks$;
constructor(private walletsService: WalletService) {}
constructor(private sectionsLinksService: SectionsLinksService) {}
}

View File

@ -3,7 +3,8 @@ import { NgModule } from '@angular/core';
import { FlexLayoutModule } from '@angular/flex-layout';
import { MatTabsModule } from '@angular/material/tabs';
import { RouterModule } from '@angular/router';
import { TranslocoModule } from '@ngneat/transloco';
import { SectionsLinksModule } from '@dsh/app/shared/services/sections-links';
import { ActionbarModule } from '../actionbar';
import { BrandModule } from '../brand';
@ -16,8 +17,8 @@ import { ToolbarComponent } from './toolbar.component';
BrandModule,
ActionbarModule,
RouterModule,
TranslocoModule,
MatTabsModule,
SectionsLinksModule,
],
declarations: [ToolbarComponent],
exports: [ToolbarComponent],

View File

@ -1,19 +0,0 @@
import { LinkId, ToolbarLink } from '../model';
export const createLinks = (hasWallets: boolean): ToolbarLink[] =>
[
{
id: LinkId.Main,
path: '/',
exact: true,
},
{
id: LinkId.Payments,
path: `/payment-section`,
},
hasWallets && {
id: LinkId.Wallets,
path: '/wallet-section',
},
{ id: LinkId.Claims, path: '/claim-section' },
].filter(Boolean);

View File

@ -6,3 +6,4 @@ export * from './fake-paginator.service';
export * from './notification';
export * from './error';
export * from './keycloak-token-info';
export * from './sections-links';

View File

@ -0,0 +1,3 @@
export * from './section-links.module';
export * from './section-links.service';
export * from './model';

View File

@ -0,0 +1 @@
export * from './section-link';

View File

@ -0,0 +1,5 @@
export interface SectionLink {
label: string;
path: string;
exact?: boolean;
}

View File

@ -0,0 +1,10 @@
import { NgModule } from '@angular/core';
import { TranslocoModule } from '@ngneat/transloco';
import { SectionsLinksService } from './section-links.service';
@NgModule({
imports: [TranslocoModule],
providers: [SectionsLinksService],
})
export class SectionsLinksModule {}

View File

@ -0,0 +1,22 @@
import { Injectable } from '@angular/core';
import { TranslocoService } from '@ngneat/transloco';
import { combineLatest, Observable } from 'rxjs';
import { first, map } from 'rxjs/operators';
import { WalletService } from '@dsh/api/wallet';
import { SectionLink } from './model';
import { createLinks } from './utils';
@Injectable()
export class SectionsLinksService {
sectionLinks$: Observable<SectionLink[]> = combineLatest([
this.transloco.selectTranslateObject<{ [k: string]: string }>('sectionLinks'),
this.walletsService.hasWallets$,
]).pipe(
map((v) => createLinks(...v)),
first()
);
constructor(private walletsService: WalletService, private transloco: TranslocoService) {}
}

View File

@ -0,0 +1,22 @@
import { SectionLink } from '../model';
export const createLinks = (
{ claims, main, payments, wallets }: { [k: string]: string },
hasWallets: boolean
): SectionLink[] =>
[
{
label: main,
path: '/',
exact: true,
},
{
label: payments,
path: `/payment-section`,
},
hasWallets && {
label: wallets,
path: '/wallet-section',
},
{ label: claims, path: '/claim-section' },
].filter(Boolean);

View File

@ -228,5 +228,11 @@
"success": "Успешно",
"httpError": "Произошла ошибка в процессе передачи / получения данных",
"unknownError": "Неизвестная ошибка"
},
"sectionLinks": {
"main": "Главная",
"payments": "Платежи",
"wallets": "Кошельки",
"claims": "Заявки"
}
}

View File

@ -3,3 +3,7 @@
@mixin dsh-navbar-theme($theme) {
@include dsh-navbar-item-theme($theme);
}
@mixin dsh-navbar-typography($config) {
@include dsh-navbar-item-typography($config);
}

View File

@ -35,7 +35,7 @@
@import '../../app/home/home-theme';
@import '../../app/home/actionbar/actionbar-theme';
@import '../../app/home/welcome-image/welcome-image-theme';
@import '../../app/home/mobile-grid/mobile_menu_theme';
@import '../../app/home/mobile-grid/mobile-grid-theme';
@import '../../app/dadata/dadata-theme';
@import '../../app/sections/landing/landing-theme';
@import '../../app/sections/payment-section/analytics/percent-difference/percent-difference-theme';
@ -52,7 +52,7 @@
@include dsh-card-theme($theme);
@include dsh-state-nav-theme($theme);
@include dsh-navbar-theme($theme);
@include dsh-mobile-menu-theme($theme);
@include dsh-mobile-grid-theme($theme);
@include dsh-button-theme($theme);
@include dsh-button-toggle-theme($theme);
@include dsh-float-panel-theme($theme);

View File

@ -25,6 +25,7 @@
@import '../../app/home/home-theme';
@import '../../app/home/toolbar/toolbar-theme';
@import '../../app/home/welcome-image/welcome-image-theme';
@import '../../app/home/mobile-grid/mobile-grid-theme';
@import '../../app/dadata/dadata-theme';
@import '../../app/sections/payment-section/balances/balances-theme';
@ -41,7 +42,6 @@
@include dsh-range-datepicker-typography($config);
@include dsh-panel-typography($config);
@include dsh-charts-typography($config);
@include dsh-navbar-item-typography($config);
@include dsh-global-banner-typography($config);
@include dsh-row-typography($config);
@include dsh-accordion-typography($config);
@ -54,4 +54,6 @@
@include dsh-breadcrumb-typography($config);
@include dsh-link-typography($config);
@include dsh-toolbar-typography($config);
@include dsh-navbar-typography($config);
@include dsh-mobile-grid-typography($config);
}