Add Navbar translations

- Make language facade loading dynamic to load given types of strings
-- Add cache per type of language strings
-- Add configuration per type of language strings
-- Enable fetching modStrings, appStrings, appListStrings
- Expose a stream for each type of strings

- Make BaseMetadataResolver language loading dynamic based on router configuration
-- Add navigation loading
-- add string loading, depending on configuration
-- Add default configuration: Loads SystemConfigs, Navigation and all Languages
-- Add ability to override default configuration on router
- Configure BaseMetadata resolver in router
- Only use default language if it there is no active language

- Update login component to cope with changes to language facade

- Refactor: move existing facades to the facades folder

- Re-write navigation facade to use reactive facades approach

- Update navbar api to cope with with api contract
-- Add modules entry
-- Move moduleSubmenus to modules entry
-- Disable collection query on graphql
-- Update frontend to cope with these changes

- Update navbar component
-- Cleanup component
-- Fetch navigation and languages reactively
-- Update navbar menu item creation with new navigation structure
-- Fix html for loading icons
-- Fix html for regular module tab menu

- Translate navbar items
-- Add translations on navbar menu items creation

- Add karma tests

- Adjust navbar to properly render links
-- Add extra query params to submenu in API
-- Adjust LegacyHandler to retrieve the extra query params
-- Adjust angular component
This commit is contained in:
Clemente Raposo 2020-03-16 17:58:38 +00:00 committed by Dillon-Brown
parent 62b05e18fe
commit a8235c444a
40 changed files with 4213 additions and 947 deletions

View file

@ -25,6 +25,7 @@ services:
$exposedSystemConfigs: '%legacy.exposed_system_configs%'
$legacyModuleNameMap: '%legacy.module_name_map%'
$legacyActionNameMap: '%legacy.action_name_map%'
$menuItemMap: '%legacy.menu_item_map%'
$legacyAssetPaths: '%legacy.asset_paths%'
# makes classes in src/ available to be used as services

View file

@ -0,0 +1,143 @@
parameters:
legacy.menu_item_map:
default:
List:
icon: view
View:
icon: view
Add:
icon: plus
Create:
icon: plus
Import:
icon: download
Security_Groups:
icon: view
Schedule_Call:
icon: plus
Schedule_Meetings:
icon: plus
Schedule_Meeting:
icon: plus
contacts:
Create_Contact_Vcard:
icon: plus
calls:
Calls:
icon: plus
calendar:
Calendar:
icon: plus
Today:
icon: calendar
workflow:
View_Process_Audit:
icon: view
workflow-processed:
View_Process_Audit:
icon: view
mail-merge:
Documents:
icon: view
email-templates:
View_Email_Templates:
icon: view
View_Create_Email_Templates:
icon: plus
campaigns:
View_Create_Email_Templates:
icon: plus
View_Email_Templates:
icon: view
Setup_Email:
icon: email
View_Diagnostics:
icon: view
Create_Person_Form:
icon: person
leads:
Create_Lead_Vcard:
icon: plus
inbound-email:
Setup_Email:
icon: email
schedulers:
Schedulers:
icon: view
address-cache:
Createjjwg_Address_Cache:
icon: plus
jjwg_Address_Cache:
icon: view
maps:
List_Maps:
icon: view
Quick_Radius_Map:
icon: view
security-groups:
Create_Security_Group:
icon: plus
Security_Groups:
icon: view
Security_Suite_Settings:
icon: padlock
Role_Management:
icon: view
acl-roles:
Role_Management:
icon: view
connectors:
icon_Connectors:
icon: view
icon_ConnectorConfig_16:
icon: view
icon_ConnectorEnable_16:
icon: view
icon_ConnectorMap_16:
icon: view
configurator:
Administration:
icon: view
Leads:
icon: view
help:
Administration:
icon: view
Accounts:
icon: view
Opportunities:
icon: view
Cases:
icon: view
Notes:
icon: view
Calls:
icon: view
Emails:
icon: view
Meetings:
icon: view
Tasks:
icon: view
project:
Resource_Chart:
icon: piechart
View_Project_Tasks:
icon: view
project-task:
View_Project_Tasks:
icon: view
roles:
Role_Management:
icon: view
users:
Create_Group_User:
icon: plus
Create_Security_Group:
icon: plus
Security_Groups:
icon: view
Role_Management:
icon: view
Security_Suite_Settings:
icon: view

View file

@ -1,7 +1,7 @@
import {NgModule} from '@angular/core';
import {Routes, RouterModule} from '@angular/router';
import {ClassicViewUiComponent} from '@components/classic-view/classic-view.component';
import {ClassicViewResolver} from '@services/classic-view/classic-view.resolver';
import {ClassicViewResolver} from "@services/classic-view/classic-view.resolver";
import {BaseMetadataResolver} from '@services/metadata/base-metadata.resolver';
import {AuthGuard} from '../services/auth/auth-guard.service';
import {ListComponent} from '@views/list/list.component';
@ -10,23 +10,37 @@ const routes: Routes = [
{
path: 'Listview',
component: ListComponent,
resolve: {view: BaseMetadataResolver}
resolve: {
metadata: BaseMetadataResolver
},
},
{
path: 'Login',
loadChildren: () => import('../components/login/login.module').then(m => m.LoginUiModule),
resolve: {view: BaseMetadataResolver}
resolve: {metadata: BaseMetadataResolver},
data: {
load: {
navigation: false,
languageStrings: ['appStrings']
}
}
},
{
path: 'Home',
loadChildren: () => import('../components/home/home.module').then(m => m.HomeUiModule),
resolve: {
metadata: BaseMetadataResolver,
}
},
{
path: ':module',
component: ClassicViewUiComponent,
canActivate: [AuthGuard],
runGuardsAndResolvers: 'always',
resolve: {view: ClassicViewResolver},
resolve: {
metadata: BaseMetadataResolver,
view: ClassicViewResolver
},
data: {
reuseRoute: false
}
@ -36,7 +50,10 @@ const routes: Routes = [
component: ClassicViewUiComponent,
canActivate: [AuthGuard],
runGuardsAndResolvers: 'always',
resolve: {view: ClassicViewResolver},
resolve: {
metadata: BaseMetadataResolver,
view: ClassicViewResolver
},
data: {
reuseRoute: false
}
@ -46,7 +63,10 @@ const routes: Routes = [
component: ClassicViewUiComponent,
canActivate: [AuthGuard],
runGuardsAndResolvers: 'always',
resolve: {view: ClassicViewResolver},
resolve: {
metadata: BaseMetadataResolver,
view: ClassicViewResolver
},
data: {
reuseRoute: false
}

View file

@ -1,6 +1,6 @@
import {Component, ViewChild, ViewContainerRef, OnInit} from '@angular/core';
import {Event, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Router} from '@angular/router';
import {AppState, AppStateFacade} from "@base/facades/app-state.facade";
import {AppState, AppStateFacade} from "@base/facades/app-state/app-state.facade";
import {Observable} from "rxjs";
@Component({

View file

@ -8,7 +8,7 @@
<div class="form-group row" *ngIf="vm.showLanguages">
<label class="col-sm-3 ml-2 col-form-label" for="languages">{{vm.appStrings['LBL_LANGUAGE']}}:</label>
<div class="select col-sm-8 p-0 inner-addon left-addon">
<select #languageSelect id="languages" (change)="languageFacade.updateLanguage(languageSelect.value)">
<select #languageSelect id="languages" (change)="languageFacade.changeLanguage(languageSelect.value)">
<option [value]="item.key"
*ngFor="let item of vm.systemConfigs['languages'].items | keyvalue">{{item.value}}</option>
</select>
@ -67,10 +67,9 @@
</div>
</div>
</div>
</form>
<!-- End of login form section -->
<!-- End of login form section -->
</div>

View file

@ -6,7 +6,11 @@ import {HttpClientTestingModule, HttpTestingController} from '@angular/common/ht
import {LoginUiComponent} from './login.component';
import {ApiService} from '../../services/api/api.service';
import {LanguageFacade} from '@base/facades/language/language.facade';
import {languageFacadeMock} from '@base/facades/language/language.facade.spec.mock';
import {ApolloTestingModule} from 'apollo-angular/testing';
import {systemConfigFacadeMock} from '@base/facades/system-config/system-config.facade.spec.mock';
import {SystemConfigFacade} from '@base/facades/system-config/system-config.facade';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
describe('LoginComponent', () => {
@ -20,10 +24,13 @@ describe('LoginComponent', () => {
RouterTestingModule,
HttpClientTestingModule,
FormsModule,
ApolloTestingModule,
BrowserAnimationsModule
],
declarations: [LoginUiComponent]
declarations: [LoginUiComponent],
providers: [
{provide: SystemConfigFacade, useValue: systemConfigFacadeMock},
{provide: LanguageFacade, useValue: languageFacadeMock},
],
})
.compileComponents();
}));
@ -34,7 +41,7 @@ describe('LoginComponent', () => {
fixture.detectChanges();
});
it(`should create`, async(inject([HttpTestingController],
it('should create', async(inject([HttpTestingController],
(router: RouterTestingModule, http: HttpTestingController, api: ApiService) => {
expect(component).toBeTruthy();
})));

View file

@ -6,10 +6,10 @@ import {LoginResponseModel} from '../../services/auth/login-response-model';
import {MessageService} from '../../services/message/message.service';
import {ApiService} from '../../services/api/api.service';
import {SystemConfigFacade, SystemConfigMap} from '@services/metadata/configs/system-config.facade';
import {SystemConfigFacade, SystemConfigMap} from '@base/facades/system-config/system-config.facade';
import {combineLatest, Observable} from 'rxjs';
import {LanguageFacade, LanguageStringMap} from '@base/facades/language.facade';
import {LanguageFacade, LanguageStringMap} from '@base/facades/language/language.facade';
import {map} from 'rxjs/operators';
import {transition, trigger, useAnimation} from '@angular/animations';
import {fadeIn} from 'ng-animate';
@ -36,7 +36,7 @@ export class LoginUiComponent {
cardState = 'front';
systemConfigs$: Observable<SystemConfigMap> = this.systemConfigFacade.configs$;
appStrings$: Observable<LanguageStringMap> = this.languageFacade.languageStrings$;
appStrings$: Observable<LanguageStringMap> = this.languageFacade.appStrings$;
vm$ = combineLatest([this.systemConfigs$, this.appStrings$]).pipe(
map(([systemConfigs, appStrings]) => {

View file

@ -1,15 +1,27 @@
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 {ActionLinkModel} from './action-link-model';
import {CurrentUserModel} from './current-user-model';
import {AllMenuModel} from './all-menu-model';
import {LogoModel} from '../logo/logo-model';
import {NavbarModuleMap} from '@base/facades/navigation/navigation.facade';
import {LanguageListStringMap, LanguageStringMap} from '@base/facades/language/language.facade';
import {MenuItem} from '@components/navbar/navbar.abstract';
export interface NavbarModel {
authenticated: boolean;
logo: LogoModel;
useGroupTabs: boolean;
globalActions: Array<ActionLinkModel>;
currentUser: CurrentUserModel;
all: AllMenuModel;
menu: any;
buildMenu(items: any, menuItemThreshold: number): void;
authenticated: boolean;
logo: LogoModel;
useGroupTabs: boolean;
globalActions: ActionLinkModel[];
currentUser: CurrentUserModel;
all: AllMenuModel;
menu: MenuItem[];
resetMenu(): void;
buildMenu(
items: string[],
modules: NavbarModuleMap,
appStrings: LanguageStringMap,
modStrings: LanguageListStringMap,
appListStrings: LanguageListStringMap,
menuItemThreshold: number): void;
}

View file

@ -1,103 +1,171 @@
import { NavbarModel } from './navbar-model';
import { LogoAbstract } from '../logo/logo-abstract';
import {NavbarModel} from './navbar-model';
import {LogoAbstract} from '../logo/logo-abstract';
import {NavbarModule, NavbarModuleMap} from "@base/facades/navigation/navigation.facade";
import {LanguageListStringMap, LanguageStringMap} from '@base/facades/language/language.facade';
import {CurrentUserModel} from './current-user-model';
import {ActionLinkModel} from './action-link-model';
export interface RecentRecordsMenuItem {
summary: string;
url: string;
}
export interface MenuItem {
link: {
label: string;
url: string;
route?: string;
params?: { [key: string]: string; }
};
icon: string;
submenu: MenuItem[];
recentRecords?: RecentRecordsMenuItem[];
}
const ROUTE_PREFIX = './#';
export class NavbarAbstract implements NavbarModel {
authenticated = true;
logo = new LogoAbstract();
useGroupTabs = true;
globalActions = [
{
link: {
url: '',
label: 'Employees'
}
},
{
link: {
url: '',
label: 'Admin'
}
},
{
link: {
url: '',
label: 'Support Forums'
}
},
{
link: {
url: '',
label: 'About'
}
}
];
currentUser = {
id: '1',
name: 'Will Rennie',
};
all = {
modules: [],
extra: [],
};
menu = [];
public buildMenu(items: {}, threshold: number): void {
const navItems = [];
const moreItems = [];
if (!items || Object.keys(items).length === 0) {
this.menu = navItems;
this.all.extra = moreItems;
}
let count = 0;
Object.keys(items).forEach(module => {
if (count <= threshold) {
navItems.push(this.buildMenuItem(module, items[module]));
} else {
moreItems.push(this.buildMenuItem(module, items[module]));
}
count++;
});
this.menu = navItems;
this.all.modules = moreItems;
}
public buildMenuItem(module: string, label: string): any {
return {
link: { label, url: `./#/${module}/index` }, icon: 'home_page',
submenu:
[
{
link: {
label: `Create ${label}`,
url: `./#/${module}/edit`
},
icon: 'plus',
submenu: []
},
{
link: {
label: `View ${label}`,
url: `./#/${module}/list`
},
icon: 'view',
submenu: []
},
{
link: {
label: `Import ${label}`,
url: `./#/${module}/import`
},
icon: 'upload',
submenu: []
},
]
authenticated = true;
logo = new LogoAbstract();
useGroupTabs = false;
globalActions: ActionLinkModel[] = [
{
link: {
url: '',
label: 'Employees'
}
},
{
link: {
url: '',
label: 'Admin'
}
},
{
link: {
url: '',
label: 'Support Forums'
}
},
{
link: {
url: '',
label: 'About'
}
}
];
currentUser: CurrentUserModel = {
id: '1',
name: 'Will Rennie',
};
}
all = {
modules: [],
extra: [],
};
menu: MenuItem[] = [];
public resetMenu() {
this.menu = [];
this.all.modules = [];
}
public buildMenu(items: string[],
modules: NavbarModuleMap,
appStrings: LanguageStringMap,
modStrings: LanguageListStringMap,
appListStrings: LanguageListStringMap,
threshold: number): void {
const navItems = [];
const moreItems = [];
if (!items || items.length === 0) {
this.menu = navItems;
this.all.extra = moreItems;
return;
}
let count = 0;
items.forEach((module: string) => {
if (module === 'home') {
return;
}
if (count <= threshold) {
navItems.push(this.buildMenuItem(module, modules[module], appStrings, modStrings, appListStrings));
} else {
moreItems.push(this.buildMenuItem(module, modules[module], appStrings, modStrings, appListStrings));
}
count++;
});
this.menu = navItems;
this.all.modules = moreItems;
this.all.extra = moreItems;
}
public buildMenuItem(
module: string,
moduleInfo: NavbarModule,
appStrings: LanguageStringMap,
modStrings: LanguageListStringMap,
appListStrings: LanguageListStringMap): MenuItem {
let moduleUrl = moduleInfo.defaultRoute;
let moduleRoute = null;
if (moduleUrl.startsWith(ROUTE_PREFIX)) {
moduleRoute = moduleUrl.replace(ROUTE_PREFIX, '');
moduleUrl = null;
}
const moduleLabel = moduleInfo.labelKey;
const menuItem = {
link: {
label: (appListStrings && appListStrings.moduleList[moduleInfo.labelKey]) || moduleLabel,
url: moduleUrl,
route: moduleRoute,
params: null
},
icon: '',
submenu: []
};
moduleInfo.menu.forEach((subMenu) => {
let label = modStrings[module][subMenu.labelKey];
if (!label) {
label = appStrings[subMenu.labelKey];
}
let actionUrl = subMenu.url;
let actionRoute = null;
let actionParams = null;
if (actionUrl.startsWith(ROUTE_PREFIX)) {
actionRoute = actionUrl.replace(ROUTE_PREFIX, '');
actionUrl = null;
if (subMenu.params) {
actionParams = subMenu.params;
}
}
menuItem.submenu.push({
link: {
label,
url: actionUrl,
route: actionRoute,
params: actionParams
},
icon: subMenu.icon,
submenu: []
});
});
return menuItem;
}
}

View file

@ -1,15 +1,15 @@
<!-- Start of main navbar section -->
<div class="top-panel fixed-top">
<div class="top-panel fixed-top" *ngIf="(vm$ | async) as vm">
<!-- Start of empty navbar section until data is loaded -->
<!-- Start of empty navbar section until data is loaded -->
<ng-template [ngIf]="!loaded">
<nav class="navbar navbar-expand-lg">
<div class="navbar-collapse collapse order-4 order-md-0 collapsenav">
<ul class="navbar-nav">
<li class="top-nav nav-item">&nbsp;
</li>
</ul>
<ng-template [ngIf]="!loaded">
<nav class="navbar navbar-expand-lg">
<div class="navbar-collapse collapse order-4 order-md-0 collapsenav">
<ul class="navbar-nav">
<li class="top-nav nav-item">&nbsp;
</li>
</ul>
</div>
</nav>
</ng-template>
@ -48,21 +48,21 @@
</ng-template>
<ng-template [ngIf]="mainNavLink">
<li class="" *ngFor="let item of navbar.menu" ngbDropdownItem>
<a class="mobile-nav-link">{{ vm.appStringsList[item.link.label] }}</a>
<a class="mobile-nav-link">{{ item.link.label }}</a>
<svg-icon class="sicon-xs mobile-nav-arrow" src="public/themes/suite8/images/arrow_right_filled.svg"
(click)="changeSubNav($event, item)"></svg-icon>
</li>
</ng-template>
<ng-template [ngIf]="mobileSubNav">
<li class="" *ngFor="let sub of submenu" ngbDropdownItem>
<a class="mobile-nav-link action-link">
{{ vm.appStringsList[sub.link.label] }}
</a>
<a class="mobile-nav-link action-link">
{{ sub.link.label }}
</a>
<ul *ngIf="sub.submenu.length" class="">
<li *ngFor="let subitem of sub.submenu" class="nav-item">
<a class="mobile-nav-link action-link" href="{{ subitem.link.url }}">
{{ vm.appStringsList[subitem.link.label] }}
</a>
<a class="mobile-nav-link action-link" href="{{ subitem.link.url }}">
{{ subitem.link.label }}
</a>
</li>
<ng-template [ngIf]="sub.recentRecords.length">
<h4 class="recently-viewed-header">RECENTLY VIEWED</h4>
@ -130,88 +130,93 @@
<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.url }}">
{{ vm.appStringsList[item.link.label] }}
{{ 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">
{{ vm.appStringsList[sub.link.label] }}
</a>
<a class="nav-link action-link dropdown-item dropdown-toggle" *ngIf="sub.submenu.length"
(click)="subItemCollapse = !subItemCollapse">
{{ vm.appStringsList[sub.link.label] }}
</a>
<a class="nav-link action-link" *ngIf="!sub.submenu.length" [href]="sub.link.url">
{{ 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 }}">
<svg-icon src="{{ subitem.link.iconRef.resolved }}.svg">
</svg-icon>
{{ vm.appStringsList[subitem.link.label] }}
</a>
<a class="nav-link action-link" href="{{ subitem.link.url }}">
<svg-icon *ngIf="subitem.icon" src="public/themes/suite8/images/{{ subitem.icon }}.svg">
</svg-icon>
{{ subitem.link.label }}
</a>
</li>
<ng-template [ngIf]="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.moduleName }}/{{ rec.itemId }}">{{ rec.itemSummary }}</a>
</li>
</ng-template>
<ng-template [ngIf]="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>
<ng-template [ngIf]="item.recentRecords && item.recentRecords.length">
<h4 class="recently-viewed-header">RECENTLY VIEWED</h4>
<li *ngFor="let recentRecord of item.recentRecords" class="nav-item">
<a class="nav-link action-link" href="#">{{ recentRecord.itemSummary }}</a>
</li>
</ng-template>
</li>
</ul>
</ul>
<!-- Navbar with non-grouped tabs -->
<!-- Navbar with non-grouped tabs -->
<ul *ngIf="!navbar.useGroupTabs" class="navbar-nav">
<li class="top-nav nav-item dropdown non-grouped" *ngFor="let item of navbar.all.modules">
<ul *ngIf="!navbar.useGroupTabs" class="navbar-nav">
<li class="top-nav nav-item dropdown non-grouped" *ngFor="let item of navbar.menu">
<!-- TODO: Implement a cleaner solution than hard coded ngIf -->
<!-- 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 }}">{{ item.link.label }}
</a>
</span>
<div aria-labelledby="navbarDropdownMenuLink" class="dropdown-menu submenu">
<li class="nav-item" *ngFor="let sub of item.submenu">
<a class="nav-link action-link" href="{{ sub.link.url }}">
<svg-icon src="{{ sub.link.iconRef.resolved }}.svg"></svg-icon>
{{ vm.appStringsList[sub.link.label] }}
</a>
</li>
<ng-template [ngIf]="item.recentRecords && item.recentRecords.length">
<h4 class="recently-viewed-header">RECENTLY VIEWED</h4>
<li *ngFor="let recentRecord of item.recentRecords" class="nav-item">
<a class="nav-link action-link" href="#">{{ recentRecord.itemSummary }}</a>
</li>
</ng-template>
</div>
</ng-template>
</li>
</ul>
<ul *ngIf="!navbar.useGroupTabs" class="navbar-nav">
<li class="top-nav nav-item dropdown non-grouped">
<a class="nav-link-nongrouped dropdown-toggle">More</a>
<div aria-labelledby="navbarDropdownMenuLink" class="dropdown-menu more-menu submenu">
<li class="nav-item" *ngFor="let item of navbar.all.modules">
<a class="nav-link action-link" href="{{ item.link.url }}">{{ vm.appStringsList[item.link.label] }}</a>
</li>
<li class="nav-item" *ngFor="let item of navbar.all.extra">
<a class="nav-link action-link" href="{{ item.link.url }}">{{ vm.appStringsList[item.link.label] }}</a>
</li>
</div>
</li>
</ul>
<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">
<svg-icon *ngIf="sub.icon"
src="public/themes/suite8/images/{{ sub.icon }}.svg"></svg-icon>
{{ 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>
</li>
</ul>
<ul *ngIf="!navbar.useGroupTabs && 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">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 class="nav-item" *ngFor="let item of navbar.all.extra">
<a class="nav-link action-link" [href]="item.link.url"
[routerLink]="item.link.route">{{ item.link.label }}</a>
</div>
</div>
</li>
</ul>
</div>
<div class="global-links" ngbDropdown>
<ul class="navbar-nav">

View file

@ -6,77 +6,54 @@ import {HttpClientTestingModule, HttpTestingController} from '@angular/common/ht
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
import {NavbarUiComponent} from './navbar.component';
import {of} from 'rxjs';
import {Metadata} from '@services/metadata/metadata.service';
import {NavigationFacade} from '@base/facades/navigation/navigation.facade';
import {LanguageFacade} from '@base/facades/language/language.facade';
import {navigationMock} from '@base/facades/navigation/navigation.facade.spec.mock';
import {languageFacadeMock} from '@base/facades/language/language.facade.spec.mock';
describe('NavbarUiComponent', () => {
describe('Test with mock service', () => {
let component: NavbarUiComponent;
let fixture: ComponentFixture<NavbarUiComponent>;
let service: Metadata;
const navigationMockData = {
navbar: {
NonGroupedTabs: {
contacts: 'Contacts',
},
groupedTabs: {
Sales: {
modules: {
Accounts: 'Accounts',
}
},
},
userActionMenu: [
{
label: 'Profile',
url: 'index.php?module=Users&action=EditView&record=1',
submenu: []
},
{
label: 'Employees',
url: 'index.php?module=Employees&action=index',
submenu: []
},
],
}
};
// Create a fake TwainService object with a 'getQuote()' spy
const metadata = jasmine.createSpyObj('Metadata', ['getNavigation']);
// Make the spy return a synchronous Observable with the test data
const getMetadata = metadata.getNavigation.and.returnValue(of(navigationMockData.navbar));
beforeEach(() => {
TestBed.configureTestingModule({
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [RouterTestingModule, HttpClientTestingModule, NgbModule],
providers: [{provide: Metadata, useValue: metadata}],
imports: [
RouterTestingModule,
HttpClientTestingModule,
NgbModule
],
providers: [
{provide: NavigationFacade, useValue: navigationMock},
{provide: LanguageFacade, useValue: languageFacadeMock},
],
declarations: [NavbarUiComponent]
}).compileComponents();
service = TestBed.get(Metadata);
fixture = TestBed.createComponent(NavbarUiComponent);
component = fixture.componentInstance;
});
it('should create', () => {
fixture.detectChanges(); // onInit()
fixture.detectChanges();
expect(component).toBeTruthy();
});
/*
it('should get metadata', async (() => {
fixture.detectChanges(); // onInit()
fixture.whenStable().then(() => {
expect(component.navigationMetadata).toEqual(jasmine.objectContaining(navigationMockData.navbar));
expect(component.navbar).toEqual(jasmine.objectContaining(navigationMockData.navbar));
});
component.ngOnInit();
}));
*/
});
});

View file

@ -1,98 +1,124 @@
import {Component, OnInit, HostListener} from '@angular/core';
import {Router, NavigationEnd} from '@angular/router';
import {ApiService} from '../../services/api/api.service';
import {NavbarModel} from './navbar-model';
import {NavbarAbstract} from './navbar.abstract';
import { Subscription } from 'rxjs';
import {combineLatest, Observable} from 'rxjs';
import { Metadata } from '../../services/metadata/metadata.service';
import {NavbarModuleMap, NavigationFacade} from '@base/facades/navigation/navigation.facade';
import {LanguageFacade, LanguageListStringMap, LanguageStringMap} from '@base/facades/language/language.facade';
import {map} from 'rxjs/operators';
@Component({
selector: 'scrm-navbar-ui',
templateUrl: './navbar.component.html',
styleUrls: []
selector: 'scrm-navbar-ui',
templateUrl: './navbar.component.html',
styleUrls: []
})
export class NavbarUiComponent implements OnInit {
private navbarSubscription: Subscription;
public navigationMetadata: any;
constructor(protected metadata: Metadata, protected api: ApiService, protected router: Router) {
NavbarUiComponent.instances.push(this);
}
protected static instances: NavbarUiComponent[] = [];
protected static instances: NavbarUiComponent[] = [];
loaded = true;
loaded = true;
mainNavCollapse = true;
subItemCollapse = true;
subNavCollapse = true;
mobileNavbar = false;
mobileSubNav = false;
backLink = false;
mainNavLink = true;
parentNavLink = '';
submenu: any = [];
mainNavCollapse = true;
subItemCollapse = true;
subNavCollapse = true;
mobileNavbar = false;
mobileSubNav = false;
backLink = false;
mainNavLink = true;
parentNavLink: string = '';
submenu: any = [];
navbar: NavbarModel = new NavbarAbstract();
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$;
public changeSubNav(event: Event, parentNavItem) {
this.mobileSubNav = !this.mobileSubNav;
this.backLink = !this.backLink;
this.mainNavLink = !this.mainNavLink;
this.submenu = parentNavItem.submenu;
}
vm$ = combineLatest([this.tabs$, this.modules$, this.appStrings$, this.appListStrings$, this.modStrings$]).pipe(
map((
[
tabs,
modules,
appStrings,
appListStrings,
modStrings
]) => {
public navBackLink(event: Event) {
this.mobileSubNav = !this.mobileSubNav;
this.backLink = !this.backLink;
this.mainNavLink = !this.mainNavLink;
}
if (tabs && tabs.length > 0 &&
modules && Object.keys(modules).length > 0 &&
appStrings && Object.keys(appStrings).length > 0 &&
modStrings && Object.keys(modStrings).length > 0 &&
appListStrings && Object.keys(appListStrings).length > 0) {
this.navbar.resetMenu();
this.navbar.buildMenu(tabs, modules, appStrings, modStrings, appListStrings, this.menuItemThreshold);
}
@HostListener("window:resize", ["$event"])
onResize(event: any) {
event.target.innerWidth;
if (innerWidth <= 768) {
this.mobileNavbar = true;
} else {
this.mobileNavbar = false;
return {
tabs,
modules,
appStrings,
appListStrings,
modStrings
};
})
);
protected menuItemThreshold = 5;
constructor(protected navigationFacade: NavigationFacade,
protected languageFacade: LanguageFacade,
protected api: ApiService) {
const navbar = new NavbarAbstract();
this.setNavbar(navbar);
NavbarUiComponent.instances.push(this);
}
}
static reset() {
NavbarUiComponent.instances.forEach((navbarComponent: NavbarUiComponent) => {
navbarComponent.loaded = false;
navbarComponent.navbar = new NavbarAbstract();
});
}
protected setNavbar(navbar: NavbarModel) {
this.navbar = navbar;
this.loaded = true;
}
protected isLoaded() {
return this.loaded;
}
ngOnInit(): void {
const menuItemThreshold = 5;
/*
* TODO : this call should be moved elsewhere once
* we have the final structure using cache
*/
this.navbarSubscription = this.metadata
.getNavigation()
.subscribe((data) => {
this.navigationMetadata = data;
if (data && data.NonGroupedTabs) {
this.navbar.buildMenu(data.NonGroupedTabs, menuItemThreshold);
}
static reset() {
NavbarUiComponent.instances.forEach((navbarComponent: NavbarUiComponent) => {
navbarComponent.loaded = false;
navbarComponent.navbar = new NavbarAbstract();
});
}
const navbar = new NavbarAbstract();
this.setNavbar(navbar);
window.dispatchEvent(new Event('resize'));
}
public changeSubNav(event: Event, parentNavItem) {
this.mobileSubNav = !this.mobileSubNav;
this.backLink = !this.backLink;
this.mainNavLink = !this.mainNavLink;
this.submenu = parentNavItem.submenu;
}
public navBackLink(event: Event) {
this.mobileSubNav = !this.mobileSubNav;
this.backLink = !this.backLink;
this.mainNavLink = !this.mainNavLink;
}
@HostListener('window:resize', ['$event'])
onResize(event: any) {
const innerWidth = event.target.innerWidth;
if (innerWidth <= 768) {
this.mobileNavbar = true;
} else {
this.mobileNavbar = false;
}
}
ngOnInit(): void {
const navbar = new NavbarAbstract();
this.setNavbar(navbar);
window.dispatchEvent(new Event('resize'));
}
protected setNavbar(navbar: NavbarModel) {
this.navbar = navbar;
this.loaded = true;
}
protected isLoaded() {
return this.loaded;
}
}

View file

@ -9,6 +9,8 @@ import {LogoutUiModule} from '../logout/logout.module';
import {ActionBarUiModule} from '../action-bar/action-bar.module';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
import {AngularSvgIconModule} from 'angular-svg-icon';
import {RouterModule} from '@angular/router';
@NgModule({
declarations: [NavbarUiComponent],
@ -20,7 +22,8 @@ import {AngularSvgIconModule} from 'angular-svg-icon';
LogoutUiModule,
ActionBarUiModule,
NgbModule,
AngularSvgIconModule
AngularSvgIconModule,
RouterModule
]
})
export class NavbarUiModule {

View file

@ -0,0 +1,4 @@
import {AppStateFacade} from '@base/facades/app-state/app-state.facade';
export const appStateFacadeMock = new AppStateFacade();

View file

@ -0,0 +1,24 @@
import {getTestBed, TestBed} from '@angular/core/testing';
import {AppStateFacade} from '@base/facades/app-state/app-state.facade';
import {appStateFacadeMock} from '@base/facades/app-state/app-state.facade.spec.mock';
describe('AppState Facade', () => {
let injector: TestBed;
const service: AppStateFacade = appStateFacadeMock;
beforeEach(() => {
TestBed.configureTestingModule({});
injector = getTestBed();
});
it('#updateLoading',
(done: DoneFn) => {
service.updateLoading(true);
service.loading$.subscribe(loading => {
expect(loading).toEqual(true);
done();
});
});
});

View file

@ -1,141 +0,0 @@
import {Injectable} from '@angular/core';
import {BehaviorSubject, Observable} from 'rxjs';
import {map, distinctUntilChanged, tap, shareReplay, first} from 'rxjs/operators';
import {RecordGQL} from '@services/api/graphql-api/api.record.get';
import {AppStateFacade} from "@base/facades/app-state.facade";
export interface LanguageStringMap {
[key: string]: string;
}
export interface LoadedLanguageStringMap {
[key: string]: LanguageStringMap;
}
export interface LanguageState {
languageStrings: LanguageStringMap;
languageType: string;
loaded?: LoadedLanguageStringMap;
}
let internalState: LanguageState = {
languageStrings: {},
languageType: 'en_us',
loaded: {}
};
const cache: { [key: string]: Observable<any> } = {};
@Injectable({
providedIn: 'root',
})
export class LanguageFacade {
protected store = new BehaviorSubject<LanguageState>(internalState);
protected state$ = this.store.asObservable();
protected resourceName = 'appStrings';
protected fieldsMetadata = {
fields: [
'id',
'_id',
'items'
]
};
/**
* Public long-lived observable streams
*/
languageStrings$ = this.state$.pipe(map(state => state.languageStrings), distinctUntilChanged());
languageType$ = this.state$.pipe(map(state => state.languageType), distinctUntilChanged());
constructor(private recordGQL: RecordGQL, private appStateFacade: AppStateFacade) {
}
/**
* Public Api
*/
/**
* Update the AppStrings to the given language
*
* @param languageType
*/
public updateLanguage(languageType: string): void {
this.loadAppStrings(languageType).subscribe();
}
/**
* Initial AppStrings Load for given if not cached and update state.
* Returns observable to be used in resolver if needed
*
* @param languageType
* @returns Observable
*/
public loadAppStrings(languageType: string): Observable<{}> {
return this.getAppStrings(languageType).pipe(
first(),
tap((languageStrings: LanguageStringMap) => {
this.updateState({...internalState, languageStrings, languageType});
})
);
}
/**
* Internal API
*/
/**
* Update internal state cache and emit from store...
*
* @param state
*/
protected updateState(state: LanguageState): void {
this.store.next(internalState = state);
}
/**
* Get AppStrings cached Observable or call the backend
*
* @param language
* @returns Observable<any>
*/
protected getAppStrings(language: string): Observable<{}> {
if (cache[language]) {
return cache[language];
}
this.appStateFacade.updateLoading(true);
cache[language] = this.fetchAppStrings(language).pipe(
shareReplay(1),
tap(() => this.appStateFacade.updateLoading(false))
);
return cache[language];
}
/**
* Fetch the App strings from the backend
*
* @param language
* @returns Observable<{}>
*/
protected fetchAppStrings(language: string): Observable<{}> {
return this.recordGQL.fetch(this.resourceName, `/api/app-strings/${language}`, this.fieldsMetadata)
.pipe(
map(({data}) => {
let items = {};
if (data.appStrings) {
items = data.appStrings.items;
}
return items;
})
);
}
}

View file

@ -0,0 +1,109 @@
import {RecordGQL} from '@services/api/graphql-api/api.record.get';
import {Observable, of} from 'rxjs';
import {shareReplay} from 'rxjs/operators';
import {LanguageFacade} from '@base/facades/language/language.facade';
import {appStateFacadeMock} from '@base/facades/app-state/app-state.facade.spec.mock';
export const languageMockData = {
appStrings: {
LBL_SEARCH_REAULTS_TITLE: 'Results',
ERR_SEARCH_INVALID_QUERY: 'An error has occurred while performing the search. Your query syntax might not be valid.',
ERR_SEARCH_NO_RESULTS: 'No results matching your search criteria. Try broadening your search.',
LBL_SEARCH_PERFORMED_IN: 'Search performed in',
LBL_EMAIL_CODE: 'Email Code:',
LBL_SEND: 'Send',
LBL_LOGOUT: 'Logout',
LBL_TOUR_NEXT: 'Next',
},
appListStrings: {
language_pack_name: 'US English',
moduleList: {
Home: 'Home',
Accounts: 'Accounts',
}
},
modStrings: {
home: {
LBL_MODULE_NAME: 'Home',
LBL_NEW_FORM_TITLE: 'New Contact',
LBL_FIRST_NAME: 'First Name:',
LBL_LAST_NAME: 'Last Name:',
LBL_LIST_LAST_NAME: 'Last Name',
LBL_PHONE: 'Phone:',
LBL_EMAIL_ADDRESS: 'Email Address:',
LBL_MY_PIPELINE_FORM_TITLE: 'My Pipeline',
LBL_PIPELINE_FORM_TITLE: 'Pipeline By Sales Stage',
LBL_RGraph_PIPELINE_FORM_TITLE: 'Pipeline By Sales Stage',
LNK_NEW_CONTACT: 'Create Contact',
LNK_NEW_ACCOUNT: 'Create Account',
LNK_NEW_OPPORTUNITY: 'Create Opportunity',
LNK_NEW_LEAD: 'Create Lead',
LNK_NEW_CASE: 'Create Case',
LNK_NEW_NOTE: 'Create Note or Attachment',
LNK_NEW_CALL: 'Log Call',
LNK_NEW_EMAIL: 'Archive Email',
LNK_NEW_MEETING: 'Schedule Meeting',
LNK_NEW_TASK: 'Create Task',
LNK_NEW_BUG: 'Report Bug',
LNK_NEW_SEND_EMAIL: 'Compose Email',
LBL_NO_ACCESS: 'You do not have access'
},
accounts: {
LBL_ID: 'ID',
LBL_NAME: 'Name:',
LBL_LIST_NAME: 'Name',
LNK_ACCOUNT_LIST: 'View Accounts',
LNK_NEW_ACCOUNT: 'Create Account',
LBL_MODULE_NAME: 'Accounts',
LBL_MODULE_TITLE: 'Accounts: Home',
LBL_MODULE_ID: 'Accounts',
LBL_NEW_FORM_TITLE: 'New Account',
},
}
};
class LanguageRecordGQLSpy extends RecordGQL {
constructor() {
super(null);
}
public fetch(module: string, id: string, metadata: { fields: string[] }): Observable<any> {
if (module === 'appStrings') {
return of({
data: {
appStrings: {
_id: 'en_us',
items: languageMockData.appStrings
}
}
}).pipe(shareReplay());
}
if (module === 'appListStrings') {
return of({
data: {
appListStrings: {
_id: 'en_us',
items: languageMockData.appListStrings
}
}
}).pipe(shareReplay());
}
if (module === 'modStrings') {
return of({
data: {
modStrings: {
_id: 'en_us',
items: languageMockData.modStrings
}
}
}).pipe(shareReplay());
}
}
}
export const languageFacadeMock = new LanguageFacade(new LanguageRecordGQLSpy(), appStateFacadeMock);

View file

@ -0,0 +1,23 @@
import {getTestBed, TestBed} from '@angular/core/testing';
import {LanguageFacade} from "@base/facades/language/language.facade";
import {languageFacadeMock, languageMockData} from "@base/facades/language/language.facade.spec.mock";
describe('Language Facade', () => {
let injector: TestBed;
const service: LanguageFacade = languageFacadeMock;
beforeEach(() => {
TestBed.configureTestingModule({});
injector = getTestBed();
});
it('#load',
(done: DoneFn) => {
service.load('en_us', languageFacadeMock.getAvailableStringsTypes()).subscribe(data => {
expect(data).toEqual(jasmine.objectContaining(languageMockData));
done();
});
});
});

View file

@ -0,0 +1,292 @@
import {Injectable} from '@angular/core';
import {BehaviorSubject, forkJoin, Observable} from 'rxjs';
import {map, distinctUntilChanged, tap, shareReplay, first} from 'rxjs/operators';
import {RecordGQL} from '@services/api/graphql-api/api.record.get';
import {AppStateFacade} from '@base/facades/app-state/app-state.facade';
export interface LanguageStringMap {
[key: string]: string;
}
export interface LanguageListStringMap {
[key: string]: string | LanguageStringMap;
}
export interface LoadedLanguageStringMap {
[key: string]: LanguageStringMap;
}
export interface LanguageState {
appStrings: LanguageStringMap;
appListStrings: LanguageListStringMap;
modStrings: LanguageListStringMap;
languageKey: string;
loaded?: LoadedLanguageStringMap;
hasChanged: boolean;
}
export interface LanguageCache {
[key: string]: {
[key: string]: Observable<any>;
};
}
let internalState: LanguageState = {
appStrings: {},
appListStrings: {},
modStrings: {},
languageKey: 'en_us',
loaded: {},
hasChanged: false
};
const loadedLanguages = {};
const cache: LanguageCache = {
appStrings: {},
appListStrings: {},
modStrings: {},
};
@Injectable({
providedIn: 'root',
})
export class LanguageFacade {
protected store = new BehaviorSubject<LanguageState>(internalState);
protected state$ = this.store.asObservable();
protected config = {
appStrings: {
fetch: 'fetchAppStrings',
resourceName: 'appStrings',
metadata: {
fields: [
'id',
'_id',
'items'
]
}
},
appListStrings: {
fetch: 'fetchAppListStrings',
resourceName: 'appListStrings',
metadata: {
fields: [
'id',
'_id',
'items'
]
}
},
modStrings: {
fetch: 'fetchModStrings',
resourceName: 'modStrings',
metadata: {
fields: [
'id',
'_id',
'items'
]
}
},
};
/**
* Public long-lived observable streams
*/
appStrings$ = this.state$.pipe(map(state => state.appStrings), distinctUntilChanged());
appListStrings$ = this.state$.pipe(map(state => state.appListStrings), distinctUntilChanged());
modStrings$ = this.state$.pipe(map(state => state.modStrings), distinctUntilChanged());
languageKey$ = this.state$.pipe(map(state => state.languageKey), distinctUntilChanged());
constructor(private recordGQL: RecordGQL, private appStateFacade: AppStateFacade) {
}
/**
* Public Api
*/
/**
* Update the language strings toe the given language
*
* @param languageKey
*/
public changeLanguage(languageKey: string): void {
const types = [];
Object.keys(loadedLanguages).forEach(type => loadedLanguages[type] && types.push(type));
internalState.hasChanged = true;
this.appStateFacade.updateLoading(true);
this.load(languageKey, types).pipe(
tap(() => this.appStateFacade.updateLoading(false))
).subscribe();
}
/**
* Get all available string types
*
* @returns Observable
*/
public getAvailableStringsTypes(): string[] {
return Object.keys(this.config);
}
/**
* Returns whether the language has changed manually
*
* @returns bool
*/
public hasLanguageChanged(): boolean {
return internalState.hasChanged;
}
/**
* Returns the currently active language
*
* @returns string
*/
public getCurrentLanguage(): string {
return internalState.languageKey;
}
/**
* 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
*/
public load(languageKey: string, types: string[]): Observable<{}> {
const streams$ = {};
types.forEach(type => streams$[type] = this.getStrings(languageKey, type));
return forkJoin(streams$).pipe(
first(),
tap(result => {
const stateUpdate = {...internalState, languageKey};
types.forEach(type => {
stateUpdate[type] = result[type];
loadedLanguages[type] = true;
});
this.updateState(stateUpdate);
})
);
}
/**
* Internal API
*/
/**
* Update internal state cache and emit from store...
*
* @param state
*/
protected updateState(state: LanguageState): void {
this.store.next(internalState = state);
}
/**
* Get given $type of strings Observable from cache or call the backend
*
* @param language
* @param type
* @returns Observable<any>
*/
protected getStrings(language: string, type: string): Observable<{}> {
const stringsCache = cache[type];
const fetchMethod = this.config[type].fetch;
if (stringsCache[language]) {
return stringsCache[language];
}
stringsCache[language] = this[fetchMethod](language).pipe(
shareReplay(1),
);
return stringsCache[language];
}
/**
* Fetch the App strings from the backend
*
* @param language
* @returns Observable<{}>
*/
protected fetchAppStrings(language: string): Observable<{}> {
const resourceName = this.config.appStrings.resourceName;
const fields = this.config.appStrings.metadata;
return this.recordGQL.fetch(resourceName, `/api/app-strings/${language}`, fields)
.pipe(
map(({data}) => {
let items = {};
if (data.appStrings) {
items = data.appStrings.items;
}
return items;
})
);
}
/**
* Fetch the App list strings from the backend
*
* @param language
* @returns Observable<{}>
*/
protected fetchAppListStrings(language: string): Observable<{}> {
const resourceName = this.config.appListStrings.resourceName;
const fields = this.config.appListStrings.metadata;
return this.recordGQL.fetch(resourceName, `/api/app-list-strings/${language}`, fields)
.pipe(
map(({data}) => {
let items = {};
if (data.appListStrings) {
items = data.appListStrings.items;
}
return items;
})
);
}
/**
* Fetch the Mod strings from the backend
*
* @param language
* @returns Observable<{}>
*/
protected fetchModStrings(language: string): Observable<{}> {
const resourceName = this.config.modStrings.resourceName;
const fields = this.config.modStrings.metadata;
return this.recordGQL.fetch(resourceName, `/api/mod-strings/${language}`, fields)
.pipe(
map(({data}) => {
let items = {};
if (data.modStrings) {
items = data.modStrings.items;
}
return items;
})
);
}
}

View file

@ -0,0 +1,214 @@
import {NavigationFacade} from '@base/facades/navigation/navigation.facade';
import {RecordGQL} from '@services/api/graphql-api/api.record.get';
import {Observable, of} from 'rxjs';
import {shareReplay} from 'rxjs/operators';
export const navigationMockData = {
navbar: {
groupedTabs: [
{
name: 'LBL_TABGROUP_SALES',
labelKey: 'LBL_TABGROUP_SALES',
modules: [
'accounts',
'home',
]
},
{
name: 'LBL_TABGROUP_MARKETING',
labelKey: 'LBL_TABGROUP_MARKETING',
modules: [
'accounts',
'home',
]
},
{
name: 'LBL_TABGROUP_SUPPORT',
labelKey: 'LBL_TABGROUP_SUPPORT',
modules: [
'accounts',
'home'
]
},
],
tabs: [
'home',
'accounts',
],
userActionMenu: [
{
name: 'profile',
labelKey: 'LBL_PROFILE',
url: 'index.php?module=Users&action=EditView&record=1',
icon: ''
},
{
name: 'employees',
labelKey: 'LBL_EMPLOYEES',
url: 'index.php?module=Employees&action=index',
icon: ''
},
{
name: 'training',
labelKey: 'LBL_TRAINING',
url: 'https://community.suitecrm.com',
icon: ''
},
{
name: 'about',
labelKey: 'LNK_ABOUT',
url: 'index.php?module=Home&action=About',
icon: ''
},
{
name: 'logout',
labelKey: 'LBL_LOGOUT',
url: 'index.php?module=Users&action=Logout',
icon: ''
}
],
modules: {
home: {
path: 'home',
defaultRoute: './#/home/index',
name: 'home',
labelKey: 'Home',
menu: []
},
accounts: {
path: 'accounts',
defaultRoute: './#/accounts/index',
name: 'accounts',
labelKey: 'Accounts',
menu: [
{
name: 'Create',
labelKey: 'LNK_NEW_ACCOUNT',
url: './#/accounts/edit',
icon: 'plus'
},
{
name: 'List',
labelKey: 'LNK_ACCOUNT_LIST',
url: './#/accounts/index',
icon: 'view'
},
{
name: 'Import',
labelKey: 'LNK_IMPORT_ACCOUNTS',
url: './#/import/step1',
icon: 'download'
}
]
},
contacts: {
path: 'contacts',
defaultRoute: './#/contacts/index',
name: 'contacts',
labelKey: 'Contacts',
menu: [
{
name: 'Create',
labelKey: 'LNK_NEW_CONTACT',
url: './#/contacts/edit',
icon: 'plus'
},
{
name: 'Create_Contact_Vcard',
labelKey: 'LNK_IMPORT_VCARD',
url: './#/contacts/importvcard',
icon: 'plus'
},
{
name: 'List',
labelKey: 'LNK_CONTACT_LIST',
url: './#/contacts/index',
icon: 'view'
},
{
name: 'Import',
labelKey: 'LNK_IMPORT_CONTACTS',
url: './#/import/step1',
icon: 'download'
}
]
},
opportunities: {
path: 'opportunities',
defaultRoute: './#/opportunities/index',
name: 'opportunities',
labelKey: 'Opportunities',
menu: [
{
name: 'Create',
labelKey: 'LNK_NEW_OPPORTUNITY',
url: './#/opportunities/edit',
icon: 'plus'
},
{
name: 'List',
labelKey: 'LNK_OPPORTUNITY_LIST',
url: './#/opportunities/index',
icon: 'view'
},
{
name: 'Import',
labelKey: 'LNK_IMPORT_OPPORTUNITIES',
url: './#/import/step1',
icon: 'download'
}
]
},
leads: {
path: 'leads',
defaultRoute: './#/leads/index',
name: 'leads',
labelKey: 'Leads',
menu: [
{
name: 'Create',
labelKey: 'LNK_NEW_LEAD',
url: './#/leads/edit',
icon: 'plus'
},
{
name: 'Create_Lead_Vcard',
labelKey: 'LNK_IMPORT_VCARD',
url: './#/leads/importvcard',
icon: 'plus'
},
{
name: 'List',
labelKey: 'LNK_LEAD_LIST',
url: './#/leads/index',
icon: 'view'
},
{
name: 'Import',
labelKey: 'LNK_IMPORT_LEADS',
url: './#/import/step1',
icon: 'download'
}
]
},
}
}
};
class NavigationRecordGQLSpy extends RecordGQL {
constructor() {
super(null);
}
public fetch(module: string, id: string, metadata: { fields: string[] }): Observable<any> {
return of({
data: {
navbar: navigationMockData.navbar
}
}).pipe(shareReplay());
}
}
export const navigationMock = new NavigationFacade(new NavigationRecordGQLSpy());

View file

@ -0,0 +1,24 @@
import {getTestBed, TestBed} from '@angular/core/testing';
import {NavigationFacade} from './navigation.facade';
import {navigationMock, navigationMockData} from './navigation.facade.spec.mock';
describe('Navigation Facade', () => {
let injector: TestBed;
const service: NavigationFacade = navigationMock;
beforeEach(() => {
TestBed.configureTestingModule({});
injector = getTestBed();
});
it('#load',
(done: DoneFn) => {
service.load().subscribe(data => {
expect(data).toEqual(jasmine.objectContaining(navigationMockData.navbar));
done();
});
});
});

View file

@ -0,0 +1,170 @@
import {Injectable} from '@angular/core';
import {BehaviorSubject, Observable} from 'rxjs';
import {map, distinctUntilChanged, tap, shareReplay} from 'rxjs/operators';
import {RecordGQL} from '@services/api/graphql-api/api.record.get';
export interface Navigation {
tabs: string[];
groupedTabs: GroupedTab[];
modules: NavbarModuleMap;
userActionMenu: UserActionMenu[];
}
export interface NavbarModuleMap {
[key: string]: NavbarModule;
}
export interface NavbarModule {
name: string;
path: string;
defaultRoute: string;
labelKey: string;
menu: ModuleSubMenu[];
}
export interface GroupedTab {
name: string;
labelKey: string;
modules: string[];
}
export interface UserActionMenu {
name: string;
labelKey: string;
url: string;
icon: string;
}
export interface ModuleSubMenu {
name: string;
labelKey: string;
label?: string;
url: string;
params?: string;
icon: string;
}
let internalState: Navigation = {
tabs: [],
groupedTabs: [],
modules: {},
userActionMenu: []
};
let cache$: Observable<any> = null;
@Injectable({
providedIn: 'root',
})
export class NavigationFacade {
protected store = new BehaviorSubject<Navigation>(internalState);
protected state$ = this.store.asObservable();
protected resourceName = 'navbar';
protected fieldsMetadata = {
fields: [
'tabs',
'groupedTabs',
'modules',
'userActionMenu'
]
};
/**
* Public long-lived observable streams
*/
tabs$ = this.state$.pipe(map(state => state.tabs), distinctUntilChanged());
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());
constructor(private recordGQL: RecordGQL) {
}
/**
* Public Api
*/
/**
* Initial Navigation load if not cached and update state.
* Returns observable to be used in resolver if needed
*
* @returns Observable<any>
*/
public load(): Observable<any> {
return this.getNavigation().pipe(
tap(navigation => {
this.updateState({
...internalState,
tabs: navigation.tabs,
groupedTabs: navigation.groupedTabs,
userActionMenu: navigation.userActionMenu,
modules: navigation.modules
});
})
);
}
/**
* Internal API
*/
/**
* Update the state
*
* @param state
*/
protected updateState(state: Navigation) {
this.store.next(internalState = state);
}
/**
* Get Navigation cached Observable or call the backend
*
* @return Observable<any>
*/
protected getNavigation(): Observable<any> {
const user = '1';
if (cache$ == null) {
cache$ = this.fetch(user).pipe(
shareReplay(1)
);
}
return cache$;
}
/**
* Fetch the Navigation from the backend
*
* @returns Observable<any>
*/
protected fetch(userId: string): Observable<any> {
return this.recordGQL
.fetch(this.resourceName, `/api/navbars/${userId}`, this.fieldsMetadata)
.pipe(
map(({data}) => {
let navigation: Navigation = null;
if (data && data.navbar) {
navigation = {
tabs: data.navbar.tabs,
groupedTabs: data.navbar.groupedTabs,
userActionMenu: data.navbar.userActionMenu,
modules: data.navbar.modules
};
}
return navigation;
})
);
}
}

View file

@ -0,0 +1,59 @@
import {Observable, of} from 'rxjs';
import {shareReplay} from 'rxjs/operators';
import {SystemConfigFacade} from '@base/facades/system-config/system-config.facade';
import {CollectionGQL} from '@services/api/graphql-api/api.collection.get';
export const systemConfigMockData = {
systemConfigs: {
default_language: {
id: '/docroot/api/system-configs/default_language',
_id: 'default_language',
value: 'en_us',
items: []
},
passwordsetting: {
id: '/docroot/api/system-configs/passwordsetting',
_id: 'passwordsetting',
value: null,
items: {
forgotpasswordON: false
}
},
languages: {
id: '/docroot/api/system-configs/languages',
_id: 'languages',
value: null,
items: {
en_us: 'English (US)',
pt_PT: 'Português (Portugal) - pt-PT'
}
}
}
};
class SystemConfigRecordGQLSpy extends CollectionGQL {
constructor() {
super(null);
}
public fetchAll(module: string, metadata: { fields: string[] }): Observable<any> {
const data = {
data: {
systemConfigs: {
edges: []
}
}
};
Object.keys(systemConfigMockData.systemConfigs).forEach(key => {
data.data.systemConfigs.edges.push({
node: systemConfigMockData.systemConfigs[key]
});
})
return of(data).pipe(shareReplay());
}
}
export const systemConfigFacadeMock = new SystemConfigFacade(new SystemConfigRecordGQLSpy());

View file

@ -0,0 +1,24 @@
import {getTestBed, TestBed} from '@angular/core/testing';
import {SystemConfigFacade} from '@base/facades/system-config/system-config.facade';
import {systemConfigFacadeMock, systemConfigMockData} from '@base/facades/system-config/system-config.facade.spec.mock';
describe('SystemConfig Facade', () => {
let injector: TestBed;
const service: SystemConfigFacade = systemConfigFacadeMock;
beforeEach(() => {
TestBed.configureTestingModule({});
injector = getTestBed();
});
it('#load',
(done: DoneFn) => {
service.load().subscribe(data => {
expect(data).toEqual(jasmine.objectContaining(systemConfigMockData.systemConfigs));
done();
});
});
});

View file

@ -2,7 +2,7 @@ import {Injectable} from '@angular/core';
import {BehaviorSubject, Observable} from 'rxjs';
import {map, distinctUntilChanged, tap, shareReplay} from 'rxjs/operators';
import {CollectionGQL} from '../../api/graphql-api/api.collection.get';
import {CollectionGQL} from '@services/api/graphql-api/api.collection.get';
export interface SystemConfig {
id: string;

View file

@ -5,20 +5,127 @@ import {
RouterStateSnapshot
} from '@angular/router';
import {SystemConfigFacade} from '@services/metadata/configs/system-config.facade';
import {LanguageFacade} from '@base/facades/language.facade';
import {flatMap} from 'rxjs/operators';
import {SystemConfigFacade} from '@base/facades/system-config/system-config.facade';
import {LanguageFacade} from '@base/facades/language/language.facade';
import {concatAll, first, flatMap, map, mergeMap, tap, toArray} from 'rxjs/operators';
import {forkJoin, Observable} from 'rxjs';
import {NavigationFacade} from '@base/facades/navigation/navigation.facade';
@Injectable({providedIn: 'root'})
export class BaseMetadataResolver implements Resolve<any> {
constructor(private systemConfigFacade: SystemConfigFacade, private languageFacade: LanguageFacade) {
constructor(private systemConfigFacade: SystemConfigFacade,
private languageFacade: LanguageFacade,
private navigationFacade: NavigationFacade) {
}
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
return this.systemConfigFacade.load().pipe(
flatMap(configs => this.languageFacade.loadAppStrings(configs['default_language'].value))
);
const streams$: { [key: string]: Observable<any> } = {};
if (this.isToLoadNavigation(route)) {
streams$.navigation = this.navigationFacade.load();
}
if (this.isToLoadConfigs(route)) {
let configs$ = this.systemConfigFacade.load();
if (this.isToLoadLanguageStrings(route)) {
const langStrings = this.getLanguagesToLoad(route);
configs$ = configs$.pipe(
map(
configs => {
let language = configs.default_language.value;
if (this.languageFacade.hasLanguageChanged()) {
language = this.languageFacade.getCurrentLanguage();
}
return this.languageFacade.load(language, langStrings);
},
),
concatAll(),
toArray()
);
}
streams$.configs = configs$;
}
return forkJoin(streams$);
}
/**
* Get Languages to Load
*
* @param route
*/
protected getLanguagesToLoad(route: ActivatedRouteSnapshot): string[] {
let langStrings: string[] = this.languageFacade.getAvailableStringsTypes();
if (this.isToLoadNavigation(route)) {
return langStrings;
}
if (!route.data || !route.data.load) {
return [];
}
if (Array.isArray(route.data.load.languageStrings)) {
langStrings = route.data.load.languageStrings;
}
return langStrings;
}
/**
* Should load language strings. True if navigation is to load
*
* @param route
* @returns boolean
*/
protected isToLoadLanguageStrings(route: ActivatedRouteSnapshot): boolean {
if (this.isToLoadNavigation(route)) {
return true;
}
if (!route.data || !route.data.load) {
return false;
}
return Array.isArray(route.data.load.languageStrings) || route.data.load.languageStrings === true;
}
/**
* Should load navigation. If not set defaults to true
*
* @param route
* @returns boolean
*/
protected isToLoadConfigs(route: ActivatedRouteSnapshot): boolean {
if (!route.data || !route.data.load) {
return true;
}
return route.data.load.configs !== false;
}
/**
* Should load navigation, If not set defaults to true
*
* @param route
* @returns boolean
*/
protected isToLoadNavigation(route: ActivatedRouteSnapshot): boolean {
if (!route.data || !route.data.load) {
return true;
}
return route.data.load.navigation !== false;
}
}

View file

@ -1,18 +0,0 @@
import {Injectable} from '@angular/core';
import { NavigationMetadata } from './navigation/navigation.metadata.service';
/**
* Facade like service to retrieve metadata
*/
@Injectable({
providedIn: 'root',
})
export class Metadata {
constructor(private navigationMetadata: NavigationMetadata) {}
public getNavigation() {
return this.navigationMetadata.fetch();
}
}

View file

@ -1,74 +0,0 @@
import {async, ComponentFixture, getTestBed, inject, TestBed} from '@angular/core/testing';
import {Metadata} from '@services/metadata/metadata.service';
import {NavigationMetadata} from '@services/metadata/navigation/navigation.metadata.service';
import {of} from 'rxjs';
describe('Metadata Service', () => {
let injector: TestBed;
let service: Metadata;
describe('Navigation Metadata', () => {
const navigationMockData = {
navbar: {
NonGroupedTabs: {
Contacts: 'Contacts',
Accounts: 'Accounts'
},
groupedTabs: {
Sales: {
modules: {
Accounts: 'Accounts',
Contacts: 'Contacts',
}
},
},
userActionMenu: [
{
label: 'Profile',
url: 'index.php?module=Users&action=EditView&record=1',
submenu: []
},
{
label: 'Employees',
url: 'index.php?module=Employees&action=index',
submenu: []
},
],
}
};
/*
const navigationMetadata = ({
fetch() {
return of(navigationMockData.navbar);
}
} as any) as NavigationMetadata;
*/
// Create a fake TwainService object with a `getQuote()` spy
const navigationMetadata = jasmine.createSpyObj('NavigationMetadata', ['fetch']);
// Make the spy return a synchronous Observable with the test data
navigationMetadata.fetch.and.returnValue( of(navigationMockData.navbar) );
beforeEach(() => {
TestBed.configureTestingModule({
providers: [{provide: NavigationMetadata, useValue: navigationMetadata}],
});
injector = getTestBed();
service = injector.get(Metadata);
});
it('#getNavigation', (done: DoneFn) => {
service.getNavigation().subscribe(data => {
expect(data).toEqual(jasmine.objectContaining(navigationMockData.navbar));
done();
});
});
});
});

View file

@ -1,30 +0,0 @@
import {Injectable} from '@angular/core';
import {map} from 'rxjs/operators';
import {Observable} from 'rxjs';
import { RecordGQL } from '../../api/graphql-api/api.record.get';
@Injectable({
providedIn: 'root',
})
export class NavigationMetadata {
protected resourceName = 'navbar';
protected fieldsMetadata = {
fields: [
'NonGroupedTabs',
'groupedTabs',
'userActionMenu'
]
};
constructor(private recordGQL: RecordGQL) {}
public fetch(): Observable<any> {
const id = '/api/navbars/1';
return this.recordGQL
.fetch(this.resourceName, id, this.fieldsMetadata)
.pipe(map(({data}) => data.navbar));
}
}

View file

@ -1,88 +0,0 @@
import {getTestBed, TestBed} from '@angular/core/testing';
import {
ApolloTestingModule,
ApolloTestingController,
} from 'apollo-angular/testing';
import gql from 'graphql-tag';
import {NavigationMetadata} from '@services/metadata/navigation/navigation.metadata.service';
describe('Navigation Metadata Service', () => {
let injector: TestBed;
let service: NavigationMetadata;
let controller: ApolloTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ApolloTestingModule],
});
injector = getTestBed();
controller = injector.get(ApolloTestingController);
service = injector.get(NavigationMetadata);
});
afterEach(() => {
controller.verify();
});
it('#fetch',
(done: DoneFn) => {
const navigationMockData = {
navbar: {
NonGroupedTabs: {
Contacts: 'Contacts',
Accounts: 'Accounts'
},
groupedTabs: {
Sales: {
modules: {
Accounts: 'Accounts',
Contacts: 'Contacts',
}
},
},
userActionMenu: [
{
label: 'Profile',
url: 'index.php?module=Users&action=EditView&record=1',
submenu: []
},
{
label: 'Employees',
url: 'index.php?module=Employees&action=index',
submenu: []
},
],
}
};
service.fetch().subscribe(data => {
expect(data).toEqual(jasmine.objectContaining(navigationMockData.navbar));
done();
});
const op = controller.expectOne(gql`
query navbar($id: ID!) {
navbar(id: $id) {
NonGroupedTabs
groupedTabs
userActionMenu
}
}
`);
// Assert that one of variables is Mr Apollo.
expect(op.operation.variables.id).toEqual('/api/navbars/1');
// Respond with mock data, causing Observable to resolve.
op.flush({
data: navigationMockData
});
// Finally, assert that there are no outstanding operations.
controller.verify();
});
});

View file

@ -18,12 +18,12 @@ class AppListStringsHandler extends LegacyHandler
*/
public function getAppListStrings(string $language): ?AppListStrings
{
$this->init();
if (empty($language)) {
return null;
}
$this->init();
$enabledLanguages = get_languages();
if (empty($enabledLanguages) || !array_key_exists($language, $enabledLanguages)) {

View file

@ -34,12 +34,12 @@ class AppStringsHandler extends LegacyHandler
*/
public function getAppStrings(string $language): ?AppStrings
{
$this->init();
if (empty($language)) {
return null;
}
$this->init();
$enabledLanguages = get_languages();
if (empty($enabledLanguages) || !array_key_exists($language, $enabledLanguages)) {

View file

@ -135,7 +135,7 @@ class LegacyHandler
/**
* Disable legacy suite translations
*/
protected function disableTranslations()
protected function disableTranslations(): void
{
global $sugar_config, $app_strings;

View file

@ -43,12 +43,12 @@ class ModStringsHandler extends LegacyHandler
*/
public function getModStrings(string $language): ?ModStrings
{
$this->init();
if (empty($language)) {
return null;
}
$this->init();
$enabledLanguages = get_languages();
if (empty($enabledLanguages) || !array_key_exists($language, $enabledLanguages)) {

View file

@ -3,6 +3,9 @@
namespace SuiteCRM\Core\Legacy;
use App\Entity\Navbar;
use App\Service\ModuleNameMapper;
use App\Service\RouteConverter;
use SugarView;
use TabController;
use GroupedTabStructure;
use TabGroupHelper;
@ -12,6 +15,46 @@ use TabGroupHelper;
*/
class NavbarHandler extends LegacyHandler
{
/**
* @var ModuleNameMapper
*/
private $moduleNameMapper;
/**
* @var RouteConverter
*/
private $routeConverter;
/**
* @var array
*/
private $menuItemMap;
/**
* SystemConfigHandler constructor.
* @param string $projectDir
* @param string $legacyDir
* @param string $legacySessionName
* @param string $defaultSessionName
* @param array $menuItemMap
* @param ModuleNameMapper $moduleNameMapper
* @param RouteConverter $routeConverter
*/
public function __construct(
string $projectDir,
string $legacyDir,
string $legacySessionName,
string $defaultSessionName,
array $menuItemMap,
ModuleNameMapper $moduleNameMapper,
RouteConverter $routeConverter
) {
parent::__construct($projectDir, $legacyDir, $legacySessionName, $defaultSessionName);
$this->moduleNameMapper = $moduleNameMapper;
$this->routeConverter = $routeConverter;
$this->menuItemMap = $menuItemMap;
}
/**
* Get Navbar using legacy information
* @return Navbar
@ -20,12 +63,19 @@ class NavbarHandler extends LegacyHandler
{
$this->init();
$this->disableTranslations();
$navbar = new Navbar();
$navbar->NonGroupedTabs = $this->fetchNonGroupedNavTabs();
$sugarView = new SugarView();
$legacyTabNames = $this->fetchNavTabs();
$nameMap = $this->createFrontendNameMap($legacyTabNames);
$navbar->tabs = array_values($nameMap);
$navbar->modules = $this->buildModuleInfo($sugarView, $nameMap);
$navbar->groupedTabs = $this->fetchGroupedNavTabs();
$navbar->userActionMenu = $this->fetchUserActionMenu();
$navbar->moduleSubmenus = $this->fetchModuleSubMenus();
$this->close();
@ -36,26 +86,11 @@ class NavbarHandler extends LegacyHandler
* Fetch module navigation tabs
* @return array
*/
protected function fetchNonGroupedNavTabs(): array
protected function fetchNavTabs(): array
{
require_once 'modules/MySettings/TabController.php';
$tabArray = (new TabController())->get_user_tabs($GLOBALS['current_user']);
$tabArray = array_map('strtolower', $tabArray);
return $tabArray;
}
/**
* Fetch the module submenus
* @return array
*/
protected function fetchModuleSubMenus(): array
{
ob_start();
require_once 'modules/Home/sitemap.php';
ob_end_clean();
return sm_build_array();
return (new TabController())->get_user_tabs($GLOBALS['current_user']);
}
/**
@ -71,25 +106,26 @@ class NavbarHandler extends LegacyHandler
require_once 'modules/Studio/TabGroups/TabGroupHelper.php';
$tg = new TabGroupHelper();
$selectedAppLanguages = return_application_language($current_language);
$availableModules = $tg->getAvailableModules($current_language);
$modList = array_keys($availableModules);
$modList = array_combine($modList, $modList);
$groupedTabStructure = (new GroupedTabStructure())->get_tab_structure($modList, '', true, true);
$moduleNameMap = $this->createFrontendNameMap($modList);
foreach ($groupedTabStructure as $mainTab => $subModules) {
$groupedTabStructure[$mainTab]['label'] = $mainTab;
$groupedTabStructure[$mainTab]['labelValue'] = strtolower($selectedAppLanguages[$mainTab]);
$submoduleArray = [];
foreach ($subModules['modules'] as $submodule) {
$submoduleArray[] = strtolower($submodule);
foreach ($subModules['modules'] as $submodule => $submoduleLabel) {
$submoduleArray[] = $moduleNameMap[$submodule];
}
sort($submoduleArray);
$output[] = [
'name' => $groupedTabStructure[$mainTab]['labelValue'],
'name' => $mainTab,
'labelKey' => $mainTab,
'modules' => array_values($submoduleArray)
@ -116,20 +152,21 @@ class NavbarHandler extends LegacyHandler
require 'include/globalControlLinks.php';
$labelKeys = [
'employees' => 'LBL_EMPLOYEES',
'training' => 'LBL_TRAINING',
'about' => 'LNK_ABOUT',
'users' => 'LBL_LOGOUT',
$actionLabelMap = [
'LBL_EMPLOYEES' => 'employees',
'LBL_TRAINING' => 'training',
'LNK_ABOUT' => 'about',
'LBL_LOGOUT' => 'logout'
];
foreach ($global_control_links as $key => $value) {
foreach ($value as $linkAttribute => $attributeValue) {
// get the main link info
if ($linkAttribute === 'linkinfo') {
$userActionMenu[] = [
'name' => strtolower(key($attributeValue)),
'labelKey' => $labelKeys[$key],
'name' => $actionLabelMap[key($attributeValue)],
'labelKey' => key($attributeValue),
'url' => current($attributeValue),
'icon' => '',
];
@ -143,4 +180,109 @@ class NavbarHandler extends LegacyHandler
return array_values($userActionMenu);
}
/**
* @param SugarView $sugarView
* @param array $legacyNameMap
* @return array
*/
protected function buildModuleInfo(SugarView $sugarView, array $legacyNameMap): array
{
$modules = [];
foreach ($legacyNameMap as $legacyName => $frontendName) {
$menu = $this->buildSubModule($sugarView, $legacyName, $frontendName);
$modules[$frontendName] = [
'path' => $frontendName,
'defaultRoute' => "./#/$frontendName/index",
'name' => $frontendName,
'labelKey' => $legacyName,
'menu' => $menu
];
}
return $modules;
}
/**
* Retrieve legacy menu information and build suite 8 entry
* @param SugarView $sugarView
* @param string $legacyModule
* @param string $frontendModule
* @return array
*/
protected function buildSubModule(SugarView $sugarView, string $legacyModule, string $frontendModule): array
{
$subMenu = [];
$legacyMenuItems = $sugarView->getMenu($legacyModule);
foreach ($legacyMenuItems as $legacyMenuItem) {
[$url, $label, $action] = $legacyMenuItem;
$routeInfo = $this->routeConverter->parseUri($url);
$subMenu[] = [
'name' => $action,
'labelKey' => $this->mapEntry($frontendModule, $action, 'labelKey', $label),
'url' => $this->mapEntry($frontendModule, $action, 'url', $routeInfo['route']),
'params' => $routeInfo['params'],
'icon' => $this->mapEntry($frontendModule, $action, 'icon', '')
];
}
return $subMenu;
}
/**
* Map entry if defined on the configuration
* @param string $moduleName
* @param string $action
* @param string $entry
* @param string $default
* @return string
*/
protected function mapEntry(string $moduleName, string $action, string $entry, string $default): string
{
$module = $moduleName;
if ($this->isEntryMapped($action, $entry, $module)) {
$module = 'default';
}
if ($this->isEntryMapped($action, $entry, $module)) {
return $default;
}
return $this->menuItemMap[$module][$action][$entry];
}
/**
* Check if there is an entry mapped for the given module
* @param string $action
* @param string $entry
* @param string $module
* @return bool
*/
protected function isEntryMapped(string $action, string $entry, string $module): bool
{
return empty($this->menuItemMap[$module]) ||
empty($this->menuItemMap[$module][$action]) ||
empty($this->menuItemMap[$module][$action][$entry]);
}
/**
* Map legacy names to front end names
* @param array $legacyTabNames
* @return array
*/
protected function createFrontendNameMap(array $legacyTabNames): array
{
$map = [];
foreach ($legacyTabNames as $legacyTabName) {
$map[$legacyTabName] = $this->moduleNameMapper->toFrontEnd($legacyTabName);
}
return $map;
}
}

View file

@ -12,6 +12,9 @@ use ApiPlatform\Core\Annotation\ApiProperty;
* "get"
* },
* collectionOperations={
* },
* graphql={
* "item_query"
* }
* )
*/
@ -26,7 +29,7 @@ final class Navbar
* @var array
* @ApiProperty
*/
public $NonGroupedTabs;
public $tabs;
/**
* @var array
@ -44,5 +47,5 @@ final class Navbar
* @var array
* @ApiProperty
*/
public $moduleSubmenus;
public $modules;
}

View file

@ -45,7 +45,7 @@ class RouteConverter
*/
public function isLegacyRoute(Request $request): bool
{
if (!empty($request->getPathInfo()) && $request->getPathInfo() !== '/'){
if (!empty($request->getPathInfo()) && $request->getPathInfo() !== '/') {
return false;
}
@ -55,7 +55,7 @@ class RouteConverter
return false;
}
if (!$this->moduleNameMapper->isValidModule($module)){
if (!$this->moduleNameMapper->isValidModule($module)) {
return false;
}
@ -83,15 +83,52 @@ class RouteConverter
$route = $this->buildRoute($module, $action, $record);
if (null !== $queryString = $request->getQueryString()) {
$queryString = $this->removeParameter($queryString, 'module', $module);
$queryString = $this->removeParameter($queryString, 'action', $action);
$queryString = $this->removeParameter($queryString, 'record', $record);
$queryString = '?' . Request::normalizeQueryString($queryString);
$queryParams = $request->query->all();
$queryString = '?' . $this->buildQueryString($queryParams, ['module', 'action', 'record']);
}
return $route . $queryString;
}
/**
* Convert given $uri route
*
* @param string $uri
* @return string
*/
public function convertUri(string $uri): string
{
$request = Request::create($uri);
return $this->convert($request);
}
/**
* Parse given $uri route
*
* @param string $uri
* @return array
*/
public function parseUri(string $uri): array
{
$request = Request::create($uri);
$module = $request->query->get('module');
$action = $request->query->get('action');
$record = $request->query->get('record');
if (empty($module)) {
throw new InvalidArgumentException('No module defined');
}
$route = $this->buildRoute($module, $action, $record);
return [
'route' => $route,
'params' => $this->excludeParams($request->query->all(), ['module', 'action', 'record'])
];
}
/**
* Check if given $action is valid
* @param string|null $action
@ -114,7 +151,8 @@ class RouteConverter
* Get map of valid action
* @return array
*/
protected function getValidActions(): array {
protected function getValidActions(): array
{
return $this->map;
}
@ -170,17 +208,35 @@ class RouteConverter
}
/**
* @param string|null $queryString
* @param string|null $param
* @param string|null $value
* @return string|string[]|null
* Build query string
* @param array $queryParams
* @param array $exclude
* @return string
*/
protected function removeParameter(?string $queryString, ?string $param, ?string $value)
protected function buildQueryString(array $queryParams, array $exclude): string
{
if (empty($value) || empty($param) || empty($queryString)) {
return $queryString;
$validParams = $this->excludeParams($queryParams, $exclude);
return Request::normalizeQueryString(http_build_query($validParams));
}
/**
* Build new array where list of query params are excluded
* @param array $queryParams
* @param array $exclude
* @return array
*/
protected function excludeParams(array $queryParams, array $exclude): array
{
$validParams = [];
foreach ($queryParams as $name => $value) {
if (in_array($name, $exclude)) {
continue;
}
$validParams[$name] = $value;
}
return str_replace("$param=$value", '', $queryString);;
return $validParams;
}
}

View file

@ -2,6 +2,7 @@
use ApiPlatform\Core\Exception\ItemNotFoundException;
use App\Entity\ModStrings;
use App\Service\ModuleNameMapper;
use Codeception\Test\Unit;
use SuiteCRM\Core\Legacy\ModStringsHandler;
@ -19,11 +20,345 @@ class ModStringsHandlerTest extends Unit
protected function _before()
{
$legacyModuleNameMap = [
'Home' => [
'frontend' => 'home',
'core' => 'Home',
],
'Calendar' => [
'frontend' => 'calendar',
'core' => 'Calendar',
],
'Calls' => [
'frontend' => 'calls',
'core' => 'Calls',
],
'Calls_Reschedule' => [
'frontend' => 'calls-reschedule',
'core' => 'CallsReschedule',
],
'Meetings' => [
'frontend' => 'meetings',
'core' => 'Meetings',
],
'Tasks' => [
'frontend' => 'tasks',
'core' => 'Tasks',
],
'Notes' => [
'frontend' => 'notes',
'core' => 'Notes',
],
'Leads' => [
'frontend' => 'leads',
'core' => 'Leads',
],
'Contacts' => [
'frontend' => 'contacts',
'core' => 'Contacts',
],
'Accounts' => [
'frontend' => 'accounts',
'core' => 'Accounts',
],
'Opportunities' => [
'frontend' => 'opportunities',
'core' => 'Opportunities',
],
'Import' => [
'frontend' => 'import',
'core' => 'Import',
],
'Emails' => [
'frontend' => 'emails',
'core' => 'Emails',
],
'EmailTemplates' => [
'frontend' => 'email-templates',
'core' => 'EmailTemplates',
],
'Campaigns' => [
'frontend' => 'campaigns',
'core' => 'Campaigns',
],
'Targets' => [
'frontend' => 'targets',
'core' => 'Targets',
],
'Targets - Lists' => [
'frontend' => 'prospect-lists',
'core' => 'ProspectLists',
],
'Prospects' => [
'frontend' => 'prospects',
'core' => 'Prospects',
],
'ProspectLists' => [
'frontend' => 'prospect-lists',
'core' => 'ProspectLists',
],
'Documents' => [
'frontend' => 'documents',
'core' => 'Documents',
],
'Cases' => [
'frontend' => 'cases',
'core' => 'Cases',
],
'Project' => [
'frontend' => 'project',
'core' => 'Project',
],
'ProjectTask' => [
'frontend' => 'project-task',
'core' => 'ProjectTask',
],
'Bugs' => [
'frontend' => 'bugs',
'core' => 'Bugs',
],
'ResourceCalendar' => [
'frontend' => 'resource-calendar',
'core' => 'ResourceCalendar',
],
'AOBH_BusinessHours' => [
'frontend' => 'business-hours',
'core' => 'BusinessHours',
],
'Spots' => [
'frontend' => 'spots',
'core' => 'Spots',
],
'SecurityGroups' => [
'frontend' => 'security-groups',
'core' => 'SecurityGroups',
],
'ACL' => [
'frontend' => 'acl',
'core' => 'ACL',
],
'ACLRoles' => [
'frontend' => 'acl-roles',
'core' => 'ACLRoles',
],
'Configurator' => [
'frontend' => 'configurator',
'core' => 'Configurator',
],
'UserPreferences' => [
'frontend' => 'user-preferences',
'core' => 'UserPreferences',
],
'SavedSearch' => [
'frontend' => 'saved-search',
'core' => 'SavedSearch',
],
'Studio' => [
'frontend' => 'studio',
'core' => 'Studio',
],
'Connectors' => [
'frontend' => 'connectors',
'core' => 'Connectors',
],
'SugarFeed' => [
'frontend' => 'sugar-feed',
'core' => 'SugarFeed',
],
'EAPM' => [
'frontend' => 'eapm',
'core' => 'EAPM',
],
'OutboundEmailAccounts' => [
'frontend' => 'outbound-email-accounts',
'core' => 'OutboundEmailAccounts',
],
'TemplateSectionLine' => [
'frontend' => 'template-section-line',
'core' => 'TemplateSectionLine',
],
'OAuthKeys' => [
'frontend' => 'oauth-keys',
'core' => 'OAuthKeys',
],
'OAuthTokens' => [
'frontend' => 'oauth-tokens',
'core' => 'OAuthTokens',
],
'OAuth2Tokens' => [
'frontend' => 'oauth2-tokens',
'core' => 'OAuth2Tokens',
],
'OAuth2Clients' => [
'frontend' => 'oauth2-clients',
'core' => 'OAuth2Clients',
],
'Surveys' => [
'frontend' => 'surveys',
'core' => 'Surveys',
],
'SurveyResponses' => [
'frontend' => 'survey-responses',
'core' => 'SurveyResponses',
],
'SurveyQuestionResponses' => [
'frontend' => 'survey-question-responses',
'core' => 'SurveyQuestionResponses',
],
'SurveyQuestions' => [
'frontend' => 'survey-questions',
'core' => 'SurveyQuestions',
],
'SurveyQuestionOptions' => [
'frontend' => 'survey-question-options',
'core' => 'SurveyQuestionOptions',
],
'Reminders' => [
'frontend' => 'reminders',
'core' => 'Reminders',
],
'Reminders_Invitees' => [
'frontend' => 'reminders-invitees',
'core' => 'RemindersInvitees',
],
'AM_ProjectTemplates' => [
'frontend' => 'project-templates',
'core' => 'ProjectTemplates',
],
'AM_TaskTemplates' => [
'frontend' => 'task-templates',
'core' => 'TaskTemplates',
],
'AOK_Knowledge_Base_Categories' => [
'frontend' => 'knowledge-base-categories',
'core' => 'KnowledgeBaseCategories',
],
'AOK_KnowledgeBase' => [
'frontend' => 'knowledge-base',
'core' => 'KnowledgeBase',
],
'FP_events' => [
'frontend' => 'events',
'core' => 'Events',
],
'FP_Event_Locations' => [
'frontend' => 'event-locations',
'core' => 'EventLocations',
],
'AOS_Contracts' => [
'frontend' => 'contracts',
'core' => 'Contracts',
],
'AOS_Invoices' => [
'frontend' => 'invoices',
'core' => 'Invoices',
],
'AOS_PDF_Templates' => [
'frontend' => 'pdf-templates',
'core' => 'PDFTemplates',
],
'AOS_Product_Categories' => [
'frontend' => 'product-categories',
'core' => 'ProductCategories',
],
'AOS_Products' => [
'frontend' => 'products',
'core' => 'Products',
],
'AOS_Quotes' => [
'frontend' => 'quotes',
'core' => 'Quotes',
],
'AOS_Products_Quotes' => [
'frontend' => 'products-quotes',
'core' => 'ProductsQuotes',
],
'AOS_Line_Item_Groups' => [
'frontend' => 'line-item-groups',
'core' => 'LineItemGroups',
],
'jjwg_Maps' => [
'frontend' => 'maps',
'core' => 'Maps',
],
'jjwg_Markers' => [
'frontend' => 'markers',
'core' => 'Markers',
],
'jjwg_Areas' => [
'frontend' => 'areas',
'core' => 'Areas',
],
'jjwg_Address_Cache' => [
'frontend' => 'address-cache',
'core' => 'AddressCache',
],
'AOD_IndexEvent' => [
'frontend' => 'index-event',
'core' => 'IndexEvent',
],
'AOD_Index' => [
'frontend' => 'index',
'core' => 'index',
],
'AOP_Case_Events' => [
'frontend' => 'case-events',
'core' => 'CaseEvents',
],
'AOP_Case_Updates' => [
'frontend' => 'case-updates',
'core' => 'CaseUpdates',
],
'AOR_Reports' => [
'frontend' => 'reports',
'core' => 'Reports',
],
'AOR_Scheduled_Reports' => [
'frontend' => 'scheduled-reports',
'core' => 'ScheduledReports',
],
'AOR_Fields' => [
'frontend' => 'report-fields',
'core' => 'ReportFields',
],
'AOR_Charts' => [
'frontend' => 'report-charts',
'core' => 'ReportCharts',
],
'AOR_Conditions' => [
'frontend' => 'report-conditions',
'core' => 'ReportConditions',
],
'AOW_WorkFlow' => [
'frontend' => 'workFlow',
'core' => 'WorkFlow',
],
'AOW_Actions' => [
'frontend' => 'workflow-actions',
'core' => 'WorkflowActions',
],
'AOW_Processed' => [
'frontend' => 'workflow-processed',
'core' => 'WorflowProcessed',
],
'AOW_Conditions' => [
'frontend' => 'workflow-conditions',
'core' => 'WorkflowConditions',
],
];
$moduleNameMapper = new ModuleNameMapper($legacyModuleNameMap);
$projectDir = codecept_root_dir();
$legacyDir = $projectDir . '/legacy';
$legacySessionName = 'LEGACYSESSID';
$defaultSessionName = 'PHPSESSID';
$this->handler = new ModStringsHandler($projectDir, $legacyDir, $legacySessionName, $defaultSessionName);
$this->handler = new ModStringsHandler(
$projectDir,
$legacyDir,
$legacySessionName,
$defaultSessionName,
$moduleNameMapper
);
}
// tests
@ -46,9 +381,9 @@ class ModStringsHandlerTest extends Unit
static::assertNotNull($modStrings);
static::assertEquals('en_us', $modStrings->getId());
static::assertIsArray($modStrings->getItems());
$this->assertLanguageKey('Home', 'LBL_MODULE_NAME', $modStrings);
$this->assertLanguageKey('Accounts', 'LNK_ACCOUNT_LIST', $modStrings);
$this->assertLanguageKey('Accounts', 'LNK_NEW_ACCOUNT', $modStrings);
$this->assertLanguageKey('home', 'LBL_MODULE_NAME', $modStrings);
$this->assertLanguageKey('accounts', 'LNK_ACCOUNT_LIST', $modStrings);
$this->assertLanguageKey('accounts', 'LNK_NEW_ACCOUNT', $modStrings);
}
/**

File diff suppressed because it is too large Load diff