diff --git a/docs/en/UI/Angular/Modifying-the-Menu.md b/docs/en/UI/Angular/Modifying-the-Menu.md index c59ce0d650..7a9c9a1004 100644 --- a/docs/en/UI/Angular/Modifying-the-Menu.md +++ b/docs/en/UI/Angular/Modifying-the-Menu.md @@ -152,6 +152,24 @@ import { APP_ROUTE_PROVIDER } from './route.provider'; export class AppModule {} ``` +### Singularize Route Item +- `name` property is must be a unique key. If there are multiple items with the same name, the last one will be displayed in the menu. +- If you want to display multiple items in different parent with the same name, you can call the **setSingularizeStatus(false)** method of the `RoutesService` to disable the singularization. + - **This method should be called before adding the routes.** +- To enable the singularization of the names, you can call the **setSingularizeStatus(true) `(default value: true)`** method of the `RoutesService`. + +```typescript +import { RoutesService } from '@abp/ng.core'; +import { Component } from '@angular/core'; + +@Component(/* component metadata */) +export class AppComponent { + constructor(private routes: RoutesService) { + routes.setSingularizeStatus(false); + } +} +``` + Here is what every property works as: - `path` is the absolute path of the navigation element. @@ -226,7 +244,7 @@ After adding the `routes` property as described above, the navigation menu looks ## How to Patch or Remove a Navigation Element -The `patch` method of `RoutesService` finds a route by its name and replaces its configuration with the new configuration passed as the second parameter. Similarly, `remove` method finds a route and removes it along with its children. +The `patch` method of `RoutesService` finds a route by its name and replaces its configuration with the new configuration passed as the second parameter. Similarly, `remove` method finds a route and removes it along with its children. Also you can use `removeByParam` method to delete the routes with given properties. ```js // this.routes is instance of RoutesService @@ -249,6 +267,9 @@ const newHomeRouteConfig: Partial = { this.routes.add([dashboardRouteConfig]); this.routes.patch('::Menu:Home', newHomeRouteConfig); this.routes.remove(['Your navigation']); + +// or +this.routes.removeByParam({ name: 'Your navigation' }); ``` - Moved the _Home_ navigation under the _Administration_ dropdown based on given `parentName`. diff --git a/npm/ng-packs/packages/core/src/lib/services/routes.service.ts b/npm/ng-packs/packages/core/src/lib/services/routes.service.ts index c3a1ffe07f..393c724423 100644 --- a/npm/ng-packs/packages/core/src/lib/services/routes.service.ts +++ b/npm/ng-packs/packages/core/src/lib/services/routes.service.ts @@ -26,6 +26,7 @@ export abstract class AbstractTreeService[]>([]); protected othersGroup: string; + protected shouldSingularizeRoutes = true; get flat(): T[] { return this._flat$.value; @@ -51,6 +52,29 @@ export abstract class AbstractTreeService | Map): T[] { + return this._flat$.value.filter(item => !setOrMap.has(item[this.id])); + } + + private findItemsToRemove(set: Set): Set { + return this._flat$.value.reduce((acc, item) => { + if (!acc.has(item[this.parentId])) { + return acc; + } + + const childSet = new Set([item[this.id]]); + const children = this.findItemsToRemove(childSet); + return new Set([...acc, ...children]); + }, set); + } + + private publish(flatItems: T[]): T[] { + this._flat$.next(flatItems); + this._tree$.next(this.createTree(flatItems)); + this._visible$.next(this.createTree(flatItems.filter(item => !this.hide(item)))); + return flatItems; + } + protected createTree(items: T[]): TreeNode[] { return createTreeFromList>( items, @@ -69,57 +93,50 @@ export abstract class AbstractTreeService ({ group: key, items })); } - private filterWith(setOrMap: Set | Map): T[] { - return this._flat$.value.filter(item => !setOrMap.has(item[this.id])); - } - - private findItemsToRemove(set: Set): Set { - return this._flat$.value.reduce((acc, item) => { - if (!acc.has(item[this.parentId])) return acc; - const childSet = new Set([item[this.id]]); - const children = this.findItemsToRemove(childSet); - return new Set([...acc, ...children]); - }, set); - } - - private publish(flatItems: T[], visibleItems: T[]): T[] { - this._flat$.next(flatItems); - this._tree$.next(this.createTree(flatItems)); - this._visible$.next(this.createTree(visibleItems)); - return flatItems; - } - add(items: T[]): T[] { - const map = new Map(); - items.forEach(item => map.set(item[this.id], item)); + let flatItems: T[] = []; + + if (!this.shouldSingularizeRoutes) { + flatItems = [...this.flat, ...items]; + } - const flatItems = this.filterWith(map); - map.forEach(pushValueTo(flatItems)); + if (this.shouldSingularizeRoutes) { + const map = new Map(); + items.forEach(item => map.set(item[this.id], item)); + flatItems = this.filterWith(map); + map.forEach(pushValueTo(flatItems)); + } flatItems.sort(this.sort); - const visibleItems = flatItems.filter(item => !this.hide(item)); - return this.publish(flatItems, visibleItems); + return this.publish(flatItems); } find(predicate: (item: TreeNode) => boolean, tree = this.tree): TreeNode | null { - return tree.reduce | null>( - (acc, node) => (acc ? acc : predicate(node) ? node : this.find(predicate, node.children)), - null, - ); + return tree.reduce | null>((acc, node) => { + if (acc) { + return acc; + } + + if (predicate(node)) { + return node; + } + + return this.find(predicate, node.children); + }, null); } patch(identifier: string, props: Partial): T[] | false { const flatItems = this._flat$.value; const index = flatItems.findIndex(item => item[this.id] === identifier); - if (index < 0) return false; + if (index < 0) { + return false; + } flatItems[index] = { ...flatItems[index], ...props }; flatItems.sort(this.sort); - const visibleItems = flatItems.filter(item => !this.hide(item)); - - return this.publish(flatItems, visibleItems); + return this.publish(flatItems); } refresh(): T[] { @@ -132,23 +149,50 @@ export abstract class AbstractTreeService !this.hide(item)); + return this.publish(flatItems); + } + + removeByParam(params: Partial): T[] | null { + if (!params) { + return null; + } + + const keys = Object.keys(params) as Array>; + if (keys.length === 0) { + return null; + } + + const excludedList = this.flat.filter(item => keys.every(key => item[key] === params[key])); + if (!excludedList?.length) { + return null; + } - return this.publish(flatItems, visibleItems); + for (const item of excludedList) { + this.removeByParam({ [this.parentId]: item[this.id] } as Partial); + } + + const flatItems = this.flat.filter(item => !excludedList.includes(item)); + return this.publish(flatItems); } search(params: Partial, tree = this.tree): TreeNode | null { const searchKeys = Object.keys(params) as Array>; - return tree.reduce | null>( - (acc, node) => - acc - ? acc - : searchKeys.every(key => node[key] === params[key]) - ? node - : this.search(params, node.children), - null, - ); + return tree.reduce | null>((acc, node) => { + if (acc) { + return acc; + } + + if (searchKeys.every(key => node[key] === params[key])) { + return node; + } + + return this.search(params, node.children); + }, null); + } + + setSingularizeStatus(singularize = true): void { + this.shouldSingularizeRoutes = singularize; } } @@ -164,7 +208,7 @@ export abstract class AbstractNavTreeService readonly parentId = 'parentName'; readonly hide = (item: T) => item.invisible || !this.isGranted(item); readonly sort = (a: T, b: T) => { - return this.compareFunc(a,b) + return this.compareFunc(a, b); }; constructor(protected injector: Injector) { diff --git a/npm/ng-packs/packages/core/src/lib/tests/routes.service.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/routes.service.spec.ts index 718b1fc0e2..affd15e873 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/routes.service.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/routes.service.spec.ts @@ -172,6 +172,64 @@ describe('Routes Service', () => { }); }); + describe('#setSingularizeStatus', () => { + it('should allow to duplicate routes when called with false', () => { + service.setSingularizeStatus(false); + + service.add(routes); + + const flat = service.flat; + + expect(flat.length).toBe(routes.length); + }); + + it('should allow to duplicate routes with the same name when called with false', () => { + service.setSingularizeStatus(false); + + service.add([...routes, { path: '/foo/bar/test', name: 'bar', parentName: 'foo', order: 2 }]); + + const flat = service.flat; + + expect(flat.length).toBe(routes.length + 1); + }); + + it('should allow to routes with the same name but different parentName when called with false', () => { + service.setSingularizeStatus(false); + + service.add([ + { path: '/foo/bar', name: 'bar', parentName: 'foo', order: 2 }, + { path: '/foo/bar', name: 'bar', parentName: 'baz', order: 1 }, + ]); + + const flat = service.flat; + + expect(flat.length).toBe(2); + }); + + it('should not allow to duplicate routes when called with true', () => { + service.setSingularizeStatus(false); + + service.add(routes); + + service.setSingularizeStatus(true); + + service.add(routes); + + const flat = service.flat; + + expect(flat.length).toBe(5); + }); + + it('should not allow to duplicate routes with the same name when called with true', () => { + service.setSingularizeStatus(true); + service.add([...routes, { path: '/foo/bar/test', name: 'bar', parentName: 'any', order: 2 }]); + + const flat = service.flat; + + expect(flat.length).toBe(5); + }); + }); + describe('#find', () => { it('should return node found based on query', () => { service.add(routes); @@ -242,6 +300,81 @@ describe('Routes Service', () => { }); }); + describe('#removeByParam', () => { + it('should remove route based on given route', () => { + service.add(routes); + + service.removeByParam({ + name: 'bar', + parentName: 'foo', + }); + + const flat = service.flat; + + expect(flat.length).toBe(2); + + const notFound = service.find(route => route.name === 'bar'); + + expect(notFound).toBe(null); + }); + + it('should remove if more than one route has the same properties', () => { + service.setSingularizeStatus(false); + + service.add([ + ...routes, + { + path: '/foo/bar', + name: 'bar', + parentName: 'foo', + invisible: true, + order: 2, + breadcrumbText: 'Bar Breadcrumb', + }, + ]); + + service.removeByParam({ + path: '/foo/bar', + name: 'bar', + parentName: 'foo', + invisible: true, + order: 2, + breadcrumbText: 'Bar Breadcrumb', + }); + + const flat = service.flat; + expect(flat.length).toBe(5); + + const notFound = service.search({ + path: '/foo/bar', + name: 'bar', + parentName: 'foo', + invisible: true, + order: 2, + breadcrumbText: 'Bar Breadcrumb', + }); + expect(notFound).toBe(null); + }); + + it("shouldn't remove if there is no route with the given properties", () => { + service.add(routes); + const flatLengthBeforeRemove = service.flat.length; + + service.removeByParam({ + name: 'bar', + parentName: 'baz', + }); + + const flat = service.flat; + + expect(flatLengthBeforeRemove - flat.length).toBe(0); + + const notFound = service.find(route => route.name === 'bar'); + + expect(notFound).not.toBe(null); + }); + }); + describe('#patch', () => { it('should patch propeties of routes based on given routeNames', () => { service['isGranted'] = jest.fn(route => route.requiredPolicy !== 'X');