Display selected module on navbar

- Add current module and action to AppState
- Calculate current module and action in BaseModuleResolver
-- Add logic to show current module as active on
--- import and find duplicates
- Update legacy handler method to retrieve available modules
-- Also send invisible modules, to
--- Allow to display invisible modules as the selected module
-- Update module name mape
- Retrieve number of navbar items from backed

- Fix grouped tab links
- Simplify navbar abstract by simplifying parameters
-- passing language map instead of individual language
-- passing navigation object instead of individual parts
- Create smaller components for repeated parts of navbar
-- grouped-menu-item
-- menu-item
-- menu-item-link
-- menu-item-list
-- home-menu-item
-- home-menu-recently-viewed

- Removing "jsdoc/no-types" from eslint
-- To avoid conflicts with jsdoc/require-param-type
This commit is contained in:
Clemente Raposo 2020-05-15 22:18:50 +01:00 committed by Dillon-Brown
parent 3672c445e7
commit f10cc2812f
47 changed files with 1860 additions and 368 deletions

View file

@ -111,7 +111,6 @@ module.exports = {
"id-match": "error",
"import/no-deprecated": "warn",
"import/order": "off",
"jsdoc/no-types": "error",
"max-classes-per-file": "off",
"max-len": [
"error",

View file

@ -29,6 +29,7 @@ services:
$exposedUserPreferences: '%legacy.exposed_user_preferences%'
$themeImagePaths: '%themes.image_paths%'
$themeImageSupportedTypes: '%themes.image_supported_types%'
$frontendExcludedModules: '%legacy.frontend_excluded_modules%'
_instanceof:
App\Service\ProcessHandlerInterface:
tags: ['app.process.handler']

View file

@ -0,0 +1,6 @@
parameters:
legacy.frontend_excluded_modules:
- EmailText
- TeamMemberships
- TeamSets
- TeamSetModule

View file

@ -1,6 +1,6 @@
import {ActionLinkModel} from './action-link-model';
import {MenuItem} from '@components/navbar/navbar.abstract';
export interface AllMenuModel {
modules: Array<ActionLinkModel>;
extra: Array<ActionLinkModel>;
modules: MenuItem[];
extra: MenuItem[];
}

View file

@ -0,0 +1,41 @@
<ng-container>
<span data-toggle="collapse" data-target=".navbar-collapse">
<a class="top-nav-link nav-link-grouped dropdown-toggle" data-toggle="dropdown">
{{ item.link.label }}
</a>
</span>
<ul class="dropdown-menu main"
aria-labelledby="navbarDropdownMenuLink"
[ngbCollapse]="subNavCollapse"
>
<li class="nav-item dropdown-submenu submenu" *ngFor="let sub of item.submenu">
<scrm-menu-item-link [link]="sub.link"
[class]="{
'sub-nav-link': true,
'nav-link': true,
'action-link': true,
'dropdown-item': sub.submenu.length,
'dropdown-toggle': sub.submenu.length
}"
>
</scrm-menu-item-link>
<ul *ngIf="sub.submenu.length" class="dropdown-menu submenu">
<li *ngFor="let subitem of sub.submenu" class="nav-item">
<scrm-menu-item-link [class]="'submenu-nav-link nav-link action-link'"
[link]="subitem.link"
[icon]="subitem.icon">
</scrm-menu-item-link>
</li>
<li>
<scrm-menu-recently-viewed [languages]="languages"
[records]="sub.recentRecords"></scrm-menu-recently-viewed>
</li>
</ul>
</li>
</ul>
</ng-container>

View file

@ -0,0 +1,168 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {GroupedMenuItemComponent} from './grouped-menu-item.component';
import {MenuItemLinkComponent} from '@components/navbar/menu-item-link/menu-item-link.component';
import {MenuRecentlyViewedComponent} from '@components/navbar/menu-recently-viewed/menu-recently-viewed.component';
import {AngularSvgIconModule} from 'angular-svg-icon';
import {RouterTestingModule} from '@angular/router/testing';
import {HttpClientTestingModule} from '@angular/common/http/testing';
import {ImageModule} from '@components/image/image.module';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
import {ThemeImagesFacade} from '@base/facades/theme-images/theme-images.facade';
import {of} from 'rxjs';
import {themeImagesMockData} from '@base/facades/theme-images/theme-images.facade.spec.mock';
import {take} from 'rxjs/operators';
import {Component} from '@angular/core';
import {MenuItem} from '@components/navbar/navbar.abstract';
import {LanguageStrings} from '@base/facades/language/language.facade';
import {languageMockData} from '@base/facades/language/language.facade.spec.mock';
const groupedMockMenuItem = {
link: {
url: '',
label: 'Top Link Label',
route: '',
},
icon: '',
submenu: [
{
link: {
url: '',
label: 'Sub Link 1',
route: '/fake-module',
},
icon: '',
submenu: [
{
link: {
url: '',
label: 'Sub Link 1 item 1',
route: '/fake-module/edit',
},
icon: 'plus',
submenu: []
},
]
},
{
link: {
url: '',
label: 'Sub Link 2',
route: '/fake-module/list',
},
icon: '',
submenu: [
{
link: {
url: '',
label: 'Sub Link 2 item 1',
route: '/fake-module/edit',
},
icon: 'plus',
submenu: []
},
]
}
],
recentRecords: null
}
@Component({
selector: 'grouped-menu-item-test-host-component',
template: '<scrm-grouped-menu-item [item]="item" [languages]="languages" [subNavCollapse]="subNavCollapse"></scrm-grouped-menu-item>'
})
class GroupedMenuItemTestHostComponent {
item: MenuItem = groupedMockMenuItem;
languages: LanguageStrings = {
...languageMockData,
languageKey: 'en_us'
};
subNavCollapse = true;
setItem(value: MenuItem): void {
this.item = value;
}
setLanguages(value: LanguageStrings): void {
this.languages = value;
}
}
describe('GroupedMenuItemComponent', () => {
let testHostComponent: GroupedMenuItemTestHostComponent;
let testHostFixture: ComponentFixture<GroupedMenuItemTestHostComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [
GroupedMenuItemComponent,
MenuItemLinkComponent,
GroupedMenuItemTestHostComponent,
MenuRecentlyViewedComponent
],
imports: [
AngularSvgIconModule,
RouterTestingModule,
HttpClientTestingModule,
ImageModule,
NgbModule
],
providers: [
{
provide: ThemeImagesFacade, useValue: {
images$: of(themeImagesMockData).pipe(take(1))
}
},
],
}).compileComponents();
}));
beforeEach(() => {
testHostFixture = TestBed.createComponent(GroupedMenuItemTestHostComponent);
testHostComponent = testHostFixture.componentInstance;
testHostFixture.detectChanges();
});
it('should create', () => {
expect(testHostComponent).toBeTruthy();
});
it('should have top link', () => {
const spanElement = testHostFixture.nativeElement.querySelector('span');
const topLinks = spanElement.getElementsByClassName('top-nav-link');
const topLink = topLinks[0];
expect(testHostComponent).toBeTruthy();
expect(spanElement).toBeTruthy();
expect(topLinks.length).toEqual(1);
expect(topLink.textContent).toContain(groupedMockMenuItem.link.label);
});
it('should have sub links', () => {
const ulElement = testHostFixture.nativeElement.querySelector('ul');
const subLinks = ulElement.getElementsByClassName('sub-nav-link');
expect(testHostComponent).toBeTruthy();
expect(ulElement).toBeTruthy();
expect(subLinks.length).toEqual(2);
});
it('should have sub links items', () => {
const ulElement = testHostFixture.nativeElement.querySelector('ul');
const subLinks = ulElement.getElementsByClassName('submenu');
let subLink; let subLinksItems;
for (let i = 0; i < 2; i++) {
subLink = subLinks[i];
subLinksItems = subLink.getElementsByClassName('submenu-nav-link');
expect(subLinksItems.length).toEqual(1);
expect(subLinksItems[0].textContent).toContain(`item 1`);
}
});
});

View file

@ -0,0 +1,17 @@
import {Component, Input} from '@angular/core';
import {MenuItem} from '@components/navbar/navbar.abstract';
import {LanguageStrings} from '@base/facades/language/language.facade';
@Component({
selector: 'scrm-grouped-menu-item',
templateUrl: './grouped-menu-item.component.html',
styleUrls: []
})
export class GroupedMenuItemComponent {
@Input() item: MenuItem;
@Input() languages: LanguageStrings;
@Input() subNavCollapse: boolean;
constructor() {
}
}

View file

@ -0,0 +1,7 @@
<ul class="navbar-nav home-nav">
<li class="nav-item" [class.active]="active">
<a class="home-nav-link" [routerLink]="route">
<scrm-image class="home-icon" image="home"></scrm-image>
</a>
</li>
</ul>

View file

@ -0,0 +1,100 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {HomeMenuItemComponent} from './home-menu-item.component';
import {MenuItemLinkComponent} from '@components/navbar/menu-item-link/menu-item-link.component';
import {AngularSvgIconModule} from 'angular-svg-icon';
import {RouterTestingModule} from '@angular/router/testing';
import {HttpClientTestingModule} from '@angular/common/http/testing';
import {ImageModule} from '@components/image/image.module';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
import {ThemeImagesFacade} from '@base/facades/theme-images/theme-images.facade';
import {of} from 'rxjs';
import {themeImagesMockData} from '@base/facades/theme-images/theme-images.facade.spec.mock';
import {take} from 'rxjs/operators';
import {Component} from '@angular/core';
@Component({
selector: 'home-menu-item-test-host-component',
template: '<scrm-home-menu-item [active]="active" [route]="route"></scrm-home-menu-item>'
})
class HomeMenuItemTestHostComponent {
active = false;
route = '';
setActive(value: boolean): void {
this.active = value;
}
setRoute(value: string): void {
this.route = value;
}
}
describe('HomeMenuItemComponent', () => {
let testHostComponent: HomeMenuItemTestHostComponent;
let testHostFixture: ComponentFixture<HomeMenuItemTestHostComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [HomeMenuItemComponent, MenuItemLinkComponent, HomeMenuItemTestHostComponent],
imports: [
AngularSvgIconModule,
RouterTestingModule,
HttpClientTestingModule,
ImageModule,
NgbModule,
],
providers: [
{
provide: ThemeImagesFacade, useValue: {
images$: of(themeImagesMockData).pipe(take(1))
}
},
],
}).compileComponents();
}));
beforeEach(() => {
testHostFixture = TestBed.createComponent(HomeMenuItemTestHostComponent);
testHostComponent = testHostFixture.componentInstance;
testHostFixture.detectChanges();
});
it('should create', () => {
expect(testHostComponent).toBeTruthy();
const linkElement = testHostFixture.nativeElement.querySelector('li');
expect(linkElement).toBeTruthy();
expect(linkElement.className).not.toContain('active');
});
it('should have image', () => {
expect(testHostComponent).toBeTruthy();
expect(testHostFixture.nativeElement.querySelector('svg-icon')).toBeTruthy();
});
it('should have image', () => {
expect(testHostComponent).toBeTruthy();
testHostComponent.setActive(true)
testHostFixture.detectChanges();
const linkElement = testHostFixture.nativeElement.querySelector('li');
expect(linkElement).toBeTruthy();
expect(linkElement.className).toContain('active');
});
it('should have image', () => {
expect(testHostComponent).toBeTruthy();
testHostComponent.setRoute('/home')
testHostFixture.detectChanges();
const linkElement = testHostFixture.nativeElement.querySelector('a');
const url = '/home';
expect(linkElement).toBeTruthy();
expect(linkElement.href).toContain(url);
});
});

View file

@ -0,0 +1,15 @@
import {Component, Input} from '@angular/core';
@Component({
selector: 'scrm-home-menu-item',
templateUrl: './home-menu-item.component.html',
styleUrls: []
})
export class HomeMenuItemComponent {
@Input() route: string;
@Input() active: boolean;
constructor() {
}
}

View file

@ -0,0 +1,16 @@
<a *ngIf="link.route"
[ngClass]="class"
[routerLink]="link.route"
[queryParams]="link.params"
>
<scrm-image *ngIf="icon" image="{{ icon }}"></scrm-image>
{{ link.label }}
</a>
<a *ngIf="!link.route"
[ngClass]="class"
[href]="link.url"
>
<scrm-image *ngIf="icon" image="{{ icon }}"></scrm-image>
{{ link.label }}
</a>

View file

@ -0,0 +1,146 @@
import {Component} from '@angular/core';
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {HttpClientTestingModule} from '@angular/common/http/testing';
import {RouterTestingModule} from '@angular/router/testing';
import {of} from 'rxjs';
import {take} from 'rxjs/operators';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
import {AngularSvgIconModule} from 'angular-svg-icon';
import {themeImagesMockData} from '@base/facades/theme-images/theme-images.facade.spec.mock';
import {MenuItemLinkComponent} from './menu-item-link.component';
import {MenuItemLink} from '@components/navbar/navbar.abstract';
import {ThemeImagesFacade} from '@base/facades/theme-images/theme-images.facade';
import {ImageModule} from '@components/image/image.module';
const mockLink = {
label: 'Test Link Label',
url: '/fake-module/edit?return_module=Opportunities&return_action=DetailView',
route: '/fake-module/edit',
params: {
'return-module': 'FakeModule',
'return-action': 'DetailView',
}
};
@Component({
selector: 'menu-item-link-test-host-component',
template: '<scrm-menu-item-link [class]="class" [link]="link" [icon]="icon"></scrm-menu-item-link>'
})
class MenuItemLinkTestHostComponent {
class = '';
link: MenuItemLink = mockLink;
icon = '';
setClass(value: string): void {
this.class = value;
}
setLink(value: MenuItemLink): void {
this.link = value;
}
setIcon(value: string): void {
this.icon = value;
}
}
describe('MenuItemActionLinkComponent', () => {
let testHostComponent: MenuItemLinkTestHostComponent;
let testHostFixture: ComponentFixture<MenuItemLinkTestHostComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [MenuItemLinkComponent, MenuItemLinkTestHostComponent],
imports: [
AngularSvgIconModule,
RouterTestingModule,
HttpClientTestingModule,
ImageModule,
NgbModule,
],
providers: [
{
provide: ThemeImagesFacade, useValue: {
images$: of(themeImagesMockData).pipe(take(1))
}
},
],
}).compileComponents();
}));
beforeEach(() => {
testHostFixture = TestBed.createComponent(MenuItemLinkTestHostComponent);
testHostComponent = testHostFixture.componentInstance;
testHostFixture.detectChanges();
});
it('should create', () => {
expect(testHostComponent).toBeTruthy();
const linkElement = testHostFixture.nativeElement.querySelector('a');
expect(linkElement).toBeTruthy();
expect(linkElement.className).toEqual('');
expect(testHostFixture.nativeElement.querySelector('svg-icon')).toBeFalsy();
});
it('should have image', () => {
expect(testHostComponent).toBeTruthy();
testHostComponent.setIcon('plus')
testHostFixture.detectChanges();
expect(testHostFixture.nativeElement.querySelector('svg-icon')).toBeTruthy();
});
it('should use route', () => {
expect(testHostComponent).toBeTruthy();
const linkElement = testHostFixture.nativeElement.querySelector('a');
const url = '/fake-module/edit?return-module=FakeModule&return-action=DetailView';
expect(linkElement).toBeTruthy();
expect(linkElement.href).toContain(url);
expect(linkElement.text).toContain(mockLink.label);
});
it('should use href', () => {
expect(testHostComponent).toBeTruthy();
const link = {
label: 'Test Link Label',
url: './fake-module/edit?return_module=FakeModule&return_action=DetailView',
route: null,
params: null
};
testHostComponent.setLink(link)
testHostFixture.detectChanges();
const linkElement = testHostFixture.nativeElement.querySelector('a');
const url = '/fake-module/edit?return_module=FakeModule&return_action=DetailView';
expect(linkElement).toBeTruthy();
expect(linkElement.href).toContain(url);
expect(linkElement.text).toContain(mockLink.label);
});
it('should have class', () => {
expect(testHostComponent).toBeTruthy();
const testClass = 'test-class';
testHostComponent.setClass(testClass)
testHostFixture.detectChanges();
const linkElement = testHostFixture.nativeElement.querySelector('a');
expect(linkElement).toBeTruthy();
expect(linkElement.className).toContain(testClass);
});
});

View file

@ -0,0 +1,15 @@
import {Component, Input} from '@angular/core';
import {MenuItemLink} from '@components/navbar/navbar.abstract';
@Component({
selector: 'scrm-menu-item-link',
templateUrl: './menu-item-link.component.html',
styleUrls: []
})
export class MenuItemLinkComponent {
@Input() link: MenuItemLink;
@Input() icon: string;
@Input() class: string;
constructor() {
}
}

View file

@ -0,0 +1,30 @@
<ng-container>
<span data-toggle="collapse" data-target=".navbar-collapse">
<scrm-menu-item-link
[class]="{
'top-nav-link': true,
'nav-link-nongrouped': true,
'dropdown-toggle': item.submenu.length
}"
[link]="item.link">
</scrm-menu-item-link>
</span>
<div *ngIf="item.submenu.length"
aria-labelledby="navbarDropdownMenuLink"
class="dropdown-menu submenu">
<div class="nav-item" *ngFor="let sub of item.submenu">
<scrm-menu-item-link
[class]="'sub-nav-link nav-link action-link'"
[link]="sub.link"
[icon]="sub.icon">
</scrm-menu-item-link>
</div>
<scrm-menu-recently-viewed [languages]="languages" [records]="item.recentRecords"></scrm-menu-recently-viewed>
</div>
</ng-container>

View file

@ -0,0 +1,132 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {MenuItemComponent} from './menu-item.component';
import {Component} from '@angular/core';
import {MenuItemLinkComponent} from '@components/navbar/menu-item-link/menu-item-link.component';
import {AngularSvgIconModule} from 'angular-svg-icon';
import {RouterTestingModule} from '@angular/router/testing';
import {HttpClientTestingModule} from '@angular/common/http/testing';
import {ImageModule} from '@components/image/image.module';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
import {ThemeImagesFacade} from '@base/facades/theme-images/theme-images.facade';
import {of} from 'rxjs';
import {themeImagesMockData} from '@base/facades/theme-images/theme-images.facade.spec.mock';
import {take} from 'rxjs/operators';
import {MenuItem} from '@components/navbar/navbar.abstract';
import {LanguageStrings} from '@base/facades/language/language.facade';
import {languageMockData} from '@base/facades/language/language.facade.spec.mock';
import {MenuRecentlyViewedComponent} from '@components/navbar/menu-recently-viewed/menu-recently-viewed.component';
const mockMenuItem = {
link: {
url: '',
label: 'Top Link Label',
route: '/fake-module',
},
icon: '',
submenu: [
{
link: {
url: '',
label: 'Sub Link 1',
route: '/fake-module/edit',
},
icon: 'plus',
submenu: []
},
{
link: {
url: '',
label: 'Sub Link 2',
route: '/fake-module/list',
},
icon: 'view',
submenu: []
}
],
recentRecords: null
}
@Component({
selector: 'menu-item-test-host-component',
template: '<scrm-menu-item [item]="item" [languages]="languages"></scrm-menu-item>'
})
class MenuItemTestHostComponent {
item: MenuItem = mockMenuItem;
languages: LanguageStrings = {
...languageMockData,
languageKey: 'en_us'
};
setItem(value: MenuItem): void {
this.item = value;
}
setLanguages(value: LanguageStrings): void {
this.languages = value;
}
}
describe('ModuleMenuItemComponent', () => {
let testHostComponent: MenuItemTestHostComponent;
let testHostFixture: ComponentFixture<MenuItemTestHostComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [
MenuItemComponent,
MenuItemLinkComponent,
MenuItemTestHostComponent,
MenuRecentlyViewedComponent
],
imports: [
AngularSvgIconModule,
RouterTestingModule,
HttpClientTestingModule,
ImageModule,
NgbModule
],
providers: [
{
provide: ThemeImagesFacade, useValue: {
images$: of(themeImagesMockData).pipe(take(1))
}
},
],
}).compileComponents();
}));
beforeEach(() => {
testHostFixture = TestBed.createComponent(MenuItemTestHostComponent);
testHostComponent = testHostFixture.componentInstance;
testHostFixture.detectChanges();
});
it('should create', () => {
expect(testHostComponent).toBeTruthy();
});
it('should have top link', () => {
const spanElement = testHostFixture.nativeElement.querySelector('span');
const topLinks = spanElement.getElementsByClassName('top-nav-link');
const topLink = topLinks[0];
expect(testHostComponent).toBeTruthy();
expect(spanElement).toBeTruthy();
expect(topLinks.length).toEqual(1);
expect(topLink.textContent).toContain(mockMenuItem.link.label);
});
it('should have sub links', () => {
const divElement = testHostFixture.nativeElement.querySelector('div');
const subLinks = divElement.getElementsByClassName('sub-nav-link');
expect(testHostComponent).toBeTruthy();
expect(divElement).toBeTruthy();
expect(subLinks.length).toEqual(2);
});
});

View file

@ -0,0 +1,16 @@
import {Component, Input} from '@angular/core';
import {MenuItem} from '@components/navbar/navbar.abstract';
import {LanguageStrings} from '@base/facades/language/language.facade';
@Component({
selector: 'scrm-menu-item',
templateUrl: './menu-item.component.html',
styleUrls: []
})
export class MenuItemComponent {
@Input() item: MenuItem;
@Input() languages: LanguageStrings;
constructor() {
}
}

View file

@ -0,0 +1,16 @@
<ul *ngIf="items && items.length > 0" class="navbar-nav">
<li class="top-nav nav-item dropdown non-grouped">
<a class="nav-link-nongrouped dropdown-toggle">{{label}}</a>
<div aria-labelledby="navbarDropdownMenuLink"
class="dropdown-menu more-menu submenu"
>
<div class="nav-item" *ngFor="let item of items">
<scrm-menu-item-link [class]="'nav-link action-link'" [link]="item.link">
</scrm-menu-item-link>
</div>
</div>
</li>
</ul>

View file

@ -0,0 +1,109 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {of} from 'rxjs';
import {take} from 'rxjs/operators';
import {Component} from '@angular/core';
import {RouterTestingModule} from '@angular/router/testing';
import {HttpClientTestingModule} from '@angular/common/http/testing';
import {AngularSvgIconModule} from 'angular-svg-icon';
import {ImageModule} from '@components/image/image.module';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
import {MenuItemsListComponent} from './menu-items-list.component';
import {ThemeImagesFacade} from '@store/theme-images/theme-images.facade';
import {themeImagesMockData} from '@store/theme-images/theme-images.facade.spec.mock';
import {MenuItemComponent} from '@components/navbar/menu-item/menu-item.component';
import {MenuItemLinkComponent} from '@components/navbar/menu-item-link/menu-item-link.component';
import {MenuRecentlyViewedComponent} from '@components/navbar/menu-recently-viewed/menu-recently-viewed.component';
const mockMenuItems = [
{
link: {
url: '',
label: 'Menu Item 1',
route: '/fake-module-1',
},
icon: '',
submenu: [],
recentRecords: null
},
{
link: {
url: '',
label: 'Menu Item 2',
route: '/fake-module-2',
},
icon: '',
submenu: [],
recentRecords: null
}
];
@Component({
selector: 'menu-item-list-test-host-component',
template: '<scrm-menu-items-list [items]="items" [label]="label"></scrm-menu-items-list>'
})
class MenuItemListTestHostComponent {
items = mockMenuItems;
label = 'More';
}
describe('MenuItemsListComponent', () => {
let testHostComponent: MenuItemListTestHostComponent;
let testHostFixture: ComponentFixture<MenuItemListTestHostComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [
MenuItemComponent,
MenuItemLinkComponent,
MenuItemsListComponent,
MenuRecentlyViewedComponent,
MenuItemListTestHostComponent
],
imports: [
AngularSvgIconModule,
RouterTestingModule,
HttpClientTestingModule,
ImageModule,
NgbModule,
],
providers: [
{
provide: ThemeImagesFacade, useValue: {
images$: of(themeImagesMockData).pipe(take(1))
}
},
],
}).compileComponents();
}));
beforeEach(() => {
testHostFixture = TestBed.createComponent(MenuItemListTestHostComponent);
testHostComponent = testHostFixture.componentInstance;
testHostFixture.detectChanges();
});
it('should create', () => {
expect(testHostComponent).toBeTruthy();
});
it('should have label', () => {
const navItemLink = testHostFixture.nativeElement.querySelector('a');
expect(navItemLink.text).toContain('More');
});
it('should have menu items', () => {
const navItemLink = testHostFixture.nativeElement.querySelector('div');
const links = navItemLink.getElementsByClassName('action-link');
expect(links.length).toEqual(2);
expect(links[0].textContent).toContain('Menu Item 1');
expect(links[0].attributes.getNamedItem('href').value).toContain('/fake-module-1');
expect(links[1].textContent).toContain('Menu Item 2');
expect(links[1].attributes.getNamedItem('href').value).toContain('/fake-module-2');
});
});

View file

@ -0,0 +1,15 @@
import {Component, Input} from '@angular/core';
import {MenuItem} from '@components/navbar/navbar.abstract';
@Component({
selector: 'scrm-menu-items-list',
templateUrl: './menu-items-list.component.html',
styleUrls: []
})
export class MenuItemsListComponent {
@Input() items: MenuItem[];
@Input() label: string;
constructor() {
}
}

View file

@ -0,0 +1,6 @@
<ng-container *ngIf="records && records.length">
<h4 class="recently-viewed-header">{{languages.appStrings['LBL_LAST_VIEWED']}}</h4>
<div *ngFor="let recentRecord of records" class="nav-item">
<a class="nav-link action-link" [href]="recentRecord.url">{{ recentRecord.summary }}</a>
</div>
</ng-container>

View file

@ -0,0 +1,103 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {of} from 'rxjs';
import {take} from 'rxjs/operators';
import {Component} from '@angular/core';
import {RouterTestingModule} from '@angular/router/testing';
import {HttpClientTestingModule} from '@angular/common/http/testing';
import {AngularSvgIconModule} from 'angular-svg-icon';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
import {MenuRecentlyViewedComponent} from './menu-recently-viewed.component';
import {LanguageStrings} from '@base/facades/language/language.facade';
import {languageMockData} from '@base/facades/language/language.facade.spec.mock';
import {MenuItemLinkComponent} from '@components/navbar/menu-item-link/menu-item-link.component';
import {ImageModule} from '@components/image/image.module';
import {ThemeImagesFacade} from '@base/facades/theme-images/theme-images.facade';
import {themeImagesMockData} from '@base/facades/theme-images/theme-images.facade.spec.mock';
const recentRecords = [
{
summary: 'Module 1',
url: '/fake-module-1'
},
{
summary: 'Module 2',
url: '/fake-module-2'
}
];
@Component({
selector: 'menu-recently-viewed-test-host-component',
template: '<scrm-menu-recently-viewed [records]="records" [languages]="languages"></scrm-menu-recently-viewed>'
})
class MenuRecentlyViewedTestHostComponent {
records = recentRecords;
languages: LanguageStrings = {
...languageMockData,
languageKey: 'en_us'
};
}
describe('MenuRecentlyViewedComponent', () => {
let testHostComponent: MenuRecentlyViewedTestHostComponent;
let testHostFixture: ComponentFixture<MenuRecentlyViewedTestHostComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [
MenuItemLinkComponent,
MenuRecentlyViewedComponent,
MenuRecentlyViewedTestHostComponent
],
imports: [
AngularSvgIconModule,
RouterTestingModule,
HttpClientTestingModule,
ImageModule,
NgbModule,
],
providers: [
{
provide: ThemeImagesFacade, useValue: {
images$: of(themeImagesMockData).pipe(take(1))
}
},
],
}).compileComponents();
}));
beforeEach(() => {
testHostFixture = TestBed.createComponent(MenuRecentlyViewedTestHostComponent);
testHostComponent = testHostFixture.componentInstance;
testHostFixture.detectChanges();
});
it('should create', () => {
expect(testHostComponent).toBeTruthy();
});
it('should have label', () => {
const title = testHostFixture.nativeElement.querySelector('h4');
expect(title.textContent).toContain('Recently Viewed');
});
it('should have recently viewed record links', () => {
const navItemLinks = testHostFixture.nativeElement.getElementsByClassName('nav-item');
expect(navItemLinks.length).toEqual(2);
let links = navItemLinks[0].getElementsByClassName('nav-link');
expect(links.length).toEqual(1);
expect(links[0].textContent).toContain('Module 1');
expect(links[0].attributes.getNamedItem('href').value).toContain('/fake-module-1');
links = navItemLinks[1].getElementsByClassName('nav-link');
expect(links.length).toEqual(1);
expect(links[0].textContent).toContain('Module 2');
expect(links[0].attributes.getNamedItem('href').value).toContain('/fake-module-2');
});
});

View file

@ -0,0 +1,16 @@
import {Component, Input} from '@angular/core';
import {LanguageStrings} from '@base/facades/language/language.facade';
import {RecentRecordsMenuItem} from '@components/navbar/navbar.abstract';
@Component({
selector: 'scrm-menu-recently-viewed',
templateUrl: './menu-recently-viewed.component.html',
styleUrls: []
})
export class MenuRecentlyViewedComponent {
@Input() records: RecentRecordsMenuItem[];
@Input() languages: LanguageStrings;
constructor() {
}
}

View file

@ -2,10 +2,11 @@ import {ActionLinkModel} from './action-link-model';
import {CurrentUserModel} from './current-user-model';
import {AllMenuModel} from './all-menu-model';
import {LogoModel} from '../logo/logo-model';
import {GroupedTab, NavbarModuleMap, UserActionMenu} from '@base/facades/navigation/navigation.facade';
import {LanguageListStringMap, LanguageStringMap} from '@base/facades/language/language.facade';
import {GroupedTab, NavbarModuleMap, Navigation, UserActionMenu} from '@base/facades/navigation/navigation.facade';
import {LanguageStrings, LanguageStringMap} from '@base/facades/language/language.facade';
import {MenuItem} from '@components/navbar/navbar.abstract';
import {UserPreferenceMap} from '@base/facades/user-preference/user-preference.facade';
import {AppState} from '@base/facades/app-state/app-state.facade';
export interface NavbarModel {
authenticated: boolean;
@ -15,40 +16,26 @@ export interface NavbarModel {
currentUser: CurrentUserModel;
all: AllMenuModel;
menu: MenuItem[];
current?: MenuItem;
resetMenu(): void;
build(
tabs: string[],
modules: NavbarModuleMap,
appStrings: LanguageStringMap,
modStrings: LanguageListStringMap,
appListStrings: LanguageListStringMap,
menuItemThreshold: number,
groupedTabs: GroupedTab[],
navigation: Navigation,
languages: LanguageStrings,
userPreferences: UserPreferenceMap,
userActionMenu: UserActionMenu[],
currentUser: CurrentUserModel
currentUser: CurrentUserModel,
appState: AppState
): void;
buildGroupTabMenu(
items: string[],
modules: NavbarModuleMap,
appStrings: LanguageStringMap,
modStrings: LanguageListStringMap,
appListStrings: LanguageListStringMap,
menuItemThreshold: number,
languages: LanguageStrings,
threshold: number,
groupedTabs: GroupedTab[]
): void;
buildTabMenu(
items: string[],
modules: NavbarModuleMap,
appStrings: LanguageStringMap,
modStrings: LanguageListStringMap,
appListStrings: LanguageListStringMap,
menuItemThreshold: number): void;
buildUserActionMenu(
appStrings: LanguageStringMap,
userActionMenu: UserActionMenu[],

View file

@ -1,25 +1,28 @@
import {NavbarModel} from './navbar-model';
import {LogoAbstract} from '../logo/logo-abstract';
import {GroupedTab, NavbarModuleMap, UserActionMenu} from '@base/facades/navigation/navigation.facade';
import {LanguageListStringMap, LanguageStringMap} from '@base/facades/language/language.facade';
import {GroupedTab, NavbarModuleMap, Navigation, UserActionMenu} from '@base/facades/navigation/navigation.facade';
import {LanguageStrings, LanguageStringMap} from '@base/facades/language/language.facade';
import {CurrentUserModel} from './current-user-model';
import {ActionLinkModel} from './action-link-model';
import {ready} from '@base/utils/object-utils';
import {UserPreferenceMap} from '@base/facades/user-preference/user-preference.facade';
import {AppState} from '@base/facades/app-state/app-state.facade';
export interface RecentRecordsMenuItem {
summary: string;
url: string;
}
export interface MenuItemLink {
label: string;
url: string;
route?: string;
params?: { [key: string]: string };
}
export interface MenuItem {
link: {
label: string;
url: string;
route?: string;
params?: { [key: string]: string };
};
link: MenuItemLink;
icon: string;
submenu: MenuItem[];
recentRecords?: RecentRecordsMenuItem[];
@ -42,6 +45,11 @@ export class NavbarAbstract implements NavbarModel {
extra: [],
};
menu: MenuItem[] = [];
current?: MenuItem;
/**
* Public API
*/
/**
* Reset menus
@ -51,8 +59,16 @@ export class NavbarAbstract implements NavbarModel {
this.globalActions = [];
this.all.modules = [];
this.all.extra = [];
this.current = null;
}
/**
* Build user action menu
*
* @param {{}} appStrings map
* @param {[]} userActionMenu info
* @param {{}} currentUser info
*/
public buildUserActionMenu(
appStrings: LanguageStringMap,
userActionMenu: UserActionMenu[],
@ -64,11 +80,11 @@ export class NavbarAbstract implements NavbarModel {
if (userActionMenu) {
userActionMenu.forEach((subMenu) => {
let name = subMenu.name;
const name = subMenu.name;
let url = subMenu.url;
let urlParams;
if (name == 'logout') {
if (name === 'logout') {
return;
}
@ -77,12 +93,12 @@ export class NavbarAbstract implements NavbarModel {
url = ROUTE_PREFIX + '/' + (urlParams.module).toLowerCase() + '/' + (urlParams.action).toLowerCase();
}
let label = appStrings[subMenu.labelKey];
const label = appStrings[subMenu.labelKey];
this.globalActions.push({
link: {
url: url,
label: label,
url,
label,
},
});
});
@ -92,76 +108,65 @@ export class NavbarAbstract implements NavbarModel {
/**
* Build navbar
* @param tabs
* @param modules
* @param appStrings
* @param modStrings
* @param appListStrings
* @param menuItemThreshold
* @param groupedTabs
* @param userPreferences
* @param userActionMenu
* @param currentUser
*
* @param {{}} navigation info
* @param {{}} language map
* @param {{}} userPreferences info
* @param {{}} currentUser info
* @param {{}} appState info
*/
public build(
tabs: string[],
modules: NavbarModuleMap,
appStrings: LanguageStringMap,
modStrings: LanguageListStringMap,
appListStrings: LanguageListStringMap,
menuItemThreshold: number,
groupedTabs: GroupedTab[],
navigation: Navigation,
language: LanguageStrings,
userPreferences: UserPreferenceMap,
userActionMenu: UserActionMenu[],
currentUser: CurrentUserModel,
appState: AppState
): void {
this.resetMenu();
if (!ready([tabs, modules, appStrings, modStrings, appListStrings, userPreferences])) {
if (!ready([language.appStrings, language.modStrings, language.appListStrings, userPreferences, currentUser])) {
return;
}
this.buildUserActionMenu(appStrings, userActionMenu, currentUser);
this.buildUserActionMenu(language.appStrings, navigation.userActionMenu, currentUser);
const navigationParadigm = userPreferences.navigation_paradigm;
const navigationParadigm = userPreferences.navigation_paradigm.toString();
if (navigationParadigm.toString() === 'm') {
this.buildTabMenu(tabs, modules, appStrings, modStrings, appListStrings, menuItemThreshold);
if (navigationParadigm === 'm') {
this.buildModuleNavigation(navigation, language, appState);
return;
}
if (navigationParadigm.toString() === 'gm') {
this.buildGroupTabMenu(tabs, modules, appStrings, modStrings, appListStrings, menuItemThreshold, groupedTabs);
if (navigationParadigm === 'gm') {
this.buildGroupedNavigation(navigation, language, appState);
return;
}
}
/**
* Build Group tab menu
* @param items
* @param modules
* @param appStrings
* @param modStrings
* @param appListStrings
* @param threshold
* @param groupedTabs
*
* @param {[]} items list
* @param {{}} modules info
* @param {{}} languages map
* @param {number} threshold limit
* @param {{}} groupedTabs info
*/
public buildGroupTabMenu(
items: string[],
modules: NavbarModuleMap,
appStrings: LanguageStringMap,
modStrings: LanguageListStringMap,
appListStrings: LanguageListStringMap,
languages: LanguageStrings,
threshold: number,
groupedTabs: GroupedTab[]): void {
groupedTabs: GroupedTab[]
): void {
const navItems = [];
const moreItems = [];
if (items && items.length > 0) {
items.forEach((module) => {
moreItems.push(this.buildTabMenuItem(module, modules[module], appStrings, modStrings, appListStrings));
moreItems.push(this.buildTabMenuItem(module, modules[module], languages));
});
}
@ -173,9 +178,7 @@ export class NavbarAbstract implements NavbarModel {
groupedTab.labelKey,
groupedTab.modules,
modules,
appStrings,
modStrings,
appListStrings
languages
));
}
@ -187,20 +190,89 @@ export class NavbarAbstract implements NavbarModel {
}
/**
* Build tab / module menu
* @param items
* @param modules
* @param appStrings
* @param modStrings
* @param appListStrings
* @param threshold
*
* Internal API
*
*/
public buildTabMenu(items: string[],
modules: NavbarModuleMap,
appStrings: LanguageStringMap,
modStrings: LanguageListStringMap,
appListStrings: LanguageListStringMap,
threshold: number
/**
* Build module navigation
*
* @param {{}} navigation info
* @param {{}} languages map
* @param {{}} appState info
*/
protected buildModuleNavigation(
navigation: Navigation,
languages: LanguageStrings,
appState: AppState
): void {
if (!ready([navigation.tabs, navigation.modules])) {
return;
}
this.buildTabMenu(navigation.tabs, navigation.modules, languages, navigation.maxTabs, appState);
this.buildSelectedModule(navigation, languages, appState);
}
/**
* Build grouped navigation
*
* @param {{}} navigation info
* @param {{}} languages map
* @param {{}} appState info
*/
protected buildGroupedNavigation(
navigation: Navigation,
languages: LanguageStrings,
appState: AppState
): void {
if (!ready([navigation.tabs, navigation.modules, navigation.groupedTabs])) {
return;
}
this.buildGroupTabMenu(navigation.tabs, navigation.modules, languages, navigation.maxTabs, navigation.groupedTabs);
this.buildSelectedModule(navigation, languages, appState);
}
/**
* Build selected module
*
* @param {{}} navigation info
* @param {{}} languages map
* @param {{}} appState info
*/
protected buildSelectedModule(navigation: Navigation, languages: LanguageStrings, appState: AppState): void {
if (!appState || !appState.module || appState.module === 'home') {
return;
}
const module = appState.module;
if (!navigation.modules[module]){
return;
}
this.current = this.buildTabMenuItem(module, navigation.modules[module], languages);
}
/**
* Build tab / module menu
*
* @param {[]} items list
* @param {{}} modules info
* @param {{}} languages map
* @param {number} threshold limit
* @param {{}} appState info
*/
protected buildTabMenu(
items: string[],
modules: NavbarModuleMap,
languages: LanguageStrings,
threshold: number,
appState: AppState
): void {
const navItems = [];
@ -219,10 +291,12 @@ export class NavbarAbstract implements NavbarModel {
return;
}
if (count <= threshold) {
navItems.push(this.buildTabMenuItem(module, modules[module], appStrings, modStrings, appListStrings));
const item = this.buildTabMenuItem(module, modules[module], languages);
if (appState.module === module || count >= threshold){
moreItems.push(item);
} else {
moreItems.push(this.buildTabMenuItem(module, modules[module], appStrings, modStrings, appListStrings));
navItems.push(item);
}
count++;
@ -232,22 +306,22 @@ export class NavbarAbstract implements NavbarModel {
this.all.modules = moreItems;
}
/**
* Build Grouped Tab menu item
* @param moduleLabel to display
* @param groupedModules list
* @param modules list
* @param appStrings list
* @param modStrings list
* @param appListStrings list
*
* @param {string} moduleLabel to display
* @param {{}} groupedModules list
* @param {{}} modules list
* @param {{}} languages map
*
* @returns {{}} group tab menu item
*/
public buildTabGroupedMenuItem(
protected buildTabGroupedMenuItem(
moduleLabel: string,
groupedModules: any[],
modules: NavbarModuleMap,
appStrings: LanguageStringMap,
modStrings: LanguageListStringMap,
appListStrings: LanguageListStringMap
languages: LanguageStrings
): any {
let moduleUrl = '';
@ -260,30 +334,29 @@ export class NavbarAbstract implements NavbarModel {
return {
link: {
label: (appStrings && appStrings[moduleLabel]) || moduleLabel,
label: (languages.appStrings && languages.appStrings[moduleLabel]) || moduleLabel,
url: moduleUrl,
route: moduleRoute,
params: null
},
icon: '',
submenu: this.buildGroupedMenu(groupedModules, modules, appStrings, modStrings, appListStrings)
submenu: this.buildGroupedMenu(groupedModules, modules, languages)
};
}
/**
* Build Grouped menu
* @param groupedModules
* @param modules
* @param appStrings
* @param modStrings
* @param appListStrings
*
* @param {{}} groupedModules info
* @param {{}} modules map
* @param {{}} languages maps
*
* @returns {[]} menu item array
*/
public buildGroupedMenu(
protected buildGroupedMenu(
groupedModules: any[],
modules: NavbarModuleMap,
appStrings: LanguageStringMap,
modStrings: LanguageListStringMap,
appListStrings: LanguageListStringMap
languages: LanguageStrings,
): MenuItem[] {
const groupedItems = [];
@ -296,7 +369,7 @@ export class NavbarAbstract implements NavbarModel {
return;
}
groupedItems.push(this.buildTabMenuItem(groupedModule, module, appStrings, modStrings, appListStrings));
groupedItems.push(this.buildTabMenuItem(groupedModule, module, languages));
});
return groupedItems;
@ -304,31 +377,30 @@ export class NavbarAbstract implements NavbarModel {
/**
* Build module menu items
* @param module
* @param moduleInfo
* @param appStrings
* @param modStrings
* @param appListStrings
*
* @param {string} module name
* @param {{}} moduleInfo info
* @param {{}} languages object
*
* @returns {{}} menuItem
*/
public buildTabMenuItem(
protected buildTabMenuItem(
module: string,
moduleInfo: any,
appStrings: LanguageStringMap,
modStrings: LanguageListStringMap,
appListStrings: LanguageListStringMap
languages: LanguageStrings,
): MenuItem {
let moduleUrl = (moduleInfo && moduleInfo.defaultRoute) || moduleInfo.defaultRoute;
let moduleUrl = (moduleInfo && moduleInfo.defaultRoute) || '';
let moduleRoute = null;
if (moduleUrl.startsWith(ROUTE_PREFIX)) {
moduleRoute = moduleUrl.replace(ROUTE_PREFIX, '');
moduleUrl = null;
}
const moduleLabel = (moduleInfo && moduleInfo.labelKey) || moduleInfo.labelKey;
const moduleLabel = (moduleInfo && moduleInfo.labelKey) || '';
const menuItem = {
link: {
label: (appListStrings && appListStrings.moduleList[moduleInfo.labelKey]) || moduleLabel,
label: (languages.appListStrings && languages.appListStrings.moduleList[moduleInfo.labelKey]) || moduleLabel,
url: moduleUrl,
route: moduleRoute,
params: null
@ -339,10 +411,10 @@ export class NavbarAbstract implements NavbarModel {
if (moduleInfo) {
moduleInfo.menu.forEach((subMenu) => {
let label = modStrings[module][subMenu.labelKey];
let label = languages.modStrings[module][subMenu.labelKey];
if (!label) {
label = appStrings[subMenu.labelKey];
label = languages.appStrings[subMenu.labelKey];
}
let actionUrl = subMenu.url;
@ -375,15 +447,18 @@ export class NavbarAbstract implements NavbarModel {
}
/**
* @param search
* Get module from url params
*
* @param {string} search query
* @returns {{}} params map
*/
private getModuleFromUrlParams(search) {
const hashes = search.slice(search.indexOf('?') + 1).split('&')
const params = {}
private getModuleFromUrlParams(search: string): any {
const hashes = search.slice(search.indexOf('?') + 1).split('&');
const params = {};
hashes.map(hash => {
const [key, val] = hash.split('=')
params[key] = decodeURIComponent(val)
})
return params
const [key, val] = hash.split('=');
params[key] = decodeURIComponent(val);
});
return params;
}
}

View file

@ -117,76 +117,35 @@
<ng-container *ngIf="this.isUserLoggedIn && !mobileNavbar">
<nav class="navbar navbar-expand-md navbar-1">
<div class="navbar-collapse collapse collapsenav" [ngbCollapse]="mainNavCollapse">
<ul class="navbar-nav home-nav">
<li class="nav-item home-nav-link">
<a class="home-nav-link" [routerLink]="systemConfigFacade.getHomePage()">
<scrm-image class="home-icon" image="home"></scrm-image>
</a>
</li>
</ul>
<scrm-home-menu-item
[route]="getHomePage()"
[active]="vm.appState.module && vm.appState.module === 'home'"
></scrm-home-menu-item>
<!-- Navbar with grouped tabs -->
<ng-container *ngIf="vm.userPreferences['navigation_paradigm'] == 'gm'">
<ul class="navbar-nav grouped">
<li class="top-nav nav-item dropdown non-grouped active" *ngIf="navbar.current">
<scrm-menu-item [languages]="vm.languages" [item]="navbar.current"></scrm-menu-item>
</li>
<li class="top-nav nav-item dropdown main-grouped" *ngFor="let item of navbar.menu">
<span data-toggle="collapse" data-target=".navbar-collapse">
<a class="nav-link-grouped dropdown-toggle" data-toggle="dropdown"
href="{{ item.link.route }}">
{{ item.link.label }}
</a>
</span>
<ul class="dropdown-menu main" aria-labelledby="navbarDropdownMenuLink"
[ngbCollapse]="subNavCollapse">
<li class="nav-item dropdown-submenu submenu" *ngFor="let sub of item.submenu">
<a class="nav-link action-link" *ngIf="!sub.submenu.length"
[href]="sub.link.url"
[routerLink]="sub.link.route"
[queryParams]="sub.link.params">
{{ sub.link.label }}
</a>
<a class="nav-link action-link dropdown-item dropdown-toggle"
*ngIf="sub.submenu.length"
(click)="subItemCollapse = !subItemCollapse">
{{ sub.link.label }}
</a>
<ul *ngIf="sub.submenu.length" class="dropdown-menu submenu">
<li *ngFor="let subitem of sub.submenu" class="nav-item">
<a class="nav-link action-link"
[href]="subitem.link.url"
[routerLink]="subitem.link.route"
[queryParams]="subitem.link.params">
<scrm-image *ngIf="subitem.icon" image="{{ subitem.icon }}">
</scrm-image>
{{ subitem.link.label }}
</a>
</li>
<ng-template [ngIf]="sub.recentRecords && sub.recentRecords.length">
<h4 class="recently-viewed-header">RECENTLY VIEWED</h4>
<li class="nav-item" *ngFor="let rec of sub.recentRecords">
<a class="nav-link action-link"
href="{{ rec.url }}">{{ rec.summary }}</a>
</li>
</ng-template>
</ul>
</li>
</ul>
<scrm-grouped-menu-item
[item]="item"
[languages]="vm.languages"
[subNavCollapse]="subNavCollapse"
>
</scrm-grouped-menu-item>
</li>
</ul>
<ul *ngIf="navbar.all.modules && navbar.all.modules.length > 0"
class="navbar-nav">
<li class="top-nav nav-item dropdown non-grouped">
<a class="nav-link-nongrouped dropdown-toggle">{{vm.appStrings['LBL_TABGROUP_ALL']}}</a>
<div aria-labelledby="navbarDropdownMenuLink" class="dropdown-menu more-menu submenu">
<div class="nav-item" *ngFor="let item of navbar.all.modules">
<a class="nav-link action-link" [href]="item.link.url"
[routerLink]="item.link.route">{{ item.link.label }}</a>
</div>
</div>
</li>
</ul>
<scrm-menu-items-list [items]="navbar.all.modules"
[label]="vm.languages.appStrings['LBL_TABGROUP_ALL']">
</scrm-menu-items-list>
</ng-container>
@ -198,54 +157,17 @@
<ng-container *ngIf="vm.userPreferences['navigation_paradigm'] != 'gm'">
<ul class="navbar-nav">
<li class="top-nav nav-item dropdown non-grouped active" *ngIf="navbar.current">
<scrm-menu-item [languages]="vm.languages" [item]="navbar.current"></scrm-menu-item>
</li>
<li class="top-nav nav-item dropdown non-grouped" *ngFor="let item of navbar.menu">
<!-- TODO: Implement a cleaner solution than hard coded ngIf -->
<ng-template [ngIf]="item.link.label !== 'Home'">
<span data-toggle="collapse" data-target=".navbar-collapse">
<a class="nav-link-nongrouped dropdown-toggle"
[href]="item.link.url"
[routerLink]="item.link.route"
[queryParams]="item.link.params"
routerLinkActive="active">
{{ item.link.label }}
</a>
</span>
<div aria-labelledby="navbarDropdownMenuLink" class="dropdown-menu submenu">
<div class="nav-item" *ngFor="let sub of item.submenu">
<a class="nav-link action-link"
[href]="sub.link.url"
[routerLink]="sub.link.route"
[queryParams]="sub.link.params">
<scrm-image *ngIf="sub.icon"
image="{{ sub.icon }}"></scrm-image>
{{ sub.link.label }}
</a>
</div>
<ng-template [ngIf]="item.recentRecords && item.recentRecords.length">
<h4 class="recently-viewed-header">{{vm.appStrings['LBL_LAST_VIEWED']}}</h4>
<div *ngFor="let recentRecord of item.recentRecords" class="nav-item">
<a class="nav-link action-link" href="#">{{ recentRecord.summary }}</a>
</div>
</ng-template>
</div>
</ng-template>
<scrm-menu-item [languages]="vm.languages" [item]="item"></scrm-menu-item>
</li>
</ul>
<ul *ngIf="navbar.all.modules && navbar.all.modules.length > 0"
class="navbar-nav">
<li class="top-nav nav-item dropdown non-grouped">
<a class="nav-link-nongrouped dropdown-toggle">{{vm.appStrings['LBL_MORE']}}</a>
<div aria-labelledby="navbarDropdownMenuLink" class="dropdown-menu more-menu submenu">
<div class="nav-item" *ngFor="let item of navbar.all.modules">
<a class="nav-link action-link" [href]="item.link.url"
[routerLink]="item.link.route">{{ item.link.label }}</a>
</div>
</div>
</li>
</ul>
<scrm-menu-items-list [items]="navbar.all.modules"
[label]="vm.languages.appStrings['LBL_MORE']">
</scrm-menu-items-list>
</ng-container>

View file

@ -47,18 +47,6 @@ describe('NavbarUiComponent', () => {
expect(component).toBeTruthy();
});
/*
it('should get metadata', async (() => {
fixture.detectChanges(); // onInit()
fixture.whenStable().then(() => {
expect(component.navbar).toEqual(jasmine.objectContaining(navigationMockData.navbar));
});
component.ngOnInit();
}));
*/
});
});

View file

@ -5,11 +5,12 @@ import {map} from 'rxjs/operators';
import {ApiService} from '@services/api/api.service';
import {NavbarModel} from './navbar-model';
import {NavbarAbstract} from './navbar.abstract';
import {NavbarModuleMap, NavigationFacade} from '@base/facades/navigation/navigation.facade';
import {LanguageFacade, LanguageListStringMap, LanguageStringMap} from '@base/facades/language/language.facade';
import {Navigation, NavigationFacade} from '@base/facades/navigation/navigation.facade';
import {UserPreferenceFacade, UserPreferenceMap} from '@base/facades/user-preference/user-preference.facade';
import {AuthService} from '@services/auth/auth.service';
import {SystemConfigFacade} from '@base/facades/system-config/system-config.facade';
import {AppState, AppStateFacade} from '@base/facades/app-state/app-state.facade';
import {LanguageFacade, LanguageStrings,} from '@base/facades/language/language.facade';
@Component({
selector: 'scrm-navbar-ui',
@ -24,7 +25,6 @@ export class NavbarUiComponent implements OnInit, OnDestroy {
isUserLoggedIn: boolean;
mainNavCollapse = true;
subItemCollapse = true;
subNavCollapse = true;
mobileNavbar = false;
mobileSubNav = false;
@ -34,55 +34,41 @@ export class NavbarUiComponent implements OnInit, OnDestroy {
navbar: NavbarModel = new NavbarAbstract();
tabs$: Observable<string[]> = this.navigationFacade.tabs$;
modules$: Observable<NavbarModuleMap> = this.navigationFacade.modules$;
appStrings$: Observable<LanguageStringMap> = this.languageFacade.appStrings$;
modStrings$: Observable<LanguageListStringMap> = this.languageFacade.modStrings$;
appListStrings$: Observable<LanguageListStringMap> = this.languageFacade.appListStrings$;
languages$: Observable<LanguageStrings> = this.languageFacade.vm$;
userPreferences$: Observable<UserPreferenceMap> = this.userPreferenceFacade.userPreferences$;
groupedTabs$: Observable<any> = this.navigationFacade.groupedTabs$;
userActionMenu$: Observable<any> = this.navigationFacade.userActionMenu$;
currentUser$: Observable<any> = this.authService.currentUser$;
appState$: Observable<AppState> = this.appState.vm$;
navigation$: Observable<Navigation> = this.navigationFacade.vm$;
vm$ = combineLatest([
this.tabs$,
this.modules$,
this.appStrings$,
this.appListStrings$,
this.modStrings$,
this.navigation$,
this.languages$,
this.userPreferences$,
this.groupedTabs$,
this.userActionMenu$,
this.currentUser$
this.currentUser$,
this.appState$
]).pipe(
map(([tabs, modules, appStrings, appListStrings, modStrings, userPreferences, groupedTabs, userActionMenu, currentUser]) => {
map(([navigation, languages, userPreferences, currentUser, appState]) => {
this.navbar.build(
tabs,
modules,
appStrings,
modStrings,
appListStrings,
this.menuItemThreshold,
groupedTabs,
navigation,
languages,
userPreferences,
userActionMenu,
currentUser
currentUser,
appState
);
return {
tabs, modules, appStrings, appListStrings, modStrings, userPreferences, groupedTabs
navigation, languages, userPreferences, appState
};
})
);
protected menuItemThreshold = 5;
constructor(protected navigationFacade: NavigationFacade,
protected languageFacade: LanguageFacade,
protected api: ApiService,
protected userPreferenceFacade: UserPreferenceFacade,
protected systemConfigFacade: SystemConfigFacade,
protected appState: AppStateFacade,
private authService: AuthService
) {
const navbar = new NavbarAbstract();
@ -91,6 +77,13 @@ export class NavbarUiComponent implements OnInit, OnDestroy {
NavbarUiComponent.instances.push(this);
}
/**
* Public API
*/
/**
* Reset component instance
*/
static reset(): void {
NavbarUiComponent.instances.forEach((navbarComponent: NavbarUiComponent) => {
navbarComponent.loaded = false;
@ -98,19 +91,6 @@ export class NavbarUiComponent implements OnInit, OnDestroy {
});
}
public changeSubNav(event: Event, parentNavItem): void {
this.mobileSubNav = !this.mobileSubNav;
this.backLink = !this.backLink;
this.mainNavLink = !this.mainNavLink;
this.submenu = parentNavItem.submenu;
}
public navBackLink(): void {
this.mobileSubNav = !this.mobileSubNav;
this.backLink = !this.backLink;
this.mainNavLink = !this.mainNavLink;
}
@HostListener('window:resize', ['$event'])
onResize(event: any): void {
const innerWidth = event.target.innerWidth;
@ -131,11 +111,56 @@ export class NavbarUiComponent implements OnInit, OnDestroy {
this.authService.isUserLoggedIn.unsubscribe();
}
/**
* Change subnavigation
*
* @param {{}} event triggered
* @param {{}} parentNavItem parent
*/
public changeSubNav(event: Event, parentNavItem): void {
this.mobileSubNav = !this.mobileSubNav;
this.backLink = !this.backLink;
this.mainNavLink = !this.mainNavLink;
this.submenu = parentNavItem.submenu;
}
/**
* Set link flags
*/
public navBackLink(): void {
this.mobileSubNav = !this.mobileSubNav;
this.backLink = !this.backLink;
this.mainNavLink = !this.mainNavLink;
}
/**
* Get home page
*
* @returns {string} homepage
*/
public getHomePage(): string {
return this.systemConfigFacade.getHomePage();
}
/**
* Internal API
*/
/**
* Set navbar model
*
* @param {{}} navbar model
*/
protected setNavbar(navbar: NavbarModel): void {
this.navbar = navbar;
this.loaded = true;
}
/**
* Check if is loaded
*
* @returns {{boolean}} is loaded
*/
protected isLoaded(): boolean {
return this.loaded;
}

View file

@ -10,11 +10,30 @@ import {ActionBarUiModule} from '../action-bar/action-bar.module';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
import {RouterModule} from '@angular/router';
import {ImageModule} from '@components/image/image.module';
import {MenuItemComponent} from '@components/navbar/menu-item/menu-item.component';
import {MenuRecentlyViewedComponent} from '@components/navbar/menu-recently-viewed/menu-recently-viewed.component';
import {HomeMenuItemComponent} from '@components/navbar/home-menu-item/home-menu-item.component';
import { MenuItemLinkComponent } from './menu-item-link/menu-item-link.component';
import { GroupedMenuItemComponent } from './grouped-menu-item/grouped-menu-item.component';
import { MenuItemsListComponent } from './menu-items-list/menu-items-list.component';
@NgModule({
declarations: [NavbarUiComponent],
exports: [NavbarUiComponent],
declarations: [
NavbarUiComponent,
MenuItemComponent,
MenuRecentlyViewedComponent,
HomeMenuItemComponent,
MenuItemLinkComponent,
GroupedMenuItemComponent,
MenuItemsListComponent
],
exports: [
NavbarUiComponent,
MenuItemComponent,
MenuRecentlyViewedComponent,
HomeMenuItemComponent
],
imports: [
CommonModule,
AppManagerModule.forChild(NavbarUiComponent),

View file

@ -13,13 +13,25 @@ describe('AppState Facade', () => {
injector = getTestBed();
});
it('#updateLoading',
(done: DoneFn) => {
service.updateLoading('test', true);
service.loading$.pipe(take(1)).subscribe(loading => {
expect(loading).toEqual(true);
done();
});
it('#updateLoading', () => {
service.updateLoading('test', true);
service.loading$.pipe(take(1)).subscribe(loading => {
expect(loading).toEqual(true);
});
});
it('#setModule', () => {
service.setModule('accounts');
service.module$.pipe(take(1)).subscribe(module => {
expect(module).toEqual('accounts');
}).unsubscribe();
});
it('#setView', () => {
service.setView('record');
service.view$.pipe(take(1)).subscribe(view => {
expect(view).toEqual('record');
}).unsubscribe();
});
});

View file

@ -1,16 +1,20 @@
import {Injectable} from '@angular/core';
import {BehaviorSubject, combineLatest, Observable} from 'rxjs';
import {distinctUntilChanged, map} from 'rxjs/operators';
import {BehaviorSubject, Observable, combineLatest} from 'rxjs';
import {map, distinctUntilChanged} from 'rxjs/operators';
import {deepClone} from '@base/utils/object-utils';
import {StateFacade} from '@base/facades/state';
export interface AppState {
loading: boolean;
module: string;
view: string;
loaded: boolean;
}
const initialState: AppState = {
loading: false,
module: null,
view: null,
loaded: false
};
@ -25,19 +29,29 @@ export class AppStateFacade implements StateFacade {
protected state$ = this.store.asObservable();
protected loadingQueue = {};
/**
* Public long-lived observable streams
*/
loading$ = this.state$.pipe(map(state => state.loading), distinctUntilChanged());
module$ = this.state$.pipe(map(state => state.module), distinctUntilChanged());
view$ = this.state$.pipe(map(state => state.view), distinctUntilChanged());
/**
* ViewModel that resolves once all the data is ready (or updated)...
*/
vm$: Observable<AppState> = combineLatest([this.loading$]).pipe(
map(([loading]) => ({loading, loaded: internalState.loaded}))
vm$: Observable<AppState> = combineLatest([this.loading$, this.module$, this.view$]).pipe(
map(([loading, module, view]) => ({loading, module, view, loaded: internalState.loaded}))
);
constructor() {
this.updateState({...internalState, loading: false});
}
/**
* Public Api
*/
/**
* Clear state
*/
@ -85,6 +99,24 @@ export class AppStateFacade implements StateFacade {
this.updateState({...internalState, loaded});
}
/**
* Set current module
*
* @param {string} module to set as current module
*/
public setModule(module: string): void {
this.updateState({...internalState, module});
}
/**
* Set current View
*
* @param {string} view to set as current view
*/
public setView(view: string): void {
this.updateState({...internalState, view});
}
/**
* Internal API
*/
@ -121,9 +153,9 @@ export class AppStateFacade implements StateFacade {
/**
* Update the state
*
* @param state
* @param {{}} state app state
*/
protected updateState(state: AppState) {
protected updateState(state: AppState): void {
this.store.next(internalState = state);
}
}

View file

@ -14,6 +14,7 @@ export const languageMockData = {
LBL_SEND: 'Send',
LBL_LOGOUT: 'Logout',
LBL_TOUR_NEXT: 'Next',
LBL_LAST_VIEWED: 'Recently Viewed'
},
appListStrings: {
language_pack_name: 'US English',

View file

@ -1,6 +1,6 @@
import {Injectable} from '@angular/core';
import {BehaviorSubject, forkJoin, Observable} from 'rxjs';
import {BehaviorSubject, combineLatest, forkJoin, Observable} from 'rxjs';
import {distinctUntilChanged, first, map, shareReplay, tap} from 'rxjs/operators';
import {RecordGQL} from '@services/api/graphql-api/api.record.get';
import {AppStateFacade} from '@base/facades/app-state/app-state.facade';
@ -28,6 +28,13 @@ export interface LanguageState {
hasChanged: boolean;
}
export interface LanguageStrings {
appStrings: LanguageStringMap;
appListStrings: LanguageListStringMap;
modStrings: LanguageListStringMap;
languageKey: string;
}
export interface LanguageCache {
[key: string]: {
[key: string]: Observable<any>;
@ -105,6 +112,27 @@ export class LanguageFacade implements StateFacade {
modStrings$ = this.state$.pipe(map(state => state.modStrings), distinctUntilChanged());
languageKey$ = this.state$.pipe(map(state => state.languageKey), distinctUntilChanged());
/**
* ViewModel that resolves once all the data is ready (or updated)...
*/
vm$: Observable<LanguageStrings> = combineLatest(
[
this.appStrings$,
this.appListStrings$,
this.modStrings$,
this.languageKey$
])
.pipe(
map((
[
appStrings,
appListStrings,
modStrings,
languageKey
]) => ({appStrings, appListStrings, modStrings, languageKey})
)
);
constructor(private recordGQL: RecordGQL, private appStateFacade: AppStateFacade) {
}
@ -124,7 +152,7 @@ export class LanguageFacade implements StateFacade {
/**
* Update the language strings toe the given language
*
* @param languageKey
* @param {string} languageKey language key
*/
public changeLanguage(languageKey: string): void {
const types = [];
@ -143,7 +171,8 @@ export class LanguageFacade implements StateFacade {
/**
* Get AppStrings label by key
*
* @param labelKey
* @param {string} labelKey to fetch
* @returns {string} label
*/
public getAppString(labelKey: string): string {
@ -156,7 +185,8 @@ export class LanguageFacade implements StateFacade {
/**
* Get AppListStrings label by key
*
* @param labelKey
* @param {string} labelKey to fetch
* @returns {string|{}} app strings
*/
public getAppListString(labelKey: string): string | LanguageStringMap {
@ -170,7 +200,8 @@ export class LanguageFacade implements StateFacade {
/**
* Get ModStrings label by key
*
* @param labelKey
* @param {string} labelKey to fetch
* @returns {string|{}} mod strings
*/
public getModString(labelKey: string): string | LanguageStringMap {
@ -184,7 +215,7 @@ export class LanguageFacade implements StateFacade {
/**
* Get all available string types
*
* @returns Observable
* @returns {string[]} string types
*/
public getAvailableStringsTypes(): string[] {
return Object.keys(this.config);
@ -193,7 +224,7 @@ export class LanguageFacade implements StateFacade {
/**
* Returns whether the language has changed manually
*
* @returns bool
* @returns {boolean} has changed
*/
public hasLanguageChanged(): boolean {
return internalState.hasChanged;
@ -202,7 +233,7 @@ export class LanguageFacade implements StateFacade {
/**
* Returns the currently active language
*
* @returns string
* @returns {string} current language key
*/
public getCurrentLanguage(): string {
return internalState.languageKey;
@ -213,9 +244,9 @@ export class LanguageFacade implements StateFacade {
* Initial Language Strings Load for given language and types if not cached and update state.
* Returns observable to be used in resolver if needed
*
* @param languageKey
* @param types
* @returns Observable
* @param {string} languageKey to load
* @param {string[]} types to load
* @returns {{}} Observable
*/
public load(languageKey: string, types: string[]): Observable<{}> {
@ -247,7 +278,7 @@ export class LanguageFacade implements StateFacade {
/**
* Update internal state cache and emit from store...
*
* @param state
* @param {{}} state to set
*/
protected updateState(state: LanguageState): void {
this.store.next(internalState = state);
@ -256,9 +287,9 @@ export class LanguageFacade implements StateFacade {
/**
* Get given $type of strings Observable from cache or call the backend
*
* @param language
* @param type
* @returns Observable<any>
* @param {string} language to load
* @param {string} type load
* @returns {{}} Observable<any>
*/
protected getStrings(language: string, type: string): Observable<{}> {
@ -279,8 +310,8 @@ export class LanguageFacade implements StateFacade {
/**
* Fetch the App strings from the backend
*
* @param language
* @returns Observable<{}>
* @param {string} language to fetch
* @returns {{}} Observable<{}>
*/
protected fetchAppStrings(language: string): Observable<{}> {
const resourceName = this.config.appStrings.resourceName;
@ -302,8 +333,8 @@ export class LanguageFacade implements StateFacade {
/**
* Fetch the App list strings from the backend
*
* @param language
* @returns Observable<{}>
* @param {string} language to fetch
* @returns {{}} Observable<{}>
*/
protected fetchAppListStrings(language: string): Observable<{}> {
const resourceName = this.config.appListStrings.resourceName;
@ -326,8 +357,8 @@ export class LanguageFacade implements StateFacade {
/**
* Fetch the Mod strings from the backend
*
* @param language
* @returns Observable<{}>
* @param {string} language to fetch
* @returns {{}} Observable<{}>
*/
protected fetchModStrings(language: string): Observable<{}> {
const resourceName = this.config.modStrings.resourceName;

View file

@ -1,5 +1,5 @@
import {Injectable} from '@angular/core';
import {BehaviorSubject, Observable} from 'rxjs';
import {BehaviorSubject, combineLatest, Observable} from 'rxjs';
import {map, distinctUntilChanged, tap, shareReplay} from 'rxjs/operators';
import {RecordGQL} from '@services/api/graphql-api/api.record.get';
@ -11,6 +11,7 @@ export interface Navigation {
groupedTabs: GroupedTab[];
modules: NavbarModuleMap;
userActionMenu: UserActionMenu[];
maxTabs: number;
}
export interface NavbarModuleMap {
@ -51,7 +52,8 @@ const initialState: Navigation = {
tabs: [],
groupedTabs: [],
modules: {},
userActionMenu: []
userActionMenu: [],
maxTabs: 0
};
let internalState: Navigation = deepClone(initialState);
@ -71,7 +73,8 @@ export class NavigationFacade implements StateFacade {
'tabs',
'groupedTabs',
'modules',
'userActionMenu'
'userActionMenu',
'maxTabs'
]
};
@ -82,6 +85,31 @@ export class NavigationFacade implements StateFacade {
groupedTabs$ = this.state$.pipe(map(state => state.groupedTabs), distinctUntilChanged());
modules$ = this.state$.pipe(map(state => state.modules), distinctUntilChanged());
userActionMenu$ = this.state$.pipe(map(state => state.userActionMenu), distinctUntilChanged());
maxTabs$ = this.state$.pipe(map(state => state.maxTabs), distinctUntilChanged());
/**
* ViewModel that resolves once all the data is ready (or updated)...
*/
vm$: Observable<Navigation> = combineLatest(
[
this.tabs$,
this.groupedTabs$,
this.modules$,
this.userActionMenu$,
this.maxTabs$
])
.pipe(
map((
[
tabs,
groupedTabs,
modules,
userActionMenu,
maxTabs
]) => ({tabs, groupedTabs, modules, userActionMenu, maxTabs})
)
);
constructor(private recordGQL: RecordGQL) {
}
@ -104,7 +132,7 @@ export class NavigationFacade implements StateFacade {
* Initial Navigation load if not cached and update state.
* Returns observable to be used in resolver if needed
*
* @returns Observable<any>
* @returns {{}} Observable<any>
*/
public load(): Observable<any> {
@ -115,7 +143,8 @@ export class NavigationFacade implements StateFacade {
tabs: navigation.tabs,
groupedTabs: navigation.groupedTabs,
userActionMenu: navigation.userActionMenu,
modules: navigation.modules
modules: navigation.modules,
maxTabs: navigation.maxTabs
});
})
);
@ -128,16 +157,16 @@ export class NavigationFacade implements StateFacade {
/**
* Update the state
*
* @param state
* @param {{}} state to set
*/
protected updateState(state: Navigation) {
protected updateState(state: Navigation): void {
this.store.next(internalState = state);
}
/**
* Get Navigation cached Observable or call the backend
*
* @returns Observable<any>
* @returns {{}} Observable<any>
*/
protected getNavigation(): Observable<any> {
@ -155,8 +184,8 @@ export class NavigationFacade implements StateFacade {
/**
* Fetch the Navigation from the backend
*
* @param userId
* @returns Observable<any>
* @param {string} userId to use
* @returns {{}} Observable<any>
*/
protected fetch(userId: string): Observable<any> {
@ -171,7 +200,8 @@ export class NavigationFacade implements StateFacade {
tabs: data.navbar.tabs,
groupedTabs: data.navbar.groupedTabs,
userActionMenu: data.navbar.userActionMenu,
modules: data.navbar.modules
modules: data.navbar.modules,
maxTabs: data.navbar.maxTabs
};
}

View file

@ -95,6 +95,16 @@ export const themeImagesMockData = {
path: 'core/app/themes/suite8/images/paginate_last.svg',
name: 'paginate_last',
type: 'svg'
},
home: {
path: 'core/app/themes/suite8/images/home.svg',
name: 'home',
type: 'svg'
},
view: {
path: 'core/app/themes/suite8/images/view.svg',
name: 'view',
type: 'svg'
}
};

View file

@ -1,5 +1,6 @@
import {Injectable} from '@angular/core';
import {ActivatedRouteSnapshot} from '@angular/router';
import {map} from 'rxjs/operators';
import {RouteConverter} from '@services/navigation/route-converter/route-converter.service';
import {BaseMetadataResolver} from '@services/metadata/base-metadata.resolver';
import {SystemConfigFacade} from '@base/facades/system-config/system-config.facade';
@ -7,11 +8,13 @@ import {LanguageFacade} from '@base/facades/language/language.facade';
import {NavigationFacade} from '@base/facades/navigation/navigation.facade';
import {UserPreferenceFacade} from '@base/facades/user-preference/user-preference.facade';
import {ThemeImagesFacade} from '@base/facades/theme-images/theme-images.facade';
import {map} from 'rxjs/operators';
import {ModuleNameMapper} from '@services/navigation/module-name-mapper/module-name-mapper.service';
import {ActionNameMapper} from '@services/navigation/action-name-mapper/action-name-mapper.service';
import {BaseModuleResolver} from '@services/metadata/base-module.resolver';
import {AppStateFacade} from '@base/facades/app-state/app-state.facade';
@Injectable({providedIn: 'root'})
export class ClassicViewResolver extends BaseMetadataResolver {
export class ClassicViewResolver extends BaseModuleResolver {
constructor(
protected systemConfigFacade: SystemConfigFacade,
@ -19,8 +22,10 @@ export class ClassicViewResolver extends BaseMetadataResolver {
protected navigationFacade: NavigationFacade,
protected userPreferenceFacade: UserPreferenceFacade,
protected themeImagesFacade: ThemeImagesFacade,
protected moduleNameMapper: ModuleNameMapper,
protected actionNameMapper: ActionNameMapper,
protected appStateFacade: AppStateFacade,
protected routeConverter: RouteConverter,
protected appState: AppStateFacade
) {
super(
systemConfigFacade,
@ -28,7 +33,9 @@ export class ClassicViewResolver extends BaseMetadataResolver {
navigationFacade,
userPreferenceFacade,
themeImagesFacade,
appState
moduleNameMapper,
actionNameMapper,
appStateFacade
);
}

View file

@ -40,7 +40,7 @@ export class BaseMetadataResolver implements Resolve<any> {
configs$ = configs$.pipe(
map(
configs => {
(configs: any) => {
let language = configs.default_language.value;

View file

@ -0,0 +1,82 @@
import {Injectable} from '@angular/core';
import {ActivatedRouteSnapshot} from '@angular/router';
import {tap} from 'rxjs/operators';
import {BaseMetadataResolver} from '@services/metadata/base-metadata.resolver';
import {ModuleNameMapper} from '@services/navigation/module-name-mapper/module-name-mapper.service';
import {ActionNameMapper} from '@services/navigation/action-name-mapper/action-name-mapper.service';
import {SystemConfigFacade} from '@base/facades/system-config/system-config.facade';
import {LanguageFacade} from '@base/facades/language/language.facade';
import {NavigationFacade} from '@base/facades/navigation/navigation.facade';
import {UserPreferenceFacade} from '@base/facades/user-preference/user-preference.facade';
import {ThemeImagesFacade} from '@base/facades/theme-images/theme-images.facade';
import {AppStateFacade} from '@base/facades/app-state/app-state.facade';
@Injectable({providedIn: 'root'})
export class BaseModuleResolver extends BaseMetadataResolver {
constructor(
protected systemConfigFacade: SystemConfigFacade,
protected languageFacade: LanguageFacade,
protected navigationFacade: NavigationFacade,
protected userPreferenceFacade: UserPreferenceFacade,
protected themeImagesFacade: ThemeImagesFacade,
protected moduleNameMapper: ModuleNameMapper,
protected actionNameMapper: ActionNameMapper,
protected appStateFacade: AppStateFacade,
) {
super(
systemConfigFacade,
languageFacade,
navigationFacade,
userPreferenceFacade,
themeImagesFacade,
appStateFacade
);
}
resolve(route: ActivatedRouteSnapshot): any {
return super.resolve(route).pipe(
tap(() => {
if (route.params.module) {
const module = this.calculateActiveModule(route);
this.appStateFacade.setModule(module);
}
if (route.params.action) {
this.appStateFacade.setView(route.params.action);
}
})
);
}
/**
* Calculate the active module
*
* @param {{}} route active
* @returns {string} active module
*/
protected calculateActiveModule(route: ActivatedRouteSnapshot): string {
let module = route.params.module;
const parentModuleParam = this.getParentModuleMap()[module] || '';
const parentModule = route.queryParams[parentModuleParam] || '';
if (parentModule) {
module = this.moduleNameMapper.toFrontend(parentModule);
}
return module;
}
/**
* Get Parent Module Map
*
* @returns {{}} parent module map
*/
protected getParentModuleMap(): { [key: string]: string } {
return {
'merge-records': 'return_module',
import: 'import_module'
};
}
}

View file

@ -48,6 +48,10 @@ a.home-nav-link {
padding: 1em 0.5em 1em 0.5em;
}
.nav-item.active .home-nav-link svg {
fill: $salmon-pink;
}
.recent-link scrm-svg-icon-ui > svg {
margin: 0.3em 0.8em 0.8em 0;
}
@ -70,6 +74,14 @@ a.home-nav-link {
color: $off-white;
}
.nav-item.active .nav-link-nongrouped,
.nav-item.active .home-nav-link {
color: $salmon-pink;
border-top: 0.2em solid $salmon-pink;
padding: 0.8em 0.5em 1em 0.5em;
text-decoration: none;
}
.global-link-item:hover {
color: $salmon-pink;
}

View file

@ -19,7 +19,8 @@
"@base/*": ["./src/*"],
"@views/*": ["./views/*"],
"@services/*": ["./src/services/*"],
"@components/*": ["./src/components/*"]
"@components/*": ["./src/components/*"],
"@store/*": ["./src/facades/*"]
},
"lib": [
"es2018",

View file

@ -7,6 +7,7 @@ namespace SuiteCRM\Core\Legacy;
use ApiPlatform\Core\Exception\ItemNotFoundException;
use App\Entity\ModStrings;
use App\Service\ModuleNameMapperInterface;
use App\Service\ModuleRegistryInterface;
class ModStringsHandler extends LegacyHandler
{
@ -18,6 +19,11 @@ class ModStringsHandler extends LegacyHandler
*/
private $moduleNameMapper;
/**
* @var ModuleRegistryInterface
*/
private $moduleRegistry;
/**
* SystemConfigHandler constructor.
* @param string $projectDir
@ -26,6 +32,7 @@ class ModStringsHandler extends LegacyHandler
* @param string $defaultSessionName
* @param LegacyScopeState $legacyScopeState
* @param ModuleNameMapperInterface $moduleNameMapper
* @param ModuleRegistryInterface $moduleRegistry
*/
public function __construct(
string $projectDir,
@ -33,10 +40,12 @@ class ModStringsHandler extends LegacyHandler
string $legacySessionName,
string $defaultSessionName,
LegacyScopeState $legacyScopeState,
ModuleNameMapperInterface $moduleNameMapper
ModuleNameMapperInterface $moduleNameMapper,
ModuleRegistryInterface $moduleRegistry
) {
parent::__construct($projectDir, $legacyDir, $legacySessionName, $defaultSessionName, $legacyScopeState);
$this->moduleNameMapper = $moduleNameMapper;
$this->moduleRegistry = $moduleRegistry;
}
/**
@ -66,10 +75,11 @@ class ModStringsHandler extends LegacyHandler
throw new ItemNotFoundException(self::MSG_LANGUAGE_NOT_FOUND . "'$language'");
}
global $moduleList;
$modules = $this->moduleRegistry->getUserAccessibleModules();
$allModStringsArray = [];
foreach ($moduleList as $module) {
foreach ($modules as $module) {
$frontendName = $this->moduleNameMapper->toFrontEnd($module);
$allModStringsArray[$frontendName] = return_module_language($language, $module);
}

View file

@ -0,0 +1,131 @@
<?php
namespace SuiteCRM\Core\Legacy;
use ACLAction;
use ACLController;
use App\Service\ModuleRegistryInterface;
class ModuleRegistryHandler extends LegacyHandler implements ModuleRegistryInterface
{
public const HANDLER_KEY = 'module-registry';
/**
* @var array
*/
private $frontendExcludedModules;
/**
* SystemConfigHandler constructor.
* @param string $projectDir
* @param string $legacyDir
* @param string $legacySessionName
* @param string $defaultSessionName
* @param LegacyScopeState $legacyScopeState
* @param array $frontendExcludedModules
*/
public function __construct(
string $projectDir,
string $legacyDir,
string $legacySessionName,
string $defaultSessionName,
LegacyScopeState $legacyScopeState,
array $frontendExcludedModules
) {
parent::__construct($projectDir, $legacyDir, $legacySessionName, $defaultSessionName, $legacyScopeState);
$this->frontendExcludedModules = $frontendExcludedModules;
}
/**
* @inheritDoc
*/
public function getHandlerKey(): string
{
return self::HANDLER_KEY;
}
/**
* Get list of modules the user has access to
* @return array
* Based on @see {soap/SoapHelperFunctions.php::get_user_module_list}
*/
public function getUserAccessibleModules(): array
{
$this->init();
global $modInvisList;
$modules = $this->getFilterVisibleModules();
if (empty($modules)) {
return [];
}
foreach ($modInvisList as $invis) {
$modules[$invis] = '';
}
$modules = $this->applyUserActionFilter($modules);
foreach ($this->frontendExcludedModules as $excluded){
unset($modules[$excluded]);
}
if (empty($modules)) {
return [];
}
$this->close();
return array_keys($modules);
}
/**
* Get of list of modules. Apply acl filter
*
* @return array
*/
protected function getFilterVisibleModules(): array
{
global $current_user;
$modules = query_module_access_list($current_user);
if (empty($modules)) {
return [];
}
ACLController:: filterModuleList($modules, false);
return $modules;
}
/**
* Apply User action filter on current module list
*
* @param array $modules
* @return array
*/
protected function applyUserActionFilter(array &$modules): array
{
global $current_user;
$actions = ACLAction::getUserActions($current_user->id, true);
foreach ($actions as $key => $value) {
if (!isset($value['module'])) {
continue;
}
if ($value['module']['access']['aclaccess'] < ACL_ALLOW_ENABLED) {
continue;
}
if ($value['module']['access']['aclaccess'] === ACL_ALLOW_DISABLED) {
unset($modules[$key]);
}
}
return $modules; // foreach
}
}

View file

@ -4,6 +4,7 @@ namespace SuiteCRM\Core\Legacy;
use App\Entity\Navbar;
use App\Service\ModuleNameMapperInterface;
use App\Service\ModuleRegistryInterface;
use App\Service\NavigationProviderInterface;
use App\Service\RouteConverterInterface;
use GroupedTabStructure;
@ -31,6 +32,11 @@ class NavbarHandler extends LegacyHandler implements NavigationProviderInterface
*/
private $menuItemMap;
/**
* @var ModuleRegistryInterface
*/
private $moduleRegistry;
/**
* SystemConfigHandler constructor.
* @param string $projectDir
@ -41,6 +47,7 @@ class NavbarHandler extends LegacyHandler implements NavigationProviderInterface
* @param array $menuItemMap
* @param ModuleNameMapperInterface $moduleNameMapper
* @param RouteConverterInterface $routeConverter
* @param ModuleRegistryInterface $moduleRegistry
*/
public function __construct(
string $projectDir,
@ -50,12 +57,14 @@ class NavbarHandler extends LegacyHandler implements NavigationProviderInterface
LegacyScopeState $legacyScopeState,
array $menuItemMap,
ModuleNameMapperInterface $moduleNameMapper,
RouteConverterInterface $routeConverter
RouteConverterInterface $routeConverter,
ModuleRegistryInterface $moduleRegistry
) {
parent::__construct($projectDir, $legacyDir, $legacySessionName, $defaultSessionName, $legacyScopeState);
$this->moduleNameMapper = $moduleNameMapper;
$this->routeConverter = $routeConverter;
$this->menuItemMap = $menuItemMap;
$this->moduleRegistry = $moduleRegistry;
}
/**
@ -92,6 +101,7 @@ class NavbarHandler extends LegacyHandler implements NavigationProviderInterface
$navbar->modules = $this->buildModuleInfo($sugarView, $accessibleModulesNameMap);
$navbar->userActionMenu = $this->fetchUserActionMenu();
$navbar->maxTabs = $this->getMaxTabs();
$this->close();
@ -116,10 +126,7 @@ class NavbarHandler extends LegacyHandler implements NavigationProviderInterface
*/
protected function getAccessibleModulesList(): array
{
/* @noinspection PhpIncludeInspection */
require_once 'modules/MySettings/TabController.php';
return (new TabController())->get_user_tabs($GLOBALS['current_user']);
return $this->moduleRegistry->getUserAccessibleModules();
}
/**
@ -148,8 +155,6 @@ class NavbarHandler extends LegacyHandler implements NavigationProviderInterface
}
}
sort($submoduleArray);
$output[] = [
'name' => $mainTab,
@ -232,6 +237,30 @@ class NavbarHandler extends LegacyHandler implements NavigationProviderInterface
return array_values($userActionMenu);
}
/**
* Get max number of tabs
* @return int
* Based on @link SugarView
*/
protected function getMaxTabs(): int
{
global $current_user;
$maxTabs = $current_user->getPreference('max_tabs');
// If the max_tabs isn't set incorrectly, set it within the range, to the default max sub tabs size
if (!isset($maxTabs) || $maxTabs <= 0 || $maxTabs > 10) {
// We have a default value. Use it
if (isset($GLOBALS['sugar_config']['default_max_tabs'])) {
$maxTabs = $GLOBALS['sugar_config']['default_max_tabs'];
} else {
$maxTabs = 8;
}
}
return $maxTabs;
}
/**
* Get global control links from legacy
* @return array

View file

@ -48,4 +48,10 @@ final class Navbar
* @ApiProperty
*/
public $modules;
/**
* @var int
* @ApiProperty
*/
public $maxTabs;
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Service;
interface ModuleRegistryInterface
{
/**
* Get list of modules the user has access to
* @return array list of module names
*/
public function getUserAccessibleModules(): array;
}

View file

@ -6,6 +6,7 @@ use Codeception\Test\Unit;
use SuiteCRM\Core\Legacy\LegacyScopeState;
use SuiteCRM\Core\Legacy\ModStringsHandler;
use SuiteCRM\Core\Legacy\ModuleNameMapperHandler;
use SuiteCRM\Core\Legacy\ModuleRegistryHandler;
class ModStringsHandlerTest extends Unit
{
@ -36,13 +37,31 @@ class ModStringsHandlerTest extends Unit
$legacyScope
);
$excludedModules = [
'EmailText',
'TeamMemberships',
'TeamSets',
'TeamSetModule'
];
$moduleRegistry = new ModuleRegistryHandler(
$projectDir,
$legacyDir,
$legacySessionName,
$defaultSessionName,
$legacyScope,
$excludedModules
);
$this->handler = new ModStringsHandler(
$projectDir,
$legacyDir,
$legacySessionName,
$defaultSessionName,
$legacyScope,
$moduleNameMapper
$moduleNameMapper,
$moduleRegistry
);
}

View file

@ -0,0 +1,59 @@
<?php namespace App\Tests;
use Codeception\Test\Unit;
use SuiteCRM\Core\Legacy\LegacyScopeState;
use SuiteCRM\Core\Legacy\ModuleRegistryHandler;
class ModuleRegistryHandlerTest extends Unit
{
/**
* @var UnitTester
*/
protected $tester;
/**
* @var ModuleRegistryHandler
*/
protected $handler;
protected function _before(): void
{
$projectDir = codecept_root_dir();
$legacyDir = $projectDir . '/legacy';
$legacySessionName = 'LEGACYSESSID';
$defaultSessionName = 'PHPSESSID';
$legacyScope = new LegacyScopeState();
$excludedModules = [
'EmailText',
'TeamMemberships',
'TeamSets',
'TeamSetModule'
];
$this->handler = new ModuleRegistryHandler(
$projectDir,
$legacyDir,
$legacySessionName,
$defaultSessionName,
$legacyScope,
$excludedModules
);
}
// tests
/**
* Test accessible modules retrieval
*/
public function testGetAccessibleModules(): void
{
$modules = $this->handler->getUserAccessibleModules();
static::assertContainsEquals('Accounts', $modules);
static::assertContainsEquals('Alert', $modules);
static::assertContainsEquals('EmailMan', $modules);
}
}

View file

@ -8,6 +8,7 @@ use Codeception\Test\Unit;
use SuiteCRM\Core\Legacy\ActionNameMapperHandler;
use SuiteCRM\Core\Legacy\LegacyScopeState;
use SuiteCRM\Core\Legacy\ModuleNameMapperHandler;
use SuiteCRM\Core\Legacy\ModuleRegistryHandler;
use SuiteCRM\Core\Legacy\NavbarHandler;
use SuiteCRM\Core\Legacy\RouteConverterHandler;
@ -169,6 +170,22 @@ final class NavbarTest extends Unit
},
]);
$excludedModules = [
'EmailText',
'TeamMemberships',
'TeamSets',
'TeamSetModule'
];
$moduleRegistry = new ModuleRegistryHandler(
$projectDir,
$legacyDir,
$legacySessionName,
$defaultSessionName,
$legacyScope,
$excludedModules
);
$this->navbarHandler = new NavbarHandler($projectDir,
$legacyDir,
$legacySessionName,
@ -176,7 +193,8 @@ final class NavbarTest extends Unit
$legacyScope,
$menuItemMap,
$moduleNameMapper,
$routeConverter
$routeConverter,
$moduleRegistry
);
$this->navbar = $this->navbarHandler->getNavbar();
}
@ -252,9 +270,9 @@ final class NavbarTest extends Unit
'labelKey' => 'LBL_TABGROUP_SALES',
// Ordered array
'modules' => [
'home',
'accounts',
'contacts',
'home',
'leads',
]
],
@ -263,9 +281,9 @@ final class NavbarTest extends Unit
'labelKey' => 'LBL_TABGROUP_MARKETING',
// Ordered array
'modules' => [
'home',
'accounts',
'contacts',
'home',
'leads',
]
],
@ -274,9 +292,9 @@ final class NavbarTest extends Unit
'labelKey' => 'LBL_TABGROUP_SUPPORT',
// Ordered array
'modules' => [
'home',
'accounts',
'contacts',
'home'
]
],
3 => [