feat: update route visibility on app config change

pull/4445/head
Arman Ozak 5 years ago
parent 304d963279
commit 72b3f9eccb

@ -23,13 +23,10 @@ import { TreeNode } from '../utils/tree-utils';
export class DynamicLayoutComponent implements OnDestroy {
layout: Type<any>;
constructor(
injector: Injector,
private route: ActivatedRoute,
private routes: RoutesService,
private store: Store,
) {
constructor(injector: Injector, private store: Store) {
const route = injector.get(ActivatedRoute);
const router = injector.get(Router);
const routes = injector.get(RoutesService);
const layouts = {
application: this.getComponent('Theme.ApplicationLayoutComponent'),
account: this.getComponent('Theme.AccountLayoutComponent'),
@ -38,11 +35,11 @@ export class DynamicLayoutComponent implements OnDestroy {
router.events.pipe(takeUntilDestroy(this)).subscribe(event => {
if (event instanceof NavigationEnd) {
let expectedLayout = (this.route.snapshot.data || {}).layout;
let expectedLayout = (route.snapshot.data || {}).layout;
const path = getRoutePath(router);
if (!expectedLayout) {
let node = { parent: this.routes.search({ path }) } as TreeNode<ABP.Route>;
let node = { parent: routes.search({ path }) } as TreeNode<ABP.Route>;
while (node.parent) {
node = node.parent;

@ -30,7 +30,7 @@ export namespace ABP {
maxResultCount?: number;
}
export interface Node {
export interface Nav {
name: string;
parentName?: string;
requiredPolicy?: string;
@ -38,13 +38,13 @@ export namespace ABP {
invisible?: boolean;
}
export interface Route extends Node {
export interface Route extends Nav {
path: string;
layout?: eLayoutType;
iconClass?: string;
}
export interface Tab extends Node {
export interface Tab extends Nav {
component: Type<any>;
}

@ -1,8 +1,10 @@
import { Injectable } from '@angular/core';
import { Store } from '@ngxs/store';
import { Actions, ofActionSuccessful, Store } from '@ngxs/store';
import { BehaviorSubject, Observable } from 'rxjs';
import { GetAppConfiguration } from '../actions';
import { ABP } from '../models/common';
import { ConfigState } from '../states/config.state';
import { takeUntilDestroy } from '../utils';
import { pushValueTo } from '../utils/array-utils';
import { BaseTreeNode, createTreeFromList, TreeNode } from '../utils/tree-utils';
@ -88,6 +90,10 @@ export abstract class AbstractTreeService<T extends object> {
return this.publish(flatItems, visibleItems);
}
refresh(): T[] {
return this.add([]);
}
remove(identifiers: string[]): T[] {
const set = new Set<string>();
identifiers.forEach(id => set.add(id));
@ -98,7 +104,7 @@ export abstract class AbstractTreeService<T extends object> {
return this.publish(flatItems, visibleItems);
}
search(params: Partial<T>, tree = this.tree): TreeNode<T> {
search(params: Partial<T>, tree = this.tree): TreeNode<T> | null {
const searchKeys = Object.keys(params);
return tree.reduce(
@ -107,38 +113,36 @@ export abstract class AbstractTreeService<T extends object> {
? acc
: searchKeys.every(key => node[key] === params[key])
? node
: node.children
? this.search(params, node.children)
: acc,
: this.search(params, node.children),
null,
);
}
}
@Injectable({
providedIn: 'root',
})
export class RoutesService extends AbstractTreeService<ABP.Route> {
readonly id = 'name';
readonly parentId = 'parentName';
readonly hide = (item: ABP.Route) => item.invisible;
readonly sort = (a: ABP.Route, b: ABP.Route) => a.order - b.order;
}
@Injectable({
providedIn: 'root',
})
export class SettingTabsService extends AbstractTreeService<ABP.Tab> {
export abstract class AbstractNavTreeService<T extends ABP.Nav> extends AbstractTreeService<T> {
readonly id = 'name';
readonly parentId = 'parentName';
readonly hide = (setting: ABP.Tab) => setting.invisible || !this.isGranted(setting);
readonly sort = (a: ABP.Tab, b: ABP.Tab) => a.order - b.order;
readonly hide = (item: T) => item.invisible || !this.isGranted(item);
readonly sort = (a: T, b: T) => a.order - b.order;
constructor(private store: Store) {
constructor(protected actions: Actions, protected store: Store) {
super();
this.actions
.pipe(takeUntilDestroy(this), ofActionSuccessful(GetAppConfiguration))
.subscribe(() => this.refresh());
}
private isGranted(setting: ABP.Tab): boolean {
return this.store.selectSnapshot(ConfigState.getGrantedPolicy(setting.requiredPolicy));
protected isGranted({ requiredPolicy }: T): boolean {
return this.store.selectSnapshot(ConfigState.getGrantedPolicy(requiredPolicy));
}
/* istanbul ignore next */
ngOnDestroy() {}
}
@Injectable({ providedIn: 'root' })
export class RoutesService extends AbstractNavTreeService<ABP.Route> {}
@Injectable({ providedIn: 'root' })
export class SettingTabsService extends AbstractNavTreeService<ABP.Tab> {}

@ -2,7 +2,8 @@ import { HttpClient } from '@angular/common/http';
import { Component, NgModule } from '@angular/core';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { createRoutingFactory, SpectatorRouting } from '@ngneat/spectator/jest';
import { NgxsModule, Store } from '@ngxs/store';
import { Actions, NgxsModule, Store } from '@ngxs/store';
import { NEVER } from 'rxjs';
import { DynamicLayoutComponent, RouterOutletComponent } from '../components';
import { eLayoutType } from '../enums/common';
import { ABP } from '../models';
@ -97,11 +98,24 @@ const storeData = {
};
describe('DynamicLayoutComponent', () => {
const mockActions: Actions = NEVER;
const mockStore = ({
selectSnapshot() {
return true;
},
} as unknown) as Store;
const createComponent = createRoutingFactory({
component: RouterOutletComponent,
stubsEnabled: false,
declarations: [DummyComponent, DynamicLayoutComponent],
mocks: [ApplicationConfigurationService, HttpClient],
providers: [
{
provide: RoutesService,
useFactory: () => new RoutesService(mockActions, mockStore),
},
],
imports: [RouterModule, DummyLayoutModule, NgxsModule.forRoot([ReplaceableComponentsState])],
routes: [
{ path: '', component: RouterOutletComponent },

@ -1,17 +1,31 @@
import { Store } from '@ngxs/store';
import { Subject } from 'rxjs';
import { take } from 'rxjs/operators';
import { GetAppConfiguration } from '../actions';
import { RoutesService } from '../services';
const routes = [
{ path: '/foo', name: 'foo' },
{ path: '/foo/bar', name: 'bar', parentName: 'foo', invisible: true, order: 2 },
{ path: '/foo/bar/baz', name: 'baz', parentName: 'bar', order: 1 },
{ path: '/foo/x', name: 'x', parentName: 'foo', order: 1 },
];
describe('Routes Service', () => {
let service: RoutesService;
const routes = [
{ path: '/foo', name: 'foo' },
{ path: '/foo/bar', name: 'bar', parentName: 'foo', invisible: true, order: 2 },
{ path: '/foo/bar/baz', name: 'baz', parentName: 'bar', order: 1 },
{ path: '/foo/x', name: 'x', parentName: 'foo', order: 1 },
];
const mockActions = new Subject();
const mockStore = ({
selectSnapshot() {
return true;
},
} as unknown) as Store;
beforeEach(() => {
service = new RoutesService(mockActions, mockStore);
});
describe('#add', () => {
it('should add given routes as flat$, tree$, and visible$', async () => {
const service = new RoutesService();
service.add(routes);
const flat = await service.flat$.pipe(take(1)).toPromise();
@ -40,7 +54,6 @@ describe('Routes Service', () => {
describe('#remove', () => {
it('should remove routes based on given routeNames', () => {
const service = new RoutesService();
service.add(routes);
service.remove(['bar']);
@ -66,9 +79,9 @@ describe('Routes Service', () => {
describe('#patch', () => {
it('should patch propeties of routes based on given routeNames', () => {
const service = new RoutesService();
service['isGranted'] = jest.fn(route => route.requiredPolicy !== 'X');
service.add(routes);
service.patch('x', { invisible: true });
service.patch('x', { requiredPolicy: 'X' });
const flat = service.flat;
const tree = service.tree;
@ -93,21 +106,40 @@ describe('Routes Service', () => {
});
it('should return false when route name is not found', () => {
const service = new RoutesService();
service.add(routes);
const result = service.patch('A man has no name.', { invisible: true });
expect(result).toBe(false);
});
});
describe('#refresh', () => {
it('should call add once with empty array', () => {
const add = jest.spyOn(service, 'add');
service.refresh();
expect(add).toHaveBeenCalledTimes(1);
expect(add).toHaveBeenCalledWith([]);
});
it('should be called upon successful GetAppConfiguration action', () => {
const refresh = jest.spyOn(service, 'refresh');
mockActions.next({ action: new GetAppConfiguration(), status: 'SUCCESSFUL' });
expect(refresh).toHaveBeenCalledTimes(1);
});
});
describe('#search', () => {
it('should return node found when route name is not found', () => {
const service = new RoutesService();
it('should return node found based on query', () => {
service.add(routes);
const result = service.search({ invisible: true });
expect(result.name).toBe('bar');
expect(result.children.length).toBe(1);
expect(result.children[0].name).toBe('baz');
});
it('should return null when query is not found', () => {
service.add(routes);
const result = service.search({ requiredPolicy: 'X' });
expect(result).toBe(null);
});
});
});

Loading…
Cancel
Save