Retrieve Navigation Menu data from API

- Add Navigation Api Service
- Add Metadata Facade Service
- Build Navigation menu using fetched menu items
- Fix submenu item link on template
- Cleanup code warnings
- Remove get method from RecordGQL
-- we won't be using GraphQL cache for the moment
- Fix classic view karma tests
- Fix Filter karma tests
- Add set of base jasmine tests for:
-- navbar component
-- metadata service
-- navigation metadata service
This commit is contained in:
Clemente Raposo 2020-02-04 00:38:51 +00:00 committed by Dillon-Brown
parent 7ed07c6091
commit e3ce6d85ef
15 changed files with 413 additions and 333 deletions

View file

@ -25,7 +25,7 @@ module.exports = function (config) {
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
browsers: ['Chrome', 'Chromium'],
singleRun: false,
restartOnFileChange: true
});

View file

@ -5,16 +5,25 @@ import {RouterTestingModule} from '@angular/router/testing';
import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing';
import {ClassicViewUiComponent} from './classic-view.component';
import {ApiService} from '../../services/api/api.service';
import {ApiService} from '@services/api/api.service';
import {ActivatedRoute} from '@angular/router';
import {of} from 'rxjs';
describe('ClassicViewUiComponent', () => {
let component: ClassicViewUiComponent;
let fixture: ComponentFixture<ClassicViewUiComponent>;
const route = ({
data: { view: { html: '<h1>haha</h1>' }},
snapshot: {
data: { view: { html: '<h1>haha</h1>' }}
}
} as any) as ActivatedRoute;
beforeEach(async(() => {
TestBed.configureTestingModule({
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [RouterTestingModule, HttpClientTestingModule, FormsModule],
providers: [{ provide: ActivatedRoute, useValue: route }],
declarations: [ClassicViewUiComponent]
})
.compileComponents();
@ -28,6 +37,15 @@ describe('ClassicViewUiComponent', () => {
it(`should create`, async(inject([HttpTestingController],
(router: RouterTestingModule, http: HttpTestingController, api: ApiService) => {
expect(component).toBeTruthy();
expect(component.data.view.html).toEqual('<h1>haha</h1>');
})));
it(`should display provided html`, async(inject([HttpTestingController],
(router: RouterTestingModule, http: HttpTestingController, api: ApiService) => {
const classicElement: HTMLElement = fixture.nativeElement;
expect(classicElement.innerHTML).toContain('<h1>haha</h1>');
})));
});

View file

@ -9,7 +9,7 @@ import {ActivatedRoute} from '@angular/router';
export class ClassicViewUiComponent {
data: any;
@ViewChild('dataContainer', {static:true}) dataContainer: ElementRef;
@ViewChild('dataContainer', {static: true}) dataContainer: ElementRef;
public element: any;
renderHtml(data) {

View file

@ -1,20 +1,20 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {FilterViewUiComponent} from './filter-view.component';
import {FilterUiComponent} from './filter.component';
describe('FilterViewUiComponent', () => {
let component: FilterViewUiComponent;
let fixture: ComponentFixture<FilterViewUiComponent>;
describe('FilterUiComponent', () => {
let component: FilterUiComponent;
let fixture: ComponentFixture<FilterUiComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [FilterViewUiComponent]
declarations: [FilterUiComponent]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(FilterViewUiComponent);
fixture = TestBed.createComponent(FilterUiComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

View file

@ -14,8 +14,6 @@ import {HttpClientModule} from '@angular/common/http';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {ListViewUiComponent} from './list-view.component';
import {SvgIconUiComponent} from '../svg-icon/svg-icon.component';
import {ModalViewUiComponent} from '../modal-view/modal-view.component';
let mockRouter: any;

View file

@ -11,4 +11,5 @@ export interface NavbarModel {
currentUser: CurrentUserModel;
all: AllMenuModel;
menu: any;
buildMenu(items: any, menuItemThreshold: number): void;
}

View file

@ -7,27 +7,27 @@ export class NavbarAbstract implements NavbarModel {
useGroupTabs = true;
globalActions = [
{
'link': {
'url': '',
'label': 'Employees'
link: {
url: '',
label: 'Employees'
}
},
{
'link': {
'url': '',
'label': 'Admin'
link: {
url: '',
label: 'Admin'
}
},
{
'link': {
'url': '',
'label': 'Support Forums'
link: {
url: '',
label: 'Support Forums'
}
},
{
'link': {
'url': '',
'label': 'About'
link: {
url: '',
label: 'About'
}
}
];
@ -39,263 +39,65 @@ export class NavbarAbstract implements NavbarModel {
modules: [],
extra: [],
};
menu = [
{
"link": {
"label": "Accounts",
"url": ''
},
"submenu":
[
{
"link": {
"label": "Create Account",
"url": "",
"iconRef": {"resolved": "home_page"}
},
"icon": "plus",
"submenu": [],
menu = [];
},
{
"link": {
"label": "View Account", "url": "/#/Accounts/index"
},
"icon": "plus",
"submenu": []
},
{
"link": {
"label": "Import Accounts", "url": ""
},
"icon": "plus",
"submenu": []
},
]
},
{
"link": { "label": "Contacts", "url": '' }, "icon": "home_page",
"submenu":
[
{
"link": {
"label": "Create Contact",
"url": ""
},
"icon": "plus",
"submenu": []
},
{
"link": {
"label": "View Contact", "url": ""
},
"icon": "plus",
"submenu": []
},
{
"link": {
"label": "Import Contacts", "url": ""
},
"icon": "plus",
"submenu": []
},
]
},
{
"link": { "label": "Opportunities", "url": '' }, "icon": "home_page",
"submenu":
[
{
"link": {
"label": "Create Opportunity",
"url": ""
},
"icon": "plus",
"submenu": []
},
{
"link": {
"label": "View Opportunity", "url": ""
},
"icon": "plus",
"submenu": []
},
{
"link": {
"label": "Import Opportunities", "url": ""
},
"icon": "plus",
"submenu": []
},
]
},
{
"link": { "label": "Leads", "url": '' }, "icon": "home_page",
"submenu":
[
{
"link": {
"label": "Create Lead",
"url": ""
},
"icon": "plus",
"submenu": []
},
{
"link": {
"label": "View Lead", "url": ""
},
"icon": "plus",
"submenu": []
},
{
"link": {
"label": "Import Leads", "url": ""
},
"icon": "plus",
"submenu": []
},
]
},
{
"link": { "label": "Quotes", "url": '' }, "icon": "home_page",
"submenu":
[
{
"link": {
"label": "Create Quote",
"url": ""
},
"icon": "plus",
"submenu": []
},
{
"link": {
"label": "View Quote", "url": ""
},
"icon": "plus",
"submenu": []
},
{
"link": {
"label": "Import Quotes", "url": ""
},
"icon": "plus",
"submenu": []
},
]
},
{
"link": { "label": "Calendar", "url": '' }, "icon": "home_page",
"submenu":
[
{
"link": {
"label": "Create Calendar",
"url": ""
},
"icon": "plus",
"submenu": []
},
{
"link": {
"label": "View Calendar", "url": ""
},
"icon": "plus",
"submenu": []
},
{
"link": {
"label": "Import Calendars", "url": ""
},
"icon": "plus",
"submenu": []
},
]
},
{
"link": { "label": "Documents", "url": '' }, "icon": "home_page",
"submenu":
[
{
"link": {
"label": "Create Document",
"url": ""
},
"icon": "plus",
"submenu": []
},
{
"link": {
"label": "View Document", "url": ""
},
"icon": "plus",
"submenu": []
},
{
"link": {
"label": "Import Documents", "url": ""
},
"icon": "plus",
"submenu": []
},
]
},
{
"link": { "label": "Emails", "url": '' }, "icon": "home_page",
"submenu":
[
{
"link": {
"label": "Create Email",
"url": ""
},
"icon": "plus",
"submenu": []
},
{
"link": {
"label": "View Email", "url": ""
},
"icon": "plus",
"submenu": []
},
{
"link": {
"label": "Import Emails", "url": ""
},
"icon": "plus",
"submenu": []
},
]
},
{
"link": { "label": "Spots", "url": '' }, "icon": "home_page",
"submenu":
[
{
"link": {
"label": "Create Spot",
"url": ""
},
"icon": "plus",
"submenu": []
},
{
"link": {
"label": "View Spot", "url": ""
},
"icon": "plus",
"submenu": []
},
{
"link": {
"label": "Import Spots", "url": ""
},
"icon": "plus",
"submenu": []
},
]
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: []
},
]
};
}
}

View file

@ -137,7 +137,10 @@
</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 dropdown-item dropdown-toggle"
<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>

View file

@ -1,4 +1,4 @@
import {async, ComponentFixture, TestBed, inject} from '@angular/core/testing';
import {async, ComponentFixture, TestBed, inject, fakeAsync, tick} from '@angular/core/testing';
import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
import {RouterTestingModule} from '@angular/router/testing';
import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing';
@ -6,28 +6,77 @@ 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';
describe('NavbarUiComponent', () => {
let component: NavbarUiComponent;
let fixture: ComponentFixture<NavbarUiComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [RouterTestingModule, HttpClientTestingModule, NgbModule],
declarations: [NavbarUiComponent]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(NavbarUiComponent);
component = fixture.componentInstance;
fixture.detectChanges();
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}],
declarations: [NavbarUiComponent]
}).compileComponents();
service = TestBed.get(Metadata);
fixture = TestBed.createComponent(NavbarUiComponent);
component = fixture.componentInstance;
});
it('should create', () => {
fixture.detectChanges(); // onInit()
expect(component).toBeTruthy();
});
it('should get metadata', async (() => {
fixture.detectChanges(); // onInit()
fixture.whenStable().then(() => {
expect(component.navigationMetadata).toEqual(jasmine.objectContaining(navigationMockData.navbar));
});
component.ngOnInit();
}));
});
it(`should create`, async(inject([HttpTestingController],
(httpClient: HttpTestingController) => {
expect(component).toBeTruthy();
})));
});

View file

@ -3,6 +3,9 @@ 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 { Metadata } from '../../services/metadata/metadata.service';
@Component({
selector: 'scrm-navbar-ui',
@ -10,7 +13,10 @@ import {NavbarAbstract} from './navbar.abstract';
styleUrls: []
})
export class NavbarUiComponent implements OnInit {
constructor(protected api: ApiService, protected router: Router) {
private navbarSubscription: Subscription;
public navigationMetadata: any;
constructor(protected metadata: Metadata, protected api: ApiService, protected router: Router) {
NavbarUiComponent.instances.push(this);
}
@ -70,8 +76,23 @@ export class NavbarUiComponent implements OnInit {
}
ngOnInit(): void {
let navbar = new NavbarAbstract();
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);
}
});
const navbar = new NavbarAbstract();
this.setNavbar(navbar);
window.dispatchEvent(new Event("resize"));
window.dispatchEvent(new Event('resize'));
}
}

View file

@ -3,7 +3,7 @@ import {Apollo, QueryRef} from 'apollo-angular';
import gql from 'graphql-tag';
@Injectable({
providedIn: 'root',
providedIn: 'root'
})
export class RecordGQL {
@ -12,15 +12,15 @@ export class RecordGQL {
/**
* Fetch data either from backend or cache
* @param module
* @param id
* @param metadata
* @param module to get from
* @param id of the record
* @param metadata with the fields to ask for
*/
public fetch(module: string, id: string, metadata: { fields: string[] }): QueryRef<any> {
const fields = metadata.fields;
const iriId = this.formatId(module, id);
let queryOptions = {
const queryOptions = {
query: gql`
query ${module}($id: ID!) {
${module}(id: $id) {
@ -36,32 +36,10 @@ export class RecordGQL {
return this.apollo.watchQuery(queryOptions);
}
/**
* Get in memory cache data
* @param module
* @param id
* @param metadata
*/
public get(module: string, id: string, metadata: { fields: string[] }): any {
const fields = metadata.fields;
const iriId = this.formatId(module, id);
let queryOptions = {
id: iriId,
fragment: gql`
fragment my${module} on ${module} {
${fields.join('\n')}
}
`
};
return this.apollo.getClient().readFragment(queryOptions);
}
/**
* Format id
* @param module
* @param id
* @param module name
* @param id of the record
*/
protected formatId(module: string, id: string) {
return `/api/${module}s/${id}`;

View file

@ -0,0 +1,18 @@
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

@ -0,0 +1,74 @@
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

@ -0,0 +1,30 @@
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 = '1';
return this.recordGQL
.fetch(this.resourceName, id, this.fieldsMetadata)
.valueChanges.pipe(map(({data}) => data.navbar));
}
}

View file

@ -0,0 +1,88 @@
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();
});
});